fix(media): 创作页任务回填与素材入库幂等 (#139)
* feat(media): 创作页 - 回填任务结果并同步素材库 * fix(media): 创作任务 - 回填与素材入库幂等
This commit is contained in:
1 parent
8c31af51d5
commit
78bf6a2c47
3 files changed
+253
-34
No files matched your search
@@ -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);
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+129
-34
@@ -11,17 +11,19 @@ import { canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-referen
|
|||||||
import { createClientId } from "@/lib/client-id";
|
import { createClientId } from "@/lib/client-id";
|
||||||
import { generationErrorMessage } from "@/lib/generation-error";
|
import { generationErrorMessage } from "@/lib/generation-error";
|
||||||
import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
|
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 { requestImageQuestion } from "@/services/api/image";
|
||||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||||
import { listGenerationTasks, queryGenerationTask, type GenerationTask } from "@/services/api/task-center";
|
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 { 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 { 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 CreationMode = "text" | "image" | "video";
|
||||||
type CreationStatus = "streaming" | "pending" | "done" | "error" | "cancelled";
|
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 CreationSettings = { ratio: string; seconds: string; quality: string; videoQuality: string; count: string };
|
||||||
type CreationMessage = {
|
type CreationMessage = {
|
||||||
id: string;
|
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 };
|
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() {
|
export default function CreatePage() {
|
||||||
const { message: toast } = App.useApp();
|
const { message: toast } = App.useApp();
|
||||||
const config = useEffectiveConfig();
|
const config = useEffectiveConfig();
|
||||||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
const updateConfig = useConfigStore((state) => state.updateConfig);
|
||||||
|
const assets = useAssetStore((state) => state.assets);
|
||||||
|
const addAsset = useAssetStore((state) => state.addAsset);
|
||||||
const [conversations, setConversations] = useState<CreationConversation[]>([]);
|
const [conversations, setConversations] = useState<CreationConversation[]>([]);
|
||||||
const [activeId, setActiveId] = useState("");
|
const [activeId, setActiveId] = useState("");
|
||||||
const [hydrated, setHydrated] = useState(false);
|
const [hydrated, setHydrated] = useState(false);
|
||||||
@@ -92,6 +120,7 @@ export default function CreatePage() {
|
|||||||
const threadScrollRef = useRef<HTMLElement>(null);
|
const threadScrollRef = useRef<HTMLElement>(null);
|
||||||
const followLatestMessageRef = useRef(true);
|
const followLatestMessageRef = useRef(true);
|
||||||
const taskSyncWarningRef = useRef(false);
|
const taskSyncWarningRef = useRef(false);
|
||||||
|
const taskSyncInFlightRef = useRef(false);
|
||||||
|
|
||||||
const activeConversation = useMemo(() => conversations.find((item) => item.id === activeId) || conversations[0], [activeId, conversations]);
|
const activeConversation = useMemo(() => conversations.find((item) => item.id === activeId) || conversations[0], [activeId, conversations]);
|
||||||
const historyConversations = useMemo(
|
const historyConversations = useMemo(
|
||||||
@@ -102,6 +131,7 @@ export default function CreatePage() {
|
|||||||
const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]);
|
const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]);
|
||||||
const isEmpty = !activeConversation?.messages.length;
|
const isEmpty = !activeConversation?.messages.length;
|
||||||
const pendingMediaKey = useMemo(() => pendingCreationMediaKey(conversations), [conversations]);
|
const pendingMediaKey = useMemo(() => pendingCreationMediaKey(conversations), [conversations]);
|
||||||
|
const pendingTaskIds = useMemo(() => pendingCreationTaskIds(conversations), [conversations]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -123,15 +153,19 @@ export default function CreatePage() {
|
|||||||
}, [conversations, hydrated]);
|
}, [conversations, hydrated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hydrated || !pendingMediaKey) return;
|
if (!hydrated || !pendingMediaKey || !pendingTaskIds.length) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const syncTasks = async () => {
|
const syncTasks = async () => {
|
||||||
|
if (taskSyncInFlightRef.current) return;
|
||||||
|
taskSyncInFlightRef.current = true;
|
||||||
try {
|
try {
|
||||||
const summaries = await listGenerationTasks(100);
|
const summaries = await listGenerationTasks(100);
|
||||||
const tasks = await enrichCreationTaskSummaries(summaries);
|
const tasks = await enrichCreationTaskSummaries(summaries);
|
||||||
|
const pendingTaskIdSet = new Set(pendingTaskIds);
|
||||||
|
const persistedTasks = await persistCreationTaskResults(tasks.filter((task) => pendingTaskIdSet.has(task.id)));
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
taskSyncWarningRef.current = false;
|
taskSyncWarningRef.current = false;
|
||||||
setConversations((current) => reconcileCreationTaskMessages(current, tasks));
|
setConversations((current) => reconcileCreationTaskMessages(current, persistedTasks));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
console.warn("创作任务状态同步失败", error);
|
console.warn("创作任务状态同步失败", error);
|
||||||
@@ -139,6 +173,8 @@ export default function CreatePage() {
|
|||||||
taskSyncWarningRef.current = true;
|
taskSyncWarningRef.current = true;
|
||||||
toast.warning("任务状态暂时无法同步,请稍后刷新");
|
toast.warning("任务状态暂时无法同步,请稍后刷新");
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
taskSyncInFlightRef.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void syncTasks();
|
void syncTasks();
|
||||||
@@ -147,7 +183,7 @@ export default function CreatePage() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [hydrated, pendingMediaKey, toast]);
|
}, [hydrated, pendingMediaKey, pendingTaskIds, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -192,12 +228,19 @@ export default function CreatePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addAttachments = (files: FileList | File[]) => {
|
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;
|
if (!next.length) return;
|
||||||
void Promise.all(next.slice(0, 6).map(async (file) => {
|
void Promise.allSettled(next.map(async (file) => {
|
||||||
const dataUrl = await readFileAsDataUrl(file);
|
const uploaded = await uploadImage(file);
|
||||||
return { id: createClientId(), name: file.name, type: file.type, dataUrl, previewUrl: dataUrl } satisfies CreationAttachment;
|
const attachment = creationAttachmentFromImage(file, uploaded);
|
||||||
})).then((items) => setAttachments((current) => [...current, ...items].slice(0, 6))).catch(() => toast.error("参考图读取失败,请重试"));
|
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>) => {
|
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 userMessage = newMessage("user", text, { mode, model: selectedModel, attachments, references, settings });
|
||||||
const assistantMessage = newMessage("assistant", "", { mode, model: selectedModel, status: mode === "text" ? "streaming" : "pending", settings });
|
const assistantMessage = newMessage("assistant", "", { mode, model: selectedModel, status: mode === "text" ? "streaming" : "pending", settings });
|
||||||
const boundTaskIds = new Set<string>();
|
const boundTaskIds = new Set<string>();
|
||||||
|
const boundTaskIdsByBatchIndex = new Map<number, string>();
|
||||||
const bindTask = (task: GenerationTask) => {
|
const bindTask = (task: GenerationTask) => {
|
||||||
|
if (typeof task.clientContext?.batchIndex === "number") boundTaskIdsByBatchIndex.set(task.clientContext.batchIndex, task.id);
|
||||||
if (boundTaskIds.has(task.id)) return;
|
if (boundTaskIds.has(task.id)) return;
|
||||||
boundTaskIds.add(task.id);
|
boundTaskIds.add(task.id);
|
||||||
updateAssistant(assistantMessage.id, (item) => ({ ...item, taskIds: Array.from(new Set([...(item.taskIds || []), 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,
|
count: taskCount,
|
||||||
});
|
});
|
||||||
if (controller.signal.aborted) throw new DOMException("Aborted", "AbortError");
|
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 boundTaskIdList = Array.from(boundTaskIds);
|
||||||
const failed = settled.filter((entry): entry is PromiseRejectedResult => entry.status === "rejected");
|
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) {
|
if (!resultUrls.length) {
|
||||||
const reason = failed[0]?.reason;
|
const reason = taskFailures[0]?.reason || resourceFailures[0]?.reason;
|
||||||
throw reason instanceof Error ? reason : new Error("后端任务没有返回图片");
|
throw reason instanceof Error ? reason : new Error("后端任务没有返回图片");
|
||||||
}
|
}
|
||||||
if (failed.length) toast.warning(`${resultUrls.length} 张图片已生成,${failed.length} 张生成失败`);
|
if (failedCount) toast.warning(`${resultUrls.length} 张图片已生成,${failedCount} 张生成失败`);
|
||||||
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done", content: failed.length ? `${resultUrls.length} 张图片已生成,${failed.length} 张失败` : "图片已生成", resultUrls }));
|
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done", content: failedCount ? `${resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成", resultUrls }));
|
||||||
} else {
|
} else {
|
||||||
const result = await runBackendGenerationTask({
|
const result = await runBackendGenerationTask({
|
||||||
mode: "video",
|
mode: "video",
|
||||||
@@ -285,8 +346,11 @@ export default function CreatePage() {
|
|||||||
onTaskUpdate: bindTask,
|
onTaskUpdate: bindTask,
|
||||||
});
|
});
|
||||||
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
|
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
|
||||||
const videoUrl = result.video.dataUrl;
|
const storedVideo = await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" });
|
||||||
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done", content: "视频已生成", resultUrls: [videoUrl] }));
|
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" }));
|
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done" }));
|
||||||
} catch (error) {
|
} 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("|");
|
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[]) {
|
async function enrichCreationTaskSummaries(tasks: GenerationTask[]) {
|
||||||
return Promise.all(tasks.map(async (task) => {
|
return Promise.all(tasks.map(async (task) => {
|
||||||
if (!task.clientContext || (task.status !== "failed" && (task.status !== "succeeded" || task.previewUrl))) return 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;
|
let changed = false;
|
||||||
const next = conversations.map((conversation) => {
|
const next = conversations.map((conversation) => {
|
||||||
let conversationChanged = false;
|
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;
|
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 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.filter((task) => task.status !== "succeeded" || Boolean(task.creationError)).length;
|
||||||
const failedCount = matches.length - succeededCount;
|
|
||||||
const nextTaskIds = Array.from(new Set([...(message.taskIds || []), ...matches.map((task) => task.id)]));
|
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);
|
completedAt = matches.reduce((latest, task) => conversationTimestamp(task.updatedAt) > conversationTimestamp(latest) ? task.updatedAt : latest, completedAt);
|
||||||
conversationChanged = true;
|
conversationChanged = true;
|
||||||
@@ -654,23 +757,17 @@ function reconcileCreationTaskMessages(conversations: CreationConversation[], ta
|
|||||||
return { ...message, status: "done" as const, content, resultUrls, error: undefined, taskIds: nextTaskIds };
|
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 };
|
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");
|
const failed = matches.find((task) => task.status === "failed" || task.creationError);
|
||||||
return { ...message, status: "error" as const, content: "生成失败", error: generationErrorMessage(failed?.error || "任务已结束,但生成结果暂时无法读取"), taskIds: nextTaskIds };
|
return { ...message, status: "error" as const, content: "生成失败", error: generationErrorMessage(failed?.creationError || failed?.error || "任务已结束,但生成结果暂时无法读取"), taskIds: nextTaskIds };
|
||||||
});
|
});
|
||||||
return conversationChanged ? { ...conversation, messages, updatedAt: completedAt } : conversation;
|
return conversationChanged ? { ...conversation, messages, updatedAt: completedAt } : conversation;
|
||||||
});
|
});
|
||||||
return changed ? next : conversations;
|
return changed ? next : conversations;
|
||||||
}
|
}
|
||||||
|
|
||||||
function creationTaskResultUrls(task: GenerationTask) {
|
function creationTaskResultUrls(task: PersistedCreationTask) {
|
||||||
if (task.previewUrl) return [task.previewUrl];
|
if (task.creationResultUrls?.length) return task.creationResultUrls;
|
||||||
if (!task.resultJson) return [];
|
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 conversationTimestamp(value: string) {
|
function conversationTimestamp(value: string) {
|
||||||
@@ -690,5 +787,3 @@ function ratioPreviewStyle(value: string) {
|
|||||||
const scale = Math.min(28 / width, 20 / height);
|
const scale = Math.min(28 / width, 20 / height);
|
||||||
return { width: Math.max(8, width * scale), height: Math.max(8, height * scale) };
|
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); }); }
|
|
||||||
Reference in new issue
Block a user