feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道
Refs #225 Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
138
web/src/lib/asset-storage-revision.ts
Normal file
138
web/src/lib/asset-storage-revision.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
export type AssetStorageDocument = {
|
||||
state: { assets: Asset[] };
|
||||
version: number;
|
||||
storageRevision: number;
|
||||
tombstones: { assets: Record<string, number> };
|
||||
};
|
||||
|
||||
export function parseAssetStorageDocument(value: string | null, fallback: Asset[] = []): AssetStorageDocument {
|
||||
if (!value) {
|
||||
return {
|
||||
state: { assets: fallback },
|
||||
version: 0,
|
||||
storageRevision: 0,
|
||||
tombstones: { assets: {} },
|
||||
};
|
||||
}
|
||||
const parsed = JSON.parse(value) as {
|
||||
state?: { assets?: unknown };
|
||||
version?: unknown;
|
||||
storageRevision?: unknown;
|
||||
tombstones?: { assets?: unknown };
|
||||
};
|
||||
if (!Array.isArray(parsed.state?.assets)) throw new Error("素材持久状态无效");
|
||||
const rawTombstones = parsed.tombstones?.assets;
|
||||
const tombstones =
|
||||
rawTombstones && typeof rawTombstones === "object" && !Array.isArray(rawTombstones) ? Object.fromEntries(Object.entries(rawTombstones).filter((entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1]))) : {};
|
||||
return {
|
||||
state: { assets: parsed.state.assets as Asset[] },
|
||||
version: typeof parsed.version === "number" ? parsed.version : 0,
|
||||
storageRevision: typeof parsed.storageRevision === "number" && Number.isFinite(parsed.storageRevision) ? parsed.storageRevision : 0,
|
||||
tombstones: { assets: tombstones },
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeAssetStorageDocument(document: AssetStorageDocument) {
|
||||
return JSON.stringify(document);
|
||||
}
|
||||
|
||||
function deepEqual(left: unknown, right: unknown) {
|
||||
if (Object.is(left, right)) return true;
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mergeValue(base: unknown, local: unknown, durable: unknown): unknown {
|
||||
if (deepEqual(local, base)) return durable;
|
||||
if (deepEqual(durable, base)) return local;
|
||||
if (isRecord(base) && isRecord(local) && isRecord(durable)) return mergeRecord(base, local, durable);
|
||||
if (!isRecord(base) && isRecord(local) && isRecord(durable)) return mergeRecord({}, local, durable);
|
||||
return durable;
|
||||
}
|
||||
|
||||
function mergeRecord(base: Record<string, unknown>, local: Record<string, unknown>, durable: Record<string, unknown>) {
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const key of new Set([...Object.keys(base), ...Object.keys(local), ...Object.keys(durable)])) {
|
||||
const baseHas = Object.prototype.hasOwnProperty.call(base, key);
|
||||
const localHas = Object.prototype.hasOwnProperty.call(local, key);
|
||||
const durableHas = Object.prototype.hasOwnProperty.call(durable, key);
|
||||
if (!localHas && baseHas) {
|
||||
if (durableHas && !deepEqual(durable[key], base[key])) merged[key] = durable[key];
|
||||
continue;
|
||||
}
|
||||
if (!localHas) {
|
||||
if (durableHas) merged[key] = durable[key];
|
||||
continue;
|
||||
}
|
||||
if (!baseHas) {
|
||||
merged[key] = durableHas ? mergeValue(undefined, local[key], durable[key]) : local[key];
|
||||
continue;
|
||||
}
|
||||
if (!durableHas) {
|
||||
if (!deepEqual(local[key], base[key])) merged[key] = local[key];
|
||||
continue;
|
||||
}
|
||||
merged[key] = mergeValue(base[key], local[key], durable[key]);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function rebaseAssetSnapshot(input: { document: AssetStorageDocument; baseAssets: Asset[]; localAssets: Asset[]; baseRevision: number }) {
|
||||
const nextRevision = input.document.storageRevision + 1;
|
||||
const tombstones = { assets: { ...input.document.tombstones.assets } };
|
||||
const baseById = new Map(input.baseAssets.map((asset) => [asset.id, asset]));
|
||||
const localById = new Map(input.localAssets.map((asset) => [asset.id, asset]));
|
||||
const durableById = new Map(input.document.state.assets.map((asset) => [asset.id, asset]));
|
||||
const assets = [...input.document.state.assets];
|
||||
const positions = new Map(assets.map((asset, index) => [asset.id, index]));
|
||||
|
||||
const remove = (id: string) => {
|
||||
const index = positions.get(id);
|
||||
if (index === undefined) return;
|
||||
assets.splice(index, 1);
|
||||
positions.clear();
|
||||
assets.forEach((asset, position) => positions.set(asset.id, position));
|
||||
};
|
||||
const set = (asset: Asset) => {
|
||||
const index = positions.get(asset.id);
|
||||
if (index === undefined) {
|
||||
positions.set(asset.id, assets.length);
|
||||
assets.push(asset);
|
||||
} else {
|
||||
assets[index] = asset;
|
||||
}
|
||||
};
|
||||
|
||||
for (const id of new Set([...baseById.keys(), ...localById.keys()])) {
|
||||
const base = baseById.get(id);
|
||||
const local = localById.get(id);
|
||||
const durable = durableById.get(id);
|
||||
|
||||
if (base && !local) {
|
||||
remove(id);
|
||||
tombstones.assets[id] = nextRevision;
|
||||
continue;
|
||||
}
|
||||
if (!local || (base && deepEqual(base, local))) continue;
|
||||
if (!durable) {
|
||||
if (base || (tombstones.assets[id] ?? 0) > input.baseRevision) continue;
|
||||
delete tombstones.assets[id];
|
||||
set(local);
|
||||
continue;
|
||||
}
|
||||
delete tombstones.assets[id];
|
||||
set(mergeRecord((base || {}) as unknown as Record<string, unknown>, local as unknown as Record<string, unknown>, durable as unknown as Record<string, unknown>) as unknown as Asset);
|
||||
}
|
||||
|
||||
return {
|
||||
state: { assets },
|
||||
version: input.document.version,
|
||||
storageRevision: nextRevision,
|
||||
tombstones,
|
||||
} satisfies AssetStorageDocument;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export type CanvasAgentOp =
|
||||
| { type: "connect_nodes"; id?: string; fromNodeId: string; toNodeId: string; fromHandleId?: string; toHandleId?: string }
|
||||
| { type: "set_viewport"; viewport: ViewportTransform }
|
||||
| { type: "select_nodes"; ids: string[] }
|
||||
| { type: "run_generation"; nodeId: string; mode?: "text" | "image" | "video" | "audio"; prompt?: string };
|
||||
| { type: "run_generation"; nodeId: string; mode?: "text" | "image" | "video" | "audio"; prompt?: string; retry?: boolean };
|
||||
|
||||
export type CanvasAgentSnapshot = {
|
||||
projectId: string;
|
||||
|
||||
99
web/src/lib/canvas/canvas-asset-handoff.ts
Normal file
99
web/src/lib/canvas/canvas-asset-handoff.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
const ASSET_PARAM = "asset";
|
||||
const unique = (values: Iterable<string>) => Array.from(new Set(Array.from(values, (value) => value.trim()).filter(Boolean)));
|
||||
|
||||
export function creationCanvasHandoffPath(assetIds: Iterable<string>, expectedCount?: number) {
|
||||
const ids = unique(assetIds);
|
||||
if (!ids.length || (expectedCount !== undefined && ids.length !== expectedCount)) return undefined;
|
||||
const params = new URLSearchParams({ mode: "handoff" });
|
||||
ids.forEach((id) => params.append(ASSET_PARAM, id));
|
||||
return `/canvas?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function canvasAssetHandoffIds(searchParams: URLSearchParams) {
|
||||
return unique(searchParams.getAll(ASSET_PARAM));
|
||||
}
|
||||
|
||||
export function consumeCanvasAssetHandoff(searchParams: URLSearchParams) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("mode");
|
||||
next.delete(ASSET_PARAM);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function canvasAssetHandoffPayloads(assets: Asset[], assetIds: Iterable<string>) {
|
||||
const byId = new Map(assets.map((asset) => [asset.id, asset]));
|
||||
const payloads: InsertAssetPayload[] = [];
|
||||
const missingAssetIds: string[] = [];
|
||||
|
||||
unique(assetIds).forEach((assetId) => {
|
||||
const asset = byId.get(assetId);
|
||||
if (asset?.kind === "image") {
|
||||
payloads.push({ kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title, assetId: asset.id });
|
||||
return;
|
||||
}
|
||||
if (asset?.kind === "video") {
|
||||
payloads.push({
|
||||
kind: "video",
|
||||
url: asset.data.url,
|
||||
storageKey: asset.data.storageKey,
|
||||
title: asset.title,
|
||||
width: asset.data.width,
|
||||
height: asset.data.height,
|
||||
durationMs: asset.data.durationMs,
|
||||
bytes: asset.data.bytes,
|
||||
mimeType: asset.data.mimeType,
|
||||
assetId: asset.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
missingAssetIds.push(assetId);
|
||||
});
|
||||
|
||||
return { payloads, missingAssetIds };
|
||||
}
|
||||
|
||||
export function canvasAssetHandoffAttempt(assets: Asset[], searchParams: URLSearchParams) {
|
||||
const assetIds = canvasAssetHandoffIds(searchParams);
|
||||
const { payloads, missingAssetIds } = canvasAssetHandoffPayloads(assets, assetIds);
|
||||
return { kind: assetIds.length && !missingAssetIds.length ? ("ready" as const) : ("retry" as const), assetIds, payloads: missingAssetIds.length ? [] : payloads, missingAssetIds };
|
||||
}
|
||||
|
||||
export function uninsertedCanvasAssetHandoffPayloads(nodes: Iterable<{ metadata?: { assetId?: unknown } }>, payloads: InsertAssetPayload[]) {
|
||||
const insertedAssetIds = new Set(Array.from(nodes, (node) => (typeof node.metadata?.assetId === "string" ? node.metadata.assetId : "")).filter(Boolean));
|
||||
return payloads.filter((payload) => !payload.assetId || !insertedAssetIds.has(payload.assetId));
|
||||
}
|
||||
|
||||
export function creationResultAssetIds(assets: Asset[], input: { messageId: string; taskIds: string[]; resultUrls: string[] }) {
|
||||
const taskOrder = new Map(input.taskIds.map((taskId, index) => [taskId, index]));
|
||||
const candidates = assets
|
||||
.filter((asset) => asset.kind === "image" || asset.kind === "video")
|
||||
.filter((asset) => asset.metadata?.source === "create-generation" || asset.metadata?.source === "generation-task")
|
||||
.filter((asset) => asset.metadata?.messageId === input.messageId || (typeof asset.metadata?.taskId === "string" && taskOrder.has(asset.metadata.taskId)))
|
||||
.sort((left, right) => {
|
||||
const leftTask = typeof left.metadata?.taskId === "string" ? (taskOrder.get(left.metadata.taskId) ?? Number.MAX_SAFE_INTEGER) : 0;
|
||||
const rightTask = typeof right.metadata?.taskId === "string" ? (taskOrder.get(right.metadata.taskId) ?? Number.MAX_SAFE_INTEGER) : 0;
|
||||
if (leftTask !== rightTask) return leftTask - rightTask;
|
||||
const leftResult = typeof left.metadata?.outputIndex === "number" ? left.metadata.outputIndex : typeof left.metadata?.resultIndex === "number" ? left.metadata.resultIndex : 0;
|
||||
const rightResult = typeof right.metadata?.outputIndex === "number" ? right.metadata.outputIndex : typeof right.metadata?.resultIndex === "number" ? right.metadata.resultIndex : 0;
|
||||
return leftResult - rightResult;
|
||||
});
|
||||
const unused = new Set(candidates.map((asset) => asset.id));
|
||||
const allowOrderedFallback = candidates.length === input.resultUrls.length;
|
||||
return input.resultUrls.flatMap((url) => {
|
||||
const exact = candidates.find((asset) => unused.has(asset.id) && (asset.coverUrl === url || (asset.kind === "image" ? asset.data.dataUrl === url : asset.data.url === url)));
|
||||
const fallback = exact || (allowOrderedFallback ? candidates.find((asset) => unused.has(asset.id)) : undefined);
|
||||
if (!fallback) return [];
|
||||
unused.delete(fallback.id);
|
||||
return [fallback.id];
|
||||
});
|
||||
}
|
||||
|
||||
export async function finalizeCanvasAssetHandoff<T extends { id: string }>(input: { searchParams: URLSearchParams; currentNodes: T[]; createdNodes: T[]; persist: (nodes: T[]) => Promise<void> }) {
|
||||
const created = new Set(input.createdNodes.map((node) => node.id));
|
||||
const nodes = [...input.currentNodes.filter((node) => !created.has(node.id)), ...input.createdNodes];
|
||||
await input.persist(nodes);
|
||||
return { nodes, searchParams: consumeCanvasAssetHandoff(input.searchParams) };
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import { compositeEmotionImage } from "@/lib/canvas/canvas-emotion";
|
||||
import { storeGeneratedAudio } from "@/services/api/audio";
|
||||
import { storeGeneratedVideo } from "@/services/api/video";
|
||||
import { parseBackendGenerationResult } from "@/services/api/generation-task";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import type { GenerationTask, GenerationTaskOutput } from "@/services/api/task-center";
|
||||
import { resolveMediaUrl, type UploadedFile } from "@/services/file-storage";
|
||||
import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { applyGenerationConsumerEffect, generationEffectApplied } from "@/services/generation-consumer-dedupe";
|
||||
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas";
|
||||
|
||||
export function generationTaskInput(task: GenerationTask) {
|
||||
@@ -34,15 +36,48 @@ export function generationTaskMode(task: GenerationTask, fallback?: CanvasGenera
|
||||
}
|
||||
|
||||
export function imageMetadata(image: UploadedImage): CanvasNodeMetadata {
|
||||
return { content: image.url, storageKey: image.storageKey, status: "success", naturalWidth: image.width, naturalHeight: image.height, bytes: image.bytes, mimeType: image.mimeType, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined };
|
||||
return {
|
||||
content: image.url,
|
||||
storageKey: image.storageKey,
|
||||
status: "success",
|
||||
naturalWidth: image.width,
|
||||
naturalHeight: image.height,
|
||||
bytes: image.bytes,
|
||||
mimeType: image.mimeType,
|
||||
errorDetails: undefined,
|
||||
generationErrorCode: undefined,
|
||||
failedPromptFingerprint: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function videoMetadata(video: UploadedFile): CanvasNodeMetadata {
|
||||
return { content: video.url, storageKey: video.storageKey, status: "success", naturalWidth: video.width, naturalHeight: video.height, bytes: video.bytes, mimeType: video.mimeType || "video/mp4", durationMs: video.durationMs, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined };
|
||||
return {
|
||||
content: video.url,
|
||||
storageKey: video.storageKey,
|
||||
status: "success",
|
||||
naturalWidth: video.width,
|
||||
naturalHeight: video.height,
|
||||
bytes: video.bytes,
|
||||
mimeType: video.mimeType || "video/mp4",
|
||||
durationMs: video.durationMs,
|
||||
errorDetails: undefined,
|
||||
generationErrorCode: undefined,
|
||||
failedPromptFingerprint: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function audioMetadata(audio: UploadedFile): CanvasNodeMetadata {
|
||||
return { content: audio.url, storageKey: audio.storageKey, status: "success", bytes: audio.bytes, mimeType: audio.mimeType || "audio/mpeg", durationMs: audio.durationMs, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined };
|
||||
return {
|
||||
content: audio.url,
|
||||
storageKey: audio.storageKey,
|
||||
status: "success",
|
||||
bytes: audio.bytes,
|
||||
mimeType: audio.mimeType || "audio/mpeg",
|
||||
durationMs: audio.durationMs,
|
||||
errorDetails: undefined,
|
||||
generationErrorCode: undefined,
|
||||
failedPromptFingerprint: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[] = [node]): Promise<CanvasNodeData> {
|
||||
@@ -63,9 +98,10 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
|
||||
if (!sourceDataUrl) throw new Error("无法读取情绪编辑源图片,未使用整图重绘结果");
|
||||
resultDataUrl = await compositeEmotionImage(sourceDataUrl, image.dataUrl, emotionEdit.editRegion, emotionEdit.faceBox);
|
||||
}
|
||||
const uploaded = image.storageKey && !emotionEdit
|
||||
? { url: await resolveImageUrl(image.storageKey, image.dataUrl), storageKey: image.storageKey, width: image.width || 1024, height: image.height || 1024, bytes: image.bytes || 0, mimeType: image.mimeType || "image/png" }
|
||||
: await uploadImage(resultDataUrl);
|
||||
const uploaded =
|
||||
image.storageKey && !emotionEdit
|
||||
? { url: await resolveImageUrl(image.storageKey, image.dataUrl), storageKey: image.storageKey, width: image.width || 1024, height: image.height || 1024, bytes: image.bytes || 0, mimeType: image.mimeType || "image/png" }
|
||||
: await uploadImage(resultDataUrl);
|
||||
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
||||
const requestedImageSize = nodeSizeFromRatio(node.metadata?.size || "auto", imageConfig.width, imageConfig.height);
|
||||
const imageSizeBounds = requestedImageSize || { width: node.width || imageConfig.width, height: node.height || imageConfig.height };
|
||||
@@ -73,9 +109,8 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
|
||||
const resultWidth = image.storageKey && !hasReportedImageSize && requestedImageSize ? requestedImageSize.width : uploaded.width;
|
||||
const resultHeight = image.storageKey && !hasReportedImageSize && requestedImageSize ? requestedImageSize.height : uploaded.height;
|
||||
const normalizedImage = resultWidth === uploaded.width && resultHeight === uploaded.height ? uploaded : { ...uploaded, width: resultWidth, height: resultHeight };
|
||||
const imageSize = node.metadata?.generationType === "edit" && !requestedImageSize
|
||||
? { width: node.width || imageConfig.width, height: node.height || imageConfig.height }
|
||||
: fitNodeSize(resultWidth, resultHeight, imageSizeBounds.width, imageSizeBounds.height);
|
||||
const imageSize =
|
||||
node.metadata?.generationType === "edit" && !requestedImageSize ? { width: node.width || imageConfig.width, height: node.height || imageConfig.height } : fitNodeSize(resultWidth, resultHeight, imageSizeBounds.width, imageSizeBounds.height);
|
||||
return {
|
||||
...node,
|
||||
type: CanvasNodeType.Image,
|
||||
@@ -89,15 +124,28 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
|
||||
if (mode === "video") {
|
||||
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
|
||||
const video = result.video.storageKey
|
||||
? { url: await resolveMediaUrl(result.video.storageKey, result.video.dataUrl), storageKey: result.video.storageKey, width: result.video.width, height: result.video.height, durationMs: result.video.durationMs, bytes: result.video.bytes || 0, mimeType: result.video.mimeType || "video/mp4" }
|
||||
? {
|
||||
url: await resolveMediaUrl(result.video.storageKey, result.video.dataUrl),
|
||||
storageKey: result.video.storageKey,
|
||||
width: result.video.width,
|
||||
height: result.video.height,
|
||||
durationMs: result.video.durationMs,
|
||||
bytes: result.video.bytes || 0,
|
||||
mimeType: result.video.mimeType || "video/mp4",
|
||||
}
|
||||
: await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" });
|
||||
const videoSize = fitNodeSize(video.width || node.width || VIDEO_NODE_MAX_SIZE.width, video.height || node.height || VIDEO_NODE_MAX_SIZE.height, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
|
||||
const geometry = node.metadata?.locked
|
||||
? {}
|
||||
: {
|
||||
width: videoSize.width,
|
||||
height: videoSize.height,
|
||||
position: { x: node.position.x + node.width / 2 - videoSize.width / 2, y: node.position.y + node.height / 2 - videoSize.height / 2 },
|
||||
};
|
||||
return {
|
||||
...node,
|
||||
type: CanvasNodeType.Video,
|
||||
width: videoSize.width,
|
||||
height: videoSize.height,
|
||||
position: { x: node.position.x + node.width / 2 - videoSize.width / 2, y: node.position.y + node.height / 2 - videoSize.height / 2 },
|
||||
...geometry,
|
||||
metadata: { ...node.metadata, ...videoMetadata(video), prompt, ...completedTaskMetadata(task), errorDetails: undefined },
|
||||
};
|
||||
}
|
||||
@@ -111,7 +159,11 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
|
||||
}
|
||||
|
||||
if (!result.text) throw new Error("后端任务没有返回文本");
|
||||
return { ...node, type: CanvasNodeType.Text, metadata: { ...node.metadata, content: result.text, richText: undefined, prompt, ...completedTaskMetadata(task), status: "success", errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } };
|
||||
return {
|
||||
...node,
|
||||
type: CanvasNodeType.Text,
|
||||
metadata: { ...node.metadata, content: result.text, richText: undefined, prompt, ...completedTaskMetadata(task), status: "success", errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined },
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyGenerationTaskResultToNodes(nodes: CanvasNodeData[], task: GenerationTask, targetNodeId?: string) {
|
||||
@@ -119,13 +171,64 @@ export async function applyGenerationTaskResultToNodes(nodes: CanvasNodeData[],
|
||||
if (!node) return { nodes, updated: false, nodeId: "", node: null };
|
||||
const updatedNode = await buildGenerationTaskNodeResult(node, task, nodes);
|
||||
return {
|
||||
nodes: nodes.map((item) => (item.id === node.id ? updatedNode : item)),
|
||||
nodes: applySuccessfulVersionSelection(nodes, updatedNode),
|
||||
updated: true,
|
||||
nodeId: node.id,
|
||||
node: updatedNode,
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyMaterializedGenerationTaskResultToNodes(nodes: CanvasNodeData[], task: GenerationTask, output: GenerationTaskOutput, effectKey: string, targetNodeId?: string) {
|
||||
const node = findGenerationTaskNode(nodes, task, targetNodeId);
|
||||
if (!node) return { nodes, updated: false, nodeId: "", node: null };
|
||||
if (generationEffectApplied(node.metadata || {}, effectKey)) {
|
||||
return { nodes, updated: true, nodeId: node.id, node };
|
||||
}
|
||||
const asset = useAssetStore.getState().assets.find((candidate) => candidate.id === output.materializedAssetId);
|
||||
if (!asset) throw new Error("生成任务输出素材不存在");
|
||||
const result = parseBackendGenerationResult(task);
|
||||
if (asset.kind === "image") {
|
||||
const images = [...(result.images || [])];
|
||||
images[output.outputIndex] = {
|
||||
dataUrl: asset.data.dataUrl || asset.coverUrl,
|
||||
storageKey: asset.data.storageKey,
|
||||
width: asset.data.width,
|
||||
height: asset.data.height,
|
||||
bytes: asset.data.bytes,
|
||||
mimeType: asset.data.mimeType,
|
||||
};
|
||||
result.images = images;
|
||||
} else if (asset.kind === "video") {
|
||||
result.video = { dataUrl: asset.data.url, ...asset.data };
|
||||
} else if (asset.kind === "audio") {
|
||||
result.audio = { dataUrl: asset.data.url, ...asset.data };
|
||||
} else {
|
||||
throw new Error("生成任务输出素材类型不支持画布节点");
|
||||
}
|
||||
const updatedNode = await buildGenerationTaskNodeResult(node, { ...task, resultJson: JSON.stringify(result) }, nodes);
|
||||
const durableNode = {
|
||||
...updatedNode,
|
||||
metadata: applyGenerationConsumerEffect({ ...updatedNode.metadata, assetId: asset.id }, effectKey, (metadata) => metadata).value,
|
||||
};
|
||||
return {
|
||||
nodes: applySuccessfulVersionSelection(nodes, durableNode),
|
||||
updated: true,
|
||||
nodeId: node.id,
|
||||
node: durableNode,
|
||||
};
|
||||
}
|
||||
|
||||
function applySuccessfulVersionSelection(nodes: CanvasNodeData[], updatedNode: CanvasNodeData) {
|
||||
const versionRootId = updatedNode.metadata?.versionOfNodeId;
|
||||
return nodes.map((item) => {
|
||||
if (item.id === updatedNode.id) {
|
||||
return versionRootId ? { ...updatedNode, metadata: { ...updatedNode.metadata, versionPrimary: true } } : updatedNode;
|
||||
}
|
||||
if (!versionRootId || (item.metadata?.versionOfNodeId || item.id) !== versionRootId) return item;
|
||||
return { ...item, metadata: { ...item.metadata, versionPrimary: false } };
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncGenerationTaskToCanvasStore(task: GenerationTask) {
|
||||
if (task.status !== "succeeded" || !task.projectId) return false;
|
||||
const store = useCanvasStore.getState();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type GenerationTask } from "@/services/api/task-center";
|
||||
import { backendProviderConfig, runBackendGenerationTask } from "@/services/api/generation-task";
|
||||
import { backendProviderConfig, runBackendGenerationTask, type GenerationTaskDependencies } from "@/services/api/generation-task";
|
||||
import { configuredModelMatchesCapability, defaultConfig, normalizeModelOptionValue, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
@@ -16,47 +16,62 @@ import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, typ
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
|
||||
export async function runBackendCanvasGenerationTask({
|
||||
projectId,
|
||||
nodeId,
|
||||
mode,
|
||||
prompt,
|
||||
config,
|
||||
referenceImages = [],
|
||||
referenceVideos = [],
|
||||
referenceAudios = [],
|
||||
mask,
|
||||
signal,
|
||||
metadata,
|
||||
onTaskCreated,
|
||||
}: {
|
||||
projectId: string;
|
||||
nodeId: string;
|
||||
mode: CanvasNodeGenerationMode;
|
||||
prompt: string;
|
||||
config: AiConfig;
|
||||
referenceImages?: ReferenceImage[];
|
||||
referenceVideos?: ReferenceVideo[];
|
||||
referenceAudios?: ReferenceAudio[];
|
||||
mask?: ReferenceImage;
|
||||
signal?: AbortSignal;
|
||||
metadata?: Record<string, unknown>;
|
||||
onTaskCreated?: (task: GenerationTask) => void;
|
||||
}) {
|
||||
const normalizedReferenceImages = mode === "image" ? limitCanvasImageReferences(config, referenceImages) : referenceImages;
|
||||
return runBackendGenerationTask({
|
||||
export async function runBackendCanvasGenerationTask(
|
||||
{
|
||||
projectId,
|
||||
nodeId,
|
||||
mode,
|
||||
prompt,
|
||||
config,
|
||||
referenceImages: normalizedReferenceImages,
|
||||
referenceVideos,
|
||||
referenceAudios,
|
||||
referenceImages = [],
|
||||
referenceVideos = [],
|
||||
referenceAudios = [],
|
||||
mask,
|
||||
signal,
|
||||
metadata: { nodeId, ...(mode === "video" && !metadata?.videoEditOperation ? { videoEditOperation: "image_to_video" } : {}), ...metadata },
|
||||
onTaskUpdate: onTaskCreated,
|
||||
});
|
||||
metadata,
|
||||
onTaskCreated,
|
||||
clientOperationId,
|
||||
retryOf,
|
||||
attemptGroupId,
|
||||
}: {
|
||||
projectId: string;
|
||||
nodeId: string;
|
||||
mode: CanvasNodeGenerationMode;
|
||||
prompt: string;
|
||||
config: AiConfig;
|
||||
referenceImages?: ReferenceImage[];
|
||||
referenceVideos?: ReferenceVideo[];
|
||||
referenceAudios?: ReferenceAudio[];
|
||||
mask?: ReferenceImage;
|
||||
signal?: AbortSignal;
|
||||
metadata?: Record<string, unknown>;
|
||||
onTaskCreated?: (task: GenerationTask) => void;
|
||||
clientOperationId?: string;
|
||||
retryOf?: string;
|
||||
attemptGroupId?: string;
|
||||
},
|
||||
dependencies?: GenerationTaskDependencies,
|
||||
) {
|
||||
const normalizedReferenceImages = mode === "image" ? limitCanvasImageReferences(config, referenceImages) : referenceImages;
|
||||
return runBackendGenerationTask(
|
||||
{
|
||||
projectId,
|
||||
mode,
|
||||
prompt,
|
||||
config,
|
||||
referenceImages: normalizedReferenceImages,
|
||||
referenceVideos,
|
||||
referenceAudios,
|
||||
mask,
|
||||
signal,
|
||||
metadata: { nodeId, ...(mode === "video" && !metadata?.videoEditOperation ? { videoEditOperation: "image_to_video" } : {}), ...metadata },
|
||||
onTaskUpdate: onTaskCreated,
|
||||
clientOperationId,
|
||||
retryOf,
|
||||
attemptGroupId,
|
||||
},
|
||||
dependencies,
|
||||
);
|
||||
}
|
||||
|
||||
// Canvas references can include a scene frame plus multiple character turnarounds. Keep
|
||||
@@ -69,13 +84,74 @@ export function limitCanvasImageReferences(config: AiConfig, referenceImages: Re
|
||||
|
||||
export { backendProviderConfig };
|
||||
|
||||
const generationOperationLocks = new Map<string, Promise<unknown>>();
|
||||
|
||||
export function runGenerationOperationOnce<T>(clientOperationId: string | undefined, operation: () => Promise<T>): Promise<T> {
|
||||
if (!clientOperationId) return operation();
|
||||
const existing = generationOperationLocks.get(clientOperationId) as Promise<T> | undefined;
|
||||
if (existing) return existing;
|
||||
const running = operation().finally(() => {
|
||||
if (generationOperationLocks.get(clientOperationId) === running) generationOperationLocks.delete(clientOperationId);
|
||||
});
|
||||
generationOperationLocks.set(clientOperationId, running);
|
||||
return running;
|
||||
}
|
||||
|
||||
export async function runCanvasGenerationTaskToConsumer(
|
||||
input: Parameters<typeof runBackendCanvasGenerationTask>[0],
|
||||
dependencies: {
|
||||
bindTask(task: GenerationTask): void;
|
||||
consumeTask(task: GenerationTask): Promise<void>;
|
||||
runTask?: (options: Parameters<typeof runBackendCanvasGenerationTask>[0]) => ReturnType<typeof runBackendCanvasGenerationTask>;
|
||||
},
|
||||
) {
|
||||
return runGenerationOperationOnce(input.clientOperationId, async () => {
|
||||
let completedTask: GenerationTask | undefined;
|
||||
const result = await (dependencies.runTask ?? runBackendCanvasGenerationTask)({
|
||||
...input,
|
||||
onTaskCreated: (task) => {
|
||||
input.onTaskCreated?.(task);
|
||||
dependencies.bindTask(task);
|
||||
if (task.status === "succeeded") completedTask = task;
|
||||
},
|
||||
});
|
||||
if (!completedTask) throw new Error("生成任务缺少成功终态");
|
||||
await dependencies.consumeTask(completedTask);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export type GenerationRetryContext = {
|
||||
retryOf: string;
|
||||
attemptGroupId: string;
|
||||
clientOperationId: string;
|
||||
};
|
||||
|
||||
export async function createGenerationRetryContext(retryOf: string, attemptGroupId = retryOf): Promise<GenerationRetryContext> {
|
||||
const bytes = new TextEncoder().encode(`generation-retry\0${attemptGroupId}\0${retryOf}`);
|
||||
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
||||
const hex = Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
return { retryOf, attemptGroupId, clientOperationId: `retry:${hex}` };
|
||||
}
|
||||
|
||||
export function createGenerationBatchRetryContexts(taskIds: readonly string[], attemptGroupId: string) {
|
||||
return Promise.all(taskIds.map((taskId) => createGenerationRetryContext(taskId, attemptGroupId)));
|
||||
}
|
||||
|
||||
export function generationTaskMetadata(task: GenerationTask): CanvasNodeMetadata {
|
||||
const progress = normalizeTaskProgress(task.progress, task.status);
|
||||
return {
|
||||
taskId: task.id,
|
||||
taskClientOperationId: task.clientOperationId,
|
||||
retryOf: task.retryOf,
|
||||
attemptGroupId: task.attemptGroupId,
|
||||
taskStatus: task.status,
|
||||
taskProgress: progress,
|
||||
taskStage: task.stage,
|
||||
taskProvider: task.provider,
|
||||
taskErrorCode: task.errorCode,
|
||||
taskOfficialStatus: task.officialStatus,
|
||||
taskReceiptRecorded: task.receiptRecorded,
|
||||
taskCreatedAt: task.createdAt || task.created_at,
|
||||
taskUpdatedAt: task.updatedAt || task.updated_at,
|
||||
};
|
||||
@@ -91,9 +167,16 @@ export function resetGenerationTaskMetadata(metadata: CanvasNodeMetadata | undef
|
||||
failedPromptFingerprint: undefined,
|
||||
};
|
||||
delete next.taskId;
|
||||
delete next.taskClientOperationId;
|
||||
delete next.retryOf;
|
||||
delete next.attemptGroupId;
|
||||
delete next.taskStatus;
|
||||
delete next.taskProgress;
|
||||
delete next.taskStage;
|
||||
delete next.taskProvider;
|
||||
delete next.taskErrorCode;
|
||||
delete next.taskOfficialStatus;
|
||||
delete next.taskReceiptRecorded;
|
||||
delete next.taskCreatedAt;
|
||||
delete next.taskUpdatedAt;
|
||||
return next;
|
||||
@@ -106,7 +189,6 @@ function normalizeTaskProgress(progress: number | undefined, status: GenerationT
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
export function imageExtension(dataUrl: string) {
|
||||
return dataUrl.match(/^data:image[/]([^;]+)/)?.[1] || dataUrl.match(/image[/]([^;]+)/)?.[1] || "png";
|
||||
}
|
||||
@@ -285,7 +367,6 @@ export function getGenerationCount(count: string) {
|
||||
return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(count)) || 1)));
|
||||
}
|
||||
|
||||
|
||||
export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode, requirements?: ModelRequirements): AiConfig {
|
||||
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel;
|
||||
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||
@@ -293,7 +374,14 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
|
||||
const preferredModel = storedModel || resolveCanvasGenerationModel(config, defaultModel, mode) || fallbackModel;
|
||||
const model = resolveCompatibleModel(config, preferredModel, requirements) || preferredModel;
|
||||
const imageProfile = mode === "image" ? modelCapabilityConfigFor(config, model).image! : undefined;
|
||||
const normalizedImage = imageProfile ? normalizeImageValue(imageProfile, { quality: node?.metadata?.quality || config.quality || defaultConfig.quality, size: node?.metadata?.size || config.size || defaultConfig.size, transparentBackground: node?.metadata?.transparentBackground || config.transparentBackground, count: String(node?.metadata?.count || config.canvasImageCount || config.count || defaultConfig.count) }) : undefined;
|
||||
const normalizedImage = imageProfile
|
||||
? normalizeImageValue(imageProfile, {
|
||||
quality: node?.metadata?.quality || config.quality || defaultConfig.quality,
|
||||
size: node?.metadata?.size || config.size || defaultConfig.size,
|
||||
transparentBackground: node?.metadata?.transparentBackground || config.transparentBackground,
|
||||
count: String(node?.metadata?.count || config.canvasImageCount || config.count || defaultConfig.count),
|
||||
})
|
||||
: undefined;
|
||||
return {
|
||||
...config,
|
||||
model,
|
||||
@@ -328,7 +416,12 @@ export function resetInterruptedGeneration(nodes: CanvasNodeData[]) {
|
||||
const configHeight = NODE_DEFAULT_SIZE[CanvasNodeType.Config].height;
|
||||
return nodes.map((node) => {
|
||||
const mediaNode = ensureMediaNodeMinimumSize(node);
|
||||
const resizedNode = mediaNode.type === CanvasNodeType.Config && mediaNode.height < configHeight ? { ...mediaNode, height: configHeight } : mediaNode.type === CanvasNodeType.Script && mediaNode.height < NODE_DEFAULT_SIZE[CanvasNodeType.Script].height ? { ...mediaNode, height: NODE_DEFAULT_SIZE[CanvasNodeType.Script].height } : mediaNode;
|
||||
const resizedNode =
|
||||
mediaNode.type === CanvasNodeType.Config && mediaNode.height < configHeight
|
||||
? { ...mediaNode, height: configHeight }
|
||||
: mediaNode.type === CanvasNodeType.Script && mediaNode.height < NODE_DEFAULT_SIZE[CanvasNodeType.Script].height
|
||||
? { ...mediaNode, height: NODE_DEFAULT_SIZE[CanvasNodeType.Script].height }
|
||||
: mediaNode;
|
||||
return resizedNode.metadata?.status === "loading" ? { ...resizedNode, metadata: { ...resizedNode.metadata, errorDetails: "正在从任务中心恢复生成状态..." } } : resizedNode;
|
||||
});
|
||||
}
|
||||
|
||||
366
web/src/lib/canvas/canvas-storage-revision.ts
Normal file
366
web/src/lib/canvas/canvas-storage-revision.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
export type CanvasStorageTombstones = {
|
||||
projects: Record<string, number>;
|
||||
nodes: Record<string, Record<string, number>>;
|
||||
connections: Record<string, Record<string, number>>;
|
||||
sessions: Record<string, Record<string, number>>;
|
||||
messages: Record<string, Record<string, Record<string, number>>>;
|
||||
};
|
||||
|
||||
export type CanvasStorageDocument = {
|
||||
state: { projects: CanvasProject[] };
|
||||
version: number;
|
||||
storageRevision: number;
|
||||
tombstones: CanvasStorageTombstones;
|
||||
};
|
||||
|
||||
export type CanvasStorageConflict = {
|
||||
kind: "project" | "node" | "connection" | "session" | "message";
|
||||
id: string;
|
||||
projectId?: string;
|
||||
sessionId?: string;
|
||||
reason?: "concurrent-update";
|
||||
};
|
||||
|
||||
const emptyTombstones = (): CanvasStorageTombstones => ({
|
||||
projects: {},
|
||||
nodes: {},
|
||||
connections: {},
|
||||
sessions: {},
|
||||
messages: {},
|
||||
});
|
||||
|
||||
function recordOfNumbers(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1])));
|
||||
}
|
||||
|
||||
function nestedRecords(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, recordOfNumbers(nested)]));
|
||||
}
|
||||
|
||||
function messageRecords(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value).map(([projectId, sessions]) => [projectId, nestedRecords(sessions)]));
|
||||
}
|
||||
|
||||
function normalizeTombstones(value: unknown): CanvasStorageTombstones {
|
||||
const source = value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
return {
|
||||
projects: recordOfNumbers(source.projects),
|
||||
nodes: nestedRecords(source.nodes),
|
||||
connections: nestedRecords(source.connections),
|
||||
sessions: nestedRecords(source.sessions),
|
||||
messages: messageRecords(source.messages),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCanvasStorageDocument(value: string | null, fallback: CanvasProject[] = []): CanvasStorageDocument {
|
||||
if (!value) {
|
||||
return {
|
||||
state: { projects: fallback },
|
||||
version: 0,
|
||||
storageRevision: 0,
|
||||
tombstones: emptyTombstones(),
|
||||
};
|
||||
}
|
||||
const parsed = JSON.parse(value) as {
|
||||
state?: { projects?: unknown };
|
||||
version?: unknown;
|
||||
storageRevision?: unknown;
|
||||
tombstones?: unknown;
|
||||
};
|
||||
if (!Array.isArray(parsed.state?.projects)) throw new Error("画布持久状态无效");
|
||||
return {
|
||||
state: { projects: parsed.state.projects as CanvasProject[] },
|
||||
version: typeof parsed.version === "number" ? parsed.version : 0,
|
||||
storageRevision: typeof parsed.storageRevision === "number" && Number.isFinite(parsed.storageRevision) ? parsed.storageRevision : 0,
|
||||
tombstones: normalizeTombstones(parsed.tombstones),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeCanvasStorageDocument(document: CanvasStorageDocument) {
|
||||
return JSON.stringify(document);
|
||||
}
|
||||
|
||||
function deepEqual(left: unknown, right: unknown) {
|
||||
if (Object.is(left, right)) return true;
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mergeValue(base: unknown, local: unknown, durable: unknown, key?: string, onConflict?: () => void): unknown {
|
||||
if (deepEqual(local, base)) return durable;
|
||||
if (deepEqual(durable, base)) return local;
|
||||
if (deepEqual(local, durable)) return local;
|
||||
if (key === "updatedAt") return durable;
|
||||
if (key === "generationEffectKeys" && Array.isArray(local) && Array.isArray(durable)) {
|
||||
return [...new Set([...durable, ...local])];
|
||||
}
|
||||
if (isRecord(base) && isRecord(local) && isRecord(durable)) return mergeRecord(base, local, durable, new Set(), onConflict);
|
||||
if (!isRecord(base) && isRecord(local) && isRecord(durable)) return mergeRecord({}, local, durable, new Set(), onConflict);
|
||||
onConflict?.();
|
||||
return durable;
|
||||
}
|
||||
|
||||
function mergeRecord(base: Record<string, unknown>, local: Record<string, unknown>, durable: Record<string, unknown>, excluded = new Set<string>(), onConflict?: () => void) {
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const key of new Set([...Object.keys(base), ...Object.keys(local), ...Object.keys(durable)])) {
|
||||
if (excluded.has(key)) continue;
|
||||
const baseHas = Object.prototype.hasOwnProperty.call(base, key);
|
||||
const localHas = Object.prototype.hasOwnProperty.call(local, key);
|
||||
const durableHas = Object.prototype.hasOwnProperty.call(durable, key);
|
||||
if (!localHas && baseHas) {
|
||||
if (durableHas && !deepEqual(durable[key], base[key])) {
|
||||
onConflict?.();
|
||||
merged[key] = durable[key];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!localHas) {
|
||||
if (durableHas) merged[key] = durable[key];
|
||||
continue;
|
||||
}
|
||||
if (!baseHas) {
|
||||
if (!durableHas) merged[key] = local[key];
|
||||
else merged[key] = mergeValue(undefined, local[key], durable[key], key, onConflict);
|
||||
continue;
|
||||
}
|
||||
if (!durableHas) {
|
||||
if (!deepEqual(local[key], base[key])) {
|
||||
onConflict?.();
|
||||
merged[key] = local[key];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
merged[key] = mergeValue(base[key], local[key], durable[key], key, onConflict);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function concurrentConflictRecorder(conflicts: CanvasStorageConflict[], conflict: Omit<CanvasStorageConflict, "reason">) {
|
||||
let recorded = false;
|
||||
return () => {
|
||||
if (recorded) return;
|
||||
recorded = true;
|
||||
conflicts.push({ ...conflict, reason: "concurrent-update" });
|
||||
};
|
||||
}
|
||||
|
||||
function nestedTombstones(parent: Record<string, Record<string, number>>, id: string) {
|
||||
return (parent[id] ||= {});
|
||||
}
|
||||
|
||||
function nestedMessageTombstones(parent: CanvasStorageTombstones["messages"], projectId: string, sessionId: string) {
|
||||
const sessions = (parent[projectId] ||= {});
|
||||
return (sessions[sessionId] ||= {});
|
||||
}
|
||||
|
||||
function mergeEntities<T extends { id: string }>(input: {
|
||||
base: T[];
|
||||
local: T[];
|
||||
durable: T[];
|
||||
tombstones: Record<string, number>;
|
||||
baseRevision: number;
|
||||
nextRevision: number;
|
||||
merge: (base: T | undefined, local: T, durable: T) => T;
|
||||
conflict: (id: string) => CanvasStorageConflict;
|
||||
conflicts: CanvasStorageConflict[];
|
||||
}) {
|
||||
const baseById = new Map(input.base.map((item) => [item.id, item]));
|
||||
const localById = new Map(input.local.map((item) => [item.id, item]));
|
||||
const durableById = new Map(input.durable.map((item) => [item.id, item]));
|
||||
const result = [...input.durable];
|
||||
const resultPositions = new Map(result.map((item, index) => [item.id, index]));
|
||||
|
||||
const remove = (id: string) => {
|
||||
const index = resultPositions.get(id);
|
||||
if (index === undefined) return;
|
||||
result.splice(index, 1);
|
||||
resultPositions.clear();
|
||||
result.forEach((item, position) => resultPositions.set(item.id, position));
|
||||
};
|
||||
const set = (item: T) => {
|
||||
const index = resultPositions.get(item.id);
|
||||
if (index === undefined) {
|
||||
resultPositions.set(item.id, result.length);
|
||||
result.push(item);
|
||||
} else {
|
||||
result[index] = item;
|
||||
}
|
||||
};
|
||||
|
||||
for (const id of new Set([...baseById.keys(), ...localById.keys()])) {
|
||||
const base = baseById.get(id);
|
||||
const local = localById.get(id);
|
||||
const durable = durableById.get(id);
|
||||
|
||||
if (base && !local) {
|
||||
remove(id);
|
||||
input.tombstones[id] = input.nextRevision;
|
||||
continue;
|
||||
}
|
||||
if (!local || (base && deepEqual(base, local))) continue;
|
||||
|
||||
if (!durable) {
|
||||
if (base || (input.tombstones[id] ?? 0) > input.baseRevision) {
|
||||
input.conflicts.push(input.conflict(id));
|
||||
continue;
|
||||
}
|
||||
delete input.tombstones[id];
|
||||
set(local);
|
||||
continue;
|
||||
}
|
||||
|
||||
delete input.tombstones[id];
|
||||
set(input.merge(base, local, durable));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeMessages(input: {
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
base: CanvasAssistantSession["messages"];
|
||||
local: CanvasAssistantSession["messages"];
|
||||
durable: CanvasAssistantSession["messages"];
|
||||
tombstones: CanvasStorageTombstones;
|
||||
baseRevision: number;
|
||||
nextRevision: number;
|
||||
conflicts: CanvasStorageConflict[];
|
||||
}) {
|
||||
return mergeEntities({
|
||||
...input,
|
||||
tombstones: nestedMessageTombstones(input.tombstones.messages, input.projectId, input.sessionId),
|
||||
merge: (base, local, durable) =>
|
||||
mergeRecord(
|
||||
(base || {}) as Record<string, unknown>,
|
||||
local as unknown as Record<string, unknown>,
|
||||
durable as unknown as Record<string, unknown>,
|
||||
new Set(),
|
||||
concurrentConflictRecorder(input.conflicts, { kind: "message", id: local.id, projectId: input.projectId, sessionId: input.sessionId }),
|
||||
) as CanvasAssistantSession["messages"][number],
|
||||
conflict: (id) => ({ kind: "message", id, projectId: input.projectId, sessionId: input.sessionId }),
|
||||
});
|
||||
}
|
||||
|
||||
function mergeSessions(input: {
|
||||
projectId: string;
|
||||
base: CanvasAssistantSession[];
|
||||
local: CanvasAssistantSession[];
|
||||
durable: CanvasAssistantSession[];
|
||||
tombstones: CanvasStorageTombstones;
|
||||
baseRevision: number;
|
||||
nextRevision: number;
|
||||
conflicts: CanvasStorageConflict[];
|
||||
}) {
|
||||
return mergeEntities({
|
||||
...input,
|
||||
tombstones: nestedTombstones(input.tombstones.sessions, input.projectId),
|
||||
merge: (base, local, durable) => {
|
||||
const merged = mergeRecord(
|
||||
(base || {}) as unknown as Record<string, unknown>,
|
||||
local as unknown as Record<string, unknown>,
|
||||
durable as unknown as Record<string, unknown>,
|
||||
new Set(["messages"]),
|
||||
concurrentConflictRecorder(input.conflicts, { kind: "session", id: local.id, projectId: input.projectId }),
|
||||
) as unknown as CanvasAssistantSession;
|
||||
merged.messages = mergeMessages({
|
||||
...input,
|
||||
sessionId: local.id,
|
||||
base: base?.messages || [],
|
||||
local: local.messages,
|
||||
durable: durable.messages,
|
||||
});
|
||||
return merged;
|
||||
},
|
||||
conflict: (id) => ({ kind: "session", id, projectId: input.projectId }),
|
||||
});
|
||||
}
|
||||
|
||||
function mergeProject(base: CanvasProject | undefined, local: CanvasProject, durable: CanvasProject, document: CanvasStorageDocument, baseRevision: number, nextRevision: number, conflicts: CanvasStorageConflict[]) {
|
||||
const common = {
|
||||
projectId: local.id,
|
||||
tombstones: document.tombstones,
|
||||
baseRevision,
|
||||
nextRevision,
|
||||
conflicts,
|
||||
};
|
||||
const merged = mergeRecord(
|
||||
(base || {}) as unknown as Record<string, unknown>,
|
||||
local as unknown as Record<string, unknown>,
|
||||
durable as unknown as Record<string, unknown>,
|
||||
new Set(["nodes", "connections", "chatSessions"]),
|
||||
concurrentConflictRecorder(conflicts, { kind: "project", id: local.id }),
|
||||
) as unknown as CanvasProject;
|
||||
merged.nodes = mergeEntities<CanvasNodeData>({
|
||||
...common,
|
||||
base: base?.nodes || [],
|
||||
local: local.nodes,
|
||||
durable: durable.nodes,
|
||||
tombstones: nestedTombstones(document.tombstones.nodes, local.id),
|
||||
merge: (baseNode, localNode, durableNode) => {
|
||||
let conflictRecorded = false;
|
||||
return mergeRecord((baseNode || {}) as unknown as Record<string, unknown>, localNode as unknown as Record<string, unknown>, durableNode as unknown as Record<string, unknown>, new Set(), () => {
|
||||
if (conflictRecorded) return;
|
||||
conflictRecorded = true;
|
||||
conflicts.push({ kind: "node", id: localNode.id, projectId: local.id, reason: "concurrent-update" });
|
||||
}) as unknown as CanvasNodeData;
|
||||
},
|
||||
conflict: (id) => ({ kind: "node", id, projectId: local.id }),
|
||||
});
|
||||
merged.connections = mergeEntities<CanvasConnection>({
|
||||
...common,
|
||||
base: base?.connections || [],
|
||||
local: local.connections,
|
||||
durable: durable.connections,
|
||||
tombstones: nestedTombstones(document.tombstones.connections, local.id),
|
||||
merge: (baseConnection, localConnection, durableConnection) =>
|
||||
mergeRecord(
|
||||
(baseConnection || {}) as unknown as Record<string, unknown>,
|
||||
localConnection as unknown as Record<string, unknown>,
|
||||
durableConnection as unknown as Record<string, unknown>,
|
||||
new Set(),
|
||||
concurrentConflictRecorder(conflicts, { kind: "connection", id: localConnection.id, projectId: local.id }),
|
||||
) as unknown as CanvasConnection,
|
||||
conflict: (id) => ({ kind: "connection", id, projectId: local.id }),
|
||||
});
|
||||
merged.chatSessions = mergeSessions({
|
||||
...common,
|
||||
base: base?.chatSessions || [],
|
||||
local: local.chatSessions,
|
||||
durable: durable.chatSessions,
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function rebaseCanvasProjects(input: { document: CanvasStorageDocument; baseProjects: CanvasProject[]; localProjects: CanvasProject[]; baseRevision: number }) {
|
||||
const document: CanvasStorageDocument = {
|
||||
...input.document,
|
||||
state: { projects: input.document.state.projects },
|
||||
storageRevision: input.document.storageRevision,
|
||||
tombstones: normalizeTombstones(input.document.tombstones),
|
||||
};
|
||||
const nextRevision = document.storageRevision + 1;
|
||||
const conflicts: CanvasStorageConflict[] = [];
|
||||
document.state.projects = mergeEntities<CanvasProject>({
|
||||
base: input.baseProjects,
|
||||
local: input.localProjects,
|
||||
durable: document.state.projects,
|
||||
tombstones: document.tombstones.projects,
|
||||
baseRevision: input.baseRevision,
|
||||
nextRevision,
|
||||
merge: (base, local, durable) => mergeProject(base, local, durable, document, input.baseRevision, nextRevision, conflicts),
|
||||
conflict: (id) => ({ kind: "project", id }),
|
||||
conflicts,
|
||||
});
|
||||
document.storageRevision = nextRevision;
|
||||
return { document, conflicts };
|
||||
}
|
||||
162
web/src/lib/canvas/local-runtime-connection.ts
Normal file
162
web/src/lib/canvas/local-runtime-connection.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { LocalRuntimeTransport } from "@/services/local-runtime";
|
||||
|
||||
export type LocalRuntimeEvent = {
|
||||
type: string;
|
||||
data: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type LocalRuntimeEventStreamOptions = {
|
||||
signal?: AbortSignal;
|
||||
lastEventId?: string;
|
||||
onEvent(event: LocalRuntimeEvent): void;
|
||||
};
|
||||
|
||||
export class CanvasRuntimeStreamError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CanvasRuntimeStreamError";
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_EVENT_BYTES = 256 * 1024;
|
||||
const AUTO_CONNECT_MODES = new Set(["new", "recent", "choose"]);
|
||||
|
||||
export function shouldAutoConnectCanvasRuntime(params: URLSearchParams) {
|
||||
return AUTO_CONNECT_MODES.has(params.get("mode") ?? "");
|
||||
}
|
||||
|
||||
type CanvasRuntimeStore = {
|
||||
getState(): {
|
||||
connection: string;
|
||||
modules: Array<{ id: string }>;
|
||||
error?: string;
|
||||
connect(signal?: AbortSignal): Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
export async function prepareCanvasRuntimeConnection(store: CanvasRuntimeStore, signal?: AbortSignal) {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
await store.getState().connect(signal);
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
const state = store.getState();
|
||||
if (state.connection !== "connected" || state.error) {
|
||||
throw new CanvasRuntimeStreamError("canvas_runtime_unavailable", "本机运行时尚未连接");
|
||||
}
|
||||
if (!state.modules.some((module) => module.id === "canvas-agent")) {
|
||||
throw new CanvasRuntimeStreamError("canvas_module_unavailable", "Canvas Agent 模块未加载");
|
||||
}
|
||||
}
|
||||
|
||||
export function waitForCanvasRuntimeReconnect(signal: AbortSignal, delayMs = 1_000) {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const finish = () => {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener("abort", finish);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(finish, delayMs);
|
||||
signal.addEventListener("abort", finish, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function postCanvasRuntimeState(client: LocalRuntimeTransport, clientId: string, snapshot: unknown) {
|
||||
try {
|
||||
const response = await client.request(`/canvas/state?clientId=${encodeURIComponent(clientId)}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(snapshot),
|
||||
});
|
||||
if (!response.ok) throw new Error("state rejected");
|
||||
} catch {
|
||||
throw new CanvasRuntimeStreamError("canvas_state_sync_failed", "画布状态同步失败");
|
||||
}
|
||||
}
|
||||
|
||||
export async function consumeLocalRuntimeEventStream(client: LocalRuntimeTransport, path: string, options: LocalRuntimeEventStreamOptions) {
|
||||
if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
const headers = new Headers();
|
||||
if (options.lastEventId) headers.set("Last-Event-ID", options.lastEventId);
|
||||
const response = await client.request(path, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!response.ok) throw new CanvasRuntimeStreamError("canvas_stream_unavailable", "Canvas Agent 事件流不可用");
|
||||
if (!response.headers.get("content-type")?.toLowerCase().startsWith("text/event-stream") || !response.body) {
|
||||
throw new CanvasRuntimeStreamError("canvas_stream_invalid", "Canvas Agent 事件流响应无效");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
let buffer = "";
|
||||
let eventType = "";
|
||||
let eventId: string | undefined;
|
||||
let data: string[] = [];
|
||||
let eventBytes = 0;
|
||||
const dispatch = () => {
|
||||
if (data.length) {
|
||||
options.onEvent({
|
||||
type: eventType || "message",
|
||||
data: data.join("\n"),
|
||||
...(eventId !== undefined ? { id: eventId } : {}),
|
||||
});
|
||||
}
|
||||
eventType = "";
|
||||
eventId = undefined;
|
||||
data = [];
|
||||
eventBytes = 0;
|
||||
};
|
||||
const consumeLine = (line: string) => {
|
||||
eventBytes += new TextEncoder().encode(line).byteLength + 1;
|
||||
if (eventBytes > MAX_EVENT_BYTES) {
|
||||
throw new CanvasRuntimeStreamError("canvas_event_too_large", "Canvas Agent 事件超过安全上限");
|
||||
}
|
||||
if (!line) {
|
||||
dispatch();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith(":")) return;
|
||||
const separator = line.indexOf(":");
|
||||
const field = separator < 0 ? line : line.slice(0, separator);
|
||||
const value = separator < 0 ? "" : line.slice(separator + 1).replace(/^ /, "");
|
||||
if (field === "event") eventType = value;
|
||||
else if (field === "data") data.push(value);
|
||||
else if (field === "id" && !value.includes("\0")) eventId = value;
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const item = await reader.read();
|
||||
if (item.done) break;
|
||||
buffer += decoder.decode(item.value, { stream: true });
|
||||
let newline = buffer.indexOf("\n");
|
||||
while (newline >= 0) {
|
||||
const line = buffer.slice(0, newline).replace(/\r$/, "");
|
||||
buffer = buffer.slice(newline + 1);
|
||||
consumeLine(line);
|
||||
newline = buffer.indexOf("\n");
|
||||
}
|
||||
if (eventBytes + new TextEncoder().encode(buffer).byteLength > MAX_EVENT_BYTES) {
|
||||
throw new CanvasRuntimeStreamError("canvas_event_too_large", "Canvas Agent 事件超过安全上限");
|
||||
}
|
||||
}
|
||||
buffer += decoder.decode();
|
||||
if (buffer) consumeLine(buffer.replace(/\r$/, ""));
|
||||
dispatch();
|
||||
} catch (error) {
|
||||
if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (error instanceof CanvasRuntimeStreamError) throw error;
|
||||
throw new CanvasRuntimeStreamError("canvas_stream_invalid", "Canvas Agent 事件流响应无效");
|
||||
} finally {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
26
web/src/lib/desktop-local-channel.ts
Normal file
26
web/src/lib/desktop-local-channel.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
export const DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL = "http://127.0.0.1:8000";
|
||||
|
||||
// 页面 host 只控制 UI 可见性;真正授权始终由后端 deployment capability 决定。
|
||||
export function desktopLocalChannelUiVisible(desktopLocalChannelsEnabled: boolean, hostname: string) {
|
||||
if (!desktopLocalChannelsEnabled) return false;
|
||||
const normalized = hostname.trim().toLowerCase();
|
||||
return normalized === "localhost" || normalized === "127.0.0.1";
|
||||
}
|
||||
|
||||
export function desktopLocalChannelPayloadValue(desktopLocalChannelsEnabled: boolean, hostname: string, requested: boolean | undefined) {
|
||||
return desktopLocalChannelUiVisible(desktopLocalChannelsEnabled, hostname) && requested === true;
|
||||
}
|
||||
|
||||
export function desktopLocalChannelFormState(desktopLocalChannelsEnabled: boolean, hostname: string, stored: boolean | undefined) {
|
||||
const visible = desktopLocalChannelUiVisible(desktopLocalChannelsEnabled, hostname);
|
||||
return { visible, checked: visible && stored === true };
|
||||
}
|
||||
|
||||
export function projectDesktopLocalChannelRuntime<T extends { allowLocalChannel?: boolean }>(config: T): T {
|
||||
const desktopLocalChannelsEnabled = useUserStore.getState().features.desktopLocalChannelsEnabled;
|
||||
const hostname = typeof window === "undefined" ? "" : window.location.hostname;
|
||||
const allowLocalChannel = desktopLocalChannelPayloadValue(desktopLocalChannelsEnabled, hostname, config.allowLocalChannel);
|
||||
return config.allowLocalChannel === allowLocalChannel ? config : { ...config, allowLocalChannel };
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export function generationFailureMetadata(error: unknown, prompt: string): Gener
|
||||
}
|
||||
|
||||
export function generationErrorMessage(error: unknown) {
|
||||
const stableCode = generationErrorCode(error);
|
||||
const stableMessage = stableCode ? DREAMINA_SUBMIT_ERROR_MESSAGES[stableCode] : undefined;
|
||||
if (stableMessage) return stableMessage;
|
||||
const raw = rawGenerationError(error);
|
||||
if (isContentModerationError(raw)) return CONTENT_MODERATION_MESSAGE;
|
||||
|
||||
@@ -38,6 +41,22 @@ export function generationErrorMessage(error: unknown) {
|
||||
return displayMessage || DEFAULT_GENERATION_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
export const DREAMINA_SUBMIT_ERROR_MESSAGES: Record<string, string> = {
|
||||
dreamina_submit_spawn_failed: "无法启动官方即梦 CLI,任务尚未提交。",
|
||||
dreamina_submit_exit_nonzero: "官方即梦 CLI 未接受本次提交,任务没有自动重试。",
|
||||
dreamina_submit_timeout: "等待官方即梦 CLI 确认提交超时,为避免重复扣费,任务没有自动重试。",
|
||||
dreamina_submit_receipt_missing: "官方即梦 CLI 未返回任务凭证,为避免重复扣费,任务没有自动重试。",
|
||||
dreamina_submission_unknown: "提交结果待确认,为避免重复扣费未自动重试。",
|
||||
};
|
||||
|
||||
export function generationErrorCode(error: unknown) {
|
||||
if (error && typeof error === "object" && "code" in error && typeof (error as { code?: unknown }).code === "string") {
|
||||
const code = (error as { code: string }).code;
|
||||
if (/^(?:dreamina|local_generation|origin)_[a-z0-9_]{2,80}$/.test(code)) return code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isContentModerationError(value: unknown) {
|
||||
const text = value instanceof Error ? value.message : String(value || "");
|
||||
return text.toLowerCase().includes(CONTENT_MODERATION_ERROR_CODE) || text.includes("内容审核未通过");
|
||||
@@ -84,9 +103,7 @@ function extractWrappedProviderMessage(raw: string) {
|
||||
const requestFailure = raw.match(/^Request failed with status code \d{3}\s*[::-]?\s*(.+)$/is);
|
||||
const wrapped = interfaceFailure?.[1] ?? requestFailure?.[1];
|
||||
if (!wrapped) return "";
|
||||
const message = wrapped
|
||||
.replace(/^\d{3}(?:\s+(?:Bad Gateway|Service Unavailable|Gateway Timeout|Internal Server Error|Not Found|Unauthorized|Forbidden|Too Many Requests))?\s*[::-]?\s*/i, "")
|
||||
.trim();
|
||||
const message = wrapped.replace(/^\d{3}(?:\s+(?:Bad Gateway|Service Unavailable|Gateway Timeout|Internal Server Error|Not Found|Unauthorized|Forbidden|Too Many Requests))?\s*[::-]?\s*/i, "").trim();
|
||||
return message && !containsInfrastructureDetails(message) ? message : "";
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,37 @@ export const statusLabel: Record<TaskStatus, string> = {
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
type GenerationTaskDisplayTarget = Pick<GenerationTask, "provider" | "status" | "stage" | "officialStatus"> & Partial<Pick<GenerationTask, "errorCode">>;
|
||||
|
||||
export function isGenerationTaskSubmissionUncertain(task: GenerationTaskDisplayTarget) {
|
||||
return task.provider === "dreamina-cli" && (task.stage === "submission_unknown" || task.errorCode === "dreamina_submission_unknown");
|
||||
}
|
||||
|
||||
export function generationTaskStatusLabel(task: GenerationTaskDisplayTarget) {
|
||||
if (isGenerationTaskSubmissionUncertain(task)) return "提交结果待确认";
|
||||
if (task.provider === "dreamina-cli" && task.stage === "submitting") return "正在提交";
|
||||
if (task.provider === "dreamina-cli" && task.officialStatus === "pending") return "官方排队中";
|
||||
if (task.provider === "dreamina-cli" && task.officialStatus === "processing") return "生成中";
|
||||
if (task.provider === "dreamina-cli" && task.officialStatus === "completed") return "官方已完成";
|
||||
if (task.provider === "dreamina-cli" && task.status === "running" && task.stage === "submitted") return "状态待更新";
|
||||
return statusLabel[task.status];
|
||||
}
|
||||
|
||||
export function generationTaskStageLabel(task: GenerationTaskDisplayTarget) {
|
||||
if (isGenerationTaskSubmissionUncertain(task)) return "为避免重复扣费,未自动重试";
|
||||
if (task.provider === "dreamina-cli" && task.stage === "submitting") return "正在提交,等待官方确认";
|
||||
if (task.provider === "dreamina-cli" && task.officialStatus) return `官方返回状态:${task.officialStatus}`;
|
||||
if (task.provider === "dreamina-cli" && task.status === "running" && task.stage === "submitted") return "已提交,等待状态更新";
|
||||
if (task.stage === "generating") return "生成中";
|
||||
if (task.stage === "queued") return "排队中";
|
||||
return task.stage || generationTaskStatusLabel(task);
|
||||
}
|
||||
|
||||
export function generationTaskShowsProgress(task: GenerationTaskDisplayTarget) {
|
||||
if (isGenerationTaskSubmissionUncertain(task)) return false;
|
||||
return !(task.provider === "dreamina-cli" && task.status === "running" && (task.stage === "submitting" || task.stage === "submitted"));
|
||||
}
|
||||
|
||||
export const operationOptions = [
|
||||
{ label: "Agent 会话:拆解影视工作流", value: "agent_session" },
|
||||
{ label: "文生视频", value: "text_to_video" },
|
||||
@@ -45,4 +76,3 @@ export function formatTaskKind(task: GenerationTask) {
|
||||
if (task.type.startsWith("video_")) return "视频任务";
|
||||
return "生成任务";
|
||||
}
|
||||
|
||||
|
||||
@@ -39,13 +39,39 @@ export function readFileAsDataUrl(file: File) {
|
||||
});
|
||||
}
|
||||
|
||||
export function readImageMeta(dataUrl: string) {
|
||||
return new Promise<{ width: number; height: number; mimeType: string }>((resolve) => {
|
||||
export function readImageMeta(dataUrl: string, signal?: AbortSignal) {
|
||||
return new Promise<{ width: number; height: number; mimeType: string }>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
const done = () => resolve({ width: image.naturalWidth || 1024, height: image.naturalHeight || 1024, mimeType: dataUrl.match(/^data:([^;]+)/)?.[1] || "image/png" });
|
||||
image.onload = done;
|
||||
image.onerror = done;
|
||||
setTimeout(done, 3000);
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const cleanup = (cancelLoad = false) => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
if (cancelLoad) image.src = "";
|
||||
};
|
||||
const done = (cancelLoad = false) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup(cancelLoad);
|
||||
resolve({ width: image.naturalWidth || 1024, height: image.naturalHeight || 1024, mimeType: dataUrl.match(/^data:([^;]+)/)?.[1] || "image/png" });
|
||||
};
|
||||
const onAbort = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup(true);
|
||||
reject(new DOMException("The operation was aborted", "AbortError"));
|
||||
};
|
||||
|
||||
image.onload = () => done();
|
||||
image.onerror = () => done();
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
timer = setTimeout(() => done(true), 3000);
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
image.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,32 +8,22 @@ localforage.config({
|
||||
storeName: "app_state",
|
||||
});
|
||||
|
||||
export const localForageStorage: StateStorage = {
|
||||
getItem: async (name) => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const key = scopedStorageKey(name);
|
||||
try {
|
||||
return (await localforage.getItem<string>(key)) || null;
|
||||
} catch {
|
||||
return window.localStorage.getItem(key);
|
||||
}
|
||||
},
|
||||
setItem: async (name, value) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const key = scopedStorageKey(name);
|
||||
try {
|
||||
await localforage.setItem(key, value);
|
||||
} catch {
|
||||
window.localStorage.setItem(key, value);
|
||||
}
|
||||
},
|
||||
removeItem: async (name) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const key = scopedStorageKey(name);
|
||||
try {
|
||||
await localforage.removeItem(key);
|
||||
} catch {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
export function localForageStorageForScope(scope?: string): StateStorage {
|
||||
const keyFor = (name: string) => scopedStorageKey(name, scope);
|
||||
return {
|
||||
getItem: async (name) => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return (await localforage.getItem<string>(keyFor(name))) || null;
|
||||
},
|
||||
setItem: async (name, value) => {
|
||||
if (typeof window === "undefined") return;
|
||||
await localforage.setItem(keyFor(name), value);
|
||||
},
|
||||
removeItem: async (name) => {
|
||||
if (typeof window === "undefined") return;
|
||||
await localforage.removeItem(keyFor(name));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const localForageStorage: StateStorage = localForageStorageForScope();
|
||||
|
||||
@@ -11,8 +11,8 @@ export function setActiveUserScope(userId?: string | null) {
|
||||
window.localStorage.setItem(ACTIVE_USER_SCOPE_KEY, userId || GUEST_SCOPE);
|
||||
}
|
||||
|
||||
export function scopedStorageKey(name: string) {
|
||||
return `${name}:user:${getActiveUserScope()}`;
|
||||
export function scopedStorageKey(name: string, scope = getActiveUserScope()) {
|
||||
return `${name}:user:${scope}`;
|
||||
}
|
||||
|
||||
export const scopedLocalStorage = {
|
||||
|
||||
@@ -3,10 +3,21 @@ import { localForageStorage } from "@/lib/localforage-storage";
|
||||
import { appQueryClient } from "@/lib/query-client";
|
||||
import { scopedLocalStorage, setActiveUserScope } from "@/lib/user-scope";
|
||||
import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { ASSET_STORE_KEY, useAssetStore } from "@/stores/use-asset-store";
|
||||
import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
|
||||
import { CONFIG_STORE_KEY, defaultConfig, normalizeConfigSnapshot, useConfigStore } from "@/stores/use-config-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { installRemoteUserDataAutoSync, resetRemoteUserDataSync, syncRemoteUserData } from "@/services/user-data-sync";
|
||||
import { installRemoteUserDataAutoSync, resetRemoteUserDataSync, syncRemoteUserData, withRemoteUserDataSyncPaused } from "@/services/user-data-sync";
|
||||
import { withGenerationConsumersPaused } from "@/services/generation-consumer-lifecycle";
|
||||
|
||||
export async function switchUserStorageScope(userId?: string | null) {
|
||||
await withGenerationConsumersPaused(async () => {
|
||||
await withRemoteUserDataSyncPaused(async () => {
|
||||
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
|
||||
resetRemoteUserDataSync();
|
||||
setActiveUserScope(userId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyUserSession(payload: AuthSessionPayload) {
|
||||
const previousUserId = useUserStore.getState().user?.id || "";
|
||||
@@ -15,13 +26,8 @@ export async function applyUserSession(payload: AuthSessionPayload) {
|
||||
try {
|
||||
// Query key 不携带用户 ID;身份变化时必须取消并清空旧账号请求,避免跨账号复用内存数据。
|
||||
if (previousUserId !== nextUserId) appQueryClient.clear();
|
||||
resetRemoteUserDataSync();
|
||||
await flushCanvasStorePersistence();
|
||||
setActiveUserScope(payload.user?.id);
|
||||
const [persistedCanvas, persistedAssets] = await Promise.all([
|
||||
localForageStorage.getItem(CANVAS_STORE_KEY),
|
||||
localForageStorage.getItem(ASSET_STORE_KEY),
|
||||
]);
|
||||
await switchUserStorageScope(payload.user?.id);
|
||||
const [persistedCanvas, persistedAssets] = await Promise.all([localForageStorage.getItem(CANVAS_STORE_KEY), localForageStorage.getItem(ASSET_STORE_KEY)]);
|
||||
const persistedConfig = scopedLocalStorage.getItem(CONFIG_STORE_KEY);
|
||||
useUserStore.getState().setUser(payload.user);
|
||||
useUserStore.getState().setRuntimeLimits(payload.runtimeLimits);
|
||||
|
||||
Reference in New Issue
Block a user