From d7a7397d28ca3929349a4de3ef75f98c548a7a09 Mon Sep 17 00:00:00 2001 From: dlh <2773843782@qq.com> Date: Wed, 19 Aug 2026 23:25:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(create):=20=E6=96=87=E6=9C=AC=E7=94=9F?= =?UTF-8?q?=E6=88=90=E4=BB=BB=E5=8A=A1=20-=20=E6=81=A2=E5=A4=8D=E7=BB=88?= =?UTF-8?q?=E6=80=81=E6=AD=A3=E6=96=87=E5=B9=B6=E5=81=9C=E6=AD=A2=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E7=9A=84=E7=94=9F=E6=88=90=E4=B8=AD=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/pages/create/index.tsx | 22 +++++++--- .../services/creation-conversation-store.ts | 11 +++-- .../services/creation-text-task-recovery.ts | 43 +++++++++++++++++++ ...user-scoped-generation-persistence.test.ts | 43 ++++++++++++++++++- 4 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 web/src/services/creation-text-task-recovery.ts diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index ee1a48498..61413ff8d 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -31,7 +31,8 @@ import { uploadImage } from "@/services/image-storage"; import { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync"; import { applyGenerationConsumerEffect } from "@/services/generation-consumer-dedupe"; import { beginGenerationConsumer, runGenerationConsumer } from "@/services/generation-consumer-lifecycle"; -import { loadCreationConversations, pendingCreationMediaKey, pendingCreationTaskIds, removeCreationConversationSnapshot, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store"; +import { loadCreationConversations, pendingCreationTaskIds, pendingCreationTaskKey, removeCreationConversationSnapshot, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store"; +import { recoverCreationTextTask } from "@/services/creation-text-task-recovery"; import { modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useAssetStore, type Asset } from "@/stores/use-asset-store"; import { useUserStore } from "@/stores/use-user-store"; @@ -190,7 +191,7 @@ export default function CreatePage() { const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6; const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]); const isEmpty = !activeConversation?.messages.length; - const pendingMediaKey = useMemo(() => pendingCreationMediaKey(conversations), [conversations]); + const pendingTaskKey = useMemo(() => pendingCreationTaskKey(conversations), [conversations]); const pendingTaskIds = useMemo(() => pendingCreationTaskIds(conversations), [conversations]); const shots = useMemo(() => shotsFromMessages(activeConversation?.messages || []), [activeConversation]); const visibleShotIndex = shots.length ? selectedShotIndex >= 0 && selectedShotIndex < shots.length ? selectedShotIndex : shots.length - 1 : -1; @@ -256,7 +257,7 @@ export default function CreatePage() { }, [conversations, hydrated]); useEffect(() => { - if (!hydrated || !pendingMediaKey || !pendingTaskIds.length) return; + if (!hydrated || !pendingTaskKey || !pendingTaskIds.length) return; let cancelled = false; const observationController = new AbortController(); const applyTasks = async (tasks: GenerationTask[]) => { @@ -292,7 +293,7 @@ export default function CreatePage() { observationController.abort(); unsubscribe(); }; - }, [hydrated, pendingMediaKey, toast]); + }, [hydrated, pendingTaskKey, toast]); useEffect(() => { let cancelled = false; @@ -1555,7 +1556,8 @@ function attachCreationTaskContexts(tasks: GenerationTask[], conversations: Crea async function materializeCreationTaskResults(tasks: GenerationTask[], signal?: AbortSignal): Promise { return Promise.all(tasks.map(async (task): Promise => { - if (task.status !== "succeeded" || !task.clientContext) return task; + // 文本正文保存在 resultJson,不进入媒体资源化链路。 + if (task.status !== "succeeded" || !task.clientContext || task.type === "canvas_text") return task; try { const materialized = await runGenerationConsumer(signal, (managedSignal) => materializeGenerationTaskAssets(task, managedSignal)); const creationResultUrls = generationTaskMaterializedUrls(materialized); @@ -1572,11 +1574,19 @@ function reconcileCreationTaskMessages(conversations: CreationConversation[], ta let conversationChanged = false; let completedAt = conversation.updatedAt; const messages = conversation.messages.map((message) => { - if (message.role !== "assistant" || message.status !== "pending" || message.mode === "text") return message; const taskIds = new Set(message.taskIds || []); const matches = tasks .filter((task) => taskIds.has(task.id) || (task.clientContext?.conversationId === conversation.id && task.clientContext.messageId === message.id)) .sort((left, right) => (left.clientContext?.batchIndex || 0) - (right.clientContext?.batchIndex || 0)); + if (message.role === "assistant" && message.mode === "text") { + const recovery = recoverCreationTextTask(message, matches); + if (!recovery) return message; + completedAt = matches.reduce((latest, task) => conversationTimestamp(task.updatedAt) > conversationTimestamp(latest) ? task.updatedAt : latest, completedAt); + conversationChanged = true; + changed = true; + return { ...message, ...recovery }; + } + if (message.role !== "assistant" || message.status !== "pending") return message; const expectedTaskCount = Math.max(0, ...matches.map((task) => task.clientContext?.batchCount || 0)); if (!matches.length || (expectedTaskCount > 0 && matches.length < expectedTaskCount) || matches.some((task) => task.status === "queued" || task.status === "running")) return message; diff --git a/web/src/services/creation-conversation-store.ts b/web/src/services/creation-conversation-store.ts index 8295e3b4a..0edb8fe4e 100644 --- a/web/src/services/creation-conversation-store.ts +++ b/web/src/services/creation-conversation-store.ts @@ -28,16 +28,21 @@ export function removeCreationConversationSnapshot(con return next; } -export function pendingCreationMediaKey(conversations: StoredCreationConversation[]) { +function isRecoverableCreationMessage(message: PendingCreationMessage) { + if (message.role !== "assistant" || !message.taskIds?.length) return false; + return message.mode === "text" ? message.status === "streaming" || message.status === "pending" : message.status === "pending"; +} + +export function pendingCreationTaskKey(conversations: StoredCreationConversation[]) { return conversations - .flatMap((conversation) => conversation.messages.flatMap((message) => (message.role === "assistant" && message.status === "pending" && message.mode !== "text" ? [`${conversation.id}:${message.id}:${(message.taskIds || []).join(",")}`] : []))) + .flatMap((conversation) => conversation.messages.flatMap((message) => (isRecoverableCreationMessage(message) ? [`${conversation.id}:${message.id}:${(message.taskIds || []).join(",")}`] : []))) .join("|"); } export function pendingCreationTaskIds(conversations: StoredCreationConversation[]) { const taskIds = conversations.flatMap((conversation) => conversation.messages.flatMap((message) => { - if (message.role !== "assistant" || message.status !== "pending" || message.mode === "text") return []; + if (!isRecoverableCreationMessage(message)) return []; return message.taskIds || []; }), ); diff --git a/web/src/services/creation-text-task-recovery.ts b/web/src/services/creation-text-task-recovery.ts new file mode 100644 index 000000000..6f7bf31d6 --- /dev/null +++ b/web/src/services/creation-text-task-recovery.ts @@ -0,0 +1,43 @@ +import { parseBackendGenerationResult } from "@/services/api/generation-task"; +import type { GenerationTask } from "@/services/api/task-center"; + +export type RecoverableCreationTextMessage = { + mode?: string; + status?: string; + content: string; + error?: string; + taskIds?: string[]; +}; + +export type CreationTextTaskRecovery = { + status: "done" | "error" | "cancelled"; + content: string; + error?: string; + taskIds: string[]; +}; + +// 页面重载后只依赖后端任务终态恢复消息,不复用已经中断的浏览器请求。 +export function recoverCreationTextTask(message: RecoverableCreationTextMessage, tasks: GenerationTask[]): CreationTextTaskRecovery | null { + if (message.mode !== "text" || (message.status !== "streaming" && message.status !== "pending")) return null; + + const taskIds = new Set(message.taskIds || []); + const matches = tasks.filter((task) => taskIds.has(task.id)); + if (!matches.length || matches.some((task) => task.status === "queued" || task.status === "running")) return null; + + const nextTaskIds = Array.from(new Set([...(message.taskIds || []), ...matches.map((task) => task.id)])); + const succeeded = matches.find((task) => task.status === "succeeded"); + if (succeeded) { + try { + const text = parseBackendGenerationResult(succeeded).text; + if (!text?.trim()) throw new Error("后端任务没有返回文本"); + return { status: "done", content: text, error: undefined, taskIds: nextTaskIds }; + } catch (error) { + return { status: "error", content: "生成失败", error: error instanceof Error ? error.message : "文本任务结果格式错误", taskIds: nextTaskIds }; + } + } + if (matches.every((task) => task.status === "cancelled")) { + return { status: "cancelled", content: "已停止", error: undefined, taskIds: nextTaskIds }; + } + const failed = matches.find((task) => task.status === "failed"); + return { status: "error", content: "生成失败", error: failed?.error || "文本任务已失败", taskIds: nextTaskIds }; +} diff --git a/web/test/user-scoped-generation-persistence.test.ts b/web/test/user-scoped-generation-persistence.test.ts index 031d13c41..e7639ac90 100644 --- a/web/test/user-scoped-generation-persistence.test.ts +++ b/web/test/user-scoped-generation-persistence.test.ts @@ -8,7 +8,8 @@ import { switchUserStorageScope } from "../src/lib/user-session"; import { canvasCinematicContinuationEntryAdapters } from "../src/components/canvas/canvas-assistant-panel"; import { activeGenerationConsumerController, beginGenerationConsumer, runGenerationConsumer } from "../src/services/generation-consumer-lifecycle"; import { createCanvasGenerationLiveProjectAdapter, persistCanvasCinematicSessionContinuationEffect, persistCanvasGenerationEffect, registerCanvasGenerationLiveProject } from "../src/services/canvas-generation-consumer"; -import { CREATION_CONVERSATIONS_KEY, loadCreationConversations, pendingCreationTaskIds, saveCreationConversations } from "../src/services/creation-conversation-store"; +import { CREATION_CONVERSATIONS_KEY, loadCreationConversations, pendingCreationTaskIds, pendingCreationTaskKey, saveCreationConversations } from "../src/services/creation-conversation-store"; +import { recoverCreationTextTask } from "../src/services/creation-text-task-recovery"; import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore, type Asset, type NewAsset } from "../src/stores/use-asset-store"; import { withGenerationAssetStorageLock } from "../src/services/generation-asset-repository"; import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore, withCanvasStorePersistenceLock, withCanvasStorePersistenceSuppressed, type CanvasProject } from "../src/stores/canvas/use-canvas-store"; @@ -16,6 +17,46 @@ import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, typ import { deleteAssetWithRemoteSync, resetRemoteUserDataSync, syncRemoteUserData, withRemoteUserDataSyncPaused } from "../src/services/user-data-sync"; import { apiClient } from "../src/services/api/request"; +test("creation recovery observes streaming text tasks after reload", () => { + const conversations = [{ + id: "conversation-text-recovery", + messages: [ + { id: "text-streaming", role: "assistant" as const, mode: "text", status: "streaming", taskIds: ["task-text"] }, + { id: "text-done", role: "assistant" as const, mode: "text", status: "done", taskIds: ["task-text-done"] }, + { id: "image-pending", role: "assistant" as const, mode: "image", status: "pending", taskIds: ["task-image"] }, + ], + }]; + + expect(pendingCreationTaskIds(conversations)).toEqual(["task-text", "task-image"]); + expect(pendingCreationTaskKey(conversations)).toContain("conversation-text-recovery:text-streaming:task-text"); +}); + +test("creation recovery restores completed text and terminal status", () => { + const message = { mode: "text", status: "streaming", content: "", taskIds: ["task-text"] }; + const baseTask = { + id: "task-text", + type: "canvas_text", + prompt: "写一篇小说", + attempts: 1, + createdAt: "2026-08-19T22:47:51.000Z", + updatedAt: "2026-08-19T22:48:39.000Z", + }; + + expect(recoverCreationTextTask(message, [{ ...baseTask, status: "running" }])).toBeNull(); + expect(recoverCreationTextTask(message, [{ ...baseTask, status: "succeeded", resultJson: JSON.stringify({ mode: "text", text: "恢复后的正文" }) }])).toEqual({ + status: "done", + content: "恢复后的正文", + error: undefined, + taskIds: ["task-text"], + }); + expect(recoverCreationTextTask(message, [{ ...baseTask, status: "failed", error: "渠道请求失败" }])).toEqual({ + status: "error", + content: "生成失败", + error: "渠道请求失败", + taskIds: ["task-text"], + }); +}); + function generatedAsset(title: string): NewAsset { return { kind: "image",