fix: prevent duplicate generation submissions
This commit is contained in:
@@ -46,9 +46,9 @@ 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. |
|
||||
| Cannot submit a new generation job while another job is 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 the submit button enabled while models are available; active job cards should render inside the results column with wrapping vertical growth, not outside the result area. |
|
||||
| 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. |
|
||||
| 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/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 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. |
|
||||
| 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. |
|
||||
| Job remains running forever | `src/app/api/generation-jobs/[id]/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-job-estimates.ts` | Stale timeout updates, `updated_at`, worker exceptions swallowed into error field. |
|
||||
| Image generation returns upstream error | `src/app/api/generate/image/route.ts`, `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts`, `src/lib/server-api-config.ts` | Resolved custom/system API credentials, endpoint URL, New API normalization, timeout, stream/progress parser, and system-default stream timeout fallback. Gateway 502/503/504 errors are retried once; system default model failures should return the last actionable upstream timeout/gateway message instead of hiding everything behind the generic busy message. |
|
||||
|
||||
@@ -50,10 +50,10 @@ 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 remains usable while jobs are running; 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/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.ts`. Reference thumbnails single-click into a bare image overlay, 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. 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. |
|
||||
| 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 usable while jobs are running; 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/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`, `src/components/create/use-generation-job-recovery.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 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. |
|
||||
| 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/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.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. |
|
||||
| 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/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`, `src/components/create/use-generation-job-recovery.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. |
|
||||
| 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. |
|
||||
@@ -68,7 +68,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Responsibility | Primary Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Client-side job polling | `src/lib/generation-job-client.ts` | Create/poll jobs from create panels. Active-job recovery skips anonymous list polling and reuses same-token, same-type list requests briefly, so refresh/auth-change recovery does not add duplicate `/api/generation-jobs` pressure while tasks keep polling individually until success/failure. |
|
||||
| Job creation API | `src/app/api/generation-jobs/route.ts` | Inserts `generation_jobs`, starts worker, increments selected image style preset usage, and preflights system-default-model credit balance through `src/lib/generation-credit-service.ts`, including queued/running system-default jobs already waiting for the same user. |
|
||||
| Job creation API | `src/app/api/generation-jobs/route.ts` | Inserts `generation_jobs`, starts worker, increments selected image style preset usage, and preflights system-default-model credit balance through `src/lib/generation-credit-service.ts`, including queued/running system-default jobs already waiting for the same user. Active queued/running jobs are semantically deduped while ignoring top-level `clientRequestId`, so a double-click or fast retry returns the existing job instead of creating a second one. |
|
||||
| Job status API | `src/app/api/generation-jobs/[id]/route.ts` | Owner/admin visibility, stale running job handling. |
|
||||
| Worker loop | `src/lib/generation-job-worker.ts` | Picks and processes queued jobs. After successful system default image/video generation, it calls `src/lib/generation-credit-service.ts` to deduct credits from `profiles.credits_balance`, insert `credit_transactions`, and add `creditsCost`/`creditsBalance` to the job result for frontend display. Failed generation jobs do not enter the charge path. |
|
||||
| Internal runner | `src/lib/generation-job-runner.ts` | Calls `/api/generate/image` or `/api/generate/video` with internal headers. |
|
||||
|
||||
@@ -104,6 +104,13 @@ await runTest('active job recovery dedupes locally submitted tasks by client req
|
||||
assert.match(textToImageSource, /task\.clientRequestId/);
|
||||
});
|
||||
|
||||
await runTest('generation job API dedupes active jobs by semantic payload without client request id', () => {
|
||||
const source = read('src/app/api/generation-jobs/route.ts');
|
||||
assert.match(source, /payload - 'clientRequestId'/);
|
||||
assert.match(source, /\$3::jsonb - 'clientRequestId'/);
|
||||
assert.match(source, /deduplicated: true/);
|
||||
});
|
||||
|
||||
await runTest('create panels do not prepend duplicate completed media urls', () => {
|
||||
for (const relativePath of [
|
||||
'src/components/create/text-to-image.tsx',
|
||||
@@ -116,6 +123,35 @@ await runTest('create panels do not prepend duplicate completed media urls', ()
|
||||
}
|
||||
});
|
||||
|
||||
await runTest('create panels block duplicate in-flight submissions before creating another job', () => {
|
||||
for (const relativePath of [
|
||||
'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',
|
||||
]) {
|
||||
const source = read(relativePath);
|
||||
assert.match(source, /activeSubmissionSignaturesRef = useRef\(new Set<string>\(\)\)/, `${relativePath} should keep in-flight submission signatures`);
|
||||
assert.match(source, /activeSubmissionSignaturesRef\.current\.has\(submissionSignature\)/, `${relativePath} should check an in-flight duplicate signature`);
|
||||
assert.match(source, /activeSubmissionSignaturesRef\.current\.add\(submissionSignature\)/, `${relativePath} should mark the signature before creating the job`);
|
||||
assert.match(source, /activeSubmissionSignaturesRef\.current\.delete\(submissionSignature\)/, `${relativePath} should clear the signature after the job settles`);
|
||||
assert.match(source, /相同任务正在生成中,请勿重复提交/, `${relativePath} should explain duplicate submit prevention`);
|
||||
}
|
||||
});
|
||||
|
||||
await runTest('create panels do not label active generation as another submit action', () => {
|
||||
for (const relativePath of [
|
||||
'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/mobile-creation-composer.tsx',
|
||||
]) {
|
||||
const source = read(relativePath);
|
||||
assert.doesNotMatch(source, /继续提交任务/, `${relativePath} should not invite duplicate submits while a task is running`);
|
||||
}
|
||||
});
|
||||
|
||||
await runTest('generation job client builds auth headers from one parsed token per request', () => {
|
||||
const source = read('src/lib/generation-job-client.ts');
|
||||
assert.match(source, /function getAuthHeaders\(authToken = getAuthToken\(\)\)/);
|
||||
|
||||
@@ -154,7 +154,7 @@ export async function POST(request: NextRequest) {
|
||||
WHERE user_id = $1
|
||||
AND type = $2
|
||||
AND status IN ('queued', 'running')
|
||||
AND payload = $3::jsonb
|
||||
AND payload - 'clientRequestId' = $3::jsonb - 'clientRequestId'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
[userId, type, payloadJson],
|
||||
|
||||
@@ -133,6 +133,7 @@ export function ImageToImagePanel() {
|
||||
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
|
||||
const [optimizing, setOptimizing] = useState(false);
|
||||
const [inspirationOpen, setInspirationOpen] = useState(false);
|
||||
const activeSubmissionSignaturesRef = useRef(new Set<string>());
|
||||
const syncConfirmationResolversRef = useRef(new Map<string, (confirmed: boolean) => void>());
|
||||
const generating = activeTasks.length > 0;
|
||||
const activeJobIds = useMemo(
|
||||
@@ -563,24 +564,13 @@ export function ImageToImagePanel() {
|
||||
if (!prompt.trim()) { toast.error('请输入创作描述'); return; }
|
||||
if (!user) { toast.error('请先登录'); return; }
|
||||
if (refImages.length === 0) { toast.error('请至少上传一张参考图片'); return; }
|
||||
|
||||
|
||||
setGenerationError(null);
|
||||
const taskId = `img2img-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
let submissionSignature: string | null = null;
|
||||
try {
|
||||
const resolvedParams = resolveGenerationParams();
|
||||
if (!resolvedParams) return;
|
||||
setActiveTasks(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: taskId,
|
||||
clientRequestId: taskId,
|
||||
title: '正在生成图片',
|
||||
startedAt: Date.now(),
|
||||
estimateSeconds: 90,
|
||||
jobStatus: null,
|
||||
finalCountdownSeconds: null,
|
||||
},
|
||||
]);
|
||||
// Send first reference image as primary, others as additional context
|
||||
const primaryImage = refImages[0].dataUrl;
|
||||
// Keep the outgoing API size aligned with the selected resolution.
|
||||
@@ -617,9 +607,39 @@ export function ImageToImagePanel() {
|
||||
} else if (isSystemModel(selectedModel)) {
|
||||
const api = systemImageApis.find(a => a.id === getSystemApiId(selectedModel));
|
||||
if (api) {
|
||||
requestBody = { ...requestBody, model: api.modelName, customApiConfig: { systemApiId: api.id, modelName: api.modelName } };
|
||||
}
|
||||
}
|
||||
requestBody = { ...requestBody, model: api.modelName, customApiConfig: { systemApiId: api.id, modelName: api.modelName } };
|
||||
}
|
||||
}
|
||||
submissionSignature = JSON.stringify({
|
||||
prompt: prompt.trim(),
|
||||
negativePrompt: negativePrompt.trim(),
|
||||
model: selectedModel,
|
||||
aspectRatio: resolvedParams.aspectRatio,
|
||||
resolution: resolvedParams.resolution,
|
||||
count: resolvedParams.count,
|
||||
outputFormat,
|
||||
imageQuality,
|
||||
styleLabel: selectedStylePreset?.label || '',
|
||||
strength,
|
||||
references: refImages.map(img => img.dataUrl),
|
||||
});
|
||||
if (activeSubmissionSignaturesRef.current.has(submissionSignature)) {
|
||||
toast.info('相同任务正在生成中,请勿重复提交');
|
||||
return;
|
||||
}
|
||||
activeSubmissionSignaturesRef.current.add(submissionSignature);
|
||||
setActiveTasks(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: taskId,
|
||||
clientRequestId: taskId,
|
||||
title: '正在生成图片',
|
||||
startedAt: Date.now(),
|
||||
estimateSeconds: 90,
|
||||
jobStatus: null,
|
||||
finalCountdownSeconds: null,
|
||||
},
|
||||
]);
|
||||
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,
|
||||
@@ -709,6 +729,7 @@ export function ImageToImagePanel() {
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (submissionSignature) activeSubmissionSignaturesRef.current.delete(submissionSignature);
|
||||
syncConfirmationResolversRef.current.delete(taskId);
|
||||
removeActiveTask(taskId);
|
||||
}
|
||||
@@ -939,8 +960,8 @@ export function ImageToImagePanel() {
|
||||
</div>
|
||||
|
||||
{/* Generate */}
|
||||
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels}>
|
||||
{generating ? (<><Plus className="h-4 w-4" />继续提交任务</>) : (<><Sparkles className="h-4 w-4" />生成图片</>)}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ export function ImageToVideoPanel() {
|
||||
const [optimizing, setOptimizing] = useState(false);
|
||||
const [inspirationOpen, setInspirationOpen] = useState(false);
|
||||
const [referencePreviewSrc, setReferencePreviewSrc] = useState<string | null>(null);
|
||||
const activeSubmissionSignaturesRef = 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)),
|
||||
@@ -409,24 +410,13 @@ export function ImageToVideoPanel() {
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!user) { toast.error('请先登录'); return; }
|
||||
if (refImages.length === 0 && !prompt.trim()) { toast.error('请上传参考图片或输入视频描述'); return; }
|
||||
|
||||
|
||||
setGenerationError(null);
|
||||
const taskId = `img2video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
let submissionSignature: string | null = null;
|
||||
try {
|
||||
setActiveTasks(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: taskId,
|
||||
clientRequestId: taskId,
|
||||
title: '正在生成视频',
|
||||
startedAt: Date.now(),
|
||||
estimateSeconds: 300,
|
||||
jobStatus: null,
|
||||
finalCountdownSeconds: null,
|
||||
},
|
||||
]);
|
||||
const primaryImage = refImages[0]?.dataUrl;
|
||||
let requestBody: Record<string, unknown> = {
|
||||
let requestBody: Record<string, unknown> = {
|
||||
prompt: prompt.trim() || undefined,
|
||||
negativePrompt: negativePrompt.trim() || undefined,
|
||||
model: selectedModel,
|
||||
@@ -448,9 +438,36 @@ export function ImageToVideoPanel() {
|
||||
} else if (isSystemModel(selectedModel)) {
|
||||
const api = systemVideoApis.find(a => a.id === getSystemApiId(selectedModel));
|
||||
if (api) {
|
||||
requestBody = { ...requestBody, model: api.modelName, customApiConfig: { systemApiId: api.id, modelName: api.modelName } };
|
||||
}
|
||||
}
|
||||
requestBody = { ...requestBody, model: api.modelName, customApiConfig: { systemApiId: api.id, modelName: api.modelName } };
|
||||
}
|
||||
}
|
||||
submissionSignature = JSON.stringify({
|
||||
prompt: prompt.trim(),
|
||||
negativePrompt: negativePrompt.trim(),
|
||||
model: selectedModel,
|
||||
aspectRatio,
|
||||
duration,
|
||||
resolution,
|
||||
cameraMovement,
|
||||
references: refImages.map(img => img.dataUrl),
|
||||
});
|
||||
if (activeSubmissionSignaturesRef.current.has(submissionSignature)) {
|
||||
toast.info('相同任务正在生成中,请勿重复提交');
|
||||
return;
|
||||
}
|
||||
activeSubmissionSignaturesRef.current.add(submissionSignature);
|
||||
setActiveTasks(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: taskId,
|
||||
clientRequestId: taskId,
|
||||
title: '正在生成视频',
|
||||
startedAt: Date.now(),
|
||||
estimateSeconds: 300,
|
||||
jobStatus: null,
|
||||
finalCountdownSeconds: null,
|
||||
},
|
||||
]);
|
||||
const data = await runGenerationJob<{ videos?: string[]; error?: string; creditsCost?: number; creditsBalance?: number }>(
|
||||
'video',
|
||||
requestBody,
|
||||
@@ -489,7 +506,10 @@ export function ImageToVideoPanel() {
|
||||
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));
|
||||
}
|
||||
}
|
||||
finally { removeActiveTask(taskId); }
|
||||
finally {
|
||||
if (submissionSignature) activeSubmissionSignaturesRef.current.delete(submissionSignature);
|
||||
removeActiveTask(taskId);
|
||||
}
|
||||
}, [prompt, negativePrompt, selectedModel, aspectRatio, duration, resolution, cameraMovement, refImages, user, videoKeys, systemVideoApis, getCurrentModelLabel, addRecord, updateProfile, removeActiveTask, updateActiveTask]);
|
||||
|
||||
const handleDownload = useCallback(async (url: string, index: number) => {
|
||||
@@ -690,8 +710,8 @@ export function ImageToVideoPanel() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels}>
|
||||
{generating ? (<><Plus className="h-4 w-4" />继续提交任务</>) : (<><Sparkles className="h-4 w-4" />生成视频</>)}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export function MobileCreationComposer({
|
||||
size="icon"
|
||||
onClick={onGenerate}
|
||||
disabled={disabled}
|
||||
aria-label={generating ? '继续提交任务' : '发送创作'}
|
||||
aria-label={generating ? '任务生成中' : '发送创作'}
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
@@ -837,8 +837,8 @@ export function TextToImagePanel() {
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels}>
|
||||
{generating ? (<><Plus className="h-4 w-4" />继续提交任务</>) : (<><Sparkles className="h-4 w-4" />生成图片</>)}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -976,7 +976,7 @@ export function TextToImagePanel() {
|
||||
placeholder="请描述画面内容"
|
||||
onPromptChange={setPrompt}
|
||||
onGenerate={handleGenerate}
|
||||
disabled={!hasModels}
|
||||
disabled={!hasModels || generating}
|
||||
generating={generating}
|
||||
styles={(
|
||||
<StylePresetSelector
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { getCustomApiModelLabel, getSystemApiModelLabel } from '@/lib/model-display';
|
||||
import { GroupedModelSelectItems } from '@/components/create/grouped-model-select-items';
|
||||
import { ensureSelectedOption, getVideoCapabilityOptions, keepSelectedOptionVisible } from '@/lib/model-capabilities';
|
||||
import { Sparkles, Loader2, Download, Wand2, Video, Film, History, ChevronDown, ChevronUp, KeyRound, Share2, Plus } from 'lucide-react';
|
||||
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';
|
||||
@@ -66,6 +66,7 @@ export function TextToVideoPanel() {
|
||||
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
|
||||
const [optimizing, setOptimizing] = useState(false);
|
||||
const [inspirationOpen, setInspirationOpen] = useState(false);
|
||||
const activeSubmissionSignaturesRef = 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)),
|
||||
@@ -301,19 +302,8 @@ export function TextToVideoPanel() {
|
||||
|
||||
setGenerationError(null);
|
||||
const taskId = `text2video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
let submissionSignature: string | null = null;
|
||||
try {
|
||||
setActiveTasks(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: taskId,
|
||||
clientRequestId: taskId,
|
||||
title: '正在生成视频',
|
||||
startedAt: Date.now(),
|
||||
estimateSeconds: 300,
|
||||
jobStatus: null,
|
||||
finalCountdownSeconds: null,
|
||||
},
|
||||
]);
|
||||
let requestBody: Record<string, unknown> = {
|
||||
prompt: prompt.trim(),
|
||||
negativePrompt: negativePrompt.trim() || undefined,
|
||||
@@ -336,6 +326,33 @@ export function TextToVideoPanel() {
|
||||
requestBody = { ...requestBody, model: api.modelName, customApiConfig: { systemApiId: api.id, modelName: api.modelName } };
|
||||
}
|
||||
}
|
||||
submissionSignature = JSON.stringify({
|
||||
prompt: prompt.trim(),
|
||||
negativePrompt: negativePrompt.trim(),
|
||||
model: selectedModel,
|
||||
aspectRatio,
|
||||
duration,
|
||||
resolution,
|
||||
cameraMovement,
|
||||
style,
|
||||
});
|
||||
if (activeSubmissionSignaturesRef.current.has(submissionSignature)) {
|
||||
toast.info('相同任务正在生成中,请勿重复提交');
|
||||
return;
|
||||
}
|
||||
activeSubmissionSignaturesRef.current.add(submissionSignature);
|
||||
setActiveTasks(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: taskId,
|
||||
clientRequestId: taskId,
|
||||
title: '正在生成视频',
|
||||
startedAt: Date.now(),
|
||||
estimateSeconds: 300,
|
||||
jobStatus: null,
|
||||
finalCountdownSeconds: null,
|
||||
},
|
||||
]);
|
||||
const data = await runGenerationJob<{ videos?: string[]; error?: string; creditsCost?: number; creditsBalance?: number }>(
|
||||
'video',
|
||||
requestBody,
|
||||
@@ -372,7 +389,10 @@ export function TextToVideoPanel() {
|
||||
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));
|
||||
}
|
||||
}
|
||||
finally { removeActiveTask(taskId); }
|
||||
finally {
|
||||
if (submissionSignature) activeSubmissionSignaturesRef.current.delete(submissionSignature);
|
||||
removeActiveTask(taskId);
|
||||
}
|
||||
}, [prompt, negativePrompt, selectedModel, aspectRatio, duration, resolution, cameraMovement, style, user, videoKeys, systemVideoApis, getCurrentModelLabel, addRecord, updateProfile, removeActiveTask, updateActiveTask]);
|
||||
|
||||
const handleDownload = useCallback(async (url: string, index: number) => {
|
||||
@@ -522,8 +542,8 @@ export function TextToVideoPanel() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels}>
|
||||
{generating ? (<><Plus className="h-4 w-4" />继续提交任务</>) : (<><Sparkles className="h-4 w-4" />生成视频</>)}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user