fix(media): 创作页任务回填与素材入库幂等 (#139)

* feat(media): 创作页 - 回填任务结果并同步素材库

* fix(media): 创作任务 - 回填与素材入库幂等
This commit is contained in:
ahyang
2026-08-05 09:15:49 +08:00
committed by GitHub
parent 99806e8e51
commit 1fe3ce367f
3 changed files with 253 additions and 34 deletions

View File

@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node 原生 TypeScript 测试运行器要求保留扩展名,项目编译器不允许该写法。
// @ts-expect-error -- Node 原生 TypeScript 测试运行器需要保留扩展名。
import { creationAssetKey, isSameCreationAsset } from "./creation-assets.ts";
test("同一任务的同一结果只能匹配同一个素材", () => {
const identity = { taskId: "task-1", resultIndex: 0 };
const asset = { metadata: { creationAssetKey: creationAssetKey(identity) } };
assert.equal(isSameCreationAsset(asset, identity), true);
assert.equal(isSameCreationAsset(asset, { taskId: "task-1", resultIndex: 1 }), false);
});
test("不同任务的结果不能被误判为重复素材", () => {
const asset = { metadata: { creationAssetKey: creationAssetKey({ taskId: "task-1", resultIndex: 0 }) } };
assert.equal(isSameCreationAsset(asset, { taskId: "task-2", resultIndex: 0 }), false);
});
test("修复前只保存任务 ID 的素材首个结果仍可被识别", () => {
const asset = { metadata: { source: "create-generation", taskId: "task-legacy" } };
assert.equal(isSameCreationAsset(asset, { taskId: "task-legacy", resultIndex: 0 }), true);
assert.equal(isSameCreationAsset(asset, { taskId: "task-legacy", resultIndex: 1 }), false);
});

View File

@@ -0,0 +1,97 @@
import type { UploadedFile } from "@/services/file-storage";
import type { UploadedImage } from "@/services/image-storage";
import type { Asset, ImageAsset, NewAsset } from "@/stores/use-asset-store";
import type { ReferenceImage } from "@/types/image";
export type CreationAttachment = ReferenceImage & { previewUrl: string };
export type CreationAssetIdentity = {
taskId?: string;
messageId?: string;
resultIndex?: number;
};
export function creationAssetKey(identity: CreationAssetIdentity): string | undefined {
const taskId = identity.taskId?.trim();
const messageId = identity.messageId?.trim();
const scope = taskId ? `task:${taskId}` : messageId ? `message:${messageId}` : "";
if (!scope) return undefined;
const resultIndex = typeof identity.resultIndex === "number" && Number.isInteger(identity.resultIndex) && identity.resultIndex >= 0 ? identity.resultIndex : 0;
return `create-generation:${scope}:${resultIndex}`;
}
export function isSameCreationAsset(asset: Pick<Asset, "metadata">, identity: CreationAssetIdentity): boolean {
const key = creationAssetKey(identity);
if (!key) return false;
if (asset.metadata?.creationAssetKey === key) return true;
// 兼容修复前已经写入的素材:旧记录没有结果序号,只能将同一任务的首个结果视为已处理。
const isLegacyResult = identity.resultIndex === 0 && typeof identity.taskId === "string";
return isLegacyResult && asset.metadata?.source === "create-generation" && asset.metadata?.taskId === identity.taskId && asset.metadata?.resultIndex === undefined;
}
export function creationAttachmentFromImage(file: File, uploaded: UploadedImage): CreationAttachment {
return {
id: `upload:${file.name}:${uploaded.storageKey}`,
name: file.name,
type: uploaded.mimeType || file.type || "image/png",
dataUrl: uploaded.url,
url: uploaded.url,
storageKey: uploaded.storageKey,
previewUrl: uploaded.url,
};
}
export function creationAttachmentFromAsset(asset: ImageAsset): CreationAttachment {
const url = asset.data.dataUrl || asset.coverUrl;
return {
id: `asset:${asset.id}`,
name: asset.title || "素材图片",
type: asset.data.mimeType || "image/png",
dataUrl: url,
url,
storageKey: asset.data.storageKey,
previewUrl: url,
};
}
export function creationImageAsset({ title, uploaded, metadata }: { title: string; uploaded: UploadedImage; metadata?: Record<string, unknown> }): NewAsset {
return {
kind: "image",
title: title.trim() || "创作图片",
coverUrl: uploaded.url,
tags: ["创作"],
status: "confirmed",
source: "创作页",
metadata: { source: "create-page", ...metadata },
data: {
dataUrl: uploaded.url,
storageKey: uploaded.storageKey,
width: uploaded.width,
height: uploaded.height,
bytes: uploaded.bytes,
mimeType: uploaded.mimeType || "image/png",
},
};
}
export function creationVideoAsset({ title, uploaded, metadata }: { title: string; uploaded: UploadedFile; metadata?: Record<string, unknown> }): NewAsset {
return {
kind: "video",
title: title.trim() || "创作视频",
coverUrl: uploaded.url,
tags: ["创作"],
status: "confirmed",
source: "创作页",
metadata: { source: "create-page", ...metadata },
data: {
url: uploaded.url,
storageKey: uploaded.storageKey,
width: uploaded.width || 0,
height: uploaded.height || 0,
durationMs: uploaded.durationMs,
bytes: uploaded.bytes,
mimeType: uploaded.mimeType || "video/mp4",
},
};
}

View File

@@ -11,17 +11,19 @@ import { canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-referen
import { createClientId } from "@/lib/client-id";
import { generationErrorMessage } from "@/lib/generation-error";
import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
import { parseBackendGenerationResult, runBackendGenerationTask, runBackendGenerationTaskBatch } from "@/services/api/generation-task";
import { parseBackendGenerationResult, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task";
import { requestImageQuestion } from "@/services/api/image";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { listGenerationTasks, queryGenerationTask, type GenerationTask } from "@/services/api/task-center";
import { storeGeneratedVideo } from "@/services/api/video";
import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage";
import { modelDisplayName, modelOptionName, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import type { ReferenceImage } from "@/types/image";
import { useAssetStore, type NewAsset } from "@/stores/use-asset-store";
import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, selectedCreationReferences, type CreationReference } from "./creation-references";
import { creationAssetKey, creationAttachmentFromImage, creationImageAsset, creationVideoAsset, isSameCreationAsset, type CreationAssetIdentity, type CreationAttachment } from "./creation-assets";
type CreationMode = "text" | "image" | "video";
type CreationStatus = "streaming" | "pending" | "done" | "error" | "cancelled";
type CreationAttachment = ReferenceImage & { previewUrl: string };
type CreationSettings = { ratio: string; seconds: string; quality: string; videoQuality: string; count: string };
type CreationMessage = {
id: string;
@@ -68,10 +70,36 @@ function newMessage(role: CreationMessage["role"], content: string, extra: Parti
return { id: createClientId(), role, content, createdAt: new Date().toISOString(), ...extra };
}
type CreationImageResult = NonNullable<BackendGenerationResult["images"]>[number];
async function persistCreationImageResult(image: CreationImageResult): Promise<UploadedImage> {
if (!image.storageKey) return uploadImage(image.dataUrl);
const url = await resolveImageUrl(image.storageKey, image.dataUrl);
if (!url) throw new Error("图片结果资源不可用");
return {
url,
storageKey: image.storageKey,
width: image.width || 1024,
height: image.height || 1024,
bytes: image.bytes || 0,
mimeType: image.mimeType || "image/png",
};
}
function addCreationAssetOnce(asset: NewAsset, identity: CreationAssetIdentity) {
const store = useAssetStore.getState();
const key = creationAssetKey(identity);
if (key && store.assets.some((existing) => isSameCreationAsset(existing, identity))) return false;
store.addAsset(key ? { ...asset, metadata: { ...asset.metadata, creationAssetKey: key } } : asset);
return true;
}
export default function CreatePage() {
const { message: toast } = App.useApp();
const config = useEffectiveConfig();
const updateConfig = useConfigStore((state) => state.updateConfig);
const assets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset);
const [conversations, setConversations] = useState<CreationConversation[]>([]);
const [activeId, setActiveId] = useState("");
const [hydrated, setHydrated] = useState(false);
@@ -92,6 +120,7 @@ export default function CreatePage() {
const threadScrollRef = useRef<HTMLElement>(null);
const followLatestMessageRef = useRef(true);
const taskSyncWarningRef = useRef(false);
const taskSyncInFlightRef = useRef(false);
const activeConversation = useMemo(() => conversations.find((item) => item.id === activeId) || conversations[0], [activeId, conversations]);
const historyConversations = useMemo(
@@ -102,6 +131,7 @@ export default function CreatePage() {
const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]);
const isEmpty = !activeConversation?.messages.length;
const pendingMediaKey = useMemo(() => pendingCreationMediaKey(conversations), [conversations]);
const pendingTaskIds = useMemo(() => pendingCreationTaskIds(conversations), [conversations]);
useEffect(() => {
let cancelled = false;
@@ -123,15 +153,19 @@ export default function CreatePage() {
}, [conversations, hydrated]);
useEffect(() => {
if (!hydrated || !pendingMediaKey) return;
if (!hydrated || !pendingMediaKey || !pendingTaskIds.length) return;
let cancelled = false;
const syncTasks = async () => {
if (taskSyncInFlightRef.current) return;
taskSyncInFlightRef.current = true;
try {
const summaries = await listGenerationTasks(100);
const tasks = await enrichCreationTaskSummaries(summaries);
const pendingTaskIdSet = new Set(pendingTaskIds);
const persistedTasks = await persistCreationTaskResults(tasks.filter((task) => pendingTaskIdSet.has(task.id)));
if (cancelled) return;
taskSyncWarningRef.current = false;
setConversations((current) => reconcileCreationTaskMessages(current, tasks));
setConversations((current) => reconcileCreationTaskMessages(current, persistedTasks));
} catch (error) {
if (cancelled) return;
console.warn("创作任务状态同步失败", error);
@@ -139,6 +173,8 @@ export default function CreatePage() {
taskSyncWarningRef.current = true;
toast.warning("任务状态暂时无法同步,请稍后刷新");
}
} finally {
taskSyncInFlightRef.current = false;
}
};
void syncTasks();
@@ -147,7 +183,7 @@ export default function CreatePage() {
cancelled = true;
window.clearInterval(timer);
};
}, [hydrated, pendingMediaKey, toast]);
}, [hydrated, pendingMediaKey, pendingTaskIds, toast]);
useEffect(() => {
let cancelled = false;
@@ -192,12 +228,19 @@ export default function CreatePage() {
};
const addAttachments = (files: FileList | File[]) => {
const next = Array.from(files).filter((file) => file.type.startsWith("image/"));
const next = Array.from(files).filter((file) => file.type.startsWith("image/")).slice(0, Math.max(0, 6 - attachments.length));
if (!next.length) return;
void Promise.all(next.slice(0, 6).map(async (file) => {
const dataUrl = await readFileAsDataUrl(file);
return { id: createClientId(), name: file.name, type: file.type, dataUrl, previewUrl: dataUrl } satisfies CreationAttachment;
})).then((items) => setAttachments((current) => [...current, ...items].slice(0, 6))).catch(() => toast.error("参考图读取失败,请重试"));
void Promise.allSettled(next.map(async (file) => {
const uploaded = await uploadImage(file);
const attachment = creationAttachmentFromImage(file, uploaded);
addAsset(creationImageAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }));
return attachment;
})).then((settled) => {
const items = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
const failed = settled.filter((entry) => entry.status === "rejected");
if (items.length) setAttachments((current) => [...current, ...items].slice(0, 6));
if (failed.length) toast.error(`${failed.length} 张参考图片上传失败,请重试`);
});
};
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
@@ -226,7 +269,9 @@ export default function CreatePage() {
const userMessage = newMessage("user", text, { mode, model: selectedModel, attachments, references, settings });
const assistantMessage = newMessage("assistant", "", { mode, model: selectedModel, status: mode === "text" ? "streaming" : "pending", settings });
const boundTaskIds = new Set<string>();
const boundTaskIdsByBatchIndex = new Map<number, string>();
const bindTask = (task: GenerationTask) => {
if (typeof task.clientContext?.batchIndex === "number") boundTaskIdsByBatchIndex.set(task.clientContext.batchIndex, task.id);
if (boundTaskIds.has(task.id)) return;
boundTaskIds.add(task.id);
updateAssistant(assistantMessage.id, (item) => ({ ...item, taskIds: Array.from(new Set([...(item.taskIds || []), task.id])) }));
@@ -266,14 +311,30 @@ export default function CreatePage() {
count: taskCount,
});
if (controller.signal.aborted) throw new DOMException("Aborted", "AbortError");
const resultUrls = settled.flatMap((entry) => entry.status === "fulfilled" ? (entry.value.images || []).map((image) => image.dataUrl).filter(Boolean) : []);
const failed = settled.filter((entry): entry is PromiseRejectedResult => entry.status === "rejected");
const boundTaskIdList = Array.from(boundTaskIds);
const generatedImages = settled.flatMap((entry, batchIndex) => {
if (entry.status !== "fulfilled") return [];
return (entry.value.images || []).map((image, resultIndex) => ({
image,
taskId: boundTaskIdsByBatchIndex.get(batchIndex) || boundTaskIdList[batchIndex],
resultIndex,
}));
});
const taskFailures = settled.filter((entry): entry is PromiseRejectedResult => entry.status === "rejected");
const storedImages = await Promise.allSettled(generatedImages.map(async ({ image, taskId, resultIndex }) => {
const uploaded = await persistCreationImageResult(image);
addCreationAssetOnce(creationImageAsset({ title: expandedPrompt.slice(0, 24), uploaded, metadata: { source: "create-generation", conversationId: activeConversation.id, messageId: assistantMessage.id, taskId, taskIds: boundTaskIdList, resultIndex, prompt: expandedPrompt } }), { taskId, messageId: assistantMessage.id, resultIndex });
return uploaded.url;
}));
const resultUrls = storedImages.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
const resourceFailures = storedImages.filter((entry) => entry.status === "rejected");
const failedCount = taskFailures.length + resourceFailures.length;
if (!resultUrls.length) {
const reason = failed[0]?.reason;
const reason = taskFailures[0]?.reason || resourceFailures[0]?.reason;
throw reason instanceof Error ? reason : new Error("后端任务没有返回图片");
}
if (failed.length) toast.warning(`${resultUrls.length} 张图片已生成,${failed.length} 张生成失败`);
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done", content: failed.length ? `${resultUrls.length} 张图片已生成,${failed.length} 张失败` : "图片已生成", resultUrls }));
if (failedCount) toast.warning(`${resultUrls.length} 张图片已生成,${failedCount} 张生成失败`);
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done", content: failedCount ? `${resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成", resultUrls }));
} else {
const result = await runBackendGenerationTask({
mode: "video",
@@ -285,8 +346,11 @@ export default function CreatePage() {
onTaskUpdate: bindTask,
});
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
const videoUrl = result.video.dataUrl;
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done", content: "视频已生成", resultUrls: [videoUrl] }));
const storedVideo = await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" });
if (!storedVideo.url) throw new Error("视频结果资源不可用");
const taskId = Array.from(boundTaskIds)[0];
addCreationAssetOnce(creationVideoAsset({ title: expandedPrompt.slice(0, 24), uploaded: storedVideo, metadata: { source: "create-generation", conversationId: activeConversation.id, messageId: assistantMessage.id, taskId, taskIds: Array.from(boundTaskIds), resultIndex: 0, prompt: expandedPrompt } }), { taskId, messageId: assistantMessage.id, resultIndex: 0 });
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done", content: "视频已生成", resultUrls: [storedVideo.url] }));
}
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done" }));
} catch (error) {
@@ -619,6 +683,14 @@ function pendingCreationMediaKey(conversations: CreationConversation[]) {
return conversations.flatMap((conversation) => conversation.messages.flatMap((message) => message.role === "assistant" && message.status === "pending" && message.mode !== "text" ? [`${conversation.id}:${message.id}:${(message.taskIds || []).join(",")}`] : [])).join("|");
}
function pendingCreationTaskIds(conversations: CreationConversation[]) {
const taskIds = conversations.flatMap((conversation) => conversation.messages.flatMap((message) => {
if (message.role !== "assistant" || message.status !== "pending" || message.mode === "text") return [];
return message.taskIds || [];
}));
return Array.from(new Set(taskIds));
}
async function enrichCreationTaskSummaries(tasks: GenerationTask[]) {
return Promise.all(tasks.map(async (task) => {
if (!task.clientContext || (task.status !== "failed" && (task.status !== "succeeded" || task.previewUrl))) return task;
@@ -627,7 +699,39 @@ async function enrichCreationTaskSummaries(tasks: GenerationTask[]) {
}));
}
function reconcileCreationTaskMessages(conversations: CreationConversation[], tasks: GenerationTask[]) {
type PersistedCreationTask = GenerationTask & { creationResultUrls?: string[]; creationError?: string };
async function persistCreationTaskResults(tasks: GenerationTask[]): Promise<PersistedCreationTask[]> {
const addAsset = useAssetStore.getState().addAsset;
return Promise.all(tasks.map(async (task): Promise<PersistedCreationTask> => {
if (task.status !== "succeeded" || !task.clientContext) return task;
try {
const result = task.resultJson ? parseBackendGenerationResult(task) : null;
const images = result?.images?.length ? result.images : task.previewUrl && task.previewKind !== "video" ? [{ dataUrl: task.previewUrl }] : [];
if (images.length) {
const storedImages = await Promise.all(images.map(async (image, resultIndex) => {
const uploaded = await persistCreationImageResult(image);
addCreationAssetOnce(creationImageAsset({ title: task.prompt.slice(0, 24), uploaded, metadata: { source: "create-generation", taskId: task.id, conversationId: task.clientContext?.conversationId, messageId: task.clientContext?.messageId, batchIndex: task.clientContext?.batchIndex, resultIndex, prompt: task.prompt } }), { taskId: task.id, messageId: task.clientContext?.messageId, resultIndex });
return uploaded.url;
}));
return { ...task, creationResultUrls: storedImages };
}
const videoUrl = result?.video?.dataUrl || (task.previewKind === "video" ? task.previewUrl : "");
if (videoUrl) {
const storedVideo = await storeGeneratedVideo({ url: videoUrl, mimeType: result?.video?.mimeType || "video/mp4" });
if (!storedVideo.url) throw new Error("视频结果资源不可用");
addCreationAssetOnce(creationVideoAsset({ title: task.prompt.slice(0, 24), uploaded: storedVideo, metadata: { source: "create-generation", taskId: task.id, conversationId: task.clientContext?.conversationId, messageId: task.clientContext?.messageId, batchIndex: task.clientContext?.batchIndex, resultIndex: 0, prompt: task.prompt } }), { taskId: task.id, messageId: task.clientContext?.messageId, resultIndex: 0 });
return { ...task, creationResultUrls: [storedVideo.url] };
}
return task;
} catch (error) {
return { ...task, creationError: error instanceof Error ? error.message : "生成结果资源化失败" };
}
}));
}
function reconcileCreationTaskMessages(conversations: CreationConversation[], tasks: PersistedCreationTask[]) {
let changed = false;
const next = conversations.map((conversation) => {
let conversationChanged = false;
@@ -642,8 +746,7 @@ function reconcileCreationTaskMessages(conversations: CreationConversation[], ta
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 succeededCount = matches.filter((task) => task.status === "succeeded").length;
const failedCount = matches.length - succeededCount;
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;
@@ -654,23 +757,17 @@ function reconcileCreationTaskMessages(conversations: CreationConversation[], ta
return { ...message, status: "done" as const, content, resultUrls, error: undefined, taskIds: nextTaskIds };
}
if (matches.every((task) => task.status === "cancelled")) return { ...message, status: "cancelled" as const, content: "已停止", error: undefined, taskIds: nextTaskIds };
const failed = matches.find((task) => task.status === "failed");
return { ...message, status: "error" as const, content: "生成失败", error: generationErrorMessage(failed?.error || "任务已结束,但生成结果暂时无法读取"), 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: GenerationTask) {
if (task.previewUrl) return [task.previewUrl];
if (!task.resultJson) return [];
try {
const result = parseBackendGenerationResult(task);
return [...(result.images || []).map((image) => image.dataUrl), result.video?.dataUrl].filter((url): url is string => Boolean(url));
} catch {
return [];
}
function creationTaskResultUrls(task: PersistedCreationTask) {
if (task.creationResultUrls?.length) return task.creationResultUrls;
return [];
}
function conversationTimestamp(value: string) {
@@ -690,5 +787,3 @@ function ratioPreviewStyle(value: string) {
const scale = Math.min(28 / width, 20 / height);
return { width: Math.max(8, width * scale), height: Math.max(8, height * scale) };
}
function readFileAsDataUrl(file: File) { return new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(reader.error || new Error("文件读取失败")); reader.readAsDataURL(file); }); }