fix: cache image history detail previews

This commit is contained in:
FengLee
2026-05-20 20:31:24 +08:00
parent d8619fd9e6
commit 5d50c72902
12 changed files with 177 additions and 32 deletions

View File

@@ -85,7 +85,7 @@ All email sends route through `src/lib/email-service.ts`, which renders HTML and
| Method | Path | Auth | Source | Request | Response |
| --- | --- | --- | --- | --- | --- |
| GET | `/api/admin/invitations` | Admin | `src/app/api/admin/invitations/route.ts` | Optional `search`, `page`, `pageSize` | Long-term invitation records joining inviter and invitee profile details. |
| POST | `/api/generate/image` | Trusted internal or resolved user/system API context | `src/app/api/generate/image/route.ts` | Image generation payload; supports prompt, negative prompt, reference images, model/system/custom API config, aspect/size/resolution/count/quality. | Calls SDK or OpenAI/New API-compatible endpoint, persists original images to object storage and local WEBP thumbnails to `thumbnails/generated/images`, returns `images` original URLs plus `thumbnails` map and `thumbnailUrls`, updates job progress when headers include job ID. |
| POST | `/api/generate/image` | Trusted internal or resolved user/system API context | `src/app/api/generate/image/route.ts` | Image generation payload; supports prompt, negative prompt, reference images, model/system/custom API config, aspect/size/resolution/count/quality. | Calls SDK or OpenAI/New API-compatible endpoint, persists original images to object storage and local WEBP thumbnails to `thumbnails/generated/images`, returns `images` original URLs plus `thumbnails`, `thumbnailUrls`, and `dimensions` `{ [originalUrl]: { width, height } }`, updates job progress when headers include job ID. |
| POST | `/api/generate/video` | Trusted internal or resolved user/system API context | `src/app/api/generate/video/route.ts` | Video generation payload; supports prompt, reference image, model/system/custom API config, ratio/duration/fps-like params. | Calls SDK or custom endpoint, persists media through the storage adapter. |
| POST | `/api/generate/reverse-prompt` | Uses supplied/resolved API config; Bearer token required when resolving user custom or gated system API IDs | `src/app/api/generate/reverse-prompt/route.ts` | `image`, `outputMode`, `language`, optional `customApiConfig`/system/custom IDs | Returns prompt fields and may persist reference image. The create-panel caller must forward the stored access token in `Authorization` because server-side API resolution cannot read browser localStorage. |
| POST | `/api/generate/suggest-prompt` | Uses supplied/resolved API config | `src/app/api/generate/suggest-prompt/route.ts` | `prompt`, optional `customApiConfig`, `systemPrefix` | Returns optimized `prompt` and optional `negativePrompt`. |
@@ -106,8 +106,8 @@ Important generation helpers:
| Method | Path | Auth | Source | Request | Response/Side Effects |
| --- | --- | --- | --- | --- | --- |
| GET | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | None | Latest 300 completed user works as `records`, including optional `thumbnailUrl`; missing image thumbnails are lazily generated into local `thumbnails/works`. |
| POST | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | Single record or `{ records: [...] }`; image records may include `thumbnailUrl` | Inserts/deduplicates completed works into `works`, storing `thumbnail_url` when supplied or generating it for image works. |
| GET | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | None | Latest 300 completed user works as `records`, including optional `thumbnailUrl`, `width`, and `height`; missing image thumbnails are lazily generated into local `thumbnails/works`. |
| POST | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | Single record or `{ records: [...] }`; image records may include `thumbnailUrl`, `width`, and `height` | Inserts/deduplicates completed works into `works`, storing `thumbnail_url` and dimensions when supplied or generating thumbnails for image works. |
| DELETE | `/api/creation-history?id=...` | User | `src/app/api/creation-history/route.ts` | Optional `id`; omit to delete all user history | Deletes user's private history rows by `id` and `user_id`. Creation detail deletion waits for this server delete before refreshing local history. |
| GET | `/api/gallery` | Public | `src/app/api/gallery/route.ts`, `src/lib/gallery-response.ts` | Query `type=image|video`, `category=text2img|img2img|text2video|img2video`, `limit`, `offset`, `sort=newest|popular`, `q`/`search` | Public completed works with `thumbnailUrl`, `total`, `nextOffset`, and `hasMore`; missing public image thumbnails are lazily generated into local `thumbnails/gallery`. Public list rows filter `data:` and oversized `publisherAvatarUrl` values to keep responses and browser caches small. Responses allow short private browser caching while the gallery page also keeps a bounded localStorage cache for instant first paint. |
| DELETE | `/api/gallery` | Admin | `src/app/api/gallery/route.ts` | Query `id` or body `{ ids: [...] }` | Unpublishes up to 100 works by setting `is_public=false`. |

View File

@@ -55,16 +55,16 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Text-to-image or image-to-image says `请在提示词中写明画面比例` even after selecting a Yuanjie resolution such as `4K 竖版 (3:4)` | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/lib/yuanjie-image-model-templates.ts` | Some Yuanjie image templates set `supportsAspectRatio: false` and encode orientation in `resolution`/`size` options. Generation validation must derive the ratio from the selected resolution label or dimensions instead of requiring a separate aspect-ratio control. Image-to-image should also default count to `1` rather than requiring prompt inference for `生成数量`. |
| Reference image upload too large or fails | `src/components/create/image-to-image.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/browser-image-compression.ts`, `src/lib/server-image-compression.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Browser compression, `MAX_UPSTREAM_REFERENCE_IMAGE_BYTES`, data URL conversion. Uploaded reference thumbnails should single-click into the no-container `BareImagePreview`; blank area closes it. |
| Generated result previews but does not persist | `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts`, `src/lib/local-storage.ts`, `src/app/api/creation-history/route.ts` | Media copied through the storage adapter, stable `/api/local-storage/<key>` URL returned, history POST called. In object storage mode, verify `STORAGE_MODE` and `OBJECT_STORAGE_*` health. |
| Image preview cards load slowly, look blurry in detail, or fetch full originals | `src/lib/media-storage.ts`, `src/lib/local-storage.ts`, `src/app/api/local-storage/[...path]/route.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/creation-history/route.ts`, `src/app/api/gallery/route.ts`, `src/app/api/gallery/publish/route.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/profile/creation-history-tab.tsx`, `src/app/gallery/page.tsx` | New generated image originals should be object-only, while WEBP thumbnails should be local-only under `thumbnails/...`. Current thumbnails should have the `m1280q86` suffix and come from the 1280px/Lanczos/sharpened profile. Cards and detail preview surfaces use `thumbnailUrl || url`; fullscreen, right-click copy/download/edit, and share must use original `url`. `GET /api/creation-history` and `GET /api/gallery` should queue missing or old-profile legacy thumbnails in the background, not block the list response. `/api/local-storage/thumbnails/...` must read local disk directly in dual mode instead of checking object storage first; original image keys should 302 to a short-lived signed object-storage URL so fullscreen does not wait for Next.js to buffer the full file. |
| Image preview cards load slowly, look blurry in detail, or fetch full originals | `src/lib/media-storage.ts`, `src/lib/local-storage.ts`, `src/app/api/local-storage/[...path]/route.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/creation-history/route.ts`, `src/app/api/gallery/route.ts`, `src/app/api/gallery/publish/route.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/profile/creation-history-tab.tsx`, `src/app/gallery/page.tsx` | New generated image originals should be object-only, while WEBP thumbnails should be local-only under `thumbnails/...`. Current thumbnails should have the `m1280q86` suffix and come from the 1280px/Lanczos/sharpened profile. Cards and detail preview surfaces use `thumbnailUrl || url`; fullscreen, right-click copy/download/edit, and share must use original `url`. Detail metadata badges must use stored width/height with `loadMetadata={false}` rather than requesting original images. `GET /api/creation-history` and `GET /api/gallery` should queue missing or old-profile legacy thumbnails in the background, not block the list response. `/api/local-storage/thumbnails/...` must read local disk directly in dual mode instead of checking object storage first; original image keys should 302 to a short-lived signed object-storage URL so fullscreen does not wait for Next.js to buffer the full file. |
| Gallery shows `加载中...` for seconds on every visit or loads too many images at once | `src/app/gallery/page.tsx`, `src/app/api/gallery/route.ts`, `src/lib/gallery-response.ts`, `src/lib/gallery-cache-policy.ts`, `src/app/api/local-storage/[...path]/route.ts`, `src/proxy.ts` | The page should show cached `miaojing:gallery:v3` rows immediately when available, even when older than the short freshness TTL, then revalidate page 0 in the background. It should show the masonry skeleton instead of the old centered `加载中...` when no cache exists, debounce search, request small `limit/offset` pages, and append more rows only through the scroll sentinel. Check `/api/gallery` response size with curl; generated default avatar `data:` URLs in `publisherAvatarUrl` can make every page hundreds of KB larger and can break localStorage caching, so public gallery serialization must filter `data:`/oversized avatars. Do not restore the old `limit=300` full-gallery request. Thumbnail URLs under `/api/local-storage/thumbnails/...` should return long immutable cache headers so browser image cache is actually used; if curl still shows `no-store`, check `src/proxy.ts` because the global `/api` cache header can override the route response. |
| `/api/health` or page probes are slow after object migration | `src/app/api/health/route.ts`, `src/lib/local-storage.ts` | Health checks call `getStorageHealthStatus()`. Object bucket checks should be cached briefly and bounded with an abort timeout so a slow S3-compatible endpoint does not hold request threads for many seconds. |
| Logs repeatedly show `must be owner of table ...` on normal requests | `src/lib/generation-job-estimates.ts`, `src/lib/email-service.ts`, `src/lib/profile-preferences.ts`, `src/lib/user-profile-defaults.ts`, `src/lib/server-api-config.ts` | Optional runtime schema checks can hit `42501` when the production app user is not the table owner. Treat existing-schema `42501` as a one-time warning and cache the skip; apply real schema migrations through deployment/DB owner operations rather than request-time DDL. |
| Fullscreen/preview/download/right-click image actions broken | `src/components/fullscreen-preview.tsx`, `src/components/lightbox.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-actions-context-menu.tsx`, `src/components/image-metadata-badge.tsx`, `src/app/image-viewer/page.tsx`, `src/app/api/download/route.ts` | Dialog state, URL type, download proxy supports local/remote URL. Image result and history/detail previews should open on single click. Right-click copy, download, edit, and share actions must use the uncompressed original image URL, not a thumbnail, preview cache, or compressed reference blob. Fullscreen components should receive a thumbnail fallback so the preview appears immediately while the original object-storage image loads. Share links should open `/image-viewer?url=...` as a standalone original-image fullscreen page. Image result and history/detail previews should show upper-right actual aspect ratio and natural resolution via `ImageMetadataBadge`. |
| Gallery console shows `Image URL being set: /api/local-storage/generated/...` while preview should use thumbnails | `src/app/gallery/page.tsx`, `src/components/image-metadata-badge.tsx` | Check the actual `<img>` `src` and `/api/gallery` response first. The console line can be caused by metadata probing rather than the preview image. Gallery detail should pass stored `width`/`height` to `ImageMetadataBadge` and set `loadMetadata={false}` so the badge does not trigger an original-image request just to calculate dimensions. |
| Gallery or history detail logs/requests original generated URLs while preview should use thumbnails | `src/app/gallery/page.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-metadata-badge.tsx` | Check the actual `<img>` `src` and `/api/gallery` or `/api/creation-history` response first. The console line/request can be caused by metadata probing rather than the preview image. Gallery and history detail should pass stored `width`/`height` to `ImageMetadataBadge` and set `loadMetadata={false}` so the badge does not trigger an original-image request just to calculate dimensions. |
| Gallery shows many historical/imported works or thumbnail rules differ between all/category/search | `src/app/gallery/page.tsx`, `src/app/api/gallery/route.ts`, `src/lib/creation-history-store.ts` | The gallery page should render only `/api/gallery` rows, not merge browser localStorage published/history records and not call `syncPublishedToSupabase()` on load. `/api/gallery` should apply `category`/`q` filters server-side against public works with stable `/api/local-storage/...` result URLs so all, text-to-image, image-to-image, text-to-video, image-to-video, and search share the same thumbnail/original split. |
| Generated image preview zooms but cannot be dragged | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx` | Result-card previews use `ImageLightbox`; after zooming above 100%, panning should be bound to the whole preview stage as well as the image so mouse drag remains available even when the transformed image extends beyond its original element box. Keep wheel zoom, double-click zoom/reset, right-click actions, and ESC close intact. |
| Create page loads slowly and console shows CORS errors for historical `coze-codingproject.tos.coze.site` images | `src/components/create/cached-preview-image.tsx`, `src/components/create/text-to-image.tsx`, `src/app/api/download/route.ts` | Hidden mobile history must not mount desktop-side image effects, and cross-origin historical result images should render through the same-origin `/api/download?disposition=inline` proxy before canvas preview generation. Otherwise every hidden history image can issue a blocked browser request and slow the page. |
| Creation detail, gallery one-click reuse, or inspiration reuse buttons do not fill create forms or switch tabs | `src/components/creation-detail-dialog.tsx`, `src/app/gallery/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx`, `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Reuse actions should write the shared draft key/event for `text2img`, `img2img`, `text2video`, or `img2video`, route to the matching `/create?type=...` when leaving gallery, and already-mounted create panels should react to the event. Image-to-image and image-to-video reuse should include reference images from the work, falling back to the output image or thumbnail only when stored references are missing. |
| Creation detail, gallery one-click reuse, or inspiration reuse buttons do not fill create forms or switch tabs | `src/components/creation-detail-dialog.tsx`, `src/app/gallery/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx`, `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Reuse actions should write the shared draft key/event for `text2img`, `img2img`, `text2video`, or `img2video`, route to the matching `/create?type=...` when leaving gallery, and already-mounted create panels should react to the event. Image-to-image and image-to-video reuse should include stored reference images from the work; when intentionally using the generated output as the new reference, fall back to the original output `url`, never `thumbnailUrl`. |
| Image generation count dropdown too wide, options missing, or manual count input unavailable | `src/components/create/image-count-combobox.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | Use the shared compact combobox instead of browser `datalist`; verify manual numeric entry and dropdown options in both text-to-image and image-to-image panels. |
| Generated image result hover actions are unreadable in light theme | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | The result-card hover overlay owns the Preview/Share/Download buttons. These buttons should use fixed dark translucent backgrounds and white text/icons so light and dark themes have the same readable hover action style. |
| Generated image is pushed down by a long prompt in the desktop result column | `src/components/create/text-to-image.tsx` | The result prompt above new images should be a compact two-line summary with the full prompt only in the title tooltip/history detail. Do not render the entire prompt as an unbounded paragraph in the live result column. |

View File

@@ -60,8 +60,8 @@ Use this document to jump directly to code before broad searching.
| Image count input/dropdown | `src/components/create/image-count-combobox.tsx` | Shared compact count control for manual image count entry and common dropdown options. |
| Style presets | `src/components/create/style-preset-selector.tsx`, `src/lib/style-presets-client.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`, `src/lib/model-config.ts` | Style presets are stored in `image_style_presets`, seeded from defaults, sorted by `usage_count`, and incremented from image generation jobs. The selector exposes stable `.style-preset-selector` and `.style-preset-list` classes so mobile create CSS can show a one-row collapsed strip and an expanded list of at least several rows inside the bottom composer. |
| Loading/error panels | `src/components/create/generation-loading-panel.tsx`, `src/components/create/generation-task-list.tsx`, `src/components/create/generation-error-panel.tsx` | Shared generation status UI. `generation-task-list` keeps multiple active job cards constrained to the results column, and image create panels render active tasks plus completed result cards together so earlier finished jobs do not disappear while later jobs keep running. |
| Creation reuse drafts | `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx` | Shared localStorage/event bridge used by detail, reverse-prompt, gallery, and inspiration actions to prefill create panels. It supports `text2img`, `img2img`, `text2video`, and `img2video` draft keys/events; `/create?type=...` changes the active tab after navigation, so callers can route directly to the matching creation mode. The inspiration dialog filters to the current mode, keeps per-card mode labels hidden, and offers a fuzzy search box that animates leftward from the header search icon; empty searches auto-collapse after the pointer leaves the search control for 1 second, while non-empty searches stay open until the dialog closes. |
| Lightbox/fullscreen/detail actions | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-actions-context-menu.tsx`, `src/components/image-metadata-badge.tsx`, `src/app/image-viewer/page.tsx`, `src/components/create/cached-preview-image.tsx` | Image cards, detail images, reference thumbnails, and generation results should enter fullscreen preview on single click, not double-click. Detail and fullscreen images use the shared right-click image action menu for copy, download, edit-to-image-to-image, and share; these actions must receive the original image URL, not thumbnails or cached display blobs. Fullscreen/lightbox components can receive a thumbnail fallback to display immediately while the original object-storage URL loads. Share copies a `/image-viewer?url=...` full-display link for the original image. Delete work must use a confirmation dialog warning that deletion cannot be recovered before calling the server delete path. Image previews show actual natural resolution and computed aspect ratio in the upper-right metadata badge. `BareImagePreview` is the no-container overlay for uploaded reference image previews. `CachedPreviewImage` generates same-origin cached previews and proxies cross-origin historical URLs through `/api/download?disposition=inline` to avoid browser CORS failures. |
| Creation reuse drafts | `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx` | Shared localStorage/event bridge used by detail, reverse-prompt, gallery, and inspiration actions to prefill create panels. It supports `text2img`, `img2img`, `text2video`, and `img2video` draft keys/events; `/create?type=...` changes the active tab after navigation, so callers can route directly to the matching creation mode. If a reuse action intentionally uses a generated output as a new reference image, it must use the original `url` rather than `thumbnailUrl`; thumbnails are display-only and must not be sent back into image-to-image or image-to-video generation. The inspiration dialog filters to the current mode, keeps per-card mode labels hidden, and offers a fuzzy search box that animates leftward from the header search icon; empty searches auto-collapse after the pointer leaves the search control for 1 second, while non-empty searches stay open until the dialog closes. |
| Lightbox/fullscreen/detail actions | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-actions-context-menu.tsx`, `src/components/image-metadata-badge.tsx`, `src/app/image-viewer/page.tsx`, `src/components/create/cached-preview-image.tsx` | Image cards, detail images, reference thumbnails, and generation results should enter fullscreen preview on single click, not double-click. Detail and fullscreen images use the shared right-click image action menu for copy, download, edit-to-image-to-image, and share; these actions must receive the original image URL, not thumbnails or cached display blobs. Fullscreen/lightbox components can receive a thumbnail fallback to display immediately while the original object-storage URL loads. Share copies a `/image-viewer?url=...` full-display link for the original image. Delete work must use a confirmation dialog warning that deletion cannot be recovered before calling the server delete path. Image previews show actual natural resolution and computed aspect ratio in the upper-right metadata badge; detail dialogs must pass stored `width`/`height` with `loadMetadata={false}` so the badge does not fetch the original image just to compute size. `BareImagePreview` is the no-container overlay for uploaded reference image previews. `CachedPreviewImage` generates same-origin cached previews and proxies cross-origin historical URLs through `/api/download?disposition=inline` to avoid browser CORS failures. |
## Generation System
@@ -73,7 +73,7 @@ Use this document to jump directly to code before broad searching.
| Worker loop | `src/lib/generation-job-worker.ts` | Picks and processes queued jobs. After successful system default image/video generation, it calls `src/lib/generation-credit-service.ts` to deduct credits from `profiles.credits_balance`, insert `credit_transactions`, and add `creditsCost`/`creditsBalance` to the job result for frontend display. Failed generation jobs do not enter the charge path. |
| Internal runner | `src/lib/generation-job-runner.ts` | Calls `/api/generate/image` or `/api/generate/video` with internal headers. |
| ETA/progress | `src/lib/generation-job-estimates.ts` | Runtime schema, ETA samples, progress payload. |
| Image route | `src/app/api/generate/image/route.ts` | SDK + custom/system API + New API image compatibility, persistence. New image originals persist through `src/lib/media-storage.ts` into object storage, while local WEBP thumbnails are returned as `thumbnails`/`thumbnailUrls` for preview rendering. For admin default system models, image generation resolves all same-type/same-display-name default API candidates, automatically retries stream-timeout failures once with `stream:false`, and returns actionable upstream timeout/gateway messages when all candidates fail. If a Manifest provider such as 元界 returns result URLs but MiaoJing cannot download or save them, the route reports a platform download/save failure instead of a resolution mismatch. User custom APIs remain single-config and do not use this polling fallback. |
| Image route | `src/app/api/generate/image/route.ts` | SDK + custom/system API + New API image compatibility, persistence. New image originals persist through `src/lib/media-storage.ts` into object storage, while local WEBP thumbnails are returned as `thumbnails`/`thumbnailUrls` for preview rendering and `dimensions` maps each original URL to persisted width/height so history detail metadata can avoid loading originals. For admin default system models, image generation resolves all same-type/same-display-name default API candidates, automatically retries stream-timeout failures once with `stream:false`, and returns actionable upstream timeout/gateway messages when all candidates fail. If a Manifest provider such as 元界 returns result URLs but MiaoJing cannot download or save them, the route reports a platform download/save failure instead of a resolution mismatch. User custom APIs remain single-config and do not use this polling fallback. |
| Video route | `src/app/api/generate/video/route.ts` | SDK + custom/system API video, persistence. Video create panels must use backend returned `creditsCost`/`creditsBalance` after job success; they should not locally predict or deduct credits. |
| Custom API transport | `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts` | Headers, one retry for 502/503/504 gateway failures, progress JSON parsing, upstream error parsing, stream-to-sync fallback policy for system image APIs. |
| Server API resolution | `src/lib/server-api-config.ts` | Resolves user custom API and admin system API IDs into decrypted credentials, enforces system API default visibility plus membership-tier allowlists before generation, and builds default-model polling candidates by media type plus admin display name (`system_api_configs.name`). The upstream `model_name` remains the per-provider request model only. |
@@ -112,7 +112,7 @@ Use this document to jump directly to code before broad searching.
| Public gallery API | `src/app/api/gallery/route.ts`, `src/lib/gallery-response.ts` | GET public works with `thumbnailUrl`, `total`, `nextOffset`, and `hasMore`, queues missing or old-profile image thumbnails for background backfill without delaying the response, admin DELETE unpublishes. Gallery author names use `profiles.display_nickname` first and never expose login username unless no display nickname exists. Public list serialization filters `data:` and oversized `publisherAvatarUrl` values so generated default avatars do not bloat the gallery JSON payload or localStorage cache. |
| Publish API | `src/app/api/gallery/publish/route.ts` | Copies image originals into object-backed gallery folders, stores local thumbnails, and inserts public work. |
| Admin gallery prompt moderation | `src/components/admin/gallery-management-tab.tsx`, `src/app/api/admin/gallery/works/route.ts`, `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts`, `src/lib/admin-gallery-works-pagination.ts`, `scripts/test-admin-gallery-prompt-service.mjs` | Console-only workflow for editing public gallery `works.prompt`. The management table uses page/pageSize pagination while the list API keeps limit/offset compatibility. Admins must send an email notification to the author; the service sends email before updating the prompt and logs metadata without storing full prompt text. |
| History persistence | `src/app/api/creation-history/route.ts`, `src/lib/creation-history-store.ts` | User-private completed works, `thumbnailUrl`, and published state. Missing image thumbnails are queued for background backfill instead of blocking the history response. Single-record deletion is server-first when logged in; detail dialogs call the same store path and then refresh local history. |
| History persistence | `src/app/api/creation-history/route.ts`, `src/lib/creation-history-store.ts` | User-private completed works, `thumbnailUrl`, stored width/height, and published state. Missing image thumbnails are queued for background backfill instead of blocking the history response. Single-record deletion is server-first when logged in; detail dialogs call the same store path and then refresh local history. |
## Admin Console

View File

@@ -17,6 +17,7 @@
"test:admin-gallery-prompt": "node --no-warnings ./scripts/test-admin-gallery-prompt-service.mjs",
"test:custom-image-fallback": "tsx ./scripts/test-custom-image-fallback.mjs",
"test:generation-credit-policy": "tsx ./scripts/test-generation-credit-policy.mjs",
"test:creation-thumbnail-policy": "tsx ./scripts/test-creation-thumbnail-policy.mjs",
"test:gallery-response": "node --no-warnings ./scripts/test-gallery-response.mjs",
"test:yuanjie-media-manifest-mapping": "tsx ./scripts/test-yuanjie-media-manifest-mapping.mjs",
"test:yuanjie-image2-persistence": "tsx ./scripts/test-yuanjie-image2-persistence.mjs",

View File

@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = path.resolve(import.meta.dirname, '..');
const {
buildCreationReuseDraft,
} = await import('../src/lib/creation-reuse.ts');
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
await runTest('creation detail renders thumbnail while fullscreen and actions keep original image', () => {
const source = read('src/components/creation-detail-dialog.tsx');
assert.match(source, /src=\{record\.thumbnailUrl \|\| record\.url\}/);
assert.match(source, /openFullscreenPreview\(record\.url,\s*record\.thumbnailUrl\)/);
assert.match(source, /openImageMenu\(event,\s*record\.url\)/);
assert.match(source, /downloadFile\(url,\s*filename\)/);
});
await runTest('creation detail metadata badge does not load original image for dimensions', () => {
const source = read('src/components/creation-detail-dialog.tsx');
assert.match(source, /<ImageMetadataBadge[\s\S]*?src=\{record\.url\}[\s\S]*?width=\{record\.width\}[\s\S]*?height=\{record\.height\}[\s\S]*?loadMetadata=\{false\}/);
});
await runTest('creation history API preserves stored image dimensions for detail metadata', () => {
const source = read('src/app/api/creation-history/route.ts');
assert.match(source, /width:\s*row\.width/);
assert.match(source, /height:\s*row\.height/);
assert.match(source, /SELECT[\s\S]*\bwidth,\s*height[\s\S]*FROM works/);
assert.match(source, /INSERT INTO works[\s\S]*width,\s*height/);
});
await runTest('image generation response exposes persisted dimensions for history records', () => {
const routeSource = read('src/app/api/generate/image/route.ts');
const textSource = read('src/components/create/text-to-image.tsx');
const imageSource = read('src/components/create/image-to-image.tsx');
assert.match(routeSource, /dimensions:\s*Object\.fromEntries\(images\.map\(image => \[image\.url,\s*\{\s*width:\s*image\.width,\s*height:\s*image\.height\s*\}\]\)\)/);
assert.match(textSource, /dimensions\?:\s*Record<string,\s*\{\s*width:\s*number;\s*height:\s*number\s*\}>/);
assert.match(textSource, /width:\s*data\.dimensions\?\.\[url\]\?\.width/);
assert.match(textSource, /height:\s*data\.dimensions\?\.\[url\]\?\.height/);
assert.match(imageSource, /dimensions\?:\s*Record<string,\s*\{\s*width:\s*number;\s*height:\s*number\s*\}>/);
assert.match(imageSource, /width:\s*data\.dimensions\?\.\[url\]\?\.width/);
assert.match(imageSource, /height:\s*data\.dimensions\?\.\[url\]\?\.height/);
});
await runTest('reuse drafts use original output as generated-reference fallback, never thumbnail', () => {
const record = {
id: 'work-1',
url: '/api/local-storage/generated/images/original.webp',
thumbnailUrl: '/api/local-storage/thumbnails/works/thumb.webp',
prompt: 'prompt',
negativePrompt: '',
model: 'model',
params: {},
};
const imageDraft = buildCreationReuseDraft(record, 'img2img', { source: 'creation-detail', useOutputAsReference: true });
const videoDraft = buildCreationReuseDraft(record, 'img2video', { source: 'gallery', useOutputAsReference: true });
assert.deepEqual(imageDraft.referenceImages, [record.url]);
assert.equal(imageDraft.referenceImage, record.url);
assert.deepEqual(videoDraft.referenceImages, [record.url]);
assert.equal(videoDraft.referenceImage, record.url);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -31,6 +31,8 @@ function mapWork(row: Record<string, unknown>) {
type: fromWorkType(String(row.type || 'text2img')),
url: row.result_url,
thumbnailUrl: row.thumbnail_url || undefined,
width: row.width || undefined,
height: row.height || undefined,
prompt: row.prompt || '',
negativePrompt: row.negative_prompt || undefined,
model: params.model || '',
@@ -49,6 +51,11 @@ function mapWork(row: Record<string, unknown>) {
};
}
function getPositiveInteger(value: unknown): number | null {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? Math.round(number) : null;
}
async function ensureWorkThumbnail(client: Awaited<ReturnType<typeof getDbClient>>, row: Record<string, unknown>) {
if (isCurrentLocalImageThumbnail(row.thumbnail_url) || typeof row.result_url !== 'string') return row;
const type = String(row.type || '');
@@ -99,7 +106,7 @@ export async function GET(request: NextRequest) {
const client = await getDbClient();
try {
const result = await client.query(
`SELECT id, type, prompt, negative_prompt, params, result_url, thumbnail_url, is_public, status, credits_cost, created_at
`SELECT id, type, prompt, negative_prompt, params, result_url, thumbnail_url, width, height, is_public, status, credits_cost, created_at
FROM works
WHERE user_id = $1 AND status = 'completed'
ORDER BY created_at DESC
@@ -135,6 +142,8 @@ export async function POST(request: NextRequest) {
const workType = toWorkType(String(record.type || 'image'), params);
let url = String(record.url || '').trim();
let thumbnailUrl = String(record.thumbnailUrl || '').trim() || null;
const width = getPositiveInteger(record.width || (record.params || {}).width);
const height = getPositiveInteger(record.height || (record.params || {}).height);
if (workType === 'reverse-prompt') {
url = url && !url.startsWith('data:') ? url : `[reverse-prompt:${record.id || Date.now()}]`;
}
@@ -147,7 +156,7 @@ export async function POST(request: NextRequest) {
}
}
const existing = await client.query(
`SELECT id, type, prompt, negative_prompt, params, result_url, thumbnail_url, is_public, status, credits_cost, created_at
`SELECT id, type, prompt, negative_prompt, params, result_url, thumbnail_url, width, height, is_public, status, credits_cost, created_at
FROM works
WHERE user_id = $1 AND result_url = $2
LIMIT 1`,
@@ -155,17 +164,26 @@ export async function POST(request: NextRequest) {
);
if (existing.rows[0]) {
const existingRow = existing.rows[0];
if (thumbnailUrl && !existingRow.thumbnail_url) {
await client.query('UPDATE works SET thumbnail_url = $1 WHERE id = $2', [thumbnailUrl, existingRow.id]);
existingRow.thumbnail_url = thumbnailUrl;
if ((thumbnailUrl && !existingRow.thumbnail_url) || (width && !existingRow.width) || (height && !existingRow.height)) {
await client.query(
`UPDATE works
SET thumbnail_url = COALESCE(thumbnail_url, $1),
width = COALESCE(width, $2),
height = COALESCE(height, $3)
WHERE id = $4`,
[thumbnailUrl, width, height, existingRow.id],
);
existingRow.thumbnail_url = existingRow.thumbnail_url || thumbnailUrl;
existingRow.width = existingRow.width || width;
existingRow.height = existingRow.height || height;
}
saved.push(mapWork(existingRow));
continue;
}
const result = await client.query(
`INSERT INTO works (user_id, type, prompt, negative_prompt, params, result_url, thumbnail_url, is_public, status, credits_cost, created_at)
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, 'completed', $9, COALESCE($10::timestamptz, NOW()))
RETURNING id, type, prompt, negative_prompt, params, result_url, thumbnail_url, is_public, status, credits_cost, created_at`,
`INSERT INTO works (user_id, type, prompt, negative_prompt, params, result_url, thumbnail_url, width, height, is_public, status, credits_cost, created_at)
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9, $10, 'completed', $11, COALESCE($12::timestamptz, NOW()))
RETURNING id, type, prompt, negative_prompt, params, result_url, thumbnail_url, width, height, is_public, status, credits_cost, created_at`,
[
userId,
workType,
@@ -174,6 +192,8 @@ export async function POST(request: NextRequest) {
JSON.stringify(params),
url,
thumbnailUrl,
width,
height,
Boolean(record.published),
Number(record.creditsCost || 0),
record.createdAt || null,

View File

@@ -214,6 +214,7 @@ type GeneratedImagePersistenceFailureKind = 'download' | 'storage' | 'invalid_im
type PersistQualifiedImageUrlsResult = {
images: string[];
thumbnails: Record<string, string>;
dimensions: Record<string, { width: number; height: number }>;
rejected: string[];
failureKinds: GeneratedImagePersistenceFailureKind[];
};
@@ -268,16 +269,18 @@ async function persistQualifiedImageUrls(
return {
images: images.map(image => image.url),
thumbnails: Object.fromEntries(images.map(image => [image.url, image.thumbnailUrl])),
dimensions: Object.fromEntries(images.map(image => [image.url, { width: image.width, height: image.height }])),
rejected,
failureKinds,
};
}
function imageResponsePayload(result: { images: string[]; thumbnails: Record<string, string> }) {
function imageResponsePayload(result: { images: string[]; thumbnails: Record<string, string>; dimensions: Record<string, { width: number; height: number }> }) {
return {
images: result.images,
thumbnails: result.thumbnails,
thumbnailUrls: result.images.map(url => result.thumbnails[url] || url),
dimensions: result.dimensions,
};
}
@@ -323,6 +326,7 @@ async function requestQualifiedCustomImages(
): Promise<PersistQualifiedImageUrlsResult & { upstreamError?: { status: number; text: string } }> {
const accepted: string[] = [];
const thumbnails: Record<string, string> = {};
const dimensions: Record<string, { width: number; height: number }> = {};
const rejected: string[] = [];
const failureKinds: GeneratedImagePersistenceFailureKind[] = [];
const maxAttempts = 1;
@@ -357,6 +361,7 @@ async function requestQualifiedCustomImages(
return {
images: accepted,
thumbnails,
dimensions,
rejected,
failureKinds,
upstreamError: { status: response.response.status, text: response.errorText },
@@ -376,6 +381,7 @@ async function requestQualifiedCustomImages(
);
accepted.push(...persisted.images);
Object.assign(thumbnails, persisted.thumbnails);
Object.assign(dimensions, persisted.dimensions);
rejected.push(...persisted.rejected);
failureKinds.push(...persisted.failureKinds);
}
@@ -384,6 +390,7 @@ async function requestQualifiedCustomImages(
return {
images,
thumbnails: Object.fromEntries(images.map(url => [url, thumbnails[url] || url])),
dimensions: Object.fromEntries(images.map(url => [url, dimensions[url]]).filter((entry): entry is [string, { width: number; height: number }] => Boolean(entry[1]))),
rejected,
failureKinds,
};

View File

@@ -127,6 +127,7 @@ export function ImageToImagePanel() {
const [activeTasks, setActiveTasks] = useState<ActiveGenerationTask[]>([]);
const [results, setResults] = useState<string[]>([]);
const [resultThumbnails, setResultThumbnails] = useState<Record<string, string>>({});
const [resultDimensions, setResultDimensions] = useState<Record<string, { width: number; height: number }>>({});
const [resultCredits, setResultCredits] = useState<Record<string, number>>({});
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
const [optimizing, setOptimizing] = useState(false);
@@ -573,12 +574,12 @@ export function ImageToImagePanel() {
requestBody = { ...requestBody, model: api.modelName, customApiConfig: { systemApiId: api.id, modelName: api.modelName } };
}
}
const runJob = (payload: Record<string, unknown>) => runGenerationJob<{ images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; error?: string; creditsCost?: number; creditsBalance?: number }>(
const runJob = (payload: Record<string, unknown>) => runGenerationJob<{ images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; dimensions?: Record<string, { width: number; height: number }>; error?: string; creditsCost?: number; creditsBalance?: number }>(
'image',
payload,
{ timeoutMs: 900_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status }) },
);
let data: { images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; error?: string; creditsCost?: number; creditsBalance?: number };
let data: { images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; dimensions?: Record<string, { width: number; height: number }>; error?: string; creditsCost?: number; creditsBalance?: number };
try {
data = await runJob({ ...requestBody, stream: true });
} catch (error) {
@@ -609,6 +610,7 @@ export function ImageToImagePanel() {
const creditsPerImage = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, data.images.length)) : 0;
setResults(prev => [...data.images!, ...prev]);
setResultThumbnails(prev => ({ ...prev, ...thumbnails }));
if (data.dimensions) setResultDimensions(prev => ({ ...prev, ...data.dimensions }));
if (creditsPerImage > 0) {
setResultCredits(prev => Object.fromEntries([
...Object.entries(prev),
@@ -623,7 +625,9 @@ export function ImageToImagePanel() {
addRecord({
type: 'image', url, prompt: prompt.trim(),
thumbnailUrl: thumbnails[url],
negativePrompt: negativePrompt.trim() || undefined,
width: data.dimensions?.[url]?.width,
height: data.dimensions?.[url]?.height,
negativePrompt: negativePrompt.trim() || undefined,
model: selectedModel,
modelLabel: getCurrentModelLabel(),
isCustomModel: isCustomModel(selectedModel) || isSystemModel(selectedModel),
@@ -682,13 +686,15 @@ export function ImageToImagePanel() {
modelLabel: getCurrentModelLabel(),
creditsCost: resultCredits[url] || 0,
thumbnailUrl: resultThumbnails[url],
width: resultDimensions[url]?.width,
height: resultDimensions[url]?.height,
params: {
creationMode: 'img2img',
styleLabel: selectedStylePreset?.label,
},
});
toast.success('已分享到画廊');
}, [prompt, selectedModel, selectedStylePreset, getCurrentModelLabel, resultCredits, resultThumbnails]);
}, [prompt, selectedModel, selectedStylePreset, getCurrentModelLabel, resultCredits, resultDimensions, resultThumbnails]);
return (
<>

View File

@@ -119,6 +119,7 @@ export function TextToImagePanel() {
const [activeTasks, setActiveTasks] = useState<ActiveGenerationTask[]>([]);
const [results, setResults] = useState<string[]>([]);
const [resultThumbnails, setResultThumbnails] = useState<Record<string, string>>({});
const [resultDimensions, setResultDimensions] = useState<Record<string, { width: number; height: number }>>({});
const [resultCredits, setResultCredits] = useState<Record<string, number>>({});
const [resultPrompt, setResultPrompt] = useState('');
const [activeGenerationPrompt, setActiveGenerationPrompt] = useState('');
@@ -496,12 +497,12 @@ export function TextToImagePanel() {
const runSingleTask = async (taskId: string, index: number) => {
try {
const runJob = (payload: Record<string, unknown>) => runGenerationJob<{ images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; error?: string; creditsCost?: number; creditsBalance?: number }>(
const runJob = (payload: Record<string, unknown>) => runGenerationJob<{ images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; dimensions?: Record<string, { width: number; height: number }>; error?: string; creditsCost?: number; creditsBalance?: number }>(
'image',
payload,
{ timeoutMs: 900_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status }) },
);
let data: { images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; error?: string; creditsCost?: number; creditsBalance?: number };
let data: { images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; dimensions?: Record<string, { width: number; height: number }>; error?: string; creditsCost?: number; creditsBalance?: number };
try {
data = await runJob({ ...requestBodyBase, count: 1, clientRequestId: `${batchId}-${index + 1}`, stream: true });
} catch (error) {
@@ -536,6 +537,7 @@ export function TextToImagePanel() {
const creditsPerImage = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, taskImages.length)) : 0;
setResults(prev => [...taskImages, ...prev]);
setResultThumbnails(prev => ({ ...prev, ...thumbnails }));
if (data.dimensions) setResultDimensions(prev => ({ ...prev, ...data.dimensions }));
if (creditsPerImage > 0) {
setResultCredits(prev => Object.fromEntries([
...Object.entries(prev),
@@ -551,6 +553,8 @@ export function TextToImagePanel() {
addRecord({
type: 'image', url, prompt: submittedPrompt,
thumbnailUrl: thumbnails[url],
width: data.dimensions?.[url]?.width,
height: data.dimensions?.[url]?.height,
negativePrompt: negativePrompt.trim() || undefined,
model: selectedModel,
modelLabel: getCurrentModelLabel(),
@@ -629,13 +633,15 @@ export function TextToImagePanel() {
modelLabel: getCurrentModelLabel(),
creditsCost: resultCredits[url] || 0,
thumbnailUrl: resultThumbnails[url],
width: resultDimensions[url]?.width,
height: resultDimensions[url]?.height,
params: {
creationMode: 'text2img',
styleLabel: selectedStylePreset?.label,
},
});
toast.success('已分享到画廊');
}, [prompt, selectedModel, selectedStylePreset, getCurrentModelLabel, resultCredits, resultThumbnails]);
}, [prompt, selectedModel, selectedStylePreset, getCurrentModelLabel, resultCredits, resultDimensions, resultThumbnails]);
return (
<>

View File

@@ -244,6 +244,9 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
negativePrompt: record.negativePrompt,
referenceImage: record.referenceImage,
referenceImages: record.referenceImages,
thumbnailUrl: record.thumbnailUrl,
width: record.width,
height: record.height,
params: record.params,
});
setIsPublished(true);
@@ -589,7 +592,13 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
)}
{/* Fullscreen button */}
{!isPlaceholderUrl && record.type === 'image' && (
<ImageMetadataBadge src={record.url} className="absolute right-3 top-3 z-20" />
<ImageMetadataBadge
src={record.url}
width={record.width}
height={record.height}
loadMetadata={false}
className="absolute right-3 top-3 z-20"
/>
)}
{!isPlaceholderUrl && record.type === 'image' && (
<button

View File

@@ -10,6 +10,8 @@ export interface CreationRecord {
type: 'image' | 'video' | 'reverse-prompt';
url: string; // 图片/视频地址(可以是 data URL 或远程 URL
thumbnailUrl?: string;
width?: number | null;
height?: number | null;
prompt: string; // 用户输入的提示词
negativePrompt?: string;
model: string; // 模型ID如 doubao-seedream-5-0-260128 或 custom:xxx
@@ -30,6 +32,8 @@ export interface PublishedWork {
type: 'image' | 'video';
url: string;
thumbnailUrl?: string;
width?: number | null;
height?: number | null;
prompt: string;
negativePrompt?: string;
model: string;
@@ -396,6 +400,8 @@ export async function shareToGallery(options: {
params?: Record<string, unknown>;
creditsCost?: number;
thumbnailUrl?: string;
width?: number | null;
height?: number | null;
}): Promise<void> {
// Save to localStorage for immediate local display
const works = loadPublished();
@@ -407,6 +413,8 @@ export async function shareToGallery(options: {
type: options.type,
url: options.url,
thumbnailUrl: options.thumbnailUrl,
width: options.width,
height: options.height,
prompt: options.prompt || '',
negativePrompt: options.negativePrompt,
model: options.model || '',
@@ -440,6 +448,8 @@ export async function shareToGallery(options: {
negativePrompt: options.negativePrompt,
resultUrl: options.url,
thumbnailUrl: options.thumbnailUrl,
width: options.width,
height: options.height,
model: options.model,
modelLabel: options.modelLabel,
referenceImage: options.referenceImage,

View File

@@ -140,14 +140,16 @@ function getReferenceImages(record: CreationReuseSource, target: CreationReuseTa
.map(normalizeReferenceUrl);
if (normalized.length > 0) return [...new Set(normalized)];
if (useOutputAsReference && target === 'img2img' && record.url && !record.url.startsWith('data:') && !record.url.startsWith('[')) {
if (
useOutputAsReference
&& (target === 'img2img' || target === 'img2video')
&& record.url
&& !record.url.startsWith('data:')
&& !record.url.startsWith('[')
) {
return [normalizeReferenceUrl(record.url)];
}
if (useOutputAsReference && target === 'img2video' && record.thumbnailUrl && !record.thumbnailUrl.startsWith('data:') && !record.thumbnailUrl.startsWith('[')) {
return [normalizeReferenceUrl(record.thumbnailUrl)];
}
return [];
}