From 44dee6314ae8e9b03356e2c6e5f1cc6b5ea049f2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 28 Apr 2026 22:16:57 +0800 Subject: [PATCH] fix: run fal gpt image edit asynchronously --- app/(chat)/api/chat/route.ts | 414 ++++------ app/api/messages/route.ts | 209 ++++- .../agents/AgentGenerationWorkspaceDarkV2.tsx | 768 ++++++++++++++++-- .../agents/AgentImageTextEditDialog.tsx | 385 +++++++++ components/canvas/CanvasChatPanel.tsx | 59 +- components/chat.tsx | 2 +- hooks/use-pending-generation.ts | 2 +- lib/ai/fal-image-models.ts | 2 +- lib/ai/providers/fal-image.ts | 349 +++++--- lib/ai/runtime/fal-image-edit-task.ts | 58 ++ lib/ai/runtime/generated-image-persistence.ts | 300 +++++++ lib/db/queries.ts | 3 +- 12 files changed, 2110 insertions(+), 441 deletions(-) create mode 100644 components/agents/AgentImageTextEditDialog.tsx create mode 100644 lib/ai/runtime/fal-image-edit-task.ts create mode 100644 lib/ai/runtime/generated-image-persistence.ts diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 440dcbe6e..1357f67ed 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -25,6 +25,10 @@ import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service"; import type { ChatModel } from "@/lib/ai/models"; import { type RequestHints, systemPrompt } from "@/lib/ai/prompts"; import { myProvider } from "@/lib/ai/providers"; +import { + isFalGptImage2EditModelId, + submitFalImageTask, +} from "@/lib/ai/providers/fal-image"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import { resolveDetailAgentLocalMoonshotModel } from "@/lib/ai/detail-agent-model-runtime"; import { shouldForceInlineVisionInputs } from "@/lib/ai/model-compat"; @@ -135,6 +139,11 @@ import { buildAgentToolVideoMarkdown, prependImageModelBadge, } from "@/lib/ai/runtime/agent-media-markdown"; +import { + parseImageAltMetadata, + saveGeneratedImages, +} from "@/lib/ai/runtime/generated-image-persistence"; +import type { FalImageEditTaskMetadata } from "@/lib/ai/runtime/fal-image-edit-task"; import { saveAssistantTextOnlyMessage } from "@/lib/ai/runtime/assistant-message-persistence"; import { resolveCanvasAgentClarificationGate } from "@/lib/ai/runtime/middlewares/canvas-agent-clarification-gate"; import { @@ -1463,6 +1472,13 @@ async function generateImageWithFailoverCandidate(params: { }) { const { candidate, messages, imageParams, fallbackImageParams } = params; const providerType = String(candidate.provider?.type || ""); + const candidateRuntimeModelId = String(candidate.model?.modelId || candidate.model?.id || ""); + if ( + providerType === "fal-image" && + isFalGptImage2EditModelId(candidateRuntimeModelId) + ) { + throw new Error("FAL GPT Image 2 Edit 仅支持异步队列调用,已跳过同步故障迁移"); + } const candidateLanguageModel = await getLanguageModelById(candidate.model.id); if (!candidateLanguageModel || typeof (candidateLanguageModel as any).doGenerate !== "function") { throw new Error(`备用模型不支持当前生图故障迁移调用:${candidate.model.displayName || candidate.model.id}`); @@ -2381,57 +2397,6 @@ async function convertMessagesWithImages( return convertedMessages; } -function parseImageAltMetadata(altText: string, thumbnailUrl: string): { - originalUrl: string; - imageSize: string; -} { - let originalUrl = thumbnailUrl; - let imageSize = "2K"; - - // 格式1: thumb|||originalUrl|||size (新格式) - if (altText.includes("thumb|||")) { - const content = altText.substring(altText.indexOf("thumb|||") + 8); - const parts = content.split("|||"); - originalUrl = parts[0] || thumbnailUrl; - imageSize = parts[1] || "2K"; - } - // 格式2: thumb$$originalUrl$$size (中间格式,可能被 LaTeX 解析器破坏) - else if (altText.includes("thumb$$")) { - const content = altText.substring(altText.indexOf("thumb$$") + 7); - const parts = content.split("$$"); - originalUrl = parts[0] || thumbnailUrl; - imageSize = parts[1] || "2K"; - } - // 格式3: thumb|originalUrl|size (旧格式) - else if (altText.includes("thumb|")) { - const content = altText.substring(altText.indexOf("thumb|") + 6); - const parts = content.split("|"); - originalUrl = parts[0] || thumbnailUrl; - imageSize = parts[1] || "2K"; - } - - // 回退策略:从缩略图 URL 推断原图 URL - if (originalUrl === thumbnailUrl) { - if (thumbnailUrl.includes("gemini-thumb-")) { - originalUrl = thumbnailUrl.replace("gemini-thumb-", "gemini-orig-"); - } else if (thumbnailUrl.includes("openai-thumb-")) { - originalUrl = thumbnailUrl.replace("openai-thumb-", "openai-orig-"); - } else if (thumbnailUrl.includes("volcano-thumb-")) { - originalUrl = thumbnailUrl.replace("volcano-thumb-", "volcano-orig-"); - } else if (thumbnailUrl.includes("bfl-image-thumb-")) { - originalUrl = thumbnailUrl.replace("bfl-image-thumb-", "bfl-image-orig-"); - } else if (thumbnailUrl.includes("alibaba-image-thumb-")) { - originalUrl = thumbnailUrl.replace("alibaba-image-thumb-", "alibaba-image-orig-"); - } else if (thumbnailUrl.includes("ruxa-image-thumb-")) { - originalUrl = thumbnailUrl.replace("ruxa-image-thumb-", "ruxa-image-orig-"); - } else if (thumbnailUrl.includes("fal-image-thumb-")) { - originalUrl = thumbnailUrl.replace("fal-image-thumb-", "fal-image-orig-"); - } - } - - return { originalUrl, imageSize }; -} - function extractOriginalImageUrlsFromText(text: string): string[] { return extractImageUrlsFromText(text); } @@ -2784,204 +2749,6 @@ function resolveModelRefByRuntimeModelId( return String(match?.model?.id || fallbackRef || normalizedModelId || ""); } -/** - * 解析生图 Markdown 并保存图片记录到数据库 - * 支持三种格式:thumb|||url|||size、thumb$$url$$size、thumb|url|size - */ -async function saveGeneratedImages( - imageMarkdown: string, - userId: string, - chatId: string, - messageId: string, - prompt: string, - modelId: string, - options?: { - sourceAssetIds?: string[]; - sourceImageUrls?: string[]; - relationType?: "generated_from" | "edited_from"; - sourceType?: string; - projectId?: string | null; - userPrompt?: string; - optimizedPrompt?: string; - requestedImageCount?: number; - aspectRatio?: string; - imageSize?: string; - }, -): Promise { - // 匹配 Markdown 图片格式: ![alt](url) - const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; - const matches = [...imageMarkdown.matchAll(imageRegex)]; - - let savedImageCount = 0; - const savedAssetIds: string[] = []; - const savedOriginalUrls: string[] = []; - let relationSourceAssetIds = Array.isArray(options?.sourceAssetIds) - ? options.sourceAssetIds.filter((assetId): assetId is string => Boolean(assetId)) - : []; - - if ( - relationSourceAssetIds.length === 0 && - Array.isArray(options?.sourceImageUrls) && - options.sourceImageUrls.length > 0 - ) { - try { - const sourceAssets = await getMediaAssetsByUserAndUrls({ - userId, - urls: options.sourceImageUrls, - }); - relationSourceAssetIds = sourceAssets.map((asset) => asset.id); - } catch (sourceLookupError) { - console.error("[Gallery] Failed to resolve source assets by URL:", sourceLookupError); - } - } - - for (const match of matches) { - const altText = match[1] || ""; - const thumbnailUrl = match[2]; - const { originalUrl, imageSize } = parseImageAltMetadata(altText, thumbnailUrl); - - try { - const [savedImage] = await db.insert(generatedImage).values({ - userId, - chatId, - messageId, - thumbnailUrl, - originalUrl, - imageSize, - prompt, - modelId, - }).returning(); - savedImageCount += 1; - if (!savedOriginalUrls.includes(originalUrl)) { - savedOriginalUrls.push(originalUrl); - } - const media = await upsertMediaAsset({ - userId, - chatId, - projectId: options?.projectId ?? null, - mediaType: "image", - sourceType: options?.sourceType || "generated", - status: "ready", - originalUrl, - previewUrl: originalUrl, - thumbnailUrl, - displayName: prompt?.slice(0, 80) || "AI 生成图片", - prompt, - modelId, - sourceMessageId: messageId, - sourceTable: "GeneratedImage", - sourceRowId: savedImage?.id || null, - primaryParentAssetId: relationSourceAssetIds[0] || null, - metadata: { - imageSize, - }, - }); - if (media?.id && !savedAssetIds.includes(media.id)) { - savedAssetIds.push(media.id); - } - if (media && relationSourceAssetIds.length > 0) { - for (const sourceAssetId of relationSourceAssetIds) { - if (!sourceAssetId) continue; - await createMediaAssetRelation({ - userId, - chatId, - projectId: options?.projectId ?? null, - fromAssetId: sourceAssetId, - toAssetId: media.id, - relationType: options?.relationType || "generated_from", - sourceMessageId: messageId, - metadata: { - modelId, - prompt, - sourceImageUrls: options?.sourceImageUrls || [], - }, - }); - } - } - console.log(`[Gallery] Saved image: ${originalUrl.substring(0, 80)}...`); - } catch (error) { - console.error("[Gallery] Failed to save image record:", error); - } - } - - if (savedOriginalUrls.length > 0) { - try { - const resultAssetIds = savedAssetIds.length > 0 ? savedAssetIds : null; - await persistImageWorkflowTurn({ - userId, - chatId, - projectId: options?.projectId || null, - currentIntent: options?.relationType === "edited_from" ? "image_edit" : "image_gen", - currentFocusAssetIds: resultAssetIds, - currentFocusImageUrls: savedOriginalUrls, - lastGeneratedAssetIds: resultAssetIds, - lastGeneratedImageUrls: savedOriginalUrls, - lastResolvedTargetAssetIds: resultAssetIds, - lastResolvedTargetImageUrls: savedOriginalUrls, - lastMessageId: messageId, - lastUserPrompt: String(options?.userPrompt || prompt || "").trim() || prompt, - lastOptimizedPrompt: - String( - options?.optimizedPrompt || - (options?.relationType !== "edited_from" ? prompt : "") || - "", - ).trim() || undefined, - lastRequestedImageCount: options?.requestedImageCount || savedOriginalUrls.length, - lastAspectRatio: options?.aspectRatio, - lastImageSize: options?.imageSize, - lastSourceImageUrls: options?.sourceImageUrls || null, - lastResultImageUrls: savedOriginalUrls, - }); - } catch (memoryError) { - console.warn("[Gallery] Failed to update working state with generated images:", memoryError); - } - } - - if (savedImageCount <= 0) return; - - try { - const pointsConfig = await getPointsSystemConfig(); - if (!pointsConfig.enabled) return; - - const userData = await getUserById({ id: userId }).catch(() => undefined); - if (userData?.isHighVip) { - console.log(`[Points] Skip image points for VIP user=${userId} model=${modelId}`); - return; - } - - const modelConfig = await resolveBillingModelByReference(modelId); - const unitCost = getModelCreditCost(modelConfig, "image"); - if (unitCost <= 0) return; - - const totalCost = unitCost * savedImageCount; - const consumeResult = await consumeUserPoints({ - userId, - amount: totalCost, - reason: "image_generation", - modelId: modelConfig?.id || modelId, - chatId, - messageId, - note: `模型(${modelConfig?.displayName || modelId}) 生图 ${savedImageCount} 张`, - }); - - if (!consumeResult.ok) { - console.warn( - `[Points] Failed to consume points after generation. user=${userId} model=${modelId} cost=${totalCost}` - ); - } else if (consumeResult.skipped) { - console.log( - `[Points] Skipped ${totalCost} image points for VIP user=${userId} model=${modelId}` - ); - } else { - console.log( - `[Points] Consumed ${totalCost} points (${unitCost} x ${savedImageCount}). balance=${consumeResult.balance}` - ); - } - } catch (error) { - console.error("[Points] Failed to consume points after image generation:", error); - } -} - type AgentPresetMessageImagePart = { type: "file"; url: string; @@ -10570,6 +10337,122 @@ export async function POST(request: Request) { } messagesForAlibaba = injectHistoryImagesToLastUserMessage(messagesForAlibaba, smartAutoHistoryImages); + const isFalAsyncImageEdit = + (languageModel as any).provider === "fal-image" && + isFalGptImage2EditModelId(actualModelId); + + if (isFalAsyncImageEdit) { + const falProvider = alibabaModelConfig?.providerId + ? await getAiProviderById({ id: alibabaModelConfig.providerId }).catch(() => undefined) + : undefined; + const falApiKey = String(falProvider?.apiKey || "").trim(); + if (!falApiKey) { + throw new Error("FAL 图片编辑模型未配置可用的 API Key"); + } + + const falMessages = await convertMessagesWithImages(messagesForAlibaba, "fal-image"); + const queuedTask = await submitFalImageTask({ + apiKey: falApiKey, + modelId: actualModelId, + messages: falMessages, + imageParams: + effectiveImageParams || { + aspectRatio: defaultImageQualityParams.aspectRatio, + imageSize: defaultImageQualityParams.imageSize, + imageCount: defaultImageQualityParams.imageCount, + }, + }); + + const placeholderMessageId = generateUUID(); + const userPrompt = + lastUserMessage?.parts?.find( + (p): p is { type: "text"; text: string } => p.type === "text", + )?.text || ""; + const modelRef = resolveModelRefByRuntimeModelId( + allModels, + queuedTask.modelId, + finalModelId, + ); + const placeholderText = `已提交 ${modelDisplayName} 编辑任务,预计 2-4 分钟完成,结果会自动回填到这里。`; + const queuedMetadata: FalImageEditTaskMetadata = { + taskKind: "fal_gpt_image_2_edit", + provider: "fal-image", + providerId: String(alibabaModelConfig?.providerId || ""), + endpointId: queuedTask.endpointId, + requestId: queuedTask.requestId, + modelId: queuedTask.modelId, + modelRef, + modelDisplayName, + prompt: queuedTask.prompt, + optimizedPrompt: smartAutoOptimizedPrompt || userPrompt || queuedTask.prompt, + userPrompt: userPrompt || queuedTask.prompt, + requestedImageCount: + effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount || 1, + aspectRatio: effectiveImageParams?.aspectRatio, + imageSize: effectiveImageParams?.imageSize, + outputSizeLabel: queuedTask.outputSizeLabel, + sourceImageUrls: requestTargetResolution?.targetImageUrls || queuedTask.sourceImageUrls, + sourceAssetIds: requestTargetResolution?.targetAssetIds || [], + relationType: requestResolvedMediaInput?.hasImage ? "edited_from" : "generated_from", + sourceType: "generated", + projectId: contextPayload?.projectId || null, + contextSource: contextPayload?.source || null, + status: "queued", + startedAt: Date.now(), + updatedAt: Date.now(), + queuePosition: null, + lastLogs: [], + error: null, + }; + + writeAssistantTextChunk( + writer, + "", + "alibaba:image-start", + ); + writeImageGenerationStatusChunk( + writer, + { + phase: "queued", + providerType: "fal-image", + modelName: modelDisplayName, + imageCount: + effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount || 1, + }, + "alibaba:image-status-queued", + ); + writeAssistantTextChunk( + writer, + placeholderText, + "alibaba:image-queued", + placeholderMessageId, + ); + + await saveMessages({ + messages: [ + { + id: placeholderMessageId, + chatId: id, + role: "assistant", + parts: [{ type: "text", text: placeholderText }], + attachments: [], + createdAt: new Date(), + videoStorageStatus: null, + temporaryVideoUrl: null, + permanentVideoUrl: null, + archivedAt: null, + videoMetadata: queuedMetadata as any, + }, + ], + }); + + console.log("[Chat API] ✅ Queued FAL GPT Image 2 Edit task", { + requestId: queuedTask.requestId, + endpointId: queuedTask.endpointId, + messageId: placeholderMessageId, + }); + return; + } const tryGenerateImage = async (modelIdToUse: string, isRetry: boolean) => { // 转换消息(图片编辑模型统一使用 URL 输入) @@ -10698,6 +10581,17 @@ export async function POST(request: Request) { console.log( `[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})` ); + if ( + candidate.provider.type === "fal-image" && + isFalGptImage2EditModelId( + String(candidate.model?.modelId || candidate.model?.id || "") + ) + ) { + console.log( + `[Chat API] Skip async-only failover candidate: ${candidate.model.displayName}` + ); + continue; + } const candidateLanguageModel = await getLanguageModelById(candidate.model.id); if (!candidateLanguageModel || typeof (candidateLanguageModel as any).doGenerate !== "function") { @@ -11124,6 +11018,17 @@ export async function POST(request: Request) { console.log( `[Chat API] 🔁 Volcano cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})` ); + if ( + candidate.provider.type === "fal-image" && + isFalGptImage2EditModelId( + String(candidate.model?.modelId || candidate.model?.id || "") + ) + ) { + console.log( + `[Chat API] Skip async-only failover candidate: ${candidate.model.displayName}` + ); + continue; + } const candidateLanguageModel = await getLanguageModelById(candidate.model.id); if (!candidateLanguageModel || typeof (candidateLanguageModel as any).doGenerate !== "function") { @@ -12014,6 +11919,17 @@ export async function POST(request: Request) { console.log( `[Chat API] 🔁 OpenAI-compatible cross-provider failover: ${candidate.model.displayName} (${candidate.provider.type})` ); + if ( + candidate.provider.type === "fal-image" && + isFalGptImage2EditModelId( + String(candidate.model?.modelId || candidate.model?.id || "") + ) + ) { + console.log( + `[Chat API] Skip async-only failover candidate: ${candidate.model.displayName}` + ); + continue; + } const candidateLanguageModel = await getLanguageModelById(candidate.model.id); if ( diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts index fb8db4ad5..d7108c6fd 100644 --- a/app/api/messages/route.ts +++ b/app/api/messages/route.ts @@ -1,11 +1,195 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; -import { saveMessages, getMessagesByChatId } from "@/lib/db/queries"; +import { prependImageModelBadge } from "@/lib/ai/runtime/agent-media-markdown"; +import { + isPendingFalImageEditTaskMetadata, + type FalImageEditTaskMetadata, +} from "@/lib/ai/runtime/fal-image-edit-task"; +import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence"; +import { + getChatById, + getMessagesByChatId, + getAiProviderById, + saveMessages, + updateMessageById, +} from "@/lib/db/queries"; import type { DBMessage } from "@/lib/db/schema"; +import { + getFalImageTaskStatus, + resolveFalImageTaskToMarkdown, +} from "@/lib/ai/providers/fal-image"; export const dynamic = "force-dynamic"; export const revalidate = 0; +const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000; +const falTaskLastPolledAt = new Map(); +const falTaskSyncInFlight = new Set(); + +async function markFalTaskFailed(params: { + message: DBMessage; + metadata: FalImageEditTaskMetadata; + errorText: string; +}) { + await updateMessageById({ + messageId: params.message.id, + chatId: params.message.chatId, + parts: [{ type: "text", text: params.errorText }], + videoMetadata: { + ...params.metadata, + status: "failed", + updatedAt: Date.now(), + error: params.errorText, + } as any, + }); +} + +async function syncPendingFalImageTask(params: { + userId: string; + message: DBMessage; +}): Promise { + const metadata = params.message.videoMetadata; + if (!isPendingFalImageEditTaskMetadata(metadata)) return false; + + const taskKey = `${params.message.id}:${metadata.requestId}`; + const now = Date.now(); + const lastPolledAt = falTaskLastPolledAt.get(taskKey) || 0; + if (now - lastPolledAt < FAL_IMAGE_TASK_POLL_INTERVAL_MS) { + return false; + } + if (falTaskSyncInFlight.has(taskKey)) { + return false; + } + falTaskSyncInFlight.add(taskKey); + falTaskLastPolledAt.set(taskKey, now); + + try { + const provider = await getAiProviderById({ id: metadata.providerId }).catch(() => undefined); + const apiKey = String(provider?.apiKey || "").trim(); + if (!apiKey) { + await markFalTaskFailed({ + message: params.message, + metadata, + errorText: "FAL 图片编辑失败:未找到可用的服务商配置", + }); + falTaskLastPolledAt.delete(taskKey); + return true; + } + + let queueStatus: Awaited>; + try { + queueStatus = await getFalImageTaskStatus({ + apiKey, + endpointId: metadata.endpointId, + requestId: metadata.requestId, + }); + } catch (error) { + console.error("[Messages API] Failed to poll FAL task status:", { + messageId: params.message.id, + requestId: metadata.requestId, + error, + }); + return false; + } + + if (queueStatus.status === "COMPLETED") { + try { + const imageMarkdown = await resolveFalImageTaskToMarkdown({ + apiKey, + endpointId: metadata.endpointId, + requestId: metadata.requestId, + imageCount: metadata.requestedImageCount, + outputSizeLabel: metadata.outputSizeLabel, + sourceImageUrls: metadata.sourceImageUrls || [], + }); + const finalText = prependImageModelBadge( + imageMarkdown, + metadata.modelDisplayName || metadata.modelId, + ); + + await updateMessageById({ + messageId: params.message.id, + chatId: params.message.chatId, + parts: [{ type: "text", text: finalText }], + videoMetadata: { + ...metadata, + status: "completed", + updatedAt: Date.now(), + queuePosition: null, + lastLogs: Array.isArray((queueStatus as any)?.logs) ? (queueStatus as any).logs : [], + error: null, + } as any, + }); + + await saveGeneratedImages( + finalText, + params.userId, + params.message.chatId, + params.message.id, + metadata.userPrompt || metadata.prompt, + metadata.modelRef || metadata.modelId, + { + sourceAssetIds: metadata.sourceAssetIds || [], + sourceImageUrls: metadata.sourceImageUrls || [], + relationType: metadata.relationType || "edited_from", + sourceType: metadata.sourceType || "generated", + projectId: metadata.projectId || null, + optimizedPrompt: metadata.optimizedPrompt || metadata.prompt, + userPrompt: metadata.userPrompt || metadata.prompt, + requestedImageCount: metadata.requestedImageCount, + aspectRatio: metadata.aspectRatio, + imageSize: metadata.imageSize, + }, + ); + + falTaskLastPolledAt.delete(taskKey); + return true; + } catch (error) { + console.error("[Messages API] Failed to finalize completed FAL task:", { + messageId: params.message.id, + requestId: metadata.requestId, + error, + }); + await markFalTaskFailed({ + message: params.message, + metadata, + errorText: "FAL 图片编辑结果处理失败,请重新提交任务", + }); + falTaskLastPolledAt.delete(taskKey); + return true; + } + } + + const nextStatus = queueStatus.status === "IN_QUEUE" ? "queued" : "running"; + const nextQueuePosition = + queueStatus.status === "IN_QUEUE" && typeof (queueStatus as any).queue_position === "number" + ? (queueStatus as any).queue_position + : null; + const hasMeaningfulChange = + metadata.status !== nextStatus || + (metadata.queuePosition ?? null) !== nextQueuePosition; + + if (hasMeaningfulChange) { + await updateMessageById({ + messageId: params.message.id, + chatId: params.message.chatId, + videoMetadata: { + ...metadata, + status: nextStatus, + updatedAt: Date.now(), + queuePosition: nextQueuePosition, + lastLogs: Array.isArray((queueStatus as any)?.logs) ? (queueStatus as any).logs : [], + } as any, + }); + return true; + } + + return false; + } finally { + falTaskSyncInFlight.delete(taskKey); + } +} + /** * 获取聊天消息 * 用于从数据库重新加载消息 @@ -27,8 +211,26 @@ export async function GET(request: NextRequest) { ); } - // 从数据库获取消息 - const messages = await getMessagesByChatId({ id: chatId }); + const selectedChat = await getChatById({ id: chatId }); + if (!selectedChat || selectedChat.userId !== session.user.id) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + let messages = await getMessagesByChatId({ id: chatId }); + let mutated = false; + for (const message of messages) { + if (message.role !== "assistant") continue; + if (!isPendingFalImageEditTaskMetadata(message.videoMetadata)) continue; + const changed = await syncPendingFalImageTask({ + userId: session.user.id, + message, + }); + mutated = mutated || changed; + } + + if (mutated) { + messages = await getMessagesByChatId({ id: chatId }); + } // 转换为前端格式 const formattedMessages = messages.map((msg: DBMessage) => { @@ -41,6 +243,7 @@ export async function GET(request: NextRequest) { createdAt: msg.createdAt, parts: parts, attachments: msg.attachments || [], + videoMetadata: msg.videoMetadata || null, }; }); diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index e6aa9a8f7..d3e4310d9 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -18,6 +18,7 @@ import JSZip from "jszip"; import { ArrowUp, CheckCircle2, + RefreshCw, ChevronDown, ChevronUp, Download, @@ -59,6 +60,7 @@ import { import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar"; import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner"; import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog"; +import { AgentImageTextEditDialog } from "@/components/agents/AgentImageTextEditDialog"; import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog"; import { ImageAnnotator } from "@/components/image-annotator"; import { useCanvasStore } from "@/store/canvas"; @@ -780,9 +782,53 @@ type ReplacementCardStatus = | "fallback" | "missing"; +type ReplacementModeType = Extract< + AgentGenerationResultModeType, + "detail_replacement" | "sku_replacement" +>; + +interface ReplacementResultRecord { + url: string | null; + text: string; + status: Extract; + updatedAt: number; +} + +interface ReplacementExecutionContext { + modeType: ReplacementModeType; + cardIds: string[]; +} + +interface ReplacementTextEditTarget { + open: boolean; + modeType: ReplacementModeType; + cardId: string; + imageUrl: string; + title: string; +} + +interface ReplacementRerunTarget { + open: boolean; + modeType: ReplacementModeType; + cardId: string; + title: string; +} + +type ReplacementResultState = Record< + ReplacementModeType, + Record +>; + const MANUAL_PASSTHROUGH_RESULT_TEXT = "已按人工标记沿用参考图,直接应用当前切片,不参与继续生成。"; +function createEmptyReplacementResultState(): ReplacementResultState { + return { + detail_replacement: {}, + sku_replacement: {}, + }; +} + function inferReplacementCardStatusFromText(text: string | null | undefined): ReplacementCardStatus | null { const normalized = normalizeAssistantText(String(text || "")) .replace(/\s+/g, " ") @@ -812,7 +858,21 @@ function getReplacementCardStatus(params: { resultText?: string | null; generatedCount: number; isGenerating: boolean; -}) : ReplacementCardStatus { + activeRunIndex?: number | null; +}): ReplacementCardStatus { + if ( + typeof params.activeRunIndex === "number" && + params.activeRunIndex >= 0 && + params.generatedCount <= params.activeRunIndex + ) { + if (params.isGenerating) { + return params.generatedCount === params.activeRunIndex ? "generating" : "queued"; + } + if (!params.resultUrl && params.generatedCount > 0) { + return "missing"; + } + } + if (params.resultUrl) { return inferReplacementCardStatusFromText(params.resultText) || "completed"; } @@ -871,6 +931,7 @@ interface LocalGenerationPendingState { expectedCount: number; userTurnCountAtStart: number; timestamp: number; + requestedCardIds?: string[] | null; } const AGENT_PENDING_GENERATION_STORAGE_KEY = "ai-chatbot-agent-pending-generations"; @@ -916,7 +977,22 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] { Number.isFinite(timestamp) && now - timestamp < AGENT_PENDING_GENERATION_EXPIRY_MS ); - }) as LocalGenerationPendingState[]; + }).map((item) => { + const record = item as Record; + return { + chatId: String(record.chatId || "").trim(), + modeType: String(record.modeType || "").trim() as AgentGenerationResultModeType, + expectedCount: Number(record.expectedCount), + userTurnCountAtStart: Number(record.userTurnCountAtStart), + timestamp: Number(record.timestamp), + requestedCardIds: Array.isArray(record.requestedCardIds) + ? record.requestedCardIds + .map((entry) => String(entry || "").trim()) + .filter(Boolean) + .slice(0, MAX_AGENT_MODE_REFERENCE_COUNT) + : null, + } satisfies LocalGenerationPendingState; + }); } catch { return []; } @@ -1069,8 +1145,10 @@ export function AgentGenerationWorkspaceDarkV2({ const workspaceStateSignatureRef = useRef(""); const generationRequestSnapshotRef = useRef<{ hiddenPrompt: string | null; + replacementExecutionCardIds: string[] | null; }>({ hiddenPrompt: null, + replacementExecutionCardIds: null, }); const [localAgentModeState, setLocalAgentModeState] = useState( () => createEmptyAgentModeWorkspaceState() @@ -1158,6 +1236,16 @@ export function AgentGenerationWorkspaceDarkV2({ const [detailGenerationRunResults, setDetailGenerationRunResults] = useState< DirectDetailGenerationImage[] >([]); + const [replacementResultState, setReplacementResultState] = useState( + () => createEmptyReplacementResultState() + ); + const [replacementExecutionContext, setReplacementExecutionContext] = + useState(null); + const [replacementTextEditTarget, setReplacementTextEditTarget] = + useState(null); + const [replacementRerunTarget, setReplacementRerunTarget] = + useState(null); + const [replacementRerunPrompt, setReplacementRerunPrompt] = useState(""); const closePsdTemplateDialog = useCallback(() => { setIsPsdTemplateDialogOpen(false); setPsdTemplateDialogSourceUrl(null); @@ -1931,6 +2019,11 @@ export function AgentGenerationWorkspaceDarkV2({ fetch: fetchWithErrorHandlers, prepareSendMessagesRequest(request) { const transportState = transportStateRef.current; + const requestedReplacementCardIdSet = new Set( + (generationRequestSnapshotRef.current.replacementExecutionCardIds || []) + .map((item) => String(item || "").trim()) + .filter(Boolean) + ); const resolvedAgentPresetId = requiresPresetSelection ? transportState.selectedAgentPresetId || selectedAgentPresetId || undefined @@ -1964,6 +2057,11 @@ export function AgentGenerationWorkspaceDarkV2({ : transportState.skuReplacementReferenceCards ) .slice(0, MAX_AGENT_MODE_REFERENCE_COUNT) + .filter( + (item) => + requestedReplacementCardIdSet.size === 0 || + requestedReplacementCardIdSet.has(String(item.id || "").trim()) + ) .filter((item) => Boolean(String(item.imageUrl || "").trim())) .map((item) => ({ id: String(item.id || "").slice(0, 128), @@ -2297,15 +2395,157 @@ export function AgentGenerationWorkspaceDarkV2({ })), [replacementGeneratedGroupsAfterLastUser] ); - const latestReplacementResultUrls = useMemo( + const currentReplacementExecutionCardIds = useMemo(() => { + if (currentResultModeType !== "detail_replacement" && currentResultModeType !== "sku_replacement") { + return [] as string[]; + } + + if ( + replacementExecutionContext?.modeType === currentResultModeType && + replacementExecutionContext.cardIds.length > 0 + ) { + return replacementExecutionContext.cardIds; + } + + if ( + pendingGeneration?.modeType === currentResultModeType && + Array.isArray(pendingGeneration.requestedCardIds) && + pendingGeneration.requestedCardIds.length > 0 + ) { + return pendingGeneration.requestedCardIds; + } + + const fallbackCards = + currentResultModeType === "detail_replacement" + ? detailReplacementReferenceCards + : skuReplacementReferenceCards; + return fallbackCards.map((card) => card.id); + }, [ + currentResultModeType, + detailReplacementReferenceCards, + pendingGeneration?.modeType, + pendingGeneration?.requestedCardIds, + replacementExecutionContext, + skuReplacementReferenceCards, + ]); + useEffect(() => { + setReplacementResultState((current) => { + const detailCardIds = new Set(detailReplacementReferenceCards.map((card) => card.id)); + const skuCardIds = new Set(skuReplacementReferenceCards.map((card) => card.id)); + let changed = false; + + const nextDetail = Object.fromEntries( + Object.entries(current.detail_replacement).filter(([cardId]) => detailCardIds.has(cardId)) + ); + if (Object.keys(nextDetail).length !== Object.keys(current.detail_replacement).length) { + changed = true; + } + + const nextSku = Object.fromEntries( + Object.entries(current.sku_replacement).filter(([cardId]) => skuCardIds.has(cardId)) + ); + if (Object.keys(nextSku).length !== Object.keys(current.sku_replacement).length) { + changed = true; + } + + if (!changed) { + return current; + } + + return { + detail_replacement: nextDetail, + sku_replacement: nextSku, + }; + }); + }, [detailReplacementReferenceCards, skuReplacementReferenceCards]); + useEffect(() => { + if ( + (currentResultModeType !== "detail_replacement" && currentResultModeType !== "sku_replacement") || + latestReplacementResults.length === 0 + ) { + return; + } + + const cardIds = currentReplacementExecutionCardIds; + if (cardIds.length === 0) { + return; + } + + setReplacementResultState((current) => { + const nextModeState = { ...current[currentResultModeType] }; + let changed = false; + + latestReplacementResults.forEach((result, index) => { + const cardId = cardIds[index]; + if (!cardId) { + return; + } + const inferredStatus = inferReplacementCardStatusFromText(result.text); + const nextRecord: ReplacementResultRecord = { + url: result.url || null, + text: result.text || "", + status: + inferredStatus === "passthrough" || inferredStatus === "fallback" + ? inferredStatus + : "completed", + updatedAt: Date.now(), + }; + const previousRecord = nextModeState[cardId]; + if ( + previousRecord?.url === nextRecord.url && + previousRecord?.text === nextRecord.text && + previousRecord?.status === nextRecord.status + ) { + return; + } + nextModeState[cardId] = nextRecord; + changed = true; + }); + + if (!changed) { + return current; + } + + return { + ...current, + [currentResultModeType]: nextModeState, + }; + }); + }, [currentReplacementExecutionCardIds, currentResultModeType, latestReplacementResults]); + const detailReplacementResultUrls = useMemo( () => - latestReplacementResults - .map((item) => item.url) - .filter((url): url is string => Boolean(url)) - .filter( - (url) => !normalizedDeletedResultCollections[currentResultModeType].includes(url) - ), - [currentResultModeType, latestReplacementResults, normalizedDeletedResultCollections] + detailReplacementReferenceCards + .map((card) => { + const recordUrl = replacementResultState.detail_replacement[card.id]?.url || null; + const fallbackUrl = !recordUrl && card.passthrough ? card.imageUrl || null : null; + const nextUrl = recordUrl || fallbackUrl; + return nextUrl && + !normalizedDeletedResultCollections.detail_replacement.includes(nextUrl) + ? nextUrl + : null; + }) + .filter((url): url is string => Boolean(url)), + [ + detailReplacementReferenceCards, + normalizedDeletedResultCollections.detail_replacement, + replacementResultState.detail_replacement, + ] + ); + const skuReplacementResultUrls = useMemo( + () => + skuReplacementReferenceCards + .map((card) => { + const nextUrl = replacementResultState.sku_replacement[card.id]?.url || null; + return nextUrl && !normalizedDeletedResultCollections.sku_replacement.includes(nextUrl) + ? nextUrl + : null; + }) + .filter((url): url is string => Boolean(url)), + [ + normalizedDeletedResultCollections.sku_replacement, + replacementResultState.sku_replacement, + skuReplacementReferenceCards, + ] ); const detailGenerationRunResultUrls = useMemo( () => @@ -2320,8 +2560,10 @@ export function AgentGenerationWorkspaceDarkV2({ const currentLiveResultUrls = currentResultModeType === "detail_generation" ? detailGenerationRunResultUrls - : isReplacementPreset - ? latestReplacementResultUrls + : currentResultModeType === "detail_replacement" + ? detailReplacementResultUrls + : currentResultModeType === "sku_replacement" + ? skuReplacementResultUrls : currentSceneGeneratedUrls; const transportIsGenerating = status === "streaming" || status === "submitted"; const pendingGenerationProgressCount = useMemo(() => { @@ -2378,13 +2620,23 @@ export function AgentGenerationWorkspaceDarkV2({ resultCollections?.detail_generation, normalizedDeletedResultCollections.detail_generation ), - detail_replacement: excludeDeletedResultUrls( - resultCollections?.detail_replacement, - normalizedDeletedResultCollections.detail_replacement + detail_replacement: Array.from( + new Set([ + ...excludeDeletedResultUrls( + resultCollections?.detail_replacement, + normalizedDeletedResultCollections.detail_replacement + ), + ...detailReplacementResultUrls, + ]) ), - sku_replacement: excludeDeletedResultUrls( - resultCollections?.sku_replacement, - normalizedDeletedResultCollections.sku_replacement + sku_replacement: Array.from( + new Set([ + ...excludeDeletedResultUrls( + resultCollections?.sku_replacement, + normalizedDeletedResultCollections.sku_replacement + ), + ...skuReplacementResultUrls, + ]) ), }; @@ -2404,8 +2656,10 @@ export function AgentGenerationWorkspaceDarkV2({ [ currentLiveResultUrls, currentResultModeType, + detailReplacementResultUrls, normalizedDeletedResultCollections, resultCollections, + skuReplacementResultUrls, ] ); const availableResultModeTypes = useMemo( @@ -2429,11 +2683,9 @@ export function AgentGenerationWorkspaceDarkV2({ const detailResultCards = useMemo( () => detailReplacementReferenceCards.map((card, index) => { - const resultMeta = latestReplacementResults[index]; + const resultMeta = replacementResultState.detail_replacement[card.id] || null; const localPassthroughResultUrl = - !resultMeta?.url && card.passthrough && card.imageUrl - ? card.imageUrl - : null; + !resultMeta?.url && card.passthrough && card.imageUrl ? card.imageUrl : null; const resultUrl = resultMeta?.url && !normalizedDeletedResultCollections.detail_replacement.includes(resultMeta.url) @@ -2449,6 +2701,11 @@ export function AgentGenerationWorkspaceDarkV2({ : localPassthroughResultUrl ? MANUAL_PASSTHROUGH_RESULT_TEXT : ""; + const activeRunIndex = + pendingGeneration?.modeType === "detail_replacement" && + Array.isArray(pendingGeneration.requestedCardIds) + ? pendingGeneration.requestedCardIds.indexOf(card.id) + : -1; return { ...card, index, @@ -2458,8 +2715,11 @@ export function AgentGenerationWorkspaceDarkV2({ index, resultUrl, resultText, - generatedCount: latestReplacementResults.length, + generatedCount: pendingGeneration?.modeType === "detail_replacement" + ? latestReplacementResults.length + : 0, isGenerating: isDetailResultModeGenerating, + activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null, }), }; }), @@ -2468,16 +2728,17 @@ export function AgentGenerationWorkspaceDarkV2({ isDetailResultModeGenerating, latestReplacementResults, normalizedDeletedResultCollections.detail_replacement, + pendingGeneration?.modeType, + pendingGeneration?.requestedCardIds, + replacementResultState.detail_replacement, ] ); const skuResultCards = useMemo( () => skuReplacementReferenceCards.map((card, index) => { - const resultMeta = latestReplacementResults[index]; + const resultMeta = replacementResultState.sku_replacement[card.id] || null; const localPassthroughResultUrl = - !resultMeta?.url && card.passthrough && card.imageUrl - ? card.imageUrl - : null; + !resultMeta?.url && card.passthrough && card.imageUrl ? card.imageUrl : null; const resultUrl = resultMeta?.url && !normalizedDeletedResultCollections.sku_replacement.includes(resultMeta.url) @@ -2493,6 +2754,11 @@ export function AgentGenerationWorkspaceDarkV2({ : localPassthroughResultUrl ? MANUAL_PASSTHROUGH_RESULT_TEXT : ""; + const activeRunIndex = + pendingGeneration?.modeType === "sku_replacement" && + Array.isArray(pendingGeneration.requestedCardIds) + ? pendingGeneration.requestedCardIds.indexOf(card.id) + : -1; return { ...card, index, @@ -2502,8 +2768,11 @@ export function AgentGenerationWorkspaceDarkV2({ index, resultUrl, resultText, - generatedCount: latestReplacementResults.length, + generatedCount: pendingGeneration?.modeType === "sku_replacement" + ? latestReplacementResults.length + : 0, isGenerating: isSkuResultModeGenerating, + activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null, }), }; }), @@ -2511,6 +2780,9 @@ export function AgentGenerationWorkspaceDarkV2({ isSkuResultModeGenerating, latestReplacementResults, normalizedDeletedResultCollections.sku_replacement, + pendingGeneration?.modeType, + pendingGeneration?.requestedCardIds, + replacementResultState.sku_replacement, skuReplacementReferenceCards, ] ); @@ -2528,6 +2800,20 @@ export function AgentGenerationWorkspaceDarkV2({ ).length, [skuResultCards] ); + const detailRemainingCount = useMemo( + () => + detailResultCards.filter( + (card) => !["completed", "passthrough", "fallback"].includes(card.status) + ).length, + [detailResultCards] + ); + const skuRemainingCount = useMemo( + () => + skuResultCards.filter( + (card) => !["completed", "passthrough", "fallback"].includes(card.status) + ).length, + [skuResultCards] + ); const detailGenerationExpectedCount = useMemo(() => { if ( pendingGeneration?.modeType === "detail_generation" && @@ -3070,8 +3356,129 @@ export function AgentGenerationWorkspaceDarkV2({ onOpenCanvasEditor?.(); toast.success("已送入画布,可继续改字"); }, [addNode, nodes.length, onOpenCanvasEditor, requestImageNodeAction, selectNodes]); + const handleUpdateReplacementResultRecord = useCallback((params: { + modeType: ReplacementModeType; + cardId: string; + url: string | null; + text?: string | null; + status?: Extract; + }) => { + setReplacementResultState((current) => { + const currentModeState = current[params.modeType]; + const previousRecord = currentModeState[params.cardId]; + const inferredStatus = inferReplacementCardStatusFromText(params.text); + const nextRecord: ReplacementResultRecord = { + url: params.url, + text: String(params.text || "").trim(), + status: + params.status || + (inferredStatus === "passthrough" || inferredStatus === "fallback" + ? inferredStatus + : null) || + previousRecord?.status || + "completed", + updatedAt: Date.now(), + }; - const handleSend = useCallback(() => { + if ( + previousRecord?.url === nextRecord.url && + previousRecord?.text === nextRecord.text && + previousRecord?.status === nextRecord.status + ) { + return current; + } + + return { + ...current, + [params.modeType]: { + ...currentModeState, + [params.cardId]: nextRecord, + }, + }; + }); + }, []); + const handleOpenReplacementTextEdit = useCallback((params: { + modeType: ReplacementModeType; + cardId: string; + imageUrl: string; + title: string; + }) => { + const rawUrl = String(params.imageUrl || "").trim(); + if (!rawUrl) { + toast.error("结果图地址无效"); + return; + } + + setReplacementTextEditTarget({ + open: true, + modeType: params.modeType, + cardId: params.cardId, + imageUrl: rawUrl, + title: String(params.title || "").trim() || "待改字结果", + }); + }, []); + const getReplacementExecutionCards = useCallback( + (params: { + modeType: ReplacementModeType; + requestedCardIds?: string[] | null; + }) => { + const requestedCardIds = (params.requestedCardIds || []) + .map((item) => String(item || "").trim()) + .filter(Boolean); + const references = + params.modeType === "detail_replacement" + ? detailReplacementReferenceCards + : skuReplacementReferenceCards; + const resultCards = + params.modeType === "detail_replacement" ? detailResultCards : skuResultCards; + + if (references.length === 0) { + return { + targetCards: [] as typeof references, + isResume: false, + }; + } + + if (requestedCardIds.length > 0) { + const requestedCardIdSet = new Set(requestedCardIds); + return { + targetCards: references.filter((card) => requestedCardIdSet.has(card.id)), + isResume: false, + }; + } + + const processedStatuses = new Set([ + "completed", + "passthrough", + "fallback", + ]); + const remainingIds = resultCards + .filter((card) => !processedStatuses.has(card.status)) + .map((card) => card.id); + const processedCount = resultCards.length - remainingIds.length; + const shouldResume = + processedCount > 0 && remainingIds.length > 0 && remainingIds.length < references.length; + + return { + targetCards: shouldResume + ? references.filter((card) => remainingIds.includes(card.id)) + : references, + isResume: shouldResume, + }; + }, + [ + detailReplacementReferenceCards, + detailResultCards, + skuReplacementReferenceCards, + skuResultCards, + ] + ); + + const handleSend = useCallback((options?: { + replacementModeType?: ReplacementModeType; + replacementCardIds?: string[] | null; + promptText?: string | null; + }) => { if (isSelectedBusinessModeUnavailable || activeBusinessModeKey === "buyer_show") { toast.error("当前生图模式暂未开放"); return; @@ -3092,6 +3499,32 @@ export function AgentGenerationWorkspaceDarkV2({ toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`); return; } + const replacementModeType = + options?.replacementModeType || + (isDetailReplacementPreset + ? "detail_replacement" + : isSkuReplacementPreset + ? "sku_replacement" + : null); + const requestedReplacementCardIds = (options?.replacementCardIds || []) + .map((item) => String(item || "").trim()) + .filter(Boolean); + const replacementExecutionPlan = + replacementModeType + ? getReplacementExecutionCards({ + modeType: replacementModeType, + requestedCardIds: requestedReplacementCardIds, + }) + : null; + const replacementExecutionCards = replacementExecutionPlan?.targetCards || []; + if (replacementModeType && replacementExecutionCards.length === 0) { + if (requestedReplacementCardIds.length > 0) { + toast.error("当前结果卡片不存在或已被移除"); + return; + } + toast.info("当前结果都已完成,如需调整请使用单张“重新生图”"); + return; + } const uploadedProductAttachmentCount = selectedProductAttachments.length; if (hasAgentUploadSlots && uploadedProductAttachmentCount < requiredProductUploadCount) { toast.error(`当前模式需要上传 ${requiredProductUploadCount} 张产品图`); @@ -3160,7 +3593,18 @@ export function AgentGenerationWorkspaceDarkV2({ }); }); const resolvedWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext; - let textContent = input.trim(); + let textContent = String(options?.promptText || "").trim() || input.trim(); + if (!textContent && replacementModeType && replacementExecutionCards.length === 1) { + textContent = "请重新生成当前这张替换结果,保持版式和主体一致。"; + } + if ( + !textContent && + replacementModeType && + replacementExecutionPlan?.isResume && + replacementExecutionCards.length > 0 + ) { + textContent = "请继续处理当前剩余的替换结果。"; + } if (!textContent && selectedAgentPreset?.type === "text" && resolvedWorkflowContext?.summaryText) { textContent = `请基于当前工作流设定生成方案:${resolvedWorkflowContext.summaryText}`; } @@ -3184,7 +3628,19 @@ export function AgentGenerationWorkspaceDarkV2({ hiddenPromptRef.current = nextHiddenPrompt; generationRequestSnapshotRef.current = { hiddenPrompt: nextHiddenPrompt, + replacementExecutionCardIds: + replacementModeType && replacementExecutionCards.length > 0 + ? replacementExecutionCards.map((card) => card.id) + : null, }; + setReplacementExecutionContext( + replacementModeType && replacementExecutionCards.length > 0 + ? { + modeType: replacementModeType, + cardIds: replacementExecutionCards.map((card) => card.id), + } + : null + ); setActiveResultModeType(currentResultModeType); setSelectedResultUrl(null); setPendingGeneration({ @@ -3193,12 +3649,21 @@ export function AgentGenerationWorkspaceDarkV2({ expectedCount: currentResultModeType === "scene_generation" ? Math.max(1, imageParams.imageCount || 1) - : currentResultModeType === "detail_replacement" - ? detailReplacementReferenceCards.length - : skuReplacementReferenceCards.length, + : replacementModeType && replacementExecutionCards.length > 0 + ? replacementExecutionCards.length + : currentResultModeType === "detail_replacement" + ? detailReplacementReferenceCards.length + : skuReplacementReferenceCards.length, userTurnCountAtStart: userMessageCount, timestamp: Date.now(), + requestedCardIds: + replacementModeType && replacementExecutionCards.length > 0 + ? replacementExecutionCards.map((card) => card.id) + : null, }); + if (replacementExecutionPlan?.isResume && replacementExecutionCards.length > 0) { + toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`); + } try { sendMessage({ role: "user", parts }); if (selectedAgentPreset?.type !== "image") { @@ -3207,6 +3672,7 @@ export function AgentGenerationWorkspaceDarkV2({ } catch (error) { generationRequestSnapshotRef.current = { hiddenPrompt: null, + replacementExecutionCardIds: null, }; setPendingGeneration(null); toast.error(error instanceof Error ? error.message : "发送生成请求失败"); @@ -3214,9 +3680,11 @@ export function AgentGenerationWorkspaceDarkV2({ }, [ attachments, activeBusinessModeKey, + detailResultCards, detailReplacementReferenceCards.length, handleRunAiDetailGeneration, currentResultModeType, + getReplacementExecutionCards, hasAgentUploadSlots, imageParams.imageCount, input, @@ -3235,6 +3703,7 @@ export function AgentGenerationWorkspaceDarkV2({ sendMessage, setPendingGeneration, skuReplacementReferenceCards.length, + skuResultCards, userMessageCount, getLatestWorkflowContext, isSceneGenerationPreset, @@ -3247,10 +3716,52 @@ export function AgentGenerationWorkspaceDarkV2({ const handleStopGeneration = useCallback(() => { detailGenerationCancelRef.current = true; stop(); + generationRequestSnapshotRef.current = { + hiddenPrompt: hiddenPromptRef.current, + replacementExecutionCardIds: null, + }; setPendingGeneration(null); setMessages((current) => current); toast.info("已停止当前生成任务"); }, [setMessages, stop]); + const handleApplyReplacementTextEditResult = useCallback((result: { + originalUrl: string; + thumbnailUrl?: string | null; + model?: string | null; + }) => { + if (!replacementTextEditTarget) { + return; + } + + handleUpdateReplacementResultRecord({ + modeType: replacementTextEditTarget.modeType, + cardId: replacementTextEditTarget.cardId, + url: result.originalUrl, + text: "已在当前页面完成文字编辑。", + status: "completed", + }); + setReplacementTextEditTarget((current) => + current + ? { + ...current, + imageUrl: result.originalUrl, + } + : current + ); + }, [handleUpdateReplacementResultRecord, replacementTextEditTarget]); + const handleConfirmReplacementRerun = useCallback(() => { + if (!replacementRerunTarget) { + return; + } + + handleSend({ + replacementModeType: replacementRerunTarget.modeType, + replacementCardIds: [replacementRerunTarget.cardId], + promptText: replacementRerunPrompt, + }); + setReplacementRerunTarget(null); + setReplacementRerunPrompt(""); + }, [handleSend, replacementRerunPrompt, replacementRerunTarget]); const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => { const raw = String(imageUrl || "").trim(); @@ -5081,7 +5592,7 @@ export function AgentGenerationWorkspaceDarkV2({
- +
+ + {!card.passthrough ? ( + + ) : null} +
) : (
{card.resultUrl - ? card.status === "passthrough" + ? card.status === "generating" + ? "正在重做这一屏,新的结果回来后会直接覆盖当前展示。" + : card.status === "queued" + ? "这一屏已加入当前批量队列,前面的屏完成后会自动继续。" + : card.status === "passthrough" ? "当前参考图未检测到可替换产品,本屏直接沿用参考图展示。" : card.status === "fallback" ? "当前参考图替换失败,本屏先沿用参考图展示。" - : "已生成,可继续逐屏调整文字或重新执行。" + : "已生成,可直接在当前页改字;如果不满意,也可以补充提示词重新生图。" : card.status === "generating" ? "当前正在生成这一屏,请稍候。" : card.status === "queued" ? "前面的屏生成完成后,会继续处理这一屏。" : card.status === "missing" - ? "当前这屏还没有回传结果,可重新执行整套生成。" - : "生成完成后,这里会接入现有无限画布的文字编辑能力。"} + ? "当前这屏还没有回传结果,继续批量时会从这张开始补跑。" + : "生成完成后,这里支持直接在当前页面改字和单张重生。"}
); @@ -6316,18 +6857,38 @@ export function AgentGenerationWorkspaceDarkV2({ > 下载 - +
+ + +
) : (
{card.resultUrl - ? card.status === "passthrough" + ? card.status === "generating" + ? "正在重做这一张,新的结果回来后会直接覆盖当前展示。" + : card.status === "queued" + ? "这一张已加入当前批量队列,前面的图完成后会自动继续。" + : card.status === "passthrough" ? "当前参考图未检测到可替换产品,这一张直接沿用参考图展示。" : card.status === "fallback" ? "当前参考图替换失败,这一张先沿用参考图展示。" - : "已生成,可继续逐张检查并后续批量下载。" + : "已生成,可直接在当前页改字;如果不满意,也可以补充提示词重新生图。" : card.status === "generating" ? "当前正在生成这一张,请稍候。" : card.status === "queued" ? "前面的图生成完成后,会继续处理这一张。" : card.status === "missing" - ? "当前这张还没有回传结果,可重新执行整套生成。" - : "生成完成后,这里会接入统一文字编辑和批量下载能力。"} + ? "当前这张还没有回传结果,继续批量时会从这张开始补跑。" + : "生成完成后,这里支持直接改字、单张重生和批量下载。"}
); @@ -6615,6 +7180,69 @@ export function AgentGenerationWorkspaceDarkV2({ /> ) : null} + { + if (!open) { + setReplacementTextEditTarget(null); + } + }} + onApplySuccess={handleApplyReplacementTextEditResult} + /> + + { + if (!open) { + setReplacementRerunTarget(null); + setReplacementRerunPrompt(""); + } + }} + > + + + 重新生图 + + 当前会只重做这一张结果图,不会把已经完成的其它结果重新跑一遍。 + + +
+
+ {replacementRerunTarget?.title || "当前结果图"} +
+
+
+ 补充提示词 +
+