fix(create): 文本生成任务 - 恢复终态正文并停止错误的生成中状态

This commit is contained in:
dlh
2026-08-19 23:25:03 +08:00
parent 35dee568c9
commit d7a7397d28
4 changed files with 109 additions and 10 deletions

View File

@@ -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<PersistedCreationTask[]> {
return Promise.all(tasks.map(async (task): Promise<PersistedCreationTask> => {
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;

View File

@@ -28,16 +28,21 @@ export function removeCreationConversationSnapshot<T extends { id: string }>(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 || [];
}),
);

View File

@@ -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 };
}

View File

@@ -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",