feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道

Refs #225

Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
ddCat
2026-08-15 12:45:29 +08:00
parent 8f2232feee
commit 57e1820f41
179 changed files with 49746 additions and 1897 deletions

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

View File

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

View 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) };
}

View File

@@ -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();

View File

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

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

View 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();
}
}

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

View File

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

View File

@@ -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 "生成任务";
}

View File

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

View File

@@ -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();

View File

@@ -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 = {

View File

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