feat: support cancellable concurrent generation jobs
This commit is contained in:
@@ -77,8 +77,9 @@ All email sends route through `src/lib/email-service.ts`, which renders HTML and
|
||||
|
||||
| Method | Path | Auth | Source | Request | Response/Side Effects |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| POST | `/api/generation-jobs` | User | `src/app/api/generation-jobs/route.ts` | `{ type: "image"|"video"|"reverse-prompt", payload: {...} }` | Inserts `generation_jobs`, starts worker, increments selected image `styleLabel` usage, returns `202` with `jobId`, `status`, `estimateSeconds`, `eta`. System-default image/video jobs preflight the selected `system_api_configs` price plus existing queued/running system-default jobs for the same user and return `402` when the available balance is insufficient. Reverse-prompt now runs through the same job queue but does not deduct credits. |
|
||||
| GET | `/api/generation-jobs/[id]` | User/admin | `src/app/api/generation-jobs/[id]/route.ts` | Path UUID | Job status/result/error/progress. Owner or admin only. The create pages use this endpoint to resume jobs after refresh, auth change, or a new tab. |
|
||||
| POST | `/api/generation-jobs` | User | `src/app/api/generation-jobs/route.ts` | `{ type: "image"|"video"|"reverse-prompt", payload: {...} }` | Inserts `generation_jobs`, starts worker, increments selected image `styleLabel` usage, returns `202` with `jobId`, `status`, `estimateSeconds`, `eta`. System-default image/video jobs preflight the selected `system_api_configs` price plus existing queued/running system-default jobs for the same user and return `402` when the available balance is insufficient. Reverse-prompt now runs through the same job queue but does not deduct credits. Duplicate active jobs are deduped semantically while ignoring top-level `clientRequestId`, but users may submit a different task while another task is running. |
|
||||
| GET | `/api/generation-jobs/[id]` | User/admin | `src/app/api/generation-jobs/[id]/route.ts` | Path UUID | Job status/result/error/progress. Owner or admin only. Status may be `queued`, `running`, `succeeded`, `failed`, or `cancelled`. The create pages use this endpoint to resume jobs after refresh, auth change, or a new tab. |
|
||||
| PATCH | `/api/generation-jobs/[id]` | User/admin | `src/app/api/generation-jobs/[id]/route.ts` | Path UUID plus `{ action: "cancel" }` | Owner or admin can cancel a `queued`/`running` job. The route marks the row `cancelled`, clears payload/result, writes a cancellation progress payload and `finished_at`, and returns `{ success: true, cancelled: true }`. Workers re-check that the job is still `running` before charging credits, persisting history, or writing success/failure so late upstream responses cannot resurrect a cancelled job. |
|
||||
|
||||
## Admin Invitation Routes
|
||||
|
||||
@@ -111,7 +112,7 @@ Important generation helpers:
|
||||
| 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 and stale video thumbnails are lazily generated into local `thumbnails/gallery`. Video thumbnails prefer `ffmpeg-static` WEBP frame extraction and fall back to SVG only if extraction fails; SVG fallback profiles such as `video-svg-v1` and `video-fallback-svg-v2` stay replaceable and do not count as current. 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`. |
|
||||
| POST | `/api/gallery/publish` | User | `src/app/api/gallery/publish/route.ts`, `src/lib/gallery-publish-media.ts` | Work metadata, `resultUrl`, optional thumbnail/reference/model fields | Reuses stable `/api/local-storage/...` image and video originals instead of synchronously copying object-backed generated media during share. External image/video URLs are still copied into object-backed gallery storage before insertion. Existing image thumbnails are reused; gallery/history reads can lazily backfill missing or stale thumbnails. Video publish thumbnails prefer WEBP frame extraction through `ffmpeg-static`; a client-provided thumbnail is copied only after frame extraction fails. If media preparation fails, the route returns an error instead of inserting a public row that `/api/gallery` will filter out. Clients should show success and mark local history as shared only after this route returns 2xx. |
|
||||
| POST | `/api/gallery/publish` | User | `src/app/api/gallery/publish/route.ts`, `src/lib/gallery-publish-media.ts` | Work metadata, `resultUrl`, optional thumbnail/reference/model fields, optional `referenceImage`/`referenceImages` plus matching `params.referenceImage`/`params.referenceImages` | Reuses stable `/api/local-storage/...` image and video originals instead of synchronously copying object-backed generated media during share. External image/video URLs are still copied into object-backed gallery storage before insertion. For image-to-image and image-to-video shares, data URL or remote reference images are persisted under stable `gallery/references/...` local-storage URLs and stored in `works.params.referenceImage/referenceImages`; stable `/api/local-storage/...` references are reused as-is. The response includes `referenceImages` so clients can keep local published state aligned. Existing image thumbnails are reused; gallery/history reads can lazily backfill missing or stale thumbnails. Video publish thumbnails prefer WEBP frame extraction through `ffmpeg-static`; a client-provided thumbnail is copied only after frame extraction fails. If media preparation fails, the route returns an error instead of inserting a public row that `/api/gallery` will filter out. Clients should show success and mark local history as shared only after this route returns 2xx. |
|
||||
| GET | `/api/admin/gallery/works` | Admin | `src/app/api/admin/gallery/works/route.ts` | Query `q`, `type=all|image|video|text2img|img2img|text2video|img2video`, `page`, `pageSize`, legacy `limit`, `offset`, `sort` | Admin gallery-management list of public completed works with author email/nickname, prompt, media URL, thumbnail, `total`, `page`, `pageSize`, `totalPages`, legacy `nextOffset`, and `hasMore`. |
|
||||
| PUT | `/api/admin/gallery/prompt` | Admin | `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts` | `{ workId, prompt, emailSubject, emailBody, reasonKey }` | Sends the author notification email first, then updates `works.prompt` only after email success, and writes a platform log without storing full prompt text. Missing/invalid author email, unchanged prompt, non-public work, or email failure blocks the update. |
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| --- | --- | --- |
|
||||
| Create button does nothing | `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`, `src/lib/generation-job-client.ts` | Client validation, auth token, `/api/generation-jobs` POST response, UI disabled/loading state. |
|
||||
| Refreshing `/create` resets to the wrong creation tab | `src/app/create/page.tsx` | Active tab should persist in `miaojing:create-active-tab` and mirror to `/create?type=...`. Verify all creation tabs (`text2img`, `img2img`, `text2video`, `img2video`, `reversePrompt`) restore after refresh and query-param links still override storage. |
|
||||
| Create button stays disabled after a job finishes, or active job cards overflow horizontally | `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`, `src/components/create/generation-task-list.tsx` | Create panels intentionally disable the submit button while that entry has active tasks, and show `任务生成中` instead of inviting another submit. Multi-image generation should use the count control. Active job cards should render inside the results column with wrapping vertical growth, not outside the result area. |
|
||||
| Create button is disabled while another task is still running, or active job cards overflow horizontally | `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`, `src/components/create/generation-task-list.tsx` | Create panels should keep submit enabled whenever models are available so users can start a different task while previous tasks run. Identical in-flight submissions are still blocked by `activeSubmissionSignaturesRef`. Active job cards should render inside the results column with wrapping vertical growth, not outside the result area. |
|
||||
| User cannot cancel a queued/running generation task, or a cancelled task still writes history | `src/components/create/generation-task-list.tsx`, `src/lib/generation-job-client.ts`, `src/app/api/generation-jobs/[id]/route.ts`, `src/lib/generation-job-worker.ts`, create panel component | Task cards should pass `onCancelTask`, the client should call `cancelGenerationJob`, and `PATCH /api/generation-jobs/[id]` should set `status='cancelled'`. The worker must check the job is still `running` before charging credits, persisting history, or updating success/failure so late upstream responses do not resurrect cancelled jobs. |
|
||||
| Earlier completed image tasks disappear while later tasks are still running | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/generation-task-list.tsx` | The results column must not be a single `generating ? taskList : results` branch. Render active task cards and completed result cards together, and append each task's images as soon as that task succeeds instead of waiting for all submitted tasks to settle. |
|
||||
| One submitted generation shows two running cards, or current results show the same media twice while refreshed history has one row | `src/app/api/generation-jobs/route.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/components/create/generation-task-list.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` | Check production `generation_jobs` first. If there are two queued/running jobs whose payload only differs by `clientRequestId`, inspect the API semantic dedupe query and each create panel's `activeSubmissionSignaturesRef`. If there is only one job and one result URL, the duplicate is frontend recovery state, not backend creation. Locally submitted tasks use temporary ids before the server job id is known; recovery must treat both `jobId` and `payload.clientRequestId` as the same task identity, and result appenders should filter duplicate URLs so a recovery poll cannot add the same completed media twice. |
|
||||
| Job remains queued | `src/app/api/generation-jobs/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-job-runner.ts` | `processNextGenerationJob()` invoked, stale job handling, DB locks/status, internal base URL. |
|
||||
@@ -117,6 +118,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| History missing after generation or login/account switch | `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, create panel component | History POST, `works` insert, URL not data URL except reverse prompt placeholder, and `miaojing_auth_updated` triggers a fresh server fetch. Create panels now also recover queued/running jobs from `/api/generation-jobs` so a refresh or re-login can reattach the live task before it finishes. If the task card reappears after refresh but never turns into a result/error, inspect `src/components/create/use-generation-job-recovery.ts`; active-task state updates must not be part of the polling effect dependency list, or the recovery poller can be cancelled immediately after reattaching a job. |
|
||||
| Detail delete removes only local history, skips confirmation, or record reappears after refresh | `src/components/creation-detail-dialog.tsx`, `src/components/ui/alert-dialog.tsx`, `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, `src/components/profile/creation-history-tab.tsx` | The detail action is labeled `删除作品` and must open a confirmation dialog warning that deletion cannot be recovered. Logged-in deletion should call `DELETE /api/creation-history?id=...` first, then refresh local history from the server. Check bearer token availability and route ownership filter (`id` + `user_id`). |
|
||||
| Published work not in gallery or share to gallery is slow | `src/lib/creation-history-store.ts`, `src/lib/gallery-publish-media.ts`, `src/app/api/gallery/publish/route.ts`, `src/app/api/gallery/route.ts`, `src/app/gallery/page.tsx` | `is_public = true`, `status = completed`, stable `/api/local-storage/...` `result_url`, media copied/reused into gallery storage, and current filters. New generated `/api/local-storage/...` image/video URLs should use the publish fast path in `gallery-publish-media` and must not synchronously copy object-backed originals during share; external URLs still need copying and should fail the publish request if media preparation fails. Also check whether the browser marked the work shared before `/api/gallery/publish` returned success; local `published=true` without `publishedAt` is stale and should not block retry. For older incidents, inspect server logs/API status for publish failures that the previous frontend swallowed. |
|
||||
| 图生图/图生视频分享到画廊后看不到参考图,或复用/获取灵感没有带上参考图 | `src/components/create/image-to-image.tsx`, `src/components/create/image-to-video.tsx`, `src/app/api/gallery/publish/route.ts`, `src/lib/gallery-publish-media.ts`, `src/app/gallery/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx`, `src/lib/creation-reuse.ts` | The create panels should send `referenceImage`, `referenceImages`, `refImageCount`, and `referenceImageAnnotations` to `shareToGallery`. `/api/gallery/publish` should persist data URL or remote reference images into stable `/api/local-storage/gallery/references/...` URLs before storing them in `works.params`. Public gallery detail and inspiration detail may preview reference images but must not expose reference-image download actions; reuse drafts should prefer original `referenceImages` and only fall back to output media as reference when no references exist. |
|
||||
| Imported gallery images do not render after production data import | `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `src/lib/local-storage.ts`, `src/app/api/local-storage/[...path]/route.ts`, DB `works.result_url` | New exports should include `_media`; import should persist media through the active storage adapter. If using an older export without `_media`, DB rows alone cannot recreate missing `/api/local-storage/*` files. For object migration, run `pnpm run storage:sync-object -- --verify-only` before switching to `STORAGE_MODE=object`. |
|
||||
| Rainyun ROS bucket created but object storage still fails | `scripts/rainyun-ros-prepare.mjs`, `.env.local`, `src/lib/local-storage.ts`, `scripts/storage-sync-to-object.mjs`, `/api/health` | The Rainyun API link is control-plane bucket creation, not the media upload path. Verify `.env.local` has reviewed `OBJECT_STORAGE_BUCKET`, `OBJECT_STORAGE_ENDPOINT`, `OBJECT_STORAGE_ACCESS_KEY_ID`, `OBJECT_STORAGE_SECRET_ACCESS_KEY`, `OBJECT_STORAGE_FORCE_PATH_STYLE=true`, and `STORAGE_MODE=dual`; then run `/api/health` and `pnpm run storage:sync-object -- --dry-run`. |
|
||||
| Gallery delete does not remove public item | `src/app/api/gallery/route.ts`, admin UI route using it | DELETE unpublishes by setting `is_public = false`, not hard delete. |
|
||||
|
||||
@@ -50,16 +50,16 @@ Use this document to jump directly to code before broad searching.
|
||||
| Feature | Primary Files | Server/API Files |
|
||||
| --- | --- | --- |
|
||||
| Tab container | `src/app/create/page.tsx` | Owns the five creation tabs. Active tab is persisted in localStorage and mirrored to `/create?type=...`, so refreshes and shared links stay on text-to-image, image-to-image, text-to-video, image-to-video, or reverse-prompt. On phones the mode switch is the single fixed icon row below the navbar; the page title and duplicate text mode strip are hidden. Mobile layout classes in this page and `src/app/globals.css` turn the create center into a chat-style flow: text-to-image sorts history from oldest to newest and auto-scrolls to the latest work above the fixed composer, hides the empty result placeholder until the user submits a prompt, renders generating tasks as the newest prompt-plus-progress message, and uses `src/components/create/mobile-creation-composer.tsx` as the fixed bottom composer with compact labeled ratio/resolution/count controls, optional style strip that expands the composer upward, prompt input, and right send button. |
|
||||
| Text to image | `src/components/create/text-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.ts`. The create button is disabled while the current entry has active tasks and shows `任务生成中`; users should use the count control for multiple images instead of repeatedly pressing submit. Active jobs render through `src/components/create/generation-task-list.tsx` inside the results column. Model select items use `src/components/create/grouped-model-select-items.tsx` so admin global system models appear under `默认模型` and user-added keys appear under `自定义模型`. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-image works can fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and guidance into the form. The mobile conversation history should only mount on mobile viewports; CSS-hidden mobile history still runs image effects if mounted on desktop. |
|
||||
| Image to image | `src/components/create/image-to-image.tsx`, `src/components/create/reference-image-mention-controls.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/lib/reference-image-prompt.ts`. Reference thumbnails single-click into a bare image overlay, active jobs render through `src/components/create/generation-task-list.tsx`, and the create button is disabled while active tasks exist to avoid duplicate in-flight submissions. Model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. 图生图 removes `自动` from ratio/resolution/count controls, defaults count to `1`, and derives ratio from Yuanjie size labels or dimensions when the selected model hides the separate ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-image works can place reference images and fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and strength into the form. 多参考图会显示 `@参考图1` 等标签,提示词输入框输入 `@` 可选择参考图,提交时发送 `referenceImageAnnotations`,后端把 token 与上传顺序、文件名、尺寸写入上游 prompt。 |
|
||||
| Text to video | `src/components/create/text-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`, `src/components/create/use-generation-job-recovery.ts`. The create button is disabled while active tasks exist, active jobs render through `src/components/create/generation-task-list.tsx`, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-video works can fill prompt, negative prompt, model, ratio, duration, camera movement, and style. |
|
||||
| Image to video | `src/components/create/image-to-video.tsx`, `src/components/create/reference-image-mention-controls.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/lib/reference-image-prompt.ts`. Uploaded reference thumbnails single-click into the same bare image overlay used by image-to-image, active jobs render through `src/components/create/generation-task-list.tsx`, and the create button is disabled while active tasks exist. Model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-video works can place reference images and fill prompt, negative prompt, model, ratio, duration, and camera movement. 多参考图会显示 `@参考图1` 等标签,提示词输入框输入 `@` 可选择参考图,提交时发送 `referenceImageAnnotations`,后端把 token 与上传顺序、文件名、尺寸写入上游 prompt。 |
|
||||
| Text to image | `src/components/create/text-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.ts`. The create button remains available while other active tasks run; duplicate in-flight submissions are still blocked by `activeSubmissionSignaturesRef`. Active jobs render through `src/components/create/generation-task-list.tsx` inside the results column and expose a cancel action that calls `PATCH /api/generation-jobs/[id]`. Model select items use `src/components/create/grouped-model-select-items.tsx` so admin global system models appear under `默认模型` and user-added keys appear under `自定义模型`. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-image works can fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and guidance into the form. The mobile conversation history should only mount on mobile viewports; CSS-hidden mobile history still runs image effects if mounted on desktop. |
|
||||
| Image to image | `src/components/create/image-to-image.tsx`, `src/components/create/reference-image-mention-controls.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/lib/reference-image-prompt.ts`. Reference thumbnails single-click into a bare image overlay, active jobs render through `src/components/create/generation-task-list.tsx`, and the create button remains available while active tasks exist; identical in-flight submissions are still deduped. Model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. 图生图 removes `自动` from ratio/resolution/count controls, defaults count to `1`, and derives ratio from Yuanjie size labels or dimensions when the selected model hides the separate ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-image works can place reference images and fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and strength into the form. 多参考图会显示 `@参考图1` 等标签,提示词输入框输入 `@` 可选择参考图,提交时发送 `referenceImageAnnotations`,后端把 token 与上传顺序、文件名、尺寸写入上游 prompt;分享到画廊会携带所有参考图和标注。 |
|
||||
| Text to video | `src/components/create/text-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`, `src/components/create/use-generation-job-recovery.ts`. The create button remains available while active tasks exist, active jobs render through `src/components/create/generation-task-list.tsx`, running tasks can be cancelled, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-video works can fill prompt, negative prompt, model, ratio, duration, camera movement, and style. |
|
||||
| Image to video | `src/components/create/image-to-video.tsx`, `src/components/create/reference-image-mention-controls.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/lib/reference-image-prompt.ts`. Uploaded reference thumbnails single-click into the same bare image overlay used by image-to-image, active jobs render through `src/components/create/generation-task-list.tsx`, the create button remains available while active tasks exist, and running tasks can be cancelled. Model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-video works can place reference images and fill prompt, negative prompt, model, ratio, duration, and camera movement. 多参考图会显示 `@参考图1` 等标签,提示词输入框输入 `@` 可选择参考图,提交时发送 `referenceImageAnnotations`,后端把 token 与上传顺序、文件名、尺寸写入上游 prompt;分享到画廊会携带所有参考图和标注。 |
|
||||
| Reverse prompt | `src/components/create/reverse-prompt-panel.tsx` | `src/app/api/generate/reverse-prompt/route.ts`, `src/app/api/generate/suggest-prompt/route.ts`, `src/lib/generation-job-client.ts`, `src/components/create/use-generation-job-recovery.ts`. Reverse prompt now runs as a background job, survives refresh/auth change/tab switch, and writes the completed result back into the normal creation history flow instead of relying on an optimistic local-only row. |
|
||||
| Prompt textarea | `src/components/create/expandable-prompt-textarea.tsx` | Shared prompt input. |
|
||||
| Mobile creation composer | `src/components/create/mobile-creation-composer.tsx`, `src/app/globals.css` | Mobile-only fixed bottom composer used by text-to-image to match chat-style clients: top parameter strip with compact dropdown buttons for ratio/resolution/count, optional style strip, prompt input, and right send button. The mobile creation center uses one 16px UI font size across selected values, style chips, composer input, and conversation prompts. The mobile text-to-image parameter strip hides the `画面比例`/`分辨率`/`生成数量` labels and removes `自动` from ratio, resolution, and count choices, defaulting to explicit values instead. The mobile style strip shows only one horizontal row when collapsed and expands upward for search/more presets after tapping `展开`. Mode selection stays only in the sticky header tabs. Desktop creation forms remain the source for full advanced controls. |
|
||||
| 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. |
|
||||
| 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, exposes cancel buttons for normal active tasks, and image/video 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. 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. |
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const createPanels = [
|
||||
'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',
|
||||
];
|
||||
|
||||
await runTest('create panels allow a new different submission while another task is active', () => {
|
||||
for (const relativePath of createPanels) {
|
||||
const source = read(relativePath);
|
||||
assert.doesNotMatch(source, /disabled=\{!hasModels \|\| generating\}/, `${relativePath} should not disable submit only because active tasks exist`);
|
||||
assert.doesNotMatch(source, /任务生成中/, `${relativePath} should keep the submit action available while tasks are running`);
|
||||
assert.match(source, /activeSubmissionSignaturesRef\.current\.has\(submissionSignature\)/, `${relativePath} should still block the same in-flight submission`);
|
||||
}
|
||||
});
|
||||
|
||||
await runTest('generation job status supports user cancellation end to end', () => {
|
||||
assert.match(read('src/lib/generation-job-client.ts'), /'cancelled'/);
|
||||
assert.match(read('src/lib/generation-job-client.ts'), /cancelGenerationJob/);
|
||||
assert.match(read('src/components/create/generation-task-list.tsx'), /onCancelTask/);
|
||||
assert.match(read('src/components/create/generation-task-list.tsx'), /取消任务/);
|
||||
|
||||
const statusRoute = read('src/app/api/generation-jobs/[id]/route.ts');
|
||||
assert.match(statusRoute, /export async function (PATCH|DELETE)/);
|
||||
assert.match(statusRoute, /status = 'cancelled'/);
|
||||
|
||||
const worker = read('src/lib/generation-job-worker.ts');
|
||||
assert.match(worker, /isJobStillRunning/);
|
||||
assert.match(worker, /cancelled/);
|
||||
assert.match(worker, /skip/i);
|
||||
});
|
||||
|
||||
await runTest('image-to-image and image-to-video share reference images to gallery', () => {
|
||||
for (const relativePath of [
|
||||
'src/components/create/image-to-image.tsx',
|
||||
'src/components/create/image-to-video.tsx',
|
||||
]) {
|
||||
const source = read(relativePath);
|
||||
assert.match(source, /referenceImage:\s*refImages\[0\]\?\.dataUrl/, `${relativePath} should share the primary reference`);
|
||||
assert.match(source, /referenceImages:\s*refImages\.map\(img => img\.dataUrl\)/, `${relativePath} should share all references`);
|
||||
}
|
||||
});
|
||||
|
||||
await runTest('gallery publish persists reference images as stable local-storage URLs', () => {
|
||||
const publishRoute = read('src/app/api/gallery/publish/route.ts');
|
||||
const mediaHelper = read('src/lib/gallery-publish-media.ts');
|
||||
assert.match(mediaHelper, /resolveGalleryReferenceImages/);
|
||||
assert.match(mediaHelper, /gallery\/references/);
|
||||
assert.match(publishRoute, /resolveGalleryReferenceImages/);
|
||||
assert.match(publishRoute, /galleryReferenceImages/);
|
||||
});
|
||||
|
||||
await runTest('gallery detail shows reference images but does not expose reference downloads', () => {
|
||||
const source = read('src/app/gallery/page.tsx');
|
||||
assert.match(source, /getWorkReferenceImages/);
|
||||
assert.match(source, /参考图/);
|
||||
assert.match(source, /referencePreviewSrc/);
|
||||
assert.match(source, /disableContextMenu/);
|
||||
assert.match(source, /onContextMenu=\{[^}]*preventDefault/s);
|
||||
assert.doesNotMatch(source, /handleDownload\([^)]*reference/i);
|
||||
});
|
||||
|
||||
await runTest('inspiration reuse preserves original reference images when available', () => {
|
||||
const reuseSource = read('src/lib/creation-reuse.ts');
|
||||
assert.match(reuseSource, /explicitReferences/);
|
||||
assert.match(reuseSource, /useOutputAsReference/);
|
||||
|
||||
const inspirationSource = read('src/components/create/inspiration-gallery-dialog.tsx');
|
||||
assert.match(inspirationSource, /referenceImages/);
|
||||
assert.match(inspirationSource, /referencePreviewSrc/);
|
||||
assert.match(inspirationSource, /disableContextMenu/);
|
||||
assert.doesNotMatch(inspirationSource, /window\.open/);
|
||||
assert.match(inspirationSource, /buildCreationReuseDraft/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
@@ -5,8 +5,8 @@ import { markStaleRunningJobs } from '@/lib/generation-job-worker';
|
||||
import { ensureGenerationJobRuntimeSchema } from '@/lib/generation-job-estimates';
|
||||
import { writePlatformLog } from '@/lib/platform-logs';
|
||||
|
||||
const STATUSES = new Set(['queued', 'running', 'succeeded', 'failed']);
|
||||
const CLEANUP_STATUSES = new Set(['failed', 'succeeded']);
|
||||
const STATUSES = new Set(['queued', 'running', 'succeeded', 'failed', 'cancelled']);
|
||||
const CLEANUP_STATUSES = new Set(['failed', 'succeeded', 'cancelled']);
|
||||
|
||||
function intParam(value: string | null, fallback: number, min: number, max: number) {
|
||||
const parsed = Number.parseInt(value || '', 10);
|
||||
@@ -92,7 +92,7 @@ export async function DELETE(request: NextRequest) {
|
||||
|
||||
if (!CLEANUP_STATUSES.has(status)) {
|
||||
return NextResponse.json(
|
||||
{ error: '只允许清理失败或已完成任务' },
|
||||
{ error: '只允许清理失败、已完成或已取消任务' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export async function DELETE(request: NextRequest) {
|
||||
type: 'admin',
|
||||
level: 'warning',
|
||||
action: 'generation_jobs_cleanup',
|
||||
message: `管理员清理了${status === 'failed' ? '失败' : '已完成'}生成任务`,
|
||||
message: `管理员清理了${status === 'failed' ? '失败' : status === 'cancelled' ? '已取消' : '已完成'}生成任务`,
|
||||
targetType: 'generation_jobs',
|
||||
metadata: { status, olderThanDays, deleted: result.rowCount || 0 },
|
||||
request,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import { resolveGalleryPublishMedia } from '@/lib/gallery-publish-media';
|
||||
import { resolveGalleryPublishMedia, resolveGalleryReferenceImages } from '@/lib/gallery-publish-media';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -42,10 +42,15 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '发布用户不存在或已停用' }, { status: 403 });
|
||||
}
|
||||
|
||||
const hasReference = Boolean(body.referenceImage)
|
||||
|| (Array.isArray(body.referenceImages) && body.referenceImages.length > 0)
|
||||
|| (Array.isArray((params as Record<string, unknown> | undefined)?.referenceImages) && ((params as Record<string, unknown>).referenceImages as unknown[]).length > 0);
|
||||
const explicitMode = (params as Record<string, unknown> | undefined)?.creationMode || body.creationMode;
|
||||
const paramsRecord = (params as Record<string, unknown> | undefined) || {};
|
||||
const referenceInput = [
|
||||
body.referenceImage,
|
||||
...(Array.isArray(body.referenceImages) ? body.referenceImages : []),
|
||||
paramsRecord.referenceImage,
|
||||
...(Array.isArray(paramsRecord.referenceImages) ? paramsRecord.referenceImages : []),
|
||||
].filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
|
||||
const hasReference = referenceInput.length > 0;
|
||||
const explicitMode = paramsRecord.creationMode || body.creationMode;
|
||||
const workType = explicitMode === 'text2img' || explicitMode === 'img2img' || explicitMode === 'text2video' || explicitMode === 'img2video'
|
||||
? explicitMode
|
||||
: type === 'video' ? (hasReference ? 'img2video' : 'text2video')
|
||||
@@ -70,6 +75,13 @@ export async function POST(request: NextRequest) {
|
||||
console.warn('[gallery/publish] prepare gallery media failed:', copyError);
|
||||
return NextResponse.json({ error: '发布作品媒体处理失败,请重试' }, { status: 502 });
|
||||
}
|
||||
let galleryReferenceImages: string[] = [];
|
||||
try {
|
||||
galleryReferenceImages = await resolveGalleryReferenceImages(referenceInput);
|
||||
} catch (referenceError) {
|
||||
console.warn('[gallery/publish] prepare gallery reference images failed:', referenceError);
|
||||
return NextResponse.json({ error: '发布参考图处理失败,请重试' }, { status: 502 });
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO works (id, user_id, type, title, prompt, negative_prompt, result_url, thumbnail_url, width, height, duration, is_public, likes_count, credits_cost, status, params)
|
||||
@@ -88,16 +100,16 @@ export async function POST(request: NextRequest) {
|
||||
duration || null,
|
||||
creditsCost || 0,
|
||||
JSON.stringify({
|
||||
...((params as Record<string, unknown>) || {}),
|
||||
model,
|
||||
modelLabel,
|
||||
referenceImage: body.referenceImage || undefined,
|
||||
referenceImages: body.referenceImages || undefined,
|
||||
}),
|
||||
...paramsRecord,
|
||||
model,
|
||||
modelLabel,
|
||||
referenceImage: galleryReferenceImages[0],
|
||||
referenceImages: galleryReferenceImages.length > 0 ? galleryReferenceImages : undefined,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, workId: id, resultUrl: galleryResultUrl });
|
||||
return NextResponse.json({ success: true, workId: id, resultUrl: galleryResultUrl, referenceImages: galleryReferenceImages });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
@@ -108,3 +108,94 @@ export async function GET(
|
||||
return NextResponse.json({ error: '查询生成任务失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const user = await getAuthenticatedUser(request);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
if (!UUID_REGEX.test(id)) {
|
||||
return NextResponse.json({ error: '任务ID格式无效' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
if (body.action && body.action !== 'cancel') {
|
||||
return NextResponse.json({ error: '不支持的任务操作' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureGenerationJobRuntimeSchema(client);
|
||||
const result = await client.query(
|
||||
`UPDATE generation_jobs
|
||||
SET status = 'cancelled',
|
||||
error = '用户已取消任务',
|
||||
payload = '{}'::jsonb,
|
||||
progress = COALESCE(progress, '{}'::jsonb) || $4::jsonb,
|
||||
finished_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND (user_id = $2 OR $3 = true)
|
||||
AND status IN ('queued', 'running')
|
||||
RETURNING id, type, status, result, error, provider, model_name, api_url, progress,
|
||||
created_at, started_at, finished_at, updated_at,
|
||||
CASE
|
||||
WHEN started_at IS NOT NULL
|
||||
THEN FLOOR(EXTRACT(EPOCH FROM (COALESCE(finished_at, NOW()) - started_at)))::int
|
||||
ELSE 0
|
||||
END AS elapsed_seconds`,
|
||||
[
|
||||
id,
|
||||
user.userId,
|
||||
user.role === 'admin' || user.role === 'enterprise_admin',
|
||||
JSON.stringify({
|
||||
percent: 100,
|
||||
message: '任务已取消',
|
||||
cancelled: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
const existing = await client.query(
|
||||
`SELECT id, type, status, result, error, provider, model_name, api_url, progress,
|
||||
created_at, started_at, finished_at, updated_at,
|
||||
CASE
|
||||
WHEN started_at IS NOT NULL
|
||||
THEN FLOOR(EXTRACT(EPOCH FROM (COALESCE(finished_at, NOW()) - started_at)))::int
|
||||
ELSE 0
|
||||
END AS elapsed_seconds
|
||||
FROM generation_jobs
|
||||
WHERE id = $1
|
||||
AND (user_id = $2 OR $3 = true)
|
||||
LIMIT 1`,
|
||||
[id, user.userId, user.role === 'admin' || user.role === 'enterprise_admin'],
|
||||
);
|
||||
if (existing.rows.length === 0) {
|
||||
return NextResponse.json({ error: '任务不存在' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({
|
||||
...existing.rows[0],
|
||||
jobId: existing.rows[0].id,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...result.rows[0],
|
||||
jobId: result.rows[0].id,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[generation-jobs] PATCH error:', err);
|
||||
return NextResponse.json({ error: '取消生成任务失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,6 +637,7 @@ export default function GalleryPage() {
|
||||
const [activeVideoWorkId, setActiveVideoWorkId] = useState<string | null>(null);
|
||||
const [fullscreenSrc, setFullscreenSrc] = useState<string | null>(null);
|
||||
const [fullscreenFallbackSrc, setFullscreenFallbackSrc] = useState<string | null>(null);
|
||||
const [referencePreviewSrc, setReferencePreviewSrc] = useState<string | null>(null);
|
||||
const [sortBy, setSortBy] = useState<'newest' | 'popular'>('newest');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
|
||||
@@ -1383,22 +1384,17 @@ export default function GalleryPage() {
|
||||
src={url}
|
||||
alt={`参考图 ${index + 1}`}
|
||||
className="aspect-square w-full cursor-zoom-in object-cover"
|
||||
onClick={() => openFullscreenPreview(url)}
|
||||
onContextMenu={(event) => openImageMenu(event, url)}
|
||||
onClick={() => setReferencePreviewSrc(url)}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-end gap-1 bg-black/35 p-1 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/90 text-black"
|
||||
onClick={() => openFullscreenPreview(url)}
|
||||
onClick={() => setReferencePreviewSrc(url)}
|
||||
title="查看参考图"
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/90 text-black"
|
||||
onClick={(event) => handleDownload(url, `miaojing-reference-${selectedWork.id}-${index + 1}.${getImageDownloadExtension(url)}`, event)}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1509,6 +1505,14 @@ export default function GalleryPage() {
|
||||
setFullscreenFallbackSrc(null);
|
||||
}}
|
||||
/>
|
||||
<FullscreenPreview
|
||||
src={referencePreviewSrc || ''}
|
||||
fallbackSrc={null}
|
||||
alt="参考图预览"
|
||||
open={!!referencePreviewSrc}
|
||||
onClose={() => setReferencePreviewSrc(null)}
|
||||
disableContextMenu
|
||||
/>
|
||||
{ImageActionsContextMenu}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Loader2, RefreshCcw, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type JobStatus = 'all' | 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
type JobStatus = 'all' | 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
|
||||
|
||||
interface GenerationJob {
|
||||
id: string;
|
||||
@@ -33,6 +33,7 @@ const STATUS_LABELS: Record<JobStatus, string> = {
|
||||
running: '执行中',
|
||||
succeeded: '已完成',
|
||||
failed: '已失败',
|
||||
cancelled: '已取消',
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<Exclude<JobStatus, 'all'>, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
@@ -40,6 +41,7 @@ const STATUS_BADGE: Record<Exclude<JobStatus, 'all'>, 'default' | 'secondary' |
|
||||
running: 'outline',
|
||||
succeeded: 'default',
|
||||
failed: 'destructive',
|
||||
cancelled: 'secondary',
|
||||
};
|
||||
|
||||
function formatTime(value: string | null) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { GenerationLoadingPanel } from '@/components/create/generation-loading-panel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { GenerationJobStatus } from '@/lib/generation-job-client';
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { AlertTriangle, Loader2, X } from 'lucide-react';
|
||||
|
||||
export type ActiveGenerationTask = {
|
||||
id: string;
|
||||
@@ -24,6 +24,7 @@ type GenerationTaskListProps = {
|
||||
tasks: ActiveGenerationTask[];
|
||||
onConfirmSync?: (taskId: string) => void;
|
||||
onCancelSync?: (taskId: string) => void;
|
||||
onCancelTask?: (taskId: string) => void;
|
||||
};
|
||||
|
||||
function TaskContent({
|
||||
@@ -32,12 +33,14 @@ function TaskContent({
|
||||
className = '',
|
||||
onConfirmSync,
|
||||
onCancelSync,
|
||||
onCancelTask,
|
||||
}: {
|
||||
task: ActiveGenerationTask;
|
||||
title: string;
|
||||
className?: string;
|
||||
onConfirmSync?: (taskId: string) => void;
|
||||
onCancelSync?: (taskId: string) => void;
|
||||
onCancelTask?: (taskId: string) => void;
|
||||
}) {
|
||||
if (task.syncConfirmation) {
|
||||
return (
|
||||
@@ -78,18 +81,31 @@ function TaskContent({
|
||||
}
|
||||
|
||||
return (
|
||||
<GenerationLoadingPanel
|
||||
startedAt={task.startedAt}
|
||||
estimateSeconds={task.estimateSeconds}
|
||||
jobStatus={task.jobStatus}
|
||||
finalCountdownSeconds={task.finalCountdownSeconds}
|
||||
title={title}
|
||||
className={className}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-3 top-3 z-20 h-8 gap-1.5 rounded-full border border-white/12 bg-black/35 px-3 text-xs text-white shadow-lg backdrop-blur-md hover:bg-black/55 hover:text-white light:border-amber-900/12 light:bg-white/70 light:text-foreground light:hover:bg-white/90"
|
||||
onClick={() => onCancelTask?.(task.id)}
|
||||
title="取消任务"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
取消任务
|
||||
</Button>
|
||||
<GenerationLoadingPanel
|
||||
startedAt={task.startedAt}
|
||||
estimateSeconds={task.estimateSeconds}
|
||||
jobStatus={task.jobStatus}
|
||||
finalCountdownSeconds={task.finalCountdownSeconds}
|
||||
title={title}
|
||||
className={className}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GenerationTaskList({ tasks, onConfirmSync, onCancelSync }: GenerationTaskListProps) {
|
||||
export function GenerationTaskList({ tasks, onConfirmSync, onCancelSync, onCancelTask }: GenerationTaskListProps) {
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
if (tasks.length === 1) {
|
||||
@@ -102,6 +118,7 @@ export function GenerationTaskList({ tasks, onConfirmSync, onCancelSync }: Gener
|
||||
title={task.title}
|
||||
onConfirmSync={onConfirmSync}
|
||||
onCancelSync={onCancelSync}
|
||||
onCancelTask={onCancelTask}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -127,6 +144,7 @@ export function GenerationTaskList({ tasks, onConfirmSync, onCancelSync }: Gener
|
||||
className="min-h-[260px] px-5 py-10"
|
||||
onConfirmSync={onConfirmSync}
|
||||
onCancelSync={onCancelSync}
|
||||
onCancelTask={onCancelTask}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -33,7 +33,7 @@ import { GroupedModelSelectItems } from '@/components/create/grouped-model-selec
|
||||
import { Sparkles, Loader2, Download, Upload, Wand2, Image as ImageIcon, History, ChevronDown, ChevronUp, Plus, X, KeyRound, Share2 } from 'lucide-react';
|
||||
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
|
||||
import { downloadFile, getImageDownloadExtension } from '@/lib/utils';
|
||||
import { GenerationJobStillRunningError, runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
||||
import { cancelGenerationJob, GenerationJobCancelledError, GenerationJobStillRunningError, runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
||||
import { toast } from 'sonner';
|
||||
import Link from 'next/link';
|
||||
import { BareImagePreview, ImageLightbox } from '@/components/lightbox';
|
||||
@@ -135,6 +135,7 @@ export function ImageToImagePanel() {
|
||||
const [optimizing, setOptimizing] = useState(false);
|
||||
const [inspirationOpen, setInspirationOpen] = useState(false);
|
||||
const activeSubmissionSignaturesRef = useRef(new Set<string>());
|
||||
const cancelledTaskIdsRef = useRef(new Set<string>());
|
||||
const syncConfirmationResolversRef = useRef(new Map<string, (confirmed: boolean) => void>());
|
||||
const generating = activeTasks.length > 0;
|
||||
const activeJobIds = useMemo(
|
||||
@@ -490,6 +491,26 @@ export function ImageToImagePanel() {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
}, []);
|
||||
|
||||
const handleCancelTask = useCallback((taskId: string) => {
|
||||
const task = activeTasks.find(item => item.id === taskId);
|
||||
cancelledTaskIdsRef.current.add(taskId);
|
||||
if (task?.clientRequestId) cancelledTaskIdsRef.current.add(task.clientRequestId);
|
||||
if (task?.jobId) cancelledTaskIdsRef.current.add(task.jobId);
|
||||
const resolve = syncConfirmationResolversRef.current.get(taskId);
|
||||
if (resolve) {
|
||||
syncConfirmationResolversRef.current.delete(taskId);
|
||||
resolve(false);
|
||||
}
|
||||
removeActiveTask(taskId);
|
||||
if (!task?.jobId) {
|
||||
toast.success('已取消任务');
|
||||
return;
|
||||
}
|
||||
void cancelGenerationJob(task.jobId)
|
||||
.then(() => toast.success('已取消任务'))
|
||||
.catch(error => toast.error(error instanceof Error ? error.message : '取消任务失败'));
|
||||
}, [activeTasks, removeActiveTask]);
|
||||
|
||||
useGenerationJobRecovery({
|
||||
types: ['image'],
|
||||
knownJobIds: activeJobIds,
|
||||
@@ -526,6 +547,7 @@ export function ImageToImagePanel() {
|
||||
},
|
||||
onTaskFailed: (taskId, error) => {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
if (error === '任务已取消') return;
|
||||
setGenerationError(createGenerationError(error));
|
||||
},
|
||||
});
|
||||
@@ -646,7 +668,17 @@ export function ImageToImagePanel() {
|
||||
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, jobId: status.jobId || undefined }) },
|
||||
{
|
||||
timeoutMs: 900_000,
|
||||
onStatus: (status: GenerationJobStatus) => {
|
||||
const statusJobId = status.jobId || status.id;
|
||||
updateActiveTask(taskId, { jobStatus: status, jobId: statusJobId || undefined });
|
||||
if (statusJobId && cancelledTaskIdsRef.current.has(taskId)) {
|
||||
cancelledTaskIdsRef.current.add(statusJobId);
|
||||
void cancelGenerationJob(statusJobId).catch(() => undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
let data: { images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; dimensions?: Record<string, { width: number; height: number }>; error?: string; creditsCost?: number; creditsBalance?: number };
|
||||
try {
|
||||
@@ -670,6 +702,7 @@ export function ImageToImagePanel() {
|
||||
});
|
||||
}
|
||||
await runGenerationFinalCountdown((seconds) => updateActiveTask(taskId, { finalCountdownSeconds: seconds }), 3);
|
||||
if (cancelledTaskIdsRef.current.has(taskId)) return;
|
||||
if (data.images && data.images.length > 0) {
|
||||
const thumbnails = Object.fromEntries(data.images.map((url, imageIndex) => [
|
||||
url,
|
||||
@@ -726,6 +759,9 @@ export function ImageToImagePanel() {
|
||||
setGenerationError(null);
|
||||
removeActiveTask(taskId);
|
||||
toast.info('生成任务仍在执行,可稍后在创作历史中查看');
|
||||
} else if (err instanceof GenerationJobCancelledError) {
|
||||
setGenerationError(null);
|
||||
toast.info('已取消任务');
|
||||
} else if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
setGenerationError(createGenerationError('请求超时,请尝试减少生成数量或降低分辨率'));
|
||||
} else {
|
||||
@@ -761,16 +797,22 @@ export function ImageToImagePanel() {
|
||||
thumbnailUrl: resultThumbnails[url],
|
||||
width: resultDimensions[url]?.width,
|
||||
height: resultDimensions[url]?.height,
|
||||
referenceImage: refImages[0]?.dataUrl,
|
||||
referenceImages: refImages.map(img => img.dataUrl),
|
||||
params: {
|
||||
creationMode: 'img2img',
|
||||
styleLabel: selectedStylePreset?.label,
|
||||
referenceImage: refImages[0]?.dataUrl,
|
||||
referenceImages: refImages.map(img => img.dataUrl),
|
||||
refImageCount: refImages.length,
|
||||
referenceImageAnnotations: buildReferenceImageAnnotations(refImages),
|
||||
},
|
||||
});
|
||||
toast.success('已分享到画廊');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '分享失败,请重试');
|
||||
}
|
||||
}, [prompt, selectedModel, selectedStylePreset, getCurrentModelLabel, resultCredits, resultDimensions, resultThumbnails]);
|
||||
}, [prompt, selectedModel, selectedStylePreset, getCurrentModelLabel, resultCredits, resultDimensions, resultThumbnails, refImages]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -976,15 +1018,15 @@ export function ImageToImagePanel() {
|
||||
</div>
|
||||
|
||||
{/* Generate */}
|
||||
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels || generating}>
|
||||
{generating ? (<><Loader2 className="h-4 w-4 animate-spin" />任务生成中</>) : (<><Sparkles className="h-4 w-4" />生成图片</>)}
|
||||
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels}>
|
||||
<Sparkles className="h-4 w-4" />生成图片
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right: Results + History */}
|
||||
<div className="create-chat-thread min-w-0 space-y-4">
|
||||
{generating && (
|
||||
<GenerationTaskList tasks={activeTasks} onConfirmSync={handleConfirmSync} onCancelSync={handleCancelSync} />
|
||||
<GenerationTaskList tasks={activeTasks} onConfirmSync={handleConfirmSync} onCancelSync={handleCancelSync} onCancelTask={handleCancelTask} />
|
||||
)}
|
||||
{!generating && generationError && (
|
||||
<GenerationErrorPanel error={generationError} />
|
||||
|
||||
@@ -24,7 +24,7 @@ import { ensureSelectedOption, getVideoCapabilityOptions, keepSelectedOptionVisi
|
||||
import { Sparkles, Loader2, Download, Upload, Wand2, Film, History, ChevronDown, ChevronUp, Plus, X, KeyRound, Share2 } from 'lucide-react';
|
||||
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
|
||||
import { triggerDownloadFile } from '@/lib/utils';
|
||||
import { runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
||||
import { cancelGenerationJob, GenerationJobCancelledError, runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
||||
import { toast } from 'sonner';
|
||||
import Link from 'next/link';
|
||||
import { CreationDetailDialog } from '@/components/creation-detail-dialog';
|
||||
@@ -77,6 +77,7 @@ export function ImageToVideoPanel() {
|
||||
const [inspirationOpen, setInspirationOpen] = useState(false);
|
||||
const [referencePreviewSrc, setReferencePreviewSrc] = useState<string | null>(null);
|
||||
const activeSubmissionSignaturesRef = useRef(new Set<string>());
|
||||
const cancelledTaskIdsRef = useRef(new Set<string>());
|
||||
const generating = activeTasks.length > 0;
|
||||
const activeJobIds = useMemo(
|
||||
() => activeTasks.flatMap(task => [task.jobId, task.clientRequestId, task.id]).filter((id): id is string => Boolean(id)),
|
||||
@@ -371,6 +372,21 @@ export function ImageToVideoPanel() {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
}, []);
|
||||
|
||||
const handleCancelTask = useCallback((taskId: string) => {
|
||||
const task = activeTasks.find(item => item.id === taskId);
|
||||
cancelledTaskIdsRef.current.add(taskId);
|
||||
if (task?.clientRequestId) cancelledTaskIdsRef.current.add(task.clientRequestId);
|
||||
if (task?.jobId) cancelledTaskIdsRef.current.add(task.jobId);
|
||||
removeActiveTask(taskId);
|
||||
if (!task?.jobId) {
|
||||
toast.success('已取消任务');
|
||||
return;
|
||||
}
|
||||
void cancelGenerationJob(task.jobId)
|
||||
.then(() => toast.success('已取消任务'))
|
||||
.catch(error => toast.error(error instanceof Error ? error.message : '取消任务失败'));
|
||||
}, [activeTasks, removeActiveTask]);
|
||||
|
||||
useGenerationJobRecovery({
|
||||
types: ['video'],
|
||||
knownJobIds: activeJobIds,
|
||||
@@ -415,6 +431,7 @@ export function ImageToVideoPanel() {
|
||||
},
|
||||
onTaskFailed: (taskId, error) => {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
if (error === '任务已取消') return;
|
||||
setGenerationError(createGenerationError(error));
|
||||
},
|
||||
});
|
||||
@@ -485,9 +502,20 @@ export function ImageToVideoPanel() {
|
||||
const data = await runGenerationJob<{ videos?: string[]; error?: string; creditsCost?: number; creditsBalance?: number }>(
|
||||
'video',
|
||||
requestBody,
|
||||
{ timeoutMs: 600_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status, jobId: status.jobId || undefined }) },
|
||||
{
|
||||
timeoutMs: 600_000,
|
||||
onStatus: (status: GenerationJobStatus) => {
|
||||
const statusJobId = status.jobId || status.id;
|
||||
updateActiveTask(taskId, { jobStatus: status, jobId: statusJobId || undefined });
|
||||
if (statusJobId && cancelledTaskIdsRef.current.has(taskId)) {
|
||||
cancelledTaskIdsRef.current.add(statusJobId);
|
||||
void cancelGenerationJob(statusJobId).catch(() => undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
await runGenerationFinalCountdown((seconds) => updateActiveTask(taskId, { finalCountdownSeconds: seconds }), 3);
|
||||
if (cancelledTaskIdsRef.current.has(taskId)) return;
|
||||
if (data.videos && data.videos.length > 0) {
|
||||
setResults(prev => [...data.videos!.filter(url => !prev.includes(url)), ...prev]);
|
||||
setGenerationError(null);
|
||||
@@ -521,8 +549,11 @@ export function ImageToVideoPanel() {
|
||||
setGenerationError(createGenerationError(data.error || '视频生成失败'));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
setGenerationError(createGenerationError('请求超时,视频生成可能需要更长时间'));
|
||||
if (err instanceof GenerationJobCancelledError) {
|
||||
setGenerationError(null);
|
||||
toast.info('已取消任务');
|
||||
} else if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
setGenerationError(createGenerationError('请求超时,视频生成可能需要更长时间'));
|
||||
} else {
|
||||
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));
|
||||
}
|
||||
@@ -550,12 +581,24 @@ export function ImageToVideoPanel() {
|
||||
prompt: prompt.trim(),
|
||||
model: selectedModel,
|
||||
modelLabel: getCurrentModelLabel(),
|
||||
referenceImage: refImages[0]?.dataUrl,
|
||||
referenceImages: refImages.map(img => img.dataUrl),
|
||||
params: {
|
||||
creationMode: 'img2video',
|
||||
aspectRatio,
|
||||
duration,
|
||||
cameraMovement,
|
||||
referenceImage: refImages[0]?.dataUrl,
|
||||
referenceImages: refImages.map(img => img.dataUrl),
|
||||
refImageCount: refImages.length,
|
||||
referenceImageAnnotations: buildReferenceImageAnnotations(refImages),
|
||||
},
|
||||
});
|
||||
toast.success('已分享到画廊');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '分享失败,请重试');
|
||||
}
|
||||
}, [prompt, selectedModel, getCurrentModelLabel]);
|
||||
}, [prompt, selectedModel, getCurrentModelLabel, refImages, aspectRatio, duration, cameraMovement]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -743,36 +786,38 @@ export function ImageToVideoPanel() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels || generating}>
|
||||
{generating ? (<><Loader2 className="h-4 w-4 animate-spin" />任务生成中</>) : (<><Sparkles className="h-4 w-4" />生成视频</>)}
|
||||
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels}>
|
||||
<Sparkles className="h-4 w-4" />生成视频
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right: Results + History */}
|
||||
<div className="create-chat-thread min-w-0 space-y-4">
|
||||
{generating ? (
|
||||
<GenerationTaskList tasks={activeTasks} />
|
||||
) : generationError ? (
|
||||
{generating && (
|
||||
<GenerationTaskList tasks={activeTasks} onCancelTask={handleCancelTask} />
|
||||
)}
|
||||
{!generating && generationError && (
|
||||
<GenerationErrorPanel error={generationError} />
|
||||
) : results.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium"><Film className="h-4 w-4" />生成结果</div>
|
||||
{results.map((url, i) => (
|
||||
)}
|
||||
{results.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium"><Film className="h-4 w-4" />生成结果</div>
|
||||
{results.map((url, i) => (
|
||||
<div key={i} className="liquid-glass-soft overflow-hidden rounded-2xl">
|
||||
<video src={url} controls className="w-full" />
|
||||
<div className="p-2 flex justify-end gap-2">
|
||||
<Button size="sm" variant="outline" className="gap-1" onClick={() => handleShareToGallery(url)}><Share2 className="h-3.5 w-3.5" />分享</Button>
|
||||
<Button size="sm" variant="outline" className="gap-1" onClick={() => handleDownload(url, i)}><Download className="h-3.5 w-3.5" />下载</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="liquid-glass flex min-h-[300px] flex-col items-center justify-center rounded-2xl border-dashed py-24 text-muted-foreground">
|
||||
<Film className="h-14 w-14 mb-3 opacity-20" />
|
||||
<p className="text-sm">生成结果将显示在这里</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !generating && !generationError ? (
|
||||
<div className="liquid-glass flex min-h-[300px] flex-col items-center justify-center rounded-2xl border-dashed py-24 text-muted-foreground">
|
||||
<Film className="h-14 w-14 mb-3 opacity-20" />
|
||||
<p className="text-sm">生成结果将显示在这里</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{videoHistory.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { ArrowLeft, Image as ImageIcon, Loader2, Search, Sparkles, X } from 'lucide-react';
|
||||
import { buildCreationReuseDraft, type CreationReuseTarget, writeCreationReuseDraft } from '@/lib/creation-reuse';
|
||||
import { useImageActionsContextMenu } from '@/components/image-actions-context-menu';
|
||||
import { FullscreenPreview } from '@/components/fullscreen-preview';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type InspirationWork = {
|
||||
@@ -58,6 +59,19 @@ function isVideoWork(work: InspirationWork): boolean {
|
||||
return mode === 'text2video' || mode === 'img2video';
|
||||
}
|
||||
|
||||
function getInspirationReferenceImages(work: InspirationWork): string[] {
|
||||
const fromArray = Array.isArray(work.referenceImages) ? work.referenceImages : [];
|
||||
const fromParams = Array.isArray(work.params?.referenceImages)
|
||||
? (work.params.referenceImages as unknown[]).filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
: [];
|
||||
const single = typeof work.referenceImage === 'string' && work.referenceImage.trim()
|
||||
? [work.referenceImage]
|
||||
: typeof work.params?.referenceImage === 'string' && work.params.referenceImage.trim()
|
||||
? [work.params.referenceImage]
|
||||
: [];
|
||||
return [...new Set([...single, ...fromArray, ...fromParams].filter(url => url && !url.startsWith('data:') && !url.startsWith('[')))];
|
||||
}
|
||||
|
||||
function formatDate(iso?: string | null): string {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
@@ -81,6 +95,7 @@ export function InspirationGalleryDialog({
|
||||
const [selectedWork, setSelectedWork] = useState<InspirationWork | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [referencePreviewSrc, setReferencePreviewSrc] = useState<string | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchCollapseTimerRef = useRef<number | null>(null);
|
||||
const { openImageMenu, ImageActionsContextMenu } = useImageActionsContextMenu();
|
||||
@@ -122,6 +137,7 @@ export function InspirationGalleryDialog({
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedWork(null);
|
||||
setReferencePreviewSrc(null);
|
||||
setSearchOpen(false);
|
||||
setSearchQuery('');
|
||||
clearSearchCollapseTimer();
|
||||
@@ -169,6 +185,11 @@ export function InspirationGalleryDialog({
|
||||
onOpenChange(false);
|
||||
}, [mode, onOpenChange]);
|
||||
|
||||
const selectedReferenceImages = useMemo(
|
||||
() => selectedWork ? getInspirationReferenceImages(selectedWork) : [],
|
||||
[selectedWork],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="h-[90vh] w-[min(96vw,1120px)] !max-w-[1120px] overflow-hidden p-0 sm:!max-w-[1120px]" showCloseButton={false}>
|
||||
@@ -269,6 +290,28 @@ export function InspirationGalleryDialog({
|
||||
{selectedWork.negativePrompt}
|
||||
</p>
|
||||
)}
|
||||
{selectedReferenceImages.length > 0 && (
|
||||
<div className="mt-3 border-t border-border/60 pt-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">参考图</span>
|
||||
<span className="text-[11px] text-muted-foreground">{selectedReferenceImages.length} 张</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{selectedReferenceImages.map((url, index) => (
|
||||
<button
|
||||
key={`${url}-${index}`}
|
||||
type="button"
|
||||
className="overflow-hidden rounded-lg border border-border/60 bg-muted"
|
||||
onClick={() => setReferencePreviewSrc(url)}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
title={`查看参考图 ${index + 1}`}
|
||||
>
|
||||
<img src={url} alt={`参考图 ${index + 1}`} className="aspect-square w-full object-cover" loading="lazy" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" onClick={() => handleReuse(selectedWork)}>
|
||||
@@ -320,6 +363,14 @@ export function InspirationGalleryDialog({
|
||||
</div>
|
||||
)}
|
||||
{ImageActionsContextMenu}
|
||||
<FullscreenPreview
|
||||
src={referencePreviewSrc || ''}
|
||||
fallbackSrc={null}
|
||||
alt="参考图预览"
|
||||
open={!!referencePreviewSrc}
|
||||
onClose={() => setReferencePreviewSrc(null)}
|
||||
disableContextMenu
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -47,7 +47,7 @@ export function MobileCreationComposer({
|
||||
size="icon"
|
||||
onClick={onGenerate}
|
||||
disabled={disabled}
|
||||
aria-label={generating ? '任务生成中' : '发送创作'}
|
||||
aria-label="发送创作"
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
@@ -32,7 +32,7 @@ import { GroupedModelSelectItems } from '@/components/create/grouped-model-selec
|
||||
import { Sparkles, Loader2, Download, Wand2, Image as ImageIcon, History, ChevronDown, ChevronUp, Plus, KeyRound, Share2 } from 'lucide-react';
|
||||
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
|
||||
import { downloadFile, getImageDownloadExtension } from '@/lib/utils';
|
||||
import { GenerationJobStillRunningError, runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
||||
import { cancelGenerationJob, GenerationJobCancelledError, GenerationJobStillRunningError, runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
||||
import { toast } from 'sonner';
|
||||
import Link from 'next/link';
|
||||
import { ImageLightbox } from '@/components/lightbox';
|
||||
@@ -128,6 +128,7 @@ export function TextToImagePanel() {
|
||||
const [optimizing, setOptimizing] = useState(false);
|
||||
const [inspirationOpen, setInspirationOpen] = useState(false);
|
||||
const activeSubmissionSignaturesRef = useRef(new Set<string>());
|
||||
const cancelledTaskIdsRef = useRef(new Set<string>());
|
||||
const syncConfirmationResolversRef = useRef(new Map<string, (confirmed: boolean) => void>());
|
||||
const generating = activeTasks.length > 0;
|
||||
const activeJobIds = useMemo(
|
||||
@@ -391,6 +392,26 @@ export function TextToImagePanel() {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
}, []);
|
||||
|
||||
const handleCancelTask = useCallback((taskId: string) => {
|
||||
const task = activeTasks.find(item => item.id === taskId);
|
||||
cancelledTaskIdsRef.current.add(taskId);
|
||||
if (task?.clientRequestId) cancelledTaskIdsRef.current.add(task.clientRequestId);
|
||||
if (task?.jobId) cancelledTaskIdsRef.current.add(task.jobId);
|
||||
const resolve = syncConfirmationResolversRef.current.get(taskId);
|
||||
if (resolve) {
|
||||
syncConfirmationResolversRef.current.delete(taskId);
|
||||
resolve(false);
|
||||
}
|
||||
removeActiveTask(taskId);
|
||||
if (!task?.jobId) {
|
||||
toast.success('已取消任务');
|
||||
return;
|
||||
}
|
||||
void cancelGenerationJob(task.jobId)
|
||||
.then(() => toast.success('已取消任务'))
|
||||
.catch(error => toast.error(error instanceof Error ? error.message : '取消任务失败'));
|
||||
}, [activeTasks, removeActiveTask]);
|
||||
|
||||
useGenerationJobRecovery({
|
||||
types: ['image'],
|
||||
knownJobIds: activeJobIds,
|
||||
@@ -433,6 +454,7 @@ export function TextToImagePanel() {
|
||||
},
|
||||
onTaskFailed: (taskId, error) => {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
if (error === '任务已取消') return;
|
||||
setGenerationError(createGenerationError(error));
|
||||
},
|
||||
});
|
||||
@@ -552,7 +574,17 @@ export function TextToImagePanel() {
|
||||
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, jobId: status.jobId || undefined }) },
|
||||
{
|
||||
timeoutMs: 900_000,
|
||||
onStatus: (status: GenerationJobStatus) => {
|
||||
const statusJobId = status.jobId || status.id;
|
||||
updateActiveTask(taskId, { jobStatus: status, jobId: statusJobId || undefined });
|
||||
if (statusJobId && cancelledTaskIdsRef.current.has(taskId)) {
|
||||
cancelledTaskIdsRef.current.add(statusJobId);
|
||||
void cancelGenerationJob(statusJobId).catch(() => undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
let data: { images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; dimensions?: Record<string, { width: number; height: number }>; error?: string; creditsCost?: number; creditsBalance?: number };
|
||||
try {
|
||||
@@ -577,6 +609,7 @@ export function TextToImagePanel() {
|
||||
});
|
||||
}
|
||||
await runGenerationFinalCountdown((seconds) => updateActiveTask(taskId, { finalCountdownSeconds: seconds }), 3);
|
||||
if (cancelledTaskIdsRef.current.has(taskId)) return [];
|
||||
if (!data.images || data.images.length === 0) {
|
||||
throw new Error(data.error || '图片生成失败');
|
||||
}
|
||||
@@ -643,18 +676,24 @@ export function TextToImagePanel() {
|
||||
|
||||
if (failedResults.length > 0) {
|
||||
const stillRunning = failedResults.some(result => result.reason instanceof GenerationJobStillRunningError);
|
||||
const cancelledCount = failedResults.filter(result => result.reason instanceof GenerationJobCancelledError).length;
|
||||
const visibleFailedResults = failedResults.filter(result => !(result.reason instanceof GenerationJobCancelledError));
|
||||
if (generatedImages.length === 0) {
|
||||
const firstError = failedResults[0]?.reason;
|
||||
setGenerationError(createGenerationError(firstError instanceof Error ? firstError.message : '图片生成失败'));
|
||||
const firstError = visibleFailedResults[0]?.reason;
|
||||
if (firstError) setGenerationError(createGenerationError(firstError instanceof Error ? firstError.message : '图片生成失败'));
|
||||
} else {
|
||||
toast.error(`${failedResults.length} 个生成任务失败`);
|
||||
if (visibleFailedResults.length > 0) toast.error(`${visibleFailedResults.length} 个生成任务失败`);
|
||||
}
|
||||
if (stillRunning) toast.info('部分生成任务仍在执行,可稍后在创作历史中查看');
|
||||
if (cancelledCount > 0 && visibleFailedResults.length === 0) toast.info('已取消任务');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof GenerationJobStillRunningError) {
|
||||
setGenerationError(null);
|
||||
toast.info('生成任务仍在执行,可稍后在创作历史中查看');
|
||||
} else if (err instanceof GenerationJobCancelledError) {
|
||||
setGenerationError(null);
|
||||
toast.info('已取消任务');
|
||||
} else if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
setGenerationError(createGenerationError('请求超时,请尝试减少生成数量或降低分辨率'));
|
||||
} else {
|
||||
@@ -837,8 +876,8 @@ export function TextToImagePanel() {
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels || generating}>
|
||||
{generating ? (<><Loader2 className="h-4 w-4 animate-spin" />任务生成中</>) : (<><Sparkles className="h-4 w-4" />生成图片</>)}
|
||||
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels}>
|
||||
<Sparkles className="h-4 w-4" />生成图片
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -848,7 +887,7 @@ export function TextToImagePanel() {
|
||||
<div className="create-desktop-results">
|
||||
<div className="space-y-4">
|
||||
{generating && (
|
||||
<GenerationTaskList tasks={activeTasks} onConfirmSync={handleConfirmSync} onCancelSync={handleCancelSync} />
|
||||
<GenerationTaskList tasks={activeTasks} onConfirmSync={handleConfirmSync} onCancelSync={handleCancelSync} onCancelTask={handleCancelTask} />
|
||||
)}
|
||||
{!generating && generationError && (
|
||||
<GenerationErrorPanel error={generationError} />
|
||||
@@ -958,7 +997,7 @@ export function TextToImagePanel() {
|
||||
{generating && (
|
||||
<div className="create-mobile-conversation-card create-mobile-active-task space-y-3">
|
||||
<p className="create-mobile-conversation-prompt">{activeGenerationPrompt || prompt || '正在生成图片'}</p>
|
||||
<GenerationTaskList tasks={activeTasks} onConfirmSync={handleConfirmSync} onCancelSync={handleCancelSync} />
|
||||
<GenerationTaskList tasks={activeTasks} onConfirmSync={handleConfirmSync} onCancelSync={handleCancelSync} onCancelTask={handleCancelTask} />
|
||||
</div>
|
||||
)}
|
||||
{!generating && generationError && (
|
||||
@@ -976,7 +1015,7 @@ export function TextToImagePanel() {
|
||||
placeholder="请描述画面内容"
|
||||
onPromptChange={setPrompt}
|
||||
onGenerate={handleGenerate}
|
||||
disabled={!hasModels || generating}
|
||||
disabled={!hasModels}
|
||||
generating={generating}
|
||||
styles={(
|
||||
<StylePresetSelector
|
||||
|
||||
@@ -26,7 +26,7 @@ import { ensureSelectedOption, getVideoCapabilityOptions, keepSelectedOptionVisi
|
||||
import { Sparkles, Loader2, Download, Wand2, Video, Film, History, ChevronDown, ChevronUp, KeyRound, Share2 } from 'lucide-react';
|
||||
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
|
||||
import { triggerDownloadFile } from '@/lib/utils';
|
||||
import { runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
||||
import { cancelGenerationJob, GenerationJobCancelledError, runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
||||
import { toast } from 'sonner';
|
||||
import Link from 'next/link';
|
||||
import { CreationDetailDialog } from '@/components/creation-detail-dialog';
|
||||
@@ -67,6 +67,7 @@ export function TextToVideoPanel() {
|
||||
const [optimizing, setOptimizing] = useState(false);
|
||||
const [inspirationOpen, setInspirationOpen] = useState(false);
|
||||
const activeSubmissionSignaturesRef = useRef(new Set<string>());
|
||||
const cancelledTaskIdsRef = useRef(new Set<string>());
|
||||
const generating = activeTasks.length > 0;
|
||||
const activeJobIds = useMemo(
|
||||
() => activeTasks.flatMap(task => [task.jobId, task.clientRequestId, task.id]).filter((id): id is string => Boolean(id)),
|
||||
@@ -258,6 +259,21 @@ export function TextToVideoPanel() {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
}, []);
|
||||
|
||||
const handleCancelTask = useCallback((taskId: string) => {
|
||||
const task = activeTasks.find(item => item.id === taskId);
|
||||
cancelledTaskIdsRef.current.add(taskId);
|
||||
if (task?.clientRequestId) cancelledTaskIdsRef.current.add(task.clientRequestId);
|
||||
if (task?.jobId) cancelledTaskIdsRef.current.add(task.jobId);
|
||||
removeActiveTask(taskId);
|
||||
if (!task?.jobId) {
|
||||
toast.success('已取消任务');
|
||||
return;
|
||||
}
|
||||
void cancelGenerationJob(task.jobId)
|
||||
.then(() => toast.success('已取消任务'))
|
||||
.catch(error => toast.error(error instanceof Error ? error.message : '取消任务失败'));
|
||||
}, [activeTasks, removeActiveTask]);
|
||||
|
||||
useGenerationJobRecovery({
|
||||
types: ['video'],
|
||||
knownJobIds: activeJobIds,
|
||||
@@ -292,6 +308,7 @@ export function TextToVideoPanel() {
|
||||
},
|
||||
onTaskFailed: (taskId, error) => {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
if (error === '任务已取消') return;
|
||||
setGenerationError(createGenerationError(error));
|
||||
},
|
||||
});
|
||||
@@ -356,9 +373,20 @@ export function TextToVideoPanel() {
|
||||
const data = await runGenerationJob<{ videos?: string[]; error?: string; creditsCost?: number; creditsBalance?: number }>(
|
||||
'video',
|
||||
requestBody,
|
||||
{ timeoutMs: 600_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status, jobId: status.jobId || undefined }) },
|
||||
{
|
||||
timeoutMs: 600_000,
|
||||
onStatus: (status: GenerationJobStatus) => {
|
||||
const statusJobId = status.jobId || status.id;
|
||||
updateActiveTask(taskId, { jobStatus: status, jobId: statusJobId || undefined });
|
||||
if (statusJobId && cancelledTaskIdsRef.current.has(taskId)) {
|
||||
cancelledTaskIdsRef.current.add(statusJobId);
|
||||
void cancelGenerationJob(statusJobId).catch(() => undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
await runGenerationFinalCountdown((seconds) => updateActiveTask(taskId, { finalCountdownSeconds: seconds }), 3);
|
||||
if (cancelledTaskIdsRef.current.has(taskId)) return;
|
||||
if (data.videos && data.videos.length > 0) {
|
||||
setResults(prev => [...data.videos!.filter(url => !prev.includes(url)), ...prev]);
|
||||
setGenerationError(null);
|
||||
@@ -383,7 +411,10 @@ export function TextToVideoPanel() {
|
||||
setGenerationError(createGenerationError(data.error || '视频生成失败'));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
if (err instanceof GenerationJobCancelledError) {
|
||||
setGenerationError(null);
|
||||
toast.info('已取消任务');
|
||||
} else if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
setGenerationError(createGenerationError('请求超时,视频生成可能需要更长时间'));
|
||||
} else {
|
||||
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));
|
||||
@@ -542,18 +573,20 @@ export function TextToVideoPanel() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels || generating}>
|
||||
{generating ? (<><Loader2 className="h-4 w-4 animate-spin" />任务生成中</>) : (<><Sparkles className="h-4 w-4" />生成视频</>)}
|
||||
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels}>
|
||||
<Sparkles className="h-4 w-4" />生成视频
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right: Results + History */}
|
||||
<div className="create-chat-thread min-w-0 space-y-4">
|
||||
{generating ? (
|
||||
<GenerationTaskList tasks={activeTasks} />
|
||||
) : generationError ? (
|
||||
{generating && (
|
||||
<GenerationTaskList tasks={activeTasks} onCancelTask={handleCancelTask} />
|
||||
)}
|
||||
{!generating && generationError && (
|
||||
<GenerationErrorPanel error={generationError} />
|
||||
) : results.length > 0 ? (
|
||||
)}
|
||||
{results.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium"><Video className="h-4 w-4" />生成结果</div>
|
||||
{results.map((url, i) => (
|
||||
@@ -566,12 +599,12 @@ export function TextToVideoPanel() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
) : !generating && !generationError ? (
|
||||
<div className="liquid-glass flex min-h-[300px] flex-col items-center justify-center rounded-2xl border-dashed py-24 text-muted-foreground">
|
||||
<Video className="h-14 w-14 mb-3 opacity-20" />
|
||||
<p className="text-sm">生成结果将显示在这里</p>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{videoHistory.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import type { ActiveGenerationTask } from '@/components/create/generation-task-list';
|
||||
import {
|
||||
GenerationJobCancelledError,
|
||||
GenerationJobStillRunningError,
|
||||
continueGenerationJob,
|
||||
fetchActiveGenerationJobs,
|
||||
@@ -123,9 +124,9 @@ export function useGenerationJobRecovery({
|
||||
const timeoutMs = job.type === 'video' ? 600_000 : job.type === 'reverse-prompt' ? 300_000 : 900_000;
|
||||
const onStatus = (status: GenerationJobStatus) => {
|
||||
if (cancelled) return;
|
||||
if (status.status === 'failed') {
|
||||
if (status.status === 'failed' || status.status === 'cancelled') {
|
||||
for (const id of identityIds) activeJobIdsRef.current.delete(id);
|
||||
onTaskFailedRef.current(task.id, status.error || '生成任务失败', status);
|
||||
onTaskFailedRef.current(task.id, status.status === 'cancelled' ? '任务已取消' : status.error || '生成任务失败', status);
|
||||
return;
|
||||
}
|
||||
if (status.status === 'succeeded') {
|
||||
@@ -147,6 +148,11 @@ export function useGenerationJobRecovery({
|
||||
await sleep(3000);
|
||||
continue;
|
||||
}
|
||||
if (error instanceof GenerationJobCancelledError) {
|
||||
for (const id of identityIds) activeJobIdsRef.current.delete(id);
|
||||
onTaskFailedRef.current(task.id, '任务已取消', error.status);
|
||||
return;
|
||||
}
|
||||
console.warn('[generation-job-recovery] polling retry after error:', error);
|
||||
await sleep(5000);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface FullscreenPreviewProps {
|
||||
initialIndex?: number;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
disableContextMenu?: boolean;
|
||||
}
|
||||
|
||||
const MIN_SCALE = 1;
|
||||
@@ -23,7 +24,7 @@ const inverseControlClass =
|
||||
'border border-white/35 bg-black/48 text-white shadow-[0_8px_30px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.20)] backdrop-blur-md';
|
||||
const inverseIconClass = 'text-white drop-shadow-[0_1px_3px_rgba(0,0,0,0.95)]';
|
||||
|
||||
export function FullscreenPreview({ src, fallbackSrc, alt, images, initialIndex = 0, open, onClose }: FullscreenPreviewProps) {
|
||||
export function FullscreenPreview({ src, fallbackSrc, alt, images, initialIndex = 0, open, onClose, disableContextMenu = false }: FullscreenPreviewProps) {
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
@@ -301,7 +302,13 @@ export function FullscreenPreview({ src, fallbackSrc, alt, images, initialIndex
|
||||
setImageFailed(true);
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => openImageMenu(event, currentSrc)}
|
||||
onContextMenu={(event) => {
|
||||
if (disableContextMenu) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
openImageMenu(event, currentSrc);
|
||||
}}
|
||||
onWheel={(event) => {
|
||||
zoomFromWheel(event.nativeEvent);
|
||||
}}
|
||||
@@ -342,7 +349,7 @@ export function FullscreenPreview({ src, fallbackSrc, alt, images, initialIndex
|
||||
}}
|
||||
onPointerCancel={() => setDragging(false)}
|
||||
/>
|
||||
{ImageActionsContextMenu}
|
||||
{!disableContextMenu && ImageActionsContextMenu}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -24,12 +24,16 @@ export type GalleryPublishMediaDeps = {
|
||||
folder: string,
|
||||
options: { storageTarget?: 'default' | 'local' | 'object' },
|
||||
) => Promise<string>;
|
||||
uploadFileObjectOnly: (input: { fileContent: Buffer; fileName: string; contentType: string }) => Promise<string>;
|
||||
generatePresignedUrl: (input: { key: string; expireTime: number }) => Promise<string>;
|
||||
ensureLocalImageThumbnail: (url: string, thumbnailPrefix: string) => Promise<string | null>;
|
||||
ensureLocalVideoThumbnail: (url: string, thumbnailPrefix: string, label?: string) => Promise<string | null>;
|
||||
};
|
||||
|
||||
const defaultDeps: GalleryPublishMediaDeps = {
|
||||
copyPublicUrlToFolder: (url, folder, options) => localStorage.copyPublicUrlToFolder(url, folder, options),
|
||||
uploadFileObjectOnly: input => localStorage.uploadFileObjectOnly(input),
|
||||
generatePresignedUrl: input => localStorage.generatePresignedUrl(input),
|
||||
ensureLocalImageThumbnail,
|
||||
ensureLocalVideoThumbnail,
|
||||
};
|
||||
@@ -38,6 +42,73 @@ function isStableLocalStorageUrl(url: string): boolean {
|
||||
return url.startsWith('/api/local-storage/');
|
||||
}
|
||||
|
||||
function normalizeReferenceUrl(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function getExtensionFromContentType(contentType: string): string {
|
||||
if (contentType === 'image/jpeg') return 'jpg';
|
||||
if (contentType === 'image/png') return 'png';
|
||||
if (contentType === 'image/webp') return 'webp';
|
||||
if (contentType === 'image/gif') return 'gif';
|
||||
return 'bin';
|
||||
}
|
||||
|
||||
function parseDataUrlImage(url: string): { buffer: Buffer; contentType: string; extension: string } | null {
|
||||
const match = url.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/);
|
||||
if (!match) return null;
|
||||
const contentType = match[1].toLowerCase();
|
||||
const buffer = Buffer.from(match[2], 'base64');
|
||||
if (buffer.length === 0) return null;
|
||||
return {
|
||||
buffer,
|
||||
contentType,
|
||||
extension: getExtensionFromContentType(contentType),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveGalleryReferenceImage(
|
||||
url: string,
|
||||
index: number,
|
||||
deps: GalleryPublishMediaDeps,
|
||||
): Promise<string | null> {
|
||||
const normalized = normalizeReferenceUrl(url);
|
||||
if (!normalized || normalized.startsWith('[')) return null;
|
||||
if (isStableLocalStorageUrl(normalized)) return normalized;
|
||||
|
||||
const dataImage = parseDataUrlImage(normalized);
|
||||
if (dataImage) {
|
||||
const key = await deps.uploadFileObjectOnly({
|
||||
fileContent: dataImage.buffer,
|
||||
fileName: `gallery/references/${Date.now()}-${index + 1}-${Math.random().toString(36).slice(2, 8)}.${dataImage.extension}`,
|
||||
contentType: dataImage.contentType,
|
||||
});
|
||||
return deps.generatePresignedUrl({ key, expireTime: 2592000 });
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(normalized)) {
|
||||
return deps.copyPublicUrlToFolder(normalized, 'gallery/references', { storageTarget: 'object' });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveGalleryReferenceImages(
|
||||
urls: unknown[],
|
||||
deps: GalleryPublishMediaDeps = defaultDeps,
|
||||
): Promise<string[]> {
|
||||
const resolved: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const [index, value] of urls.entries()) {
|
||||
const url = normalizeReferenceUrl(value);
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
const galleryUrl = await resolveGalleryReferenceImage(url, index, deps);
|
||||
if (galleryUrl && !resolved.includes(galleryUrl)) resolved.push(galleryUrl);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function resolveGalleryPublishMedia(
|
||||
input: GalleryPublishMediaInput,
|
||||
deps: GalleryPublishMediaDeps = defaultDeps,
|
||||
|
||||
@@ -4,7 +4,7 @@ export type GenerationJobStatus = {
|
||||
id?: string;
|
||||
jobId?: string;
|
||||
type?: GenerationJobType;
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
|
||||
result?: Record<string, unknown>;
|
||||
error?: string | null;
|
||||
payload?: Record<string, unknown>;
|
||||
@@ -50,6 +50,16 @@ export class GenerationJobStillRunningError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class GenerationJobCancelledError extends Error {
|
||||
status: GenerationJobStatus | null;
|
||||
|
||||
constructor(status: GenerationJobStatus | null) {
|
||||
super('任务已取消');
|
||||
this.name = 'GenerationJobCancelledError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthToken(): string | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem('miaojing_auth');
|
||||
@@ -124,6 +134,9 @@ async function pollGenerationJob<T extends Record<string, unknown>>(
|
||||
if (statusData.status === 'failed') {
|
||||
throw new Error(statusData.error || '生成任务失败');
|
||||
}
|
||||
if (statusData.status === 'cancelled') {
|
||||
throw new GenerationJobCancelledError(statusData as GenerationJobStatus);
|
||||
}
|
||||
}
|
||||
|
||||
throw new GenerationJobStillRunningError(lastStatus);
|
||||
@@ -189,6 +202,23 @@ export async function fetchActiveGenerationJobs(types?: GenerationJobType[]): Pr
|
||||
return promise;
|
||||
}
|
||||
|
||||
export async function cancelGenerationJob(jobId: string): Promise<GenerationJobStatus> {
|
||||
const authHeaders = getAuthHeaders();
|
||||
const res = await fetch(`/api/generation-jobs/${encodeURIComponent(jobId)}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders,
|
||||
},
|
||||
body: JSON.stringify({ action: 'cancel' }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `取消任务失败 (${res.status})`);
|
||||
}
|
||||
return data as GenerationJobStatus;
|
||||
}
|
||||
|
||||
export async function runGenerationJob<T extends Record<string, unknown>>(
|
||||
type: GenerationJobType,
|
||||
payload: Record<string, unknown>,
|
||||
|
||||
@@ -236,7 +236,8 @@ async function updateJob(
|
||||
progress = COALESCE(progress, '{}'::jsonb) || $5::jsonb,
|
||||
finished_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $4`,
|
||||
WHERE id = $4
|
||||
AND status = 'running'`,
|
||||
[
|
||||
fields.status,
|
||||
fields.result === undefined ? null : JSON.stringify(fields.result),
|
||||
@@ -255,6 +256,23 @@ async function updateJob(
|
||||
}
|
||||
}
|
||||
|
||||
async function isJobStillRunning(jobId: string): Promise<boolean> {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureGenerationJobRuntimeSchema(client);
|
||||
const result = await client.query(
|
||||
`SELECT status
|
||||
FROM generation_jobs
|
||||
WHERE id = $1
|
||||
LIMIT 1`,
|
||||
[jobId],
|
||||
);
|
||||
return result.rows[0]?.status === 'running';
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function markStaleRunningJobs() {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
@@ -351,6 +369,10 @@ export async function processNextGenerationJob() {
|
||||
userId: job.user_id,
|
||||
jobId: job.id,
|
||||
}) as Record<string, unknown>;
|
||||
if (!await isJobStillRunning(job.id)) {
|
||||
console.info('[generation-worker] skip cancelled job result:', job.id);
|
||||
return true;
|
||||
}
|
||||
const creditCharge = job.type === 'image' || job.type === 'video'
|
||||
? await settleJobCredits({
|
||||
userId: job.user_id,
|
||||
@@ -359,6 +381,10 @@ export async function processNextGenerationJob() {
|
||||
result,
|
||||
})
|
||||
: null;
|
||||
if (!await isJobStillRunning(job.id)) {
|
||||
console.info('[generation-worker] skip cancelled job persistence:', job.id);
|
||||
return true;
|
||||
}
|
||||
const finalResult = creditCharge
|
||||
? {
|
||||
...result,
|
||||
@@ -392,6 +418,10 @@ export async function processNextGenerationJob() {
|
||||
metadata: { type: job.type },
|
||||
});
|
||||
} catch (err) {
|
||||
if (!await isJobStillRunning(job.id)) {
|
||||
console.info('[generation-worker] skip cancelled job failure update:', job.id);
|
||||
return true;
|
||||
}
|
||||
await updateJob(job.id, {
|
||||
status: 'failed',
|
||||
error: err instanceof Error ? err.message : 'Generation job failed',
|
||||
|
||||
Reference in New Issue
Block a user