refactor(*): 稳定期治理 - 收敛核心架构、错误语义并修复跨项目数据竞态
This commit is contained in:
139
web/src/pages/create/creation-conversations.ts
Normal file
139
web/src/pages/create/creation-conversations.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
import type { BackendGenerationResult } from "@/services/api/generation-task";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { creationAttachmentKind, type CreationAttachment } from "./creation-assets";
|
||||
import type { CreationConversation, CreationMessage, CreationShotRailEntry } from "./creation-types";
|
||||
|
||||
type CreationRuntime = typeof import("./creation-runtime");
|
||||
type PersistedCreationTask = GenerationTask & { creationResultUrls?: string[]; creationError?: string };
|
||||
|
||||
export function newConversation(): CreationConversation {
|
||||
return { id: createClientId(), title: "新创作", updatedAt: new Date().toISOString(), messages: [] };
|
||||
}
|
||||
|
||||
export function newMessage(role: CreationMessage["role"], content: string, extra: Partial<CreationMessage> = {}): CreationMessage {
|
||||
return { id: createClientId(), role, content, createdAt: new Date().toISOString(), ...extra };
|
||||
}
|
||||
|
||||
export function creationShotRail(messages: CreationMessage[]): CreationShotRailEntry[] {
|
||||
const rail: CreationShotRailEntry[] = [];
|
||||
let ordinal = 0;
|
||||
let lastIndex = -1;
|
||||
for (const message of messages) {
|
||||
const isVideo = (message.mode || "text") === "video";
|
||||
if (message.role === "user") {
|
||||
if (!isVideo) continue;
|
||||
ordinal += 1;
|
||||
rail.push({ key: message.id, ordinal, user: message });
|
||||
lastIndex = rail.length - 1;
|
||||
} else if (isVideo && lastIndex >= 0 && !rail[lastIndex].result) {
|
||||
rail[lastIndex].result = message;
|
||||
}
|
||||
}
|
||||
return rail;
|
||||
}
|
||||
|
||||
export function creationVideoShotOrdinal(shots: CreationShotRailEntry[], item: CreationMessage): number {
|
||||
const own = shots.find((entry) => item.role === "user" ? entry.user.id === item.id : entry.result?.id === item.id);
|
||||
return own?.ordinal || 0;
|
||||
}
|
||||
|
||||
export function completedCreationGenerationTask(runtime: CreationRuntime, input: { taskId: string; task?: GenerationTask; mode: "image" | "video"; prompt: string; result: BackendGenerationResult; conversationId: string; messageId: string; batchIndex?: number; batchCount?: number }): GenerationTask {
|
||||
const now = new Date().toISOString();
|
||||
const task = input.task ?? { id: input.taskId, type: input.mode, status: "succeeded" as const, prompt: input.prompt, attempts: 1, createdAt: now, updatedAt: now };
|
||||
return runtime.projectGenerationTaskResult({ ...task, status: "succeeded", prompt: input.prompt, clientContext: { conversationId: input.conversationId, messageId: input.messageId, ...(typeof input.batchIndex === "number" ? { batchIndex: input.batchIndex } : {}), ...(typeof input.batchCount === "number" ? { batchCount: input.batchCount } : {}) } }, input.result);
|
||||
}
|
||||
|
||||
export function isVideoAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { url: string } {
|
||||
return creationAttachmentKind(attachment) === "video";
|
||||
}
|
||||
|
||||
export function isImageAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { dataUrl: string; width?: number; height?: number } {
|
||||
return creationAttachmentKind(attachment) === "image";
|
||||
}
|
||||
|
||||
|
||||
export function attachCreationTaskContexts(tasks: GenerationTask[], conversations: CreationConversation[]) {
|
||||
const contexts = new Map<string, { prompt: string; clientContext: NonNullable<GenerationTask["clientContext"]> }>();
|
||||
for (const conversation of conversations) {
|
||||
for (const [messageIndex, message] of conversation.messages.entries()) {
|
||||
if (message.role !== "assistant" || !message.taskIds?.length) continue;
|
||||
const prompt = conversation.messages[messageIndex - 1]?.role === "user" ? conversation.messages[messageIndex - 1].content : "";
|
||||
for (const [batchIndex, taskId] of message.taskIds.entries()) contexts.set(taskId, { prompt, clientContext: { conversationId: conversation.id, messageId: message.id, batchIndex, batchCount: message.taskIds.length } });
|
||||
}
|
||||
}
|
||||
return tasks.map((task) => {
|
||||
const context = contexts.get(task.id);
|
||||
return context ? { ...task, prompt: context.prompt, clientContext: context.clientContext } : task;
|
||||
});
|
||||
}
|
||||
|
||||
export async function materializeCreationTaskResults(runtime: CreationRuntime, tasks: GenerationTask[], signal?: AbortSignal): Promise<PersistedCreationTask[]> {
|
||||
return Promise.all(tasks.map(async (task): Promise<PersistedCreationTask> => {
|
||||
// 文本正文保存在 resultJson,不进入媒体资源化链路。
|
||||
if (task.status !== "succeeded" || !task.clientContext || task.type === "canvas_text") return task;
|
||||
try {
|
||||
const materialized = await runtime.runGenerationConsumer(signal, (managedSignal: AbortSignal) => runtime.materializeGenerationTaskAssets(task, managedSignal));
|
||||
const creationResultUrls = runtime.generationTaskMaterializedUrls(materialized);
|
||||
return creationResultUrls.length ? { ...materialized, creationResultUrls } : materialized;
|
||||
} catch (error) {
|
||||
return { ...task, creationError: error instanceof Error ? error.message : "生成结果资源化失败" };
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
export function reconcileCreationTaskMessages(runtime: CreationRuntime, conversations: CreationConversation[], tasks: PersistedCreationTask[]) {
|
||||
let changed = false;
|
||||
const next = conversations.map((conversation) => {
|
||||
let conversationChanged = false;
|
||||
let completedAt = conversation.updatedAt;
|
||||
const messages = conversation.messages.map((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 = runtime.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;
|
||||
|
||||
const resultUrls = Array.from(new Set(matches.filter((task) => task.status === "succeeded").flatMap(creationTaskResultUrls)));
|
||||
const failedCount = matches.filter((task) => task.status !== "succeeded" || Boolean(task.creationError)).length;
|
||||
const nextTaskIds = Array.from(new Set([...(message.taskIds || []), ...matches.map((task) => task.id)]));
|
||||
completedAt = matches.reduce((latest, task) => conversationTimestamp(task.updatedAt) > conversationTimestamp(latest) ? task.updatedAt : latest, completedAt);
|
||||
conversationChanged = true;
|
||||
changed = true;
|
||||
|
||||
if (resultUrls.length) {
|
||||
const content = message.mode === "video" ? "视频已生成" : failedCount ? `${resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成";
|
||||
return { ...message, status: "done" as const, content, resultUrls, error: undefined, taskIds: nextTaskIds };
|
||||
}
|
||||
if (matches.every((task) => task.status === "cancelled")) {
|
||||
const localOnly = matches.find(runtime.isLocalDreaminaWaitStopped);
|
||||
return { ...message, status: "cancelled" as const, content: localOnly ? runtime.localDreaminaCancellationMessage(localOnly) : "已停止", error: undefined, taskIds: nextTaskIds };
|
||||
}
|
||||
const failed = matches.find((task) => task.status === "failed" || task.creationError);
|
||||
return { ...message, status: "error" as const, content: "生成失败", error: generationErrorMessage(failed?.creationError || failed?.error || "任务已结束,但生成结果暂时无法读取"), taskIds: nextTaskIds };
|
||||
});
|
||||
return conversationChanged ? { ...conversation, messages, updatedAt: completedAt } : conversation;
|
||||
});
|
||||
return changed ? next : conversations;
|
||||
}
|
||||
|
||||
function creationTaskResultUrls(task: PersistedCreationTask) {
|
||||
if (task.creationResultUrls?.length) return task.creationResultUrls;
|
||||
return [];
|
||||
}
|
||||
|
||||
export function conversationTimestamp(value: string) {
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user