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

@@ -1,6 +1,7 @@
import { projectDesktopLocalChannelRuntime } from "@/lib/desktop-local-channel";
import { isSystemProxyBaseUrl, resolveBackendApiUrl, type AiConfig, type ChannelHeader } from "@/stores/use-config-store";
type RelayConfig = Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat"> & { headers?: ChannelHeader[] };
type RelayConfig = Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat"> & { allowLocalChannel?: boolean; headers?: ChannelHeader[] };
export type ChannelRequest = {
url: string;
@@ -10,18 +11,26 @@ export type ChannelRequest = {
/** 自定义渠道统一经登录态后端中转,避免依赖第三方服务的浏览器 CORS。 */
export function channelRequest(config: RelayConfig, upstreamUrl: string, headers: HeadersInit = {}): ChannelRequest {
const runtimeConfig = projectDesktopLocalChannelRuntime(config);
const normalizedHeaders = new Headers(headers);
if (isSystemProxyBaseUrl(config.baseUrl)) {
if (isSystemProxyBaseUrl(runtimeConfig.baseUrl)) {
return { url: upstreamUrl, headers: Object.fromEntries(normalizedHeaders.entries()), credentials: "include" };
}
const normalizedUpstreamUrl = new URL(upstreamUrl).toString();
normalizedHeaders.delete("X-Canvas-Upstream-Headers");
normalizedHeaders.delete("x-goog-api-key");
normalizedHeaders.set("Authorization", `Bearer ${config.apiKey}`);
normalizedHeaders.set("Authorization", `Bearer ${runtimeConfig.apiKey}`);
normalizedHeaders.set("X-Canvas-Upstream-URL", normalizedUpstreamUrl);
normalizedHeaders.set("X-Canvas-Upstream-Format", config.apiFormat === "gemini" ? "gemini" : "openai");
if (config.headers?.length) normalizedHeaders.set("X-Canvas-Upstream-Headers", encodeChannelHeaders(config.headers));
normalizedHeaders.set("X-Canvas-Upstream-Format", runtimeConfig.apiFormat === "gemini" ? "gemini" : "openai");
if (runtimeConfig.allowLocalChannel === true) {
normalizedHeaders.set("X-Canvas-Allow-Local-Channel", "1");
normalizedHeaders.set("X-Canvas-Upstream-Base-URL", new URL(runtimeConfig.baseUrl).toString());
} else {
normalizedHeaders.delete("X-Canvas-Allow-Local-Channel");
normalizedHeaders.delete("X-Canvas-Upstream-Base-URL");
}
if (runtimeConfig.headers?.length) normalizedHeaders.set("X-Canvas-Upstream-Headers", encodeChannelHeaders(runtimeConfig.headers));
return {
url: resolveBackendApiUrl("/api/ai/custom"),
headers: Object.fromEntries(normalizedHeaders.entries()),

View File

@@ -2,6 +2,8 @@ import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage";
import { resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { createGenerationTask, waitForGenerationTask, type GenerationTask } from "@/services/api/task-center";
import { LOCAL_DREAMINA_WAIT_STOPPED_CODE, LocalDreaminaGenerationClientError, runLocalDreaminaGenerationTask, type LocalDreaminaGenerationInput, type LocalDreaminaGenerationTask } from "@/services/local-dreamina-generation";
import { isLocalDreaminaBackgroundTask, localDreaminaTaskId, projectLocalDreaminaTask, stripLocalDreaminaTaskPrefix } from "@/services/local-dreamina-task-projection";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import type { ReferenceImage } from "@/types/image";
@@ -29,6 +31,28 @@ type BackendGenerationTaskOptions = {
signal?: AbortSignal;
metadata?: Record<string, unknown>;
onTaskUpdate?: (task: GenerationTask) => void;
localIdempotencyKey?: string;
localResumeOnly?: boolean;
clientOperationId?: string;
retryOf?: string;
retryContextsByBatchIndex?: Array<{ retryOf: string; attemptGroupId: string; clientOperationId: string }>;
attemptGroupId?: string;
};
export type GenerationTaskDependencies = {
createTask: typeof createGenerationTask;
waitTask: typeof waitForGenerationTask;
runLocal: (input: LocalDreaminaGenerationInput, signal?: AbortSignal, onTaskUpdate?: (task: LocalDreaminaGenerationTask) => void) => ReturnType<typeof runLocalDreaminaGenerationTask>;
createId: () => string;
now: () => string;
};
const defaultDependencies: GenerationTaskDependencies = {
createTask: createGenerationTask,
waitTask: waitForGenerationTask,
runLocal: (input, signal, onTaskUpdate) => runLocalDreaminaGenerationTask(input, { onTaskUpdate }, signal),
createId: () => crypto.randomUUID(),
now: () => new Date().toISOString(),
};
type PreparedGenerationReferences = {
@@ -39,41 +63,254 @@ type PreparedGenerationReferences = {
};
// 生成、计费、取消和任务记录必须共用后端任务生命周期,页面层不能再直连供应商。
export async function runBackendGenerationTask({
projectId,
mode,
prompt,
config,
referenceImages = [],
referenceVideos = [],
referenceAudios = [],
mask,
signal,
metadata,
onTaskUpdate,
}: BackendGenerationTaskOptions) {
export async function runBackendGenerationTask(
{
projectId,
mode,
prompt,
config,
referenceImages = [],
referenceVideos = [],
referenceAudios = [],
mask,
signal,
metadata,
onTaskUpdate,
localIdempotencyKey,
localResumeOnly,
clientOperationId,
retryOf,
attemptGroupId,
}: BackendGenerationTaskOptions,
dependencies: GenerationTaskDependencies = defaultDependencies,
) {
throwIfAborted(signal);
if (isLocalDreaminaModel(config.model)) {
return await runLocalDreaminaGeneration(
{ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, mask, signal, metadata, onTaskUpdate, localIdempotencyKey, localResumeOnly, clientOperationId, retryOf, attemptGroupId },
dependencies,
);
}
const prepared = await prepareGenerationReferences({ referenceImages, referenceVideos, referenceAudios, mask });
throwIfAborted(signal);
return createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages, referenceVideos, signal, metadata, onTaskUpdate }, prepared);
return createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, signal, metadata, onTaskUpdate }, prepared, dependencies);
}
export async function runBackendGenerationTaskBatch(options: BackendGenerationTaskOptions & { count: number }) {
export async function runBackendGenerationTaskBatch(options: BackendGenerationTaskOptions & { count: number }, dependencies: GenerationTaskDependencies = defaultDependencies) {
const count = Math.max(1, Math.min(15, Math.floor(Number(options.count)) || 1));
throwIfAborted(options.signal);
if (options.retryContextsByBatchIndex && options.retryContextsByBatchIndex.length !== count) throw new Error("生成重试批次任务数量不匹配");
if (isLocalDreaminaModel(options.config.model)) {
return Promise.allSettled(
Array.from({ length: count }, (_, batchIndex) => {
const retryContext = options.retryContextsByBatchIndex?.[batchIndex];
return runLocalDreaminaGeneration(
{
...options,
config: { ...options.config, count: "1" },
metadata: { ...options.metadata, batchIndex, batchCount: count },
localIdempotencyKey: options.localIdempotencyKey ? `${options.localIdempotencyKey}:${batchIndex + 1}` : undefined,
clientOperationId: retryContext?.clientOperationId ?? (options.clientOperationId ? `${options.clientOperationId}:${batchIndex + 1}` : undefined),
retryOf: retryContext?.retryOf ?? options.retryOf,
attemptGroupId: retryContext?.attemptGroupId ?? options.attemptGroupId,
},
dependencies,
);
}),
);
}
const prepared = await prepareGenerationReferences(options);
throwIfAborted(options.signal);
return Promise.allSettled(Array.from({ length: count }, (_, batchIndex) => createAndWaitGenerationTask({
...options,
metadata: { ...options.metadata, batchIndex, batchCount: count },
}, prepared)));
return Promise.allSettled(
Array.from({ length: count }, (_, batchIndex) =>
createAndWaitGenerationTask(
{
...options,
metadata: { ...options.metadata, batchIndex, batchCount: count },
},
prepared,
dependencies,
),
),
);
}
async function runLocalDreaminaGeneration(options: BackendGenerationTaskOptions, dependencies: GenerationTaskDependencies): Promise<BackendGenerationResult> {
if (options.mode !== "image" && options.mode !== "video") throw new Error("即梦 CLI 仅支持图片或视频生成");
const runtimeId = stripLocalDreaminaTaskPrefix(options.localIdempotencyKey || options.clientOperationId || dependencies.createId());
const clientOperationId = options.clientOperationId ?? runtimeId;
const context = localTaskContext(options);
const timestamp = dependencies.now();
const task: GenerationTask = {
id: localDreaminaTaskId(runtimeId),
clientOperationId,
...(options.projectId ? { projectId: options.projectId } : {}),
type: `canvas_${options.mode}`,
status: "running",
stage: "submitting",
prompt: options.prompt,
operation: generationOperation(options),
provider: "dreamina-cli",
model: options.config.model,
attempts: 1,
createdAt: timestamp,
updatedAt: timestamp,
startedAt: timestamp,
clientContext: generationClientContext(context),
...(context.retryOf ? { retryOf: context.retryOf } : {}),
...(context.attemptGroupId ? { attemptGroupId: context.attemptGroupId } : {}),
};
let latestPublicTask = task;
options.onTaskUpdate?.(task);
try {
const references = await localGenerationReferences([...(options.referenceImages ?? []), ...(options.mask ? [options.mask] : [])], options.referenceVideos ?? [], options.referenceAudios ?? []);
const resolution = options.mode === "video" ? options.config.vquality : options.config.quality;
const result = await dependencies.runLocal(
{
model: options.config.model as `local:dreamina-cli:${string}`,
mode: options.mode,
prompt: options.prompt,
settings: {
aspect: options.config.size,
resolution,
...(options.mode === "video" ? { duration: Number(options.config.videoSeconds) } : { count: Number(options.config.count) }),
},
references,
resumeOnly: options.localResumeOnly,
idempotencyKey: runtimeId,
clientOperationId,
context,
},
options.signal,
(runtimeTask) => {
latestPublicTask = projectLocalDreaminaTask(runtimeTask, task);
options.onTaskUpdate?.(latestPublicTask);
},
);
const completedAt = dependencies.now();
options.onTaskUpdate?.({ ...task, status: "succeeded", progress: 100, stage: "local_cli_succeeded", resultJson: JSON.stringify(result), completedAt, updatedAt: completedAt });
return result;
} catch (error) {
const completedAt = dependencies.now();
const cancelled = isGenerationTaskCancelled(error, options.signal);
const localWaitStopped = error instanceof LocalDreaminaGenerationClientError && error.code === LOCAL_DREAMINA_WAIT_STOPPED_CODE;
const localErrorCode = error instanceof LocalDreaminaGenerationClientError ? error.code : undefined;
if (!(cancelled && isLocalDreaminaBackgroundTask(latestPublicTask))) {
options.onTaskUpdate?.({
...task,
status: cancelled ? "cancelled" : "failed",
stage: cancelled ? "local_cli_cancelled" : "local_cli_failed",
completedAt,
updatedAt: completedAt,
...(localWaitStopped
? { errorCode: error.code, error: error.message }
: !cancelled
? {
...(localErrorCode ? { errorCode: localErrorCode } : {}),
error: error instanceof Error ? error.message : "即梦本机生成失败",
}
: {}),
});
}
throw error;
}
}
function generationOperation(options: BackendGenerationTaskOptions) {
if (options.mode !== "video") return options.mode;
const imageCount = options.referenceImages?.length ?? 0;
if ((options.referenceVideos?.length ?? 0) > 0 || (options.referenceAudios?.length ?? 0) > 0 || imageCount > 2) return "reference_to_video";
if (imageCount > 0) return "image_to_video";
return "text_to_video";
}
export function isGenerationTaskCancelled(error: unknown, signal?: AbortSignal) {
if (error instanceof LocalDreaminaGenerationClientError && error.code === "dreamina_submission_unknown") return false;
return signal?.aborted === true || (error instanceof Error && error.name === "AbortError") || (error instanceof LocalDreaminaGenerationClientError && error.code === LOCAL_DREAMINA_WAIT_STOPPED_CODE);
}
async function localGenerationReferences(images: ReferenceImage[], videos: ReferenceVideo[], audios: ReferenceAudio[]): Promise<LocalDreaminaGenerationInput["references"]> {
const imageReferences = await Promise.all(
images.map(async (image) => {
const source = image.dataUrl || image.url;
if (!source && !image.storageKey) throw new LocalDreaminaGenerationClientError("dreamina_reference_invalid", "即梦图片参考素材不可用", 400);
const blob = image.storageKey ? await getImageBlob(image.storageKey) : await (await fetch(source!)).blob();
if (!blob || !["image/png", "image/jpeg", "image/webp"].includes(blob.type)) throw invalidLocalReference();
return {
kind: "image" as const,
mimeType: blob.type as "image/png" | "image/jpeg" | "image/webp",
bytes: new Uint8Array(await blob.arrayBuffer()),
metadata: compactReferenceMetadata({ name: image.name, width: image.width, height: image.height }),
};
}),
);
const mediaReferences = async (items: Array<ReferenceVideo | ReferenceAudio>, kind: "video" | "audio") =>
Promise.all(
items.map(async (media) => {
const source = media.url || "";
const blob = media.storageKey ? await getMediaBlob(media.storageKey) : source ? await (await fetch(source)).blob() : null;
const allowed = kind === "video" ? ["video/mp4", "video/quicktime", "video/webm"] : ["audio/mpeg", "audio/wav", "audio/mp4", "audio/aac", "audio/flac"];
if (!blob || !allowed.includes(blob.type)) throw invalidLocalReference();
return {
kind,
mimeType: blob.type,
bytes: new Uint8Array(await blob.arrayBuffer()),
metadata: compactReferenceMetadata({
name: media.name,
...("width" in media ? { width: media.width, height: media.height } : {}),
durationMs: media.durationMs,
}),
};
}),
);
const references = [...imageReferences, ...(await mediaReferences(videos, "video")), ...(await mediaReferences(audios, "audio"))] as LocalDreaminaGenerationInput["references"];
if (references.reduce((total, reference) => total + reference.bytes.byteLength, 0) > 20 * 1024 * 1024) throw invalidLocalReference();
return references;
}
function invalidLocalReference() {
return new LocalDreaminaGenerationClientError("dreamina_reference_invalid", "即梦参考素材无效", 400);
}
function compactReferenceMetadata(metadata: Record<string, string | number | undefined>) {
return Object.fromEntries(Object.entries(metadata).filter(([, value]) => value !== undefined));
}
function localTaskContext(options: BackendGenerationTaskOptions): Extract<LocalDreaminaGenerationInput["context"], { scope: "scoped" }> {
const metadata = options.metadata ?? {};
return {
scope: "scoped",
...(options.projectId ? { projectId: options.projectId } : {}),
...(typeof metadata.nodeId === "string" ? { nodeId: metadata.nodeId } : {}),
...(typeof metadata.conversationId === "string" ? { conversationId: metadata.conversationId } : {}),
...(typeof metadata.messageId === "string" ? { messageId: metadata.messageId } : {}),
...(typeof metadata.batchIndex === "number" ? { batchIndex: metadata.batchIndex } : {}),
...(typeof metadata.batchCount === "number" ? { batchCount: metadata.batchCount } : {}),
...(options.retryOf ? { retryOf: options.retryOf } : {}),
...(options.attemptGroupId ? { attemptGroupId: options.attemptGroupId } : {}),
};
}
function generationClientContext(context: Extract<LocalDreaminaGenerationInput["context"], { scope: "scoped" }>) {
const { conversationId, messageId, nodeId, batchIndex, batchCount } = context;
if (!conversationId && !messageId && !nodeId && batchIndex === undefined && batchCount === undefined) return undefined;
return { ...(conversationId ? { conversationId } : {}), ...(messageId ? { messageId } : {}), ...(nodeId ? { nodeId } : {}), ...(batchIndex !== undefined ? { batchIndex } : {}), ...(batchCount !== undefined ? { batchCount } : {}) };
}
function isLocalDreaminaModel(model: string) {
return /^local:dreamina-cli:[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(model.trim());
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
}
async function prepareGenerationReferences({ referenceImages = [], referenceVideos = [], referenceAudios = [], mask }: Pick<BackendGenerationTaskOptions, "referenceImages" | "referenceVideos" | "referenceAudios" | "mask">): Promise<PreparedGenerationReferences> {
async function prepareGenerationReferences({
referenceImages = [],
referenceVideos = [],
referenceAudios = [],
mask,
}: Pick<BackendGenerationTaskOptions, "referenceImages" | "referenceVideos" | "referenceAudios" | "mask">): Promise<PreparedGenerationReferences> {
const preparedImages = await Promise.all(referenceImages.map(prepareBackendImageReference));
const preparedVideos = await Promise.all(referenceVideos.map(prepareBackendMediaReference));
const preparedAudios = await Promise.all(referenceAudios.map(prepareBackendMediaReference));
@@ -81,9 +318,10 @@ async function prepareGenerationReferences({ referenceImages = [], referenceVide
return { referenceImages: preparedImages, referenceVideos: preparedVideos, referenceAudios: preparedAudios, mask: preparedMask };
}
async function createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages = [], signal, metadata, onTaskUpdate }: BackendGenerationTaskOptions, prepared: PreparedGenerationReferences) {
const videoOperation = String(metadata?.videoEditOperation || (referenceImages.length ? "image_to_video" : "text_to_video"));
const task = await createGenerationTask({
async function createAndWaitGenerationTask(options: BackendGenerationTaskOptions, prepared: PreparedGenerationReferences, dependencies: GenerationTaskDependencies) {
const { projectId, mode, prompt, config, signal, metadata, onTaskUpdate } = options;
const videoOperation = generationOperation(options);
const task = await dependencies.createTask({
...(projectId ? { projectId } : {}),
type: `canvas_${mode}`,
operation: mode === "video" ? videoOperation : mode,
@@ -101,7 +339,7 @@ async function createAndWaitGenerationTask({ projectId, mode, prompt, config, re
},
});
onTaskUpdate?.(task);
const completed = await waitForGenerationTask(task.id, { signal, initialTask: task, onTaskUpdate });
const completed = await dependencies.waitTask(task.id, { signal, initialTask: task, onTaskUpdate });
return parseBackendGenerationResult(completed);
}
@@ -172,6 +410,7 @@ export function backendProviderConfig(config: AiConfig) {
apiFormat: requestConfig.apiFormat,
interfaceType: requestConfig.interfaceType,
baseUrl: requestConfig.baseUrl,
allowLocalChannel: requestConfig.allowLocalChannel === true,
apiKey: requestConfig.apiKey,
secretKey: requestConfig.secretKey,
model: requestConfig.model,

View File

@@ -5,6 +5,7 @@ import { nanoid } from "nanoid";
import { dataUrlToFile } from "@/lib/image-utils";
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
import { createClientId } from "@/lib/client-id";
import { projectDesktopLocalChannelRuntime } from "@/lib/desktop-local-channel";
import { channelRequest } from "@/services/api/custom-channel-relay";
import { imageToDataUrl } from "@/services/image-storage";
import type { ReferenceImage } from "@/types/image";
@@ -1089,7 +1090,7 @@ export async function requestToolResponse(config: AiConfig, messages: ResponseIn
}
}
export async function fetchImageModels(config: Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat">) {
export async function fetchImageModels(config: Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat"> & { allowLocalChannel?: boolean }) {
try {
if (config.apiFormat === "gemini") {
const requestConfig = { ...defaultGeminiConfig, ...config };
@@ -1117,8 +1118,9 @@ export type ChannelModelCatalogItem = { id: string; supportedEndpointTypes?: str
export type ChannelModelFetchResult = { models: string[]; catalog: ChannelModelCatalogItem[] };
export async function fetchChannelModels(channel: ModelChannel, viaBackend = false): Promise<ChannelModelFetchResult> {
const runtimeChannel = projectDesktopLocalChannelRuntime(channel);
if (!viaBackend) {
const models = await fetchImageModels({ baseUrl: channel.baseUrl, apiKey: channel.apiKey, apiFormat: channel.apiFormat });
const models = await fetchImageModels({ baseUrl: runtimeChannel.baseUrl, allowLocalChannel: runtimeChannel.allowLocalChannel === true, apiKey: runtimeChannel.apiKey, apiFormat: runtimeChannel.apiFormat });
return { models, catalog: models.map((id) => ({ id })) };
}
try {
@@ -1126,10 +1128,11 @@ export async function fetchChannelModels(channel: ModelChannel, viaBackend = fal
const response = await axios.post<{ code?: number; data?: { models?: Array<string | ChannelModelCatalogItem> }; msg?: string }>(
resolveBackendApiUrl("/api/ai/models"),
{
baseUrl: channel.baseUrl,
apiKey: channel.apiKey,
apiFormat: channel.apiFormat,
headers: channel.headers,
baseUrl: runtimeChannel.baseUrl,
allowLocalChannel: runtimeChannel.allowLocalChannel === true,
apiKey: runtimeChannel.apiKey,
apiFormat: runtimeChannel.apiFormat,
headers: runtimeChannel.headers,
},
{ withCredentials: true },
);

View File

@@ -166,7 +166,6 @@ export type ProjectDetail = {
assetCandidates: ProjectAssetCandidate[];
};
export function listProjects() {
return request<{ projects: ProjectSummary[] }>(api.get("/projects"));
}
@@ -223,16 +222,16 @@ export function unlinkCanvasProject(projectId: string, canvasId: string) {
return request<{ canvasId: string }>(api.delete(`/projects/${encodeURIComponent(projectId)}/canvases/${encodeURIComponent(canvasId)}`));
}
export function linkProjectAsset(projectId: string, input: { assetId: string; category: string }) {
return request<{ asset: ProjectAsset }>(api.post(`/projects/${encodeURIComponent(projectId)}/assets`, input));
export function linkProjectAsset(projectId: string, input: { assetId: string; category: string }, signal?: AbortSignal) {
return request<{ asset: ProjectAsset }>(api.post(`/projects/${encodeURIComponent(projectId)}/assets`, input, { signal }));
}
export function unlinkProjectAsset(projectId: string, assetId: string) {
return request<{ id: string }>(api.delete(`/projects/${encodeURIComponent(projectId)}/assets/${encodeURIComponent(assetId)}`));
}
export function updateProjectAssetCategory(projectId: string, assetId: string, category: string) {
return request<{ asset: ProjectAsset }>(api.patch(`/projects/${encodeURIComponent(projectId)}/assets/${encodeURIComponent(assetId)}`, { category }));
export function updateProjectAssetCategory(projectId: string, assetId: string, category: string, signal?: AbortSignal) {
return request<{ asset: ProjectAsset }>(api.patch(`/projects/${encodeURIComponent(projectId)}/assets/${encodeURIComponent(assetId)}`, { category }, { signal }));
}
export function createProjectAssetVersion(projectId: string, assetId: string, input: { prompt?: string; definitionJson?: string; note?: string }) {

View File

@@ -1,5 +1,15 @@
import { generationErrorMessage } from "@/lib/generation-error";
import { DREAMINA_SUBMIT_ERROR_MESSAGES, generationErrorMessage } from "@/lib/generation-error";
import { apiClient, request, type BackendEnvelope } from "@/services/api/request";
import {
cancelLocalDreaminaGenerationTask,
deleteLocalDreaminaGenerationTask,
listLocalDreaminaGenerationTaskPage,
queryLocalDreaminaGenerationTask,
refreshLocalDreaminaGenerationTask,
waitForLocalDreaminaGenerationTask,
type LocalDreaminaGenerationTask,
} from "@/services/local-dreamina-generation";
import { isLocalDreaminaTaskId, projectLocalDreaminaDiagnosticLog, projectLocalDreaminaTask, stripLocalDreaminaTaskPrefix } from "@/services/local-dreamina-task-projection";
export type { BackendEnvelope } from "@/services/api/request";
@@ -7,9 +17,20 @@ export type TaskStatus = "queued" | "running" | "succeeded" | "failed" | "cancel
export type TaskBillingStatus = "reserved" | "running" | "settled" | "refunded" | "uncertain";
export type ProviderCancelStatus = "requested" | "confirmed" | "uncertain";
export type AgentSessionStatus = "active" | "completed" | "failed";
export type GenerationTaskResultState = "NOT_AVAILABLE" | "PENDING_MATERIALIZATION" | "MATERIALIZING" | "READY" | "FAILED_RETRYABLE" | "FAILED_PERMANENT";
export type GenerationTaskOutput = {
outputIndex: number;
mediaType: "image" | "video" | "audio";
providerArtifactRef?: string;
materializedAssetId?: string;
materializationErrorCode?: string;
};
export type GenerationTask = {
id: string;
clientOperationId?: string;
retryOf?: string;
attemptGroupId?: string;
sessionId?: string;
projectId?: string;
type: string;
@@ -27,10 +48,14 @@ export type GenerationTask = {
providerCancelRequestedAt?: string;
providerCancelledAt?: string;
errorCode?: string;
officialStatus?: "pending" | "processing" | "completed" | "failed" | "cancelled";
receiptRecorded?: boolean;
previewUrl?: string;
previewKind?: "image" | "video";
inputJson?: string;
resultJson?: string;
resultState?: GenerationTaskResultState;
outputs?: GenerationTaskOutput[];
textDraft?: string;
error?: string;
attempts: number;
@@ -43,8 +68,9 @@ export type GenerationTask = {
status: TaskBillingStatus;
};
clientContext?: {
conversationId: string;
messageId: string;
conversationId?: string;
messageId?: string;
nodeId?: string;
batchIndex?: number;
batchCount?: number;
};
@@ -118,9 +144,11 @@ export type SessionFile = {
export type TaskLog = {
id: string;
taskId: string;
level: "info" | "warn" | "error" | string;
message: string;
payload?: string;
level: "info" | "warn" | "error";
stage: string;
errorCode?: string;
provenance: "task_state" | "provider_observation" | "background_reconcile" | "manual_refresh" | "backend";
observedAt?: string;
createdAt: string;
};
@@ -197,14 +225,166 @@ export function createGenerationTask(input: CreateTaskInput) {
});
}
export function listGenerationTasks(limit = 30, options?: { projectId?: string; activeOnly?: boolean }) {
return request<GenerationTask[]>(api.get("/tasks", { params: { limit, projectId: options?.projectId, activeOnly: options?.activeOnly || undefined } }));
export type GenerationTaskPageRequest = {
limit: number;
projectId?: string;
activeOnly: boolean;
cursor?: string;
};
type GenerationTaskPage<T> = { tasks: T[]; nextCursor?: string };
type GenerationTaskListOptions = { projectId?: string; activeOnly?: boolean };
type GenerationTaskListDependencies = {
listBackend?(limit: number, options?: GenerationTaskListOptions, signal?: AbortSignal): Promise<GenerationTask[]>;
listLocal?(options?: GenerationTaskListOptions, signal?: AbortSignal): Promise<LocalDreaminaGenerationTask[]>;
listBackendPage?(request: GenerationTaskPageRequest, signal?: AbortSignal): Promise<GenerationTaskPage<GenerationTask>>;
listLocalPage?(request: GenerationTaskPageRequest, signal?: AbortSignal): Promise<GenerationTaskPage<LocalDreaminaGenerationTask>>;
};
const defaultGenerationTaskListDependencies: GenerationTaskListDependencies = {
listBackendPage: async (page, signal) => ({
tasks: await request<GenerationTask[]>(
api.get("/tasks", {
params: { limit: Math.min(page.limit, 100), projectId: page.projectId, activeOnly: page.activeOnly || undefined },
signal,
}),
),
}),
listLocalPage: (page, signal) => listLocalDreaminaGenerationTaskPage(page, {}, signal),
};
export async function listGenerationTasks(limit = 30, options?: { projectId?: string; activeOnly?: boolean }, dependencies: GenerationTaskListDependencies = defaultGenerationTaskListDependencies, signal?: AbortSignal) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
const boundedLimit = Number.isFinite(limit) ? Math.max(1, Math.min(10_000, Math.trunc(limit))) : 30;
const baseRequest = {
limit: Math.min(100, boundedLimit),
...(options?.projectId ? { projectId: options.projectId } : {}),
activeOnly: options?.activeOnly === true,
} satisfies GenerationTaskPageRequest;
const backendPageReader =
dependencies.listBackendPage ??
(async (page: GenerationTaskPageRequest, pageSignal?: AbortSignal) => ({
tasks:
(await dependencies.listBackend?.(
page.limit,
{
...(page.projectId ? { projectId: page.projectId } : {}),
activeOnly: page.activeOnly,
},
pageSignal,
)) ?? [],
}));
const localPageReader =
dependencies.listLocalPage ??
(async (page: GenerationTaskPageRequest, pageSignal?: AbortSignal) => ({
tasks:
(await dependencies.listLocal?.(
{
...(page.projectId ? { projectId: page.projectId } : {}),
activeOnly: page.activeOnly,
},
pageSignal,
)) ?? [],
}));
const [backendTasks, localTasks] = await Promise.all([collectGenerationTaskPages(backendPageReader, baseRequest, boundedLimit, signal), collectGenerationTaskPages(localPageReader, baseRequest, boundedLimit, signal).catch(() => [])]);
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return [...backendTasks, ...localTasks.map((task) => projectLocalDreaminaTask(task))]
.filter((task) => !options?.projectId || task.projectId === options.projectId)
.filter((task) => !options?.activeOnly || task.status === "queued" || task.status === "running")
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id))
.slice(0, boundedLimit);
}
async function collectGenerationTaskPages<T>(readPage: (request: GenerationTaskPageRequest, signal?: AbortSignal) => Promise<GenerationTaskPage<T>>, baseRequest: GenerationTaskPageRequest, limit: number, signal?: AbortSignal) {
const items: T[] = [];
const seenCursors = new Set<string>();
let cursor: string | undefined;
do {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
const response = await readPage({ ...baseRequest, ...(cursor ? { cursor } : {}) }, signal);
const page = Array.isArray(response) ? { tasks: response as T[] } : response;
items.push(...page.tasks);
if (!page.nextCursor || seenCursors.has(page.nextCursor)) break;
seenCursors.add(page.nextCursor);
cursor = page.nextCursor;
} while (items.length < limit);
return items.slice(0, limit);
}
export function queryGenerationTask(id: string, options?: { signal?: AbortSignal }) {
if (isLocalDreaminaTaskId(id)) {
return queryLocalDreaminaGenerationTask(stripLocalDreaminaTaskPrefix(id), undefined, {}, options?.signal).then((task) => projectLocalDreaminaTask(task));
}
return request<GenerationTask>(api.get(`/tasks/${encodeURIComponent(id)}`, { signal: options?.signal }));
}
export function waitForLocalGenerationTask(id: string, options?: { signal?: AbortSignal }) {
if (!isLocalDreaminaTaskId(id)) return Promise.reject(new Error("当前任务不是本机即梦任务"));
return waitForLocalDreaminaGenerationTask(stripLocalDreaminaTaskPrefix(id), undefined, {}, options?.signal).then((task) => projectLocalDreaminaTask(task));
}
export function splitGenerationTaskObservationIds(ids: readonly string[]) {
return {
localWaitIds: ids.filter(isLocalDreaminaTaskId),
remotePollIds: ids.filter((id) => !isLocalDreaminaTaskId(id)),
};
}
type GenerationTaskSubscriptionDependencies = {
queryTask(id: string): Promise<GenerationTask>;
waitTask(id: string, options?: { initialTask?: GenerationTask; onTaskUpdate?: (task: GenerationTask) => void }): Promise<GenerationTask>;
};
export function createGenerationTaskSubscriptionService(dependencies: GenerationTaskSubscriptionDependencies) {
type Entry = {
listeners: Set<(task: GenerationTask) => void>;
latest?: GenerationTask;
observation?: Promise<void>;
};
const entries = new Map<string, Entry>();
const publish = (entry: Entry, task: GenerationTask) => {
entry.latest = task;
for (const listener of entry.listeners) listener(task);
};
const observe = (id: string, entry: Entry) => {
if (entry.observation) return;
entry.observation = (async () => {
const initial = await dependencies.queryTask(id);
publish(entry, initial);
if (initial.status === "succeeded" || initial.status === "failed" || initial.status === "cancelled") return;
const terminal = await dependencies.waitTask(id, {
initialTask: initial,
onTaskUpdate: (task) => publish(entry, task),
});
publish(entry, terminal);
})().catch(() => undefined);
};
return {
subscribe(ids: readonly string[], listener: (task: GenerationTask) => void) {
const uniqueIds = [...new Set(ids)];
for (const id of uniqueIds) {
const entry = entries.get(id) ?? { listeners: new Set<(task: GenerationTask) => void>() };
entries.set(id, entry);
entry.listeners.add(listener);
if (entry.latest) listener(entry.latest);
observe(id, entry);
}
return () => {
for (const id of uniqueIds) entries.get(id)?.listeners.delete(listener);
};
},
};
}
const generationTaskSubscriptionService = createGenerationTaskSubscriptionService({
queryTask: (id) => queryGenerationTask(id),
waitTask: (id, options) => waitForGenerationTask(id, options),
});
export function subscribeGenerationTasks(ids: readonly string[], listener: (task: GenerationTask) => void) {
return generationTaskSubscriptionService.subscribe(ids, listener);
}
export function appendTaskTextDelta(id: string, content: string) {
return request<TaskTextDelta>(api.post(`/tasks/${encodeURIComponent(id)}/text-deltas`, { content }));
}
@@ -222,14 +402,99 @@ export function queryFailedVideoProviderTask(id: string) {
}
export function cancelGenerationTask(id: string) {
if (isLocalDreaminaTaskId(id)) {
return cancelLocalDreaminaGenerationTask(stripLocalDreaminaTaskPrefix(id)).then((task) => projectLocalDreaminaTask(task));
}
return request<GenerationTask>(api.post(`/tasks/${encodeURIComponent(id)}/cancel`));
}
export function listTaskLogs(id: string) {
return request<TaskLog[]>(api.get(`/tasks/${encodeURIComponent(id)}/logs`));
export function refreshGenerationTaskStatus(id: string, options?: { signal?: AbortSignal }) {
if (!isLocalDreaminaTaskId(id)) return Promise.reject(new Error("当前任务不支持手动更新官方状态"));
return refreshLocalDreaminaGenerationTask(stripLocalDreaminaTaskPrefix(id), {}, options?.signal).then((task) => projectLocalDreaminaTask(task));
}
export function deleteGenerationTask(id: string) {
if (!isLocalDreaminaTaskId(id)) return Promise.reject(new Error("当前任务不支持删除"));
return deleteLocalDreaminaGenerationTask(stripLocalDreaminaTaskPrefix(id));
}
export async function listTaskLogs(id: string) {
if (isLocalDreaminaTaskId(id)) {
const task = await queryGenerationTask(id);
return [projectGenerationTaskSafeLog(task)];
}
const raw = await request<Array<{ level?: unknown; message?: unknown; payload?: unknown; createdAt?: unknown }>>(api.get(`/tasks/${encodeURIComponent(id)}/logs`));
return raw.map((log, index) => projectBackendSafeTaskLog(id, log, index));
}
export function formatTaskLog(log: TaskLog) {
return [`stage=${log.stage}`, ...(log.errorCode ? [`error=${log.errorCode}`] : []), `provenance=${log.provenance}`, ...(log.observedAt ? [`observedAt=${log.observedAt}`] : [])].join(" ");
}
function projectGenerationTaskSafeLog(task: GenerationTask): TaskLog {
const diagnostic = projectLocalDreaminaDiagnosticLog({
level: task.errorCode || task.status === "failed" || task.status === "cancelled" ? "error" : "info",
stage: task.stage || task.status,
errorCode: task.errorCode,
provenance: task.officialStatus ? "provider_observation" : "task_state",
observedAt: task.updatedAt,
});
return {
id: `safe:${task.id}:${task.updatedAt}`,
taskId: task.id,
...diagnostic,
createdAt: diagnostic.observedAt,
};
}
export function projectBackendSafeTaskLog(taskId: string, raw: { level?: unknown; message?: unknown; payload?: unknown; createdAt?: unknown }, index: number): TaskLog {
const text = [raw.message, raw.payload].filter((value): value is string => typeof value === "string").join(" ");
const stage = safeTaskLogStage(text) || "backend_event";
const errorCode = safeTaskLogErrorCode(text);
const createdAt = typeof raw.createdAt === "string" && Number.isFinite(Date.parse(raw.createdAt)) ? raw.createdAt : "1970-01-01T00:00:00.000Z";
return {
id: `safe:${taskId}:${index}`,
taskId,
level: raw.level === "error" ? "error" : raw.level === "warn" ? "warn" : "info",
stage,
...(errorCode ? { errorCode } : {}),
provenance: "backend",
createdAt,
};
}
function safeTaskLogStage(value: unknown) {
if (typeof value !== "string") return undefined;
const match = value.match(/(?:^|[^a-z0-9_])(queued|submitting|submitted|generating|submission_unknown|processing|completed|succeeded|failed|cancelled)(?:$|[^a-z0-9_])/i);
return match?.[1]?.toLowerCase();
}
const SAFE_TASK_LOG_ERROR_CODES = new Set([...Object.keys(DREAMINA_SUBMIT_ERROR_MESSAGES), "dreamina_query_failed", "dreamina_submission_unknown", "dreamina_reference_invalid"]);
function safeTaskLogErrorCode(value: unknown) {
if (typeof value !== "string") return undefined;
for (const match of value.matchAll(/(?:dreamina|local_generation|model|provider)_[a-z0-9_]{2,80}/gi)) {
const errorCode = match[0].toLowerCase();
if (SAFE_TASK_LOG_ERROR_CODES.has(errorCode)) return errorCode;
}
return undefined;
}
export async function waitForGenerationTask(id: string, options?: { signal?: AbortSignal; intervalMs?: number; timeoutMs?: number; initialTask?: GenerationTask; onTaskUpdate?: (task: GenerationTask) => void }) {
if (isLocalDreaminaTaskId(id)) {
try {
const task = await waitForLocalGenerationTask(id, { signal: options?.signal });
options?.onTaskUpdate?.(task);
if (task.status === "succeeded") return task;
if (task.status === "failed" || task.status === "cancelled") {
throw new Error(task.error ? generationErrorMessage(task.error) : `任务${task.status === "cancelled" ? "已取消" : "失败"}`);
}
throw new Error("本机即梦任务等待提前结束");
} catch (error) {
if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError");
throw error;
}
}
const startedAt = Date.now();
const intervalMs = options?.intervalMs || 2000;
let lastTask = options?.initialTask;

View File

@@ -0,0 +1,525 @@
import type { Dispatch, SetStateAction } from "react";
import { applyMaterializedGenerationTaskResultToNodes } from "@/lib/canvas/canvas-generation-task-sync";
import { parseCanvasStorageDocument, rebaseCanvasProjects, serializeCanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision";
import { localForageStorageForScope } from "@/lib/localforage-storage";
import { getActiveUserScope } from "@/lib/user-scope";
import type { GenerationTask, GenerationTaskOutput } from "@/services/api/task-center";
import { generationEffectApplied } from "@/services/generation-consumer-dedupe";
import {
CANVAS_STORE_KEY,
canvasStoreStorageRevision,
commitPendingCanvasStorePersistenceLocked,
flushCanvasStorePersistence,
pendingCanvasStorePersistence,
rebasePendingCanvasStorePersistenceAfterGenerationCommitLocked,
reconcileCanvasGenerationFailure,
recordCanvasStorageDocument,
useCanvasStore,
withCanvasStorePersistenceLock,
withCanvasStorePersistenceSuppressed,
type CanvasProject,
} from "@/stores/canvas/use-canvas-store";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData } from "@/types/canvas";
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw new DOMException("The operation was aborted", "AbortError");
}
export class CanvasGenerationDurableAckError extends Error {
readonly cause: unknown;
constructor(cause: unknown) {
super(cause instanceof Error ? cause.message : "画布生成副作用持久化失败");
this.name = "CanvasGenerationDurableAckError";
this.cause = cause;
}
}
export function isCanvasGenerationDurableAckError(error: unknown): error is CanvasGenerationDurableAckError {
return error instanceof CanvasGenerationDurableAckError;
}
type CanvasGenerationLiveProjectState = Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId">;
type CanvasGenerationLiveProjectAdapter = {
read: () => CanvasGenerationLiveProjectState;
write: (state: CanvasGenerationLiveProjectState) => void;
};
export function createCanvasGenerationLiveProjectAdapter(input: {
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
chatSessionsRef: { current: CanvasAssistantSession[] };
activeChatIdRef: { current: string | null };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setChatSessions: Dispatch<SetStateAction<CanvasAssistantSession[]>>;
setActiveChatId: Dispatch<SetStateAction<string | null>>;
}): CanvasGenerationLiveProjectAdapter {
return {
read: () => ({ nodes: input.nodesRef.current, connections: input.connectionsRef.current, chatSessions: input.chatSessionsRef.current, activeChatId: input.activeChatIdRef.current }),
write: (state) => {
input.nodesRef.current = state.nodes;
input.connectionsRef.current = state.connections;
input.chatSessionsRef.current = state.chatSessions;
input.activeChatIdRef.current = state.activeChatId;
input.setNodes(state.nodes);
input.setConnections(state.connections);
input.setChatSessions(state.chatSessions);
input.setActiveChatId(state.activeChatId);
},
};
}
const canvasGenerationLiveAdapters = new Map<string, CanvasGenerationLiveProjectAdapter>();
function canvasGenerationLiveAdapterKey(scope: string, projectId: string) {
return `${scope}\0${projectId}`;
}
export function registerCanvasGenerationLiveProject(input: { scope: string; projectId: string; adapter: CanvasGenerationLiveProjectAdapter }) {
const key = canvasGenerationLiveAdapterKey(input.scope, input.projectId);
canvasGenerationLiveAdapters.set(key, input.adapter);
return () => {
if (canvasGenerationLiveAdapters.get(key) === input.adapter) canvasGenerationLiveAdapters.delete(key);
};
}
function reconcileCanvasGenerationLiveProject(scope: string, durableDocument: ReturnType<typeof parseCanvasStorageDocument>, baseProject: CanvasProject, attemptedProject: CanvasProject) {
const adapter = canvasGenerationLiveAdapters.get(canvasGenerationLiveAdapterKey(scope, attemptedProject.id));
if (!adapter) return;
const durableProject = durableDocument.state.projects.find((project) => project.id === attemptedProject.id);
if (!durableProject) return;
const live = adapter.read();
const liveProject: CanvasProject = { ...attemptedProject, ...live };
const rebased = rebaseCanvasProjects({
document: { ...durableDocument, state: { projects: [durableProject] } },
baseProjects: [attemptedProject],
localProjects: [liveProject],
baseRevision: durableDocument.storageRevision,
});
const reconciled = rebased.document.state.projects.find((project) => project.id === attemptedProject.id) ?? durableProject;
adapter.write({ nodes: reconciled.nodes, connections: reconciled.connections, chatSessions: reconciled.chatSessions, activeChatId: reconciled.activeChatId });
}
export async function applyCanvasGenerationTaskNodeEffect(input: {
projectId: string;
nodeId: string;
task: GenerationTask;
output: GenerationTaskOutput;
effectKey: string;
signal?: AbortSignal;
nodesRef: { current: CanvasNodeData[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
}) {
throwIfAborted(input.signal);
const previousNodes = input.nodesRef.current;
const applied = await applyMaterializedGenerationTaskResultToNodes(previousNodes, input.task, input.output, input.effectKey, input.nodeId);
if (!applied.updated || !applied.node) throw new Error("画布中找不到对应任务节点");
const persistedProject = await persistCanvasGenerationEffect({
projectId: input.projectId,
effectKey: input.effectKey,
previousNodes,
nodes: applied.nodes,
signal: input.signal,
});
input.nodesRef.current = persistedProject.nodes;
input.setNodes(persistedProject.nodes);
}
export async function persistCanvasAgentGenerationContinuationEffect(input: {
projectId: string;
nodeId: string;
continuation: NonNullable<NonNullable<CanvasNodeData["metadata"]>["agentGenerationContinuation"]>;
effectKey: string;
signal?: AbortSignal;
previousNodes?: CanvasNodeData[];
nodesRef: { current: CanvasNodeData[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
}) {
throwIfAborted(input.signal);
const previousNodes = input.previousNodes ?? input.nodesRef.current;
const currentNodes = input.nodesRef.current;
const currentNode = currentNodes.find((node) => node.id === input.nodeId);
if (!currentNode) throw new Error("画布中找不到 Agent continuation 节点");
if (generationEffectApplied(currentNode.metadata || {}, input.effectKey) && currentNode.metadata?.agentGenerationContinuation?.status === "completed") return;
const nodes = currentNodes.map((node) =>
node.id === input.nodeId
? {
...node,
metadata: {
...node.metadata,
agentGenerationContinuation: input.continuation,
generationEffectKeys: Array.from(new Set([...(node.metadata?.generationEffectKeys || []), input.effectKey])),
},
}
: node,
);
let persistedProject: CanvasProject;
try {
persistedProject = await persistCanvasGenerationEffect({
projectId: input.projectId,
effectKey: input.effectKey,
previousNodes,
nodes,
signal: input.signal,
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw error;
throw new CanvasGenerationDurableAckError(error);
}
input.nodesRef.current = persistedProject.nodes;
input.setNodes(persistedProject.nodes);
}
type CinematicCanvasRollbackState = Pick<CanvasProject, "nodes" | "connections">;
type IdentifiedCanvasEntity = { id: string };
function sameCanvasEntity(left: unknown, right: unknown) {
return JSON.stringify(left) === JSON.stringify(right);
}
function reconcileCinematicEntities<T extends IdentifiedCanvasEntity>(previous: T[], attempted: T[], live: T[]): T[] {
const previousById = new Map(previous.map((entity) => [entity.id, entity]));
const attemptedById = new Map(attempted.map((entity) => [entity.id, entity]));
const cinematicIds = new Set<string>();
for (const id of new Set([...previousById.keys(), ...attemptedById.keys()])) {
if (!sameCanvasEntity(previousById.get(id), attemptedById.get(id))) cinematicIds.add(id);
}
const reconciled: T[] = [];
const included = new Set<string>();
for (const liveEntity of live) {
if (!cinematicIds.has(liveEntity.id)) {
reconciled.push(liveEntity);
included.add(liveEntity.id);
continue;
}
const previousEntity = previousById.get(liveEntity.id);
if (!previousEntity) continue;
reconciled.push(previousEntity);
included.add(previousEntity.id);
}
for (let previousIndex = 0; previousIndex < previous.length; previousIndex += 1) {
const previousEntity = previous[previousIndex]!;
if (!cinematicIds.has(previousEntity.id) || included.has(previousEntity.id)) continue;
let insertAt = reconciled.length;
for (let index = previousIndex - 1; index >= 0; index -= 1) {
const predecessorIndex = reconciled.findIndex((entity) => entity.id === previous[index]!.id);
if (predecessorIndex >= 0) {
insertAt = predecessorIndex + 1;
break;
}
}
if (insertAt === reconciled.length) {
for (let index = previousIndex + 1; index < previous.length; index += 1) {
const successorIndex = reconciled.findIndex((entity) => entity.id === previous[index]!.id);
if (successorIndex >= 0) {
insertAt = successorIndex;
break;
}
}
}
reconciled.splice(insertAt, 0, previousEntity);
included.add(previousEntity.id);
}
return reconciled;
}
function reconcileCinematicAckFailureCanvas(input: {
previousNodes: CanvasNodeData[];
attemptedNodes: CanvasNodeData[];
liveNodes: CanvasNodeData[];
previousConnections: CanvasConnection[];
attemptedConnections: CanvasConnection[];
liveConnections: CanvasConnection[];
}): CinematicCanvasRollbackState {
return {
nodes: reconcileCinematicEntities(input.previousNodes, input.attemptedNodes, input.liveNodes),
connections: reconcileCinematicEntities(input.previousConnections, input.attemptedConnections, input.liveConnections),
};
}
function reconcileCinematicActiveChatId(previous: string | null, attempted: string | null, live: string | null) {
return live === attempted ? previous : live;
}
function reconcileCinematicAckFailureSessions(input: { previous: CanvasAssistantSession[]; attempted: CanvasAssistantSession[]; live: CanvasAssistantSession[]; effectKey: string }) {
const previousById = new Map(input.previous.map((session) => [session.id, session]));
const attemptedById = new Map(input.attempted.map((session) => [session.id, session]));
return input.live.map((liveSession) => {
const previousSession = previousById.get(liveSession.id);
const attemptedSession = attemptedById.get(liveSession.id);
if (!previousSession || !attemptedSession || !generationEffectApplied(attemptedSession, input.effectKey) || generationEffectApplied(previousSession, input.effectKey)) return liveSession;
const pendingMessageId = previousSession.pendingBackendSession?.messageId;
let messages = liveSession.messages;
if (pendingMessageId) {
const pendingMessage = previousSession.messages.find((message) => message.id === pendingMessageId);
if (pendingMessage) {
let replaced = false;
messages = liveSession.messages.map((message) => {
if (message.id !== pendingMessageId) return message;
replaced = true;
return pendingMessage;
});
if (!replaced) {
const previousIndex = Math.max(
0,
previousSession.messages.findIndex((message) => message.id === pendingMessageId),
);
const insertAt = Math.min(previousIndex, messages.length);
messages = [...messages.slice(0, insertAt), pendingMessage, ...messages.slice(insertAt)];
}
}
}
const generationEffectKeys = Array.from(new Set([...(previousSession.generationEffectKeys || []), ...(liveSession.generationEffectKeys || []).filter((key) => key !== input.effectKey)]));
const restored: CanvasAssistantSession = {
...liveSession,
pendingBackendSession: previousSession.pendingBackendSession,
messages,
updatedAt: liveSession.updatedAt === attemptedSession.updatedAt ? previousSession.updatedAt : liveSession.updatedAt,
};
if (generationEffectKeys.length) restored.generationEffectKeys = generationEffectKeys;
else delete restored.generationEffectKeys;
return restored;
});
}
export async function persistCanvasCinematicSessionContinuationEffect(input: {
projectId: string;
effectKey: string;
previousNodes: CanvasNodeData[];
nodes: CanvasNodeData[];
previousConnections: CanvasConnection[];
connections: CanvasConnection[];
previousChatSessions: CanvasAssistantSession[];
chatSessions: CanvasAssistantSession[];
previousActiveChatId: string | null;
activeChatId: string | null;
signal?: AbortSignal;
readLiveSessionState: () => { sessions: CanvasAssistantSession[]; activeChatId: string | null };
restoreLiveSessions: (sessions: CanvasAssistantSession[], activeChatId: string | null) => void;
restoreLiveSnapshot?: (state: CinematicCanvasRollbackState) => void;
}) {
const scope = getActiveUserScope();
const liveAdapter = canvasGenerationLiveAdapters.get(canvasGenerationLiveAdapterKey(scope, input.projectId));
const initialLive = liveAdapter?.read();
const safeOrdinaryState = reconcileCinematicAckFailureCanvas({
previousNodes: input.previousNodes,
attemptedNodes: input.nodes,
liveNodes: initialLive?.nodes ?? input.nodes,
previousConnections: input.previousConnections,
attemptedConnections: input.connections,
liveConnections: initialLive?.connections ?? input.connections,
});
const safeOrdinaryActiveChatId = reconcileCinematicActiveChatId(input.previousActiveChatId, input.activeChatId, initialLive?.activeChatId ?? input.activeChatId);
if (getActiveUserScope() === scope) {
useCanvasStore.getState().updateProject(input.projectId, { ...safeOrdinaryState, activeChatId: safeOrdinaryActiveChatId });
}
try {
return await persistCanvasGenerationEffect({
projectId: input.projectId,
effectKey: input.effectKey,
previousNodes: input.previousNodes,
nodes: input.nodes,
previousConnections: input.previousConnections,
connections: input.connections,
previousChatSessions: input.previousChatSessions,
chatSessions: input.chatSessions,
previousActiveChatId: input.previousActiveChatId,
activeChatId: input.activeChatId,
signal: input.signal,
});
} catch (error) {
const latestLive = liveAdapter?.read();
const liveSessionState = input.readLiveSessionState();
const reconciledSessions = reconcileCinematicAckFailureSessions({
previous: input.previousChatSessions,
attempted: input.chatSessions,
live: liveSessionState.sessions,
effectKey: input.effectKey,
});
const reconciledActiveChatId = reconcileCinematicActiveChatId(input.previousActiveChatId, input.activeChatId, liveSessionState.activeChatId);
const reconciledCanvas = reconcileCinematicAckFailureCanvas({
previousNodes: input.previousNodes,
attemptedNodes: input.nodes,
liveNodes: latestLive?.nodes ?? input.nodes,
previousConnections: input.previousConnections,
attemptedConnections: input.connections,
liveConnections: latestLive?.connections ?? input.connections,
});
if (liveAdapter && latestLive) {
liveAdapter.write({ ...latestLive, nodes: reconciledCanvas.nodes, connections: reconciledCanvas.connections, chatSessions: reconciledSessions, activeChatId: reconciledActiveChatId });
}
input.restoreLiveSnapshot?.(reconciledCanvas);
if (getActiveUserScope() === scope) {
useCanvasStore.getState().updateProject(input.projectId, { ...reconciledCanvas, chatSessions: reconciledSessions, activeChatId: reconciledActiveChatId });
try {
await flushCanvasStorePersistence();
} catch {
// 保留 latest safe snapshot 给后续普通 persistence retry;原始 generation ack 错误仍是本次退出语义。
}
}
input.restoreLiveSessions(reconciledSessions, reconciledActiveChatId);
if (error instanceof Error && error.name === "AbortError") throw error;
throw new CanvasGenerationDurableAckError(error);
}
}
export type CanvasGenerationEffectInput = {
projectId: string;
effectKey: string;
previousNodes?: CanvasNodeData[];
nodes?: CanvasNodeData[];
previousConnections?: CanvasConnection[];
connections?: CanvasConnection[];
previousChatSessions?: CanvasAssistantSession[];
chatSessions?: CanvasAssistantSession[];
previousActiveChatId?: string | null;
activeChatId?: string | null;
signal?: AbortSignal;
};
function generationProjectDelta(input: CanvasGenerationEffectInput, memoryProject: CanvasProject) {
const stampedNodes = (input.nodes || []).filter((node) => generationEffectApplied(node.metadata || {}, input.effectKey));
const stampedSessions = (input.chatSessions || []).filter((session) => generationEffectApplied(session, input.effectKey));
if (input.connections && !input.previousConnections) throw new Error("生成副作用缺少连接前置快照");
const baseProject: CanvasProject = {
...memoryProject,
nodes: input.previousNodes ?? [],
connections: input.previousConnections ?? [],
chatSessions: input.previousChatSessions ?? [],
activeChatId: input.previousActiveChatId !== undefined ? input.previousActiveChatId : memoryProject.activeChatId,
};
const localProject: CanvasProject = {
...baseProject,
nodes: input.nodes ? (input.previousNodes ? input.nodes : stampedNodes) : baseProject.nodes,
connections: input.connections ?? baseProject.connections,
chatSessions: input.chatSessions ? (input.previousChatSessions ? input.chatSessions : stampedSessions) : baseProject.chatSessions,
activeChatId: input.activeChatId !== undefined ? input.activeChatId : baseProject.activeChatId,
updatedAt: new Date().toISOString(),
};
return { baseProject, localProject, stamped: stampedNodes.length > 0 || stampedSessions.length > 0 };
}
function rebaseCommittedCanvasGenerationOntoLiveProject(scope: string, projectId: string, committedDocument: ReturnType<typeof parseCanvasStorageDocument>, liveBaseProject: CanvasProject, baseRevision: number) {
const committedProject = committedDocument.state.projects.find((project) => project.id === projectId);
if (!committedProject) return undefined;
const storeProject = getActiveUserScope() === scope ? useCanvasStore.getState().projects.find((project) => project.id === projectId) : undefined;
const liveAdapter = canvasGenerationLiveAdapters.get(canvasGenerationLiveAdapterKey(scope, projectId));
const liveState = liveAdapter?.read();
const liveProject: CanvasProject = {
...(storeProject ?? committedProject),
...(liveState ?? {}),
};
const liveDocument = {
...committedDocument,
state: {
projects: committedDocument.state.projects.map((project) => (project.id === projectId ? liveProject : project)),
},
};
return rebaseCanvasProjects({
document: liveDocument,
baseProjects: [liveBaseProject],
localProjects: [committedProject],
baseRevision,
}).document.state.projects.find((project) => project.id === projectId);
}
export async function persistCanvasGenerationEffect(input: CanvasGenerationEffectInput) {
throwIfAborted(input.signal);
const scope = getActiveUserScope();
const baseRevision = canvasStoreStorageRevision(scope);
const memoryProjects = useCanvasStore.getState().projects;
const memoryProject = memoryProjects.find((candidate) => candidate.id === input.projectId);
if (!memoryProject) throw new Error("画布项目不存在,无法持久化生成副作用");
const delta = generationProjectDelta(input, memoryProject);
if (!delta.stamped) throw new Error("生成副作用缺少持久幂等标记");
return withCanvasStorePersistenceLock(
scope,
async () => {
let latestDurable: ReturnType<typeof parseCanvasStorageDocument> | undefined;
let generationCommittedProject: CanvasProject | undefined;
let reconcileLiveOnFailure = false;
try {
throwIfAborted(input.signal);
const storage = localForageStorageForScope(scope);
await commitPendingCanvasStorePersistenceLocked(scope);
throwIfAborted(input.signal);
const durable = parseCanvasStorageDocument(await storage.getItem(CANVAS_STORE_KEY), memoryProjects);
latestDurable = durable;
throwIfAborted(input.signal);
const rebased = rebaseCanvasProjects({
document: durable,
baseProjects: [delta.baseProject],
localProjects: [delta.localProject],
baseRevision,
});
if (rebased.conflicts.some((conflict) => conflict.reason === "concurrent-update")) {
reconcileLiveOnFailure = true;
throw new Error("画布生成副作用与并发修改冲突");
}
if (rebased.conflicts.length) {
reconcileLiveOnFailure = true;
throw new Error("画布生成副作用与已删除内容冲突");
}
throwIfAborted(input.signal);
await storage.setItem(CANVAS_STORE_KEY, serializeCanvasStorageDocument(rebased.document));
// Dedicated generation setItem resolving is the commit point. From here, abort or ordinary persistence failures cannot negate the committed effect.
latestDurable = rebased.document;
generationCommittedProject = rebased.document.state.projects.find((candidate) => candidate.id === input.projectId);
recordCanvasStorageDocument(scope, rebased.document);
rebasePendingCanvasStorePersistenceAfterGenerationCommitLocked(scope, rebased.document);
while (pendingCanvasStorePersistence(scope)) {
const ordinaryDocument = await commitPendingCanvasStorePersistenceLocked(scope);
if (ordinaryDocument) {
latestDurable = ordinaryDocument;
recordCanvasStorageDocument(scope, ordinaryDocument);
}
}
const finalDocument = parseCanvasStorageDocument(await storage.getItem(CANVAS_STORE_KEY), rebased.document.state.projects);
latestDurable = finalDocument;
recordCanvasStorageDocument(scope, finalDocument);
const persistedProject = rebaseCommittedCanvasGenerationOntoLiveProject(scope, input.projectId, finalDocument, memoryProject, baseRevision) ?? generationCommittedProject;
if (!persistedProject) throw new Error("画布项目不存在,无法确认生成副作用");
if (getActiveUserScope() === scope) {
withCanvasStorePersistenceSuppressed(() => {
useCanvasStore.setState((state) => ({
projects: state.projects.map((project) => (project.id === input.projectId ? persistedProject : project)),
}));
});
}
return persistedProject;
} catch (error) {
if (generationCommittedProject) {
if (latestDurable) {
recordCanvasStorageDocument(scope, latestDurable);
return rebaseCommittedCanvasGenerationOntoLiveProject(scope, input.projectId, latestDurable, memoryProject, baseRevision) ?? generationCommittedProject;
}
return generationCommittedProject;
}
if (latestDurable) {
recordCanvasStorageDocument(scope, latestDurable);
reconcileCanvasGenerationFailure(scope, latestDurable.state.projects);
if (reconcileLiveOnFailure) reconcileCanvasGenerationLiveProject(scope, latestDurable, delta.baseProject, delta.localProject);
}
throw error;
}
},
{ requireCrossRealmLock: true },
);
}

View File

@@ -0,0 +1,56 @@
import { localForageStorageForScope } from "@/lib/localforage-storage";
import { getActiveUserScope } from "@/lib/user-scope";
export const CREATION_CONVERSATIONS_KEY = "creation-conversations-v1";
type PendingCreationMessage = {
id: string;
role: "user" | "assistant";
mode?: string;
status?: string;
taskIds?: string[];
};
export type StoredCreationConversation = {
id: string;
messages: PendingCreationMessage[];
};
export function updateCreationConversationSnapshot<T extends { id: string }>(conversations: T[], conversationId: string, updater: (conversation: T) => T) {
return conversations.map((conversation) => (conversation.id === conversationId ? updater(conversation) : conversation));
}
export function pendingCreationMediaKey(conversations: StoredCreationConversation[]) {
return conversations
.flatMap((conversation) => conversation.messages.flatMap((message) => (message.role === "assistant" && message.status === "pending" && message.mode !== "text" ? [`${conversation.id}:${message.id}:${(message.taskIds || []).join(",")}`] : [])))
.join("|");
}
export function pendingCreationTaskIds(conversations: StoredCreationConversation[]) {
const taskIds = conversations.flatMap((conversation) =>
conversation.messages.flatMap((message) => {
if (message.role !== "assistant" || message.status !== "pending" || message.mode === "text") return [];
return message.taskIds || [];
}),
);
return Array.from(new Set(taskIds));
}
export async function loadCreationConversations<T extends StoredCreationConversation>() {
const storage = localForageStorageForScope(getActiveUserScope());
const value = await storage.getItem(CREATION_CONVERSATIONS_KEY);
if (!value) return null;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new Error("创作对话持久状态无效");
}
if (!Array.isArray(parsed)) throw new Error("创作对话持久状态无效");
return parsed as T[];
}
export async function saveCreationConversations<T extends StoredCreationConversation>(conversations: T[]) {
const storage = localForageStorageForScope(getActiveUserScope());
await storage.setItem(CREATION_CONVERSATIONS_KEY, JSON.stringify(conversations));
}

View File

@@ -71,9 +71,9 @@ export async function deleteStoredMedia(keys: Iterable<string>) {
);
}
export async function cleanupUnusedMedia(usedData: unknown) {
export async function cleanupUnusedMedia(usedData: unknown, scope = getActiveUserScope()) {
const usedKeys = collectMediaStorageKeys(usedData);
const currentScope = getActiveUserScope();
const currentScope = scope;
const unused: string[] = [];
await store.iterate((_value, key) => {
const parts = key.split(":");

View File

@@ -0,0 +1,16 @@
export async function loadOrStoreGenerationArtifact<TArtifact>(dependencies: {
effectKey: string;
read: (effectKey: string) => Promise<TArtifact | null>;
materialize: () => Promise<TArtifact>;
write: (effectKey: string, artifact: TArtifact) => Promise<void>;
}): Promise<TArtifact> {
const existing = await dependencies.read(dependencies.effectKey);
if (existing) return existing;
const materialized = await dependencies.materialize();
await dependencies.write(dependencies.effectKey, materialized);
return (await dependencies.read(dependencies.effectKey)) ?? materialized;
}
export function generationArtifactStorageKey(effectKey: string, mediaType: "image" | "video" | "audio", scope = "local"): string {
return `generation-${mediaType}:${scope}:${encodeURIComponent(effectKey)}`;
}

View File

@@ -0,0 +1,138 @@
export type GenerationAssetRecord = {
id: string;
metadata?: Record<string, unknown>;
};
type AsyncStorageLock = {
request<T>(name: string, callback: () => Promise<T>): Promise<T>;
};
type GenerationStorageLockOptions = {
requireCrossRealmLock?: boolean;
};
const ASSET_STORAGE_LOCK_PREFIX = "infinite-canvas:generation-asset-storage-lock:";
const ARTIFACT_COMMIT_LOCK_PREFIX = "infinite-canvas:generation-artifact-commit-lock:";
const assetStorageTails = new Map<string, Promise<void>>();
const artifactCommitTails = new Map<string, Promise<void>>();
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw new DOMException("The operation was aborted", "AbortError");
}
function isBlobUrl(value: unknown): value is string {
return typeof value === "string" && value.startsWith("blob:");
}
function preserveHydratedPreview<TAsset extends GenerationAssetRecord>(persisted: TAsset, hydrated?: TAsset): TAsset {
if (!hydrated) return persisted;
const durable = persisted as TAsset & { coverUrl?: unknown; data?: Record<string, unknown> };
const current = hydrated as TAsset & { coverUrl?: unknown; data?: Record<string, unknown> };
const durableStorageKey = durable.data?.storageKey;
if (typeof durableStorageKey !== "string" || durableStorageKey !== current.data?.storageKey) return persisted;
const hydratedDataUrl = current.data?.dataUrl;
const hydratedUrl = current.data?.url;
const nextData = durable.data && (isBlobUrl(hydratedDataUrl) || isBlobUrl(hydratedUrl)) ? { ...durable.data } : durable.data;
if (nextData && isBlobUrl(hydratedDataUrl)) nextData.dataUrl = hydratedDataUrl;
if (nextData && isBlobUrl(hydratedUrl)) nextData.url = hydratedUrl;
const hydratedCoverUrl = current.coverUrl;
if (!isBlobUrl(hydratedCoverUrl) && nextData === durable.data) return persisted;
return {
...persisted,
...(isBlobUrl(hydratedCoverUrl) ? { coverUrl: hydratedCoverUrl } : {}),
...(nextData !== durable.data ? { data: nextData } : {}),
} as TAsset;
}
function runWithBrowserStorageLock<T>(scope: string, operation: () => Promise<T>, options: GenerationStorageLockOptions) {
const locks = typeof window !== "undefined" && typeof navigator !== "undefined" ? (navigator.locks as AsyncStorageLock | undefined) : undefined;
if (locks) return locks.request(`${ASSET_STORAGE_LOCK_PREFIX}${scope}`, operation);
if (options.requireCrossRealmLock && typeof window !== "undefined" && typeof document !== "undefined") {
throw new Error("当前浏览器不支持跨标签存储锁,已停止生成素材持久化");
}
return operation();
}
export function withGenerationAssetStorageLock<T>(scope: string, operation: () => Promise<T>, options: GenerationStorageLockOptions = {}): Promise<T> {
const previous = assetStorageTails.get(scope) ?? Promise.resolve();
const pending = previous.catch(() => undefined).then(() => runWithBrowserStorageLock(scope, operation, options));
const tail = pending.then(
() => undefined,
() => undefined,
);
assetStorageTails.set(scope, tail);
void tail.finally(() => {
if (assetStorageTails.get(scope) === tail) assetStorageTails.delete(scope);
});
return pending;
}
export function withGenerationArtifactCommitLock<T>(scope: string, operation: () => Promise<T>, options: GenerationStorageLockOptions = {}): Promise<T> {
const previous = artifactCommitTails.get(scope) ?? Promise.resolve();
const pending = previous
.catch(() => undefined)
.then(() => {
const locks = typeof window !== "undefined" && typeof navigator !== "undefined" ? (navigator.locks as AsyncStorageLock | undefined) : undefined;
if (locks) return locks.request(`${ARTIFACT_COMMIT_LOCK_PREFIX}${scope}`, operation);
if (options.requireCrossRealmLock && typeof window !== "undefined" && typeof document !== "undefined") {
throw new Error("当前浏览器不支持跨标签存储锁,已停止生成文件提交");
}
return operation();
});
const tail = pending.then(
() => undefined,
() => undefined,
);
artifactCommitTails.set(scope, tail);
void tail.finally(() => {
if (artifactCommitTails.get(scope) === tail) artifactCommitTails.delete(scope);
});
return pending;
}
export async function flushGenerationAssetStorageLocks() {
while (assetStorageTails.size || artifactCommitTails.size) await Promise.all([...assetStorageTails.values(), ...artifactCommitTails.values()]);
}
export async function insertOrReturnGenerationAsset<TAsset extends GenerationAssetRecord>(dependencies: {
storageScope: string;
effectKey: string;
assetId: string;
createAsset: () => TAsset;
updateAssets: (updater: (assets: TAsset[]) => TAsset[]) => void;
readAssets: () => TAsset[];
readPersistedAssets: () => Promise<TAsset[]>;
isAssetDeleted?: () => boolean;
persistAssets: (assets: TAsset[]) => Promise<void>;
requireCrossRealmLock?: boolean;
signal?: AbortSignal;
}): Promise<string> {
return withGenerationAssetStorageLock(
dependencies.storageScope,
async () => {
throwIfAborted(dependencies.signal);
const persistedAssets = await dependencies.readPersistedAssets();
throwIfAborted(dependencies.signal);
if (dependencies.isAssetDeleted?.()) throw new Error("生成素材已被用户删除");
const existing = persistedAssets.find((asset) => asset.metadata?.generationEffectKey === dependencies.effectKey);
if (existing) {
const hydratedExisting = dependencies.readAssets().find((asset) => asset.id === existing.id && asset.metadata?.generationEffectKey === dependencies.effectKey);
dependencies.updateAssets(() => persistedAssets.map((asset) => (asset.id === existing.id ? preserveHydratedPreview(asset, hydratedExisting) : asset)));
return existing.id;
}
if (persistedAssets.some((asset) => asset.id === dependencies.assetId)) {
throw new Error("生成素材幂等键冲突");
}
const assets = [dependencies.createAsset(), ...persistedAssets];
throwIfAborted(dependencies.signal);
dependencies.updateAssets(() => assets);
throwIfAborted(dependencies.signal);
await dependencies.persistAssets(assets);
throwIfAborted(dependencies.signal);
return dependencies.assetId;
},
{ requireCrossRealmLock: dependencies.requireCrossRealmLock },
);
}

View File

@@ -0,0 +1,19 @@
export type GenerationEffectStamped = {
generationEffectKeys?: string[];
};
export function generationEffectApplied(target: GenerationEffectStamped, effectKey: string | undefined): boolean {
return Boolean(effectKey && target.generationEffectKeys?.includes(effectKey));
}
export function applyGenerationConsumerEffect<T extends GenerationEffectStamped>(target: T, effectKey: string, apply: (current: T) => T): { value: T; applied: boolean } {
if (generationEffectApplied(target, effectKey)) return { value: target, applied: false };
const value = apply(target);
return {
value: {
...value,
generationEffectKeys: [...new Set([...(value.generationEffectKeys || []), effectKey])],
},
applied: true,
};
}

View File

@@ -0,0 +1,97 @@
type ActiveGenerationConsumer = {
controller: AbortController;
settled: Promise<unknown>;
};
export type GenerationConsumerHandle = {
signal: AbortSignal;
release(): void;
};
const activeGenerationConsumers = new Set<ActiveGenerationConsumer>();
let pausedTransitions = 0;
let transitionTail = Promise.resolve();
function abortError(): DOMException {
return new DOMException("The operation was aborted", "AbortError");
}
export function activeGenerationConsumerController(controller: AbortController): AbortController {
return controller.signal.aborted ? new AbortController() : controller;
}
export function runGenerationConsumer<T>(signal: AbortSignal | undefined, operation: (signal: AbortSignal) => Promise<T>): Promise<T> {
if (pausedTransitions > 0) return Promise.reject(abortError());
const controller = new AbortController();
const abort = () => controller.abort();
if (signal?.aborted) abort();
else signal?.addEventListener("abort", abort, { once: true });
const active = {} as ActiveGenerationConsumer;
const result = Promise.resolve().then(async () => {
if (controller.signal.aborted) throw abortError();
return operation(controller.signal);
});
active.controller = controller;
active.settled = result.finally(() => {
signal?.removeEventListener("abort", abort);
activeGenerationConsumers.delete(active);
});
activeGenerationConsumers.add(active);
return active.settled as Promise<T>;
}
export function beginGenerationConsumer(signal?: AbortSignal): GenerationConsumerHandle {
const controller = new AbortController();
if (pausedTransitions > 0) {
controller.abort();
return { signal: controller.signal, release() {} };
}
const abort = () => controller.abort();
if (signal?.aborted) abort();
else signal?.addEventListener("abort", abort, { once: true });
let release!: () => void;
const settled = new Promise<void>((resolve) => {
release = resolve;
});
const active: ActiveGenerationConsumer = { controller, settled };
activeGenerationConsumers.add(active);
let released = false;
return {
signal: controller.signal,
release() {
if (released) return;
released = true;
signal?.removeEventListener("abort", abort);
activeGenerationConsumers.delete(active);
release();
},
};
}
async function cancelAndDrainActiveGenerationConsumers() {
while (activeGenerationConsumers.size) {
for (const active of activeGenerationConsumers) active.controller.abort();
await Promise.allSettled([...activeGenerationConsumers].map((active) => active.settled));
}
}
export function withGenerationConsumersPaused<T>(operation: () => Promise<T>): Promise<T> {
pausedTransitions += 1;
const result = transitionTail.then(async () => {
await cancelAndDrainActiveGenerationConsumers();
return operation();
});
transitionTail = result.then(
() => undefined,
() => undefined,
);
return result.finally(() => {
pausedTransitions -= 1;
});
}
export function cancelAndDrainGenerationConsumers(): Promise<void> {
return withGenerationConsumersPaused(async () => undefined);
}

View File

@@ -0,0 +1,228 @@
import type { GenerationTask, GenerationTaskOutput } from "@/services/api/task-center";
export type GenerationTaskEffectResult = {
materializedAssetId?: string;
};
export type GenerationTaskEffectClaim = { status: "claimed"; fence: number; binding?: string } | { status: "busy"; retryAt?: string } | { status: "completed"; result: GenerationTaskEffectResult };
export type GenerationTaskEffectStore = {
claim(effectKey: string, taskId: string): Promise<GenerationTaskEffectClaim>;
renew(effectKey: string, taskId: string, binding?: string): Promise<{ fence: number }>;
complete(effectKey: string, taskId: string, result: GenerationTaskEffectResult, binding?: string): Promise<void>;
release(effectKey: string, taskId: string, binding?: string): Promise<void>;
};
export type MaterializeGenerationTaskOutput = (input: { task: GenerationTask; output: GenerationTaskOutput; effectKey: string; signal?: AbortSignal }) => Promise<GenerationTaskEffectResult>;
export type GenerationTaskEffectConsumer = (input: { task: GenerationTask; output?: GenerationTaskOutput; effectKey: string; signal?: AbortSignal }) => Promise<void>;
function abortError(): DOMException {
return new DOMException("The operation was aborted", "AbortError");
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw abortError();
}
async function waitForAbortableDelay(delayMs: number, signal?: AbortSignal) {
throwIfAborted(signal);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(done, delayMs);
function done() {
signal?.removeEventListener("abort", aborted);
resolve();
}
function aborted() {
clearTimeout(timer);
signal?.removeEventListener("abort", aborted);
reject(abortError());
}
signal?.addEventListener("abort", aborted, { once: true });
});
}
export function createIdempotentMaterializeOutput(dependencies: { insertOrReturnAsset(input: { task: GenerationTask; output: GenerationTaskOutput; effectKey: string; signal?: AbortSignal }): Promise<string> }): MaterializeGenerationTaskOutput {
return async (input) => ({
materializedAssetId: await dependencies.insertOrReturnAsset(input),
});
}
export function materializeEffectKey(taskId: string, outputIndex: number): string {
return `materialize:${taskId}:${outputIndex}`;
}
export function attachNodeEffectKey(taskId: string, nodeId: string, outputIndex: number): string {
return `attach-node:${taskId}:${nodeId}:${outputIndex}`;
}
export function attachMessageEffectKey(taskId: string, messageId: string, outputIndex: number): string {
return `attach-message:${taskId}:${messageId}:${outputIndex}`;
}
export function agentResumeEffectKey(taskId: string, continuationId: string): string {
return `agent-resume:${taskId}:${continuationId}`;
}
export function createGenerationTaskMaterializer(dependencies: { effects: GenerationTaskEffectStore; materializeOutput: MaterializeGenerationTaskOutput; leaseHeartbeatMs?: number; waitUntil?: (retryAt: string, signal?: AbortSignal) => Promise<void> }) {
const leaseHeartbeatMs = dependencies.leaseHeartbeatMs ?? 10_000;
if (!Number.isSafeInteger(leaseHeartbeatMs) || leaseHeartbeatMs < 1) throw new Error("Invalid generation effect heartbeat interval");
const waitUntil =
dependencies.waitUntil ??
(async (retryAt: string, signal?: AbortSignal) => {
const delayMs = Math.max(0, Date.parse(retryAt) - Date.now());
await waitForAbortableDelay(delayMs, signal);
});
async function claimWhenAvailable(effectKey: string, taskId: string, signal?: AbortSignal): Promise<Exclude<GenerationTaskEffectClaim, { status: "busy" }>> {
while (true) {
throwIfAborted(signal);
const claim = await dependencies.effects.claim(effectKey, taskId);
throwIfAborted(signal);
if (claim.status !== "busy") return claim;
const leaseRetryAt = claim.retryAt && Number.isFinite(Date.parse(claim.retryAt)) ? Date.parse(claim.retryAt) : Date.now() + leaseHeartbeatMs;
// 同一 effect 可能已由当前 owner 提前完成;短周期复核 completed,同时仍以租约到期为接管边界。
const nextCheckAt = Math.min(leaseRetryAt, Date.now() + 250);
await waitUntil(new Date(nextCheckAt).toISOString(), signal);
}
}
function startLeaseHeartbeat(effectKey: string, taskId: string, binding?: string, parentSignal?: AbortSignal) {
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let wake: (() => void) | undefined;
let failure: unknown;
const controller = new AbortController();
const abortFromParent = () => controller.abort();
if (parentSignal?.aborted) abortFromParent();
else parentSignal?.addEventListener("abort", abortFromParent, { once: true });
const done = (async () => {
while (!stopped) {
await new Promise<void>((resolve) => {
wake = resolve;
timer = setTimeout(resolve, leaseHeartbeatMs);
});
timer = undefined;
wake = undefined;
if (stopped) break;
await dependencies.effects.renew(effectKey, taskId, binding);
}
})().catch((error) => {
failure = error;
controller.abort();
});
const stop = async () => {
if (!stopped) {
stopped = true;
if (timer) clearTimeout(timer);
wake?.();
}
parentSignal?.removeEventListener("abort", abortFromParent);
await done;
if (failure) throw failure;
};
return { signal: controller.signal, stop };
}
async function releaseClaim(effectKey: string, taskId: string, binding: string | undefined, stopHeartbeat: () => Promise<void>, original: unknown): Promise<never> {
let failure = original;
try {
await stopHeartbeat();
} catch (error) {
failure = error;
}
try {
await dependencies.effects.release(effectKey, taskId, binding);
} catch (error) {
failure = error;
}
throw failure;
}
async function consumeEffect(
effectKey: string,
task: GenerationTask,
consumer: GenerationTaskEffectConsumer,
output?: GenerationTaskOutput,
signal?: AbortSignal,
options: { consumerResolveCommits?: boolean } = {},
): Promise<"applied" | "completed" | "busy"> {
const claim = await claimWhenAvailable(effectKey, task.id, signal);
if (claim.status === "completed") return "completed";
const heartbeat = startLeaseHeartbeat(effectKey, task.id, claim.binding, signal);
try {
throwIfAborted(heartbeat.signal);
await consumer({ task, output, effectKey, signal: heartbeat.signal });
if (!options.consumerResolveCommits) throwIfAborted(heartbeat.signal);
await heartbeat.stop();
await dependencies.effects.complete(effectKey, task.id, {}, claim.binding);
return "applied";
} catch (error) {
return releaseClaim(effectKey, task.id, claim.binding, heartbeat.stop, error);
}
}
function requireMaterializedOutput(task: GenerationTask, outputIndex: number): GenerationTaskOutput {
const output = task.outputs?.find((candidate) => candidate.outputIndex === outputIndex);
if (!output?.materializedAssetId) {
throw new Error(`Task output ${outputIndex} is not materialized`);
}
return output;
}
return {
async materialize(task: GenerationTask, signal?: AbortSignal): Promise<GenerationTask> {
throwIfAborted(signal);
if (task.status !== "succeeded" || !task.outputs?.length) return task;
const outputs: GenerationTaskOutput[] = [];
for (const originalOutput of task.outputs) {
const output = { ...originalOutput };
if (!output.materializedAssetId) {
const effectKey = materializeEffectKey(task.id, output.outputIndex);
const claim = await claimWhenAvailable(effectKey, task.id, signal);
if (claim.status === "completed") {
output.materializedAssetId = claim.result.materializedAssetId;
} else if (claim.status === "claimed") {
const heartbeat = startLeaseHeartbeat(effectKey, task.id, claim.binding, signal);
try {
throwIfAborted(heartbeat.signal);
const result = await dependencies.materializeOutput({
task,
output,
effectKey,
signal: heartbeat.signal,
});
throwIfAborted(heartbeat.signal);
await heartbeat.stop();
await dependencies.effects.complete(effectKey, task.id, result, claim.binding);
output.materializedAssetId = result.materializedAssetId;
} catch (error) {
return releaseClaim(effectKey, task.id, claim.binding, heartbeat.stop, error);
}
}
}
outputs.push(output);
}
return {
...task,
outputs,
resultState: outputs.every((output) => Boolean(output.materializedAssetId)) ? "READY" : "MATERIALIZING",
};
},
attachNode(task: GenerationTask, nodeId: string, outputIndex: number, consumer: GenerationTaskEffectConsumer, signal?: AbortSignal) {
const output = requireMaterializedOutput(task, outputIndex);
return consumeEffect(attachNodeEffectKey(task.id, nodeId, outputIndex), task, consumer, output, signal);
},
attachMessage(task: GenerationTask, messageId: string, outputIndex: number, consumer: GenerationTaskEffectConsumer, signal?: AbortSignal) {
const output = requireMaterializedOutput(task, outputIndex);
return consumeEffect(attachMessageEffectKey(task.id, messageId, outputIndex), task, consumer, output, signal);
},
resumeAgent(task: GenerationTask, continuationId: string, consumer: GenerationTaskEffectConsumer, signal?: AbortSignal) {
return consumeEffect(agentResumeEffectKey(task.id, continuationId), task, consumer, undefined, signal, { consumerResolveCommits: true });
},
};
}

View File

@@ -122,19 +122,19 @@ export async function deleteStoredImages(keys: Iterable<string>) {
);
}
export async function cleanupUnusedImages(usedData: unknown) {
export async function cleanupUnusedImages(usedData: unknown, scope = getActiveUserScope()) {
const usedKeys = collectImageStorageKeys(usedData);
const currentPrefix = `image:${getActiveUserScope()}:`;
const currentPrefixes = [`image:${scope}:`, `generation-image:${scope}:`];
const unused: string[] = [];
await store.iterate((_value, key) => {
if (key.startsWith(currentPrefix) && !usedKeys.has(key)) unused.push(key);
if (currentPrefixes.some((prefix) => key.startsWith(prefix)) && !usedKeys.has(key)) unused.push(key);
});
await deleteStoredImages(unused);
}
export function collectImageStorageKeys(value: unknown, keys = new Set<string>()) {
if (!value || typeof value !== "object") return keys;
if ("storageKey" in value && typeof value.storageKey === "string" && (value.storageKey.startsWith("image:") || resourceIdFromStorageKey(value.storageKey))) keys.add(value.storageKey);
if ("storageKey" in value && typeof value.storageKey === "string" && (value.storageKey.startsWith("image:") || value.storageKey.startsWith("generation-image:") || resourceIdFromStorageKey(value.storageKey))) keys.add(value.storageKey);
Object.values(value).forEach((item) => (Array.isArray(item) ? item.forEach((child) => collectImageStorageKeys(child, keys)) : collectImageStorageKeys(item, keys)));
return keys;
}

View File

@@ -0,0 +1,298 @@
import type { LocalRuntimeTransport } from "@/services/local-runtime";
import { LocalRuntimeClientError } from "@/services/local-runtime-session";
export type DreaminaCliState = "missing" | "installed" | "login_pending" | "authenticated" | "error";
export type DreaminaLifecycleStatusCode = "dreamina_missing" | "dreamina_version_failed" | "dreamina_login_required" | "dreamina_login_pending";
export type DreaminaCliStatus = {
provider: "dreamina-cli";
state: DreaminaCliState;
installed: boolean;
authenticated: boolean;
version?: string;
totalCredit?: number;
accountBinding?: string;
sessionEpoch?: number;
creditObservedAt?: string;
code?: DreaminaLifecycleStatusCode;
message: string;
verificationUri?: string;
userCode?: string;
expiresAt?: string;
};
export type DreaminaRequestOptions = {
signal?: AbortSignal;
timeoutMs?: number;
now?: () => number;
};
export class DreaminaAgentError extends Error {
constructor(
readonly code: string,
readonly status: number,
message: string,
) {
super(message);
this.name = "DreaminaAgentError";
}
}
const MAX_RESPONSE_BYTES = 64 * 1024;
export const DREAMINA_REQUEST_TIMEOUTS = { status: 30_000, login: 35_000, logout: 20_000 } as const;
export type DreaminaLifecycleAction = keyof typeof DREAMINA_REQUEST_TIMEOUTS;
export function dreaminaRequestTimeout(action: DreaminaLifecycleAction, override?: number) {
return override ?? DREAMINA_REQUEST_TIMEOUTS[action];
}
const LIFECYCLE_CODES = new Set<DreaminaLifecycleStatusCode>(["dreamina_missing", "dreamina_version_failed", "dreamina_login_required", "dreamina_login_pending"]);
const STABLE_ERROR_MESSAGES: Record<string, string> = {
dreamina_cancelled: "Dreamina 操作已取消",
dreamina_cleanup_failed: "Dreamina 进程清理失败,请检查本机进程状态",
dreamina_command_invalid: "Dreamina CLI 命令无效",
dreamina_command_timeout: "Dreamina 操作超时,相关进程已清理",
dreamina_environment_invalid: "Dreamina 环境变量无效",
dreamina_idempotency_conflict: "Dreamina 操作标识冲突",
dreamina_internal_error: "Dreamina CLI 请求失败",
dreamina_login_failed: "无法启动 Dreamina OAuth 登录",
dreamina_login_pending: "请先完成 Dreamina 官方页面授权",
dreamina_login_required: "Dreamina CLI 需要登录",
dreamina_login_response_invalid: "Dreamina 登录响应无法识别,请升级 CLI",
dreamina_logout_failed: "Dreamina 退出失败,请重试",
dreamina_missing: "未检测到 Dreamina CLI",
dreamina_output_too_large: "Dreamina 返回内容超过安全上限",
dreamina_query_failed: "Dreamina 查询失败",
dreamina_query_response_invalid: "Dreamina 查询响应无效",
dreamina_reference_budget_exceeded: "Dreamina 参考文件超过安全上限",
dreamina_reference_cleanup_failed: "Dreamina 参考文件清理失败",
dreamina_reference_invalid: "Dreamina 参考文件无效",
dreamina_request_invalid: "Dreamina 请求参数无效",
dreamina_spawn_failed: "无法启动 Dreamina CLI",
dreamina_state_busy: "Dreamina 正在处理另一项操作",
dreamina_state_fenced: "Dreamina 操作已失效",
dreamina_state_invalid: "Dreamina 本机状态无效",
dreamina_submission_unknown: "Dreamina 提交结果未知,请先检查状态",
dreamina_version_failed: "Dreamina CLI 版本检测失败",
};
export function getDreaminaStatus(client: LocalRuntimeTransport, options: DreaminaRequestOptions = {}) {
return requestDreamina(client, "GET", "/dreamina/status", {
...options,
timeoutMs: dreaminaRequestTimeout("status", options.timeoutMs),
});
}
export function loginDreamina(client: LocalRuntimeTransport, options: DreaminaRequestOptions = {}) {
return requestDreamina(client, "POST", "/dreamina/login", {
...options,
timeoutMs: dreaminaRequestTimeout("login", options.timeoutMs),
});
}
export function logoutDreamina(client: LocalRuntimeTransport, options: DreaminaRequestOptions = {}) {
return requestDreamina(client, "POST", "/dreamina/logout", {
...options,
timeoutMs: dreaminaRequestTimeout("logout", options.timeoutMs),
});
}
async function requestDreamina(client: LocalRuntimeTransport, method: "GET" | "POST", route: "/dreamina/status" | "/dreamina/login" | "/dreamina/logout", options: DreaminaRequestOptions) {
if (options.signal?.aborted) throw publicError("dreamina_cancelled", 499);
const controller = new AbortController();
let timedOut = false;
const abort = () => controller.abort();
options.signal?.addEventListener("abort", abort, { once: true });
const timer = setTimeout(
() => {
timedOut = true;
controller.abort();
},
Math.max(1, options.timeoutMs ?? DREAMINA_REQUEST_TIMEOUTS.status),
);
try {
const response = await client.request(route, {
method,
...(method === "POST"
? {
headers: { "content-type": "application/json" },
body: "{}",
}
: {}),
signal: controller.signal,
});
if (response.redirected || response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) {
throw publicError("dreamina_response_invalid", 502);
}
const body = await readBoundedJson(response);
if (!response.ok || !isRecord(body) || body.ok !== true) {
const code = isRecord(body) && typeof body.code === "string" && body.code in STABLE_ERROR_MESSAGES ? body.code : "dreamina_internal_error";
throw publicError(code, response.status || 500);
}
return parseStatus(body.status, options.now?.() ?? Date.now());
} catch (error) {
if (error instanceof DreaminaAgentError) throw error;
if (controller.signal.aborted) {
throw publicError(timedOut ? "dreamina_timeout" : "dreamina_cancelled", timedOut ? 504 : 499);
}
if (error instanceof LocalRuntimeClientError && error.code === "session_required") {
throw publicError("dreamina_runtime_required", 401);
}
throw publicError("dreamina_runtime_unreachable", 503);
} finally {
clearTimeout(timer);
options.signal?.removeEventListener("abort", abort);
}
}
async function readBoundedJson(response: Response): Promise<unknown> {
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
throw publicError("dreamina_response_invalid", 502);
}
if (!response.body) throw publicError("dreamina_response_invalid", 502);
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
const item = await reader.read();
if (item.done) break;
total += item.value.byteLength;
if (total > MAX_RESPONSE_BYTES) {
await reader.cancel();
throw publicError("dreamina_response_invalid", 502);
}
chunks.push(item.value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
try {
return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
} catch {
throw publicError("dreamina_response_invalid", 502);
}
}
function parseStatus(value: unknown, now: number): DreaminaCliStatus {
if (!isRecord(value)) throw publicError("dreamina_response_invalid", 502);
const allowed = new Set(["provider", "state", "installed", "authenticated", "version", "code", "message"]);
if (value.state === "authenticated") {
allowed.add("totalCredit");
allowed.add("accountBinding");
allowed.add("sessionEpoch");
allowed.add("creditObservedAt");
}
if (value.state === "login_pending") {
allowed.add("verificationUri");
allowed.add("userCode");
allowed.add("expiresAt");
}
if (
Object.keys(value).some((key) => !allowed.has(key)) ||
value.provider !== "dreamina-cli" ||
!isDreaminaState(value.state) ||
typeof value.installed !== "boolean" ||
typeof value.authenticated !== "boolean" ||
typeof value.message !== "string" ||
value.message.length < 1 ||
value.message.length > 500 ||
(value.version !== undefined && (typeof value.version !== "string" || !/^[A-Za-z0-9._+:-]{1,120}$/.test(value.version))) ||
(value.totalCredit !== undefined && (value.state !== "authenticated" || !Number.isInteger(value.totalCredit) || (value.totalCredit as number) < 0 || (value.totalCredit as number) > 1_000_000_000)) ||
(value.accountBinding === undefined) !== (value.sessionEpoch === undefined) ||
(value.accountBinding !== undefined && (value.state !== "authenticated" || typeof value.accountBinding !== "string" || !/^[A-Za-z0-9._:-]{8,160}$/.test(value.accountBinding))) ||
(value.sessionEpoch !== undefined && (value.state !== "authenticated" || !Number.isSafeInteger(value.sessionEpoch) || (value.sessionEpoch as number) < 0)) ||
(value.creditObservedAt !== undefined && (value.totalCredit === undefined || typeof value.creditObservedAt !== "string" || !Number.isFinite(Date.parse(value.creditObservedAt)))) ||
(value.code !== undefined && (typeof value.code !== "string" || !LIFECYCLE_CODES.has(value.code as DreaminaLifecycleStatusCode)))
) {
throw publicError("dreamina_response_invalid", 502);
}
const code = value.code as DreaminaLifecycleStatusCode | undefined;
if (!consistentStatus(value.state, value.installed, value.authenticated, code)) {
throw publicError("dreamina_response_invalid", 502);
}
let verification: Pick<DreaminaCliStatus, "verificationUri" | "userCode" | "expiresAt"> = {};
if (value.state === "login_pending") {
if (!isOfficialVerificationUri(value.verificationUri) || typeof value.userCode !== "string" || !/^[A-Za-z0-9-]{4,32}$/.test(value.userCode) || typeof value.expiresAt !== "string") {
throw publicError("dreamina_response_invalid", 502);
}
const expiresAt = Date.parse(value.expiresAt);
if (!Number.isFinite(expiresAt) || expiresAt <= now || expiresAt > now + 31 * 60_000) {
throw publicError("dreamina_response_invalid", 502);
}
verification = {
verificationUri: value.verificationUri,
userCode: value.userCode,
expiresAt: value.expiresAt,
};
}
return {
provider: "dreamina-cli",
state: value.state,
installed: value.installed,
authenticated: value.authenticated,
...(typeof value.version === "string" ? { version: value.version } : {}),
...(typeof value.totalCredit === "number" ? { totalCredit: value.totalCredit } : {}),
...(typeof value.accountBinding === "string" ? { accountBinding: value.accountBinding } : {}),
...(typeof value.sessionEpoch === "number" ? { sessionEpoch: value.sessionEpoch } : {}),
...(typeof value.creditObservedAt === "string" ? { creditObservedAt: value.creditObservedAt } : {}),
...(code ? { code } : {}),
message: publicStatusMessage(value.state),
...verification,
};
}
function consistentStatus(state: DreaminaCliState, installed: boolean, authenticated: boolean, code: DreaminaLifecycleStatusCode | undefined) {
if (state === "missing") return !installed && !authenticated && code === "dreamina_missing";
if (state === "installed") return installed && !authenticated && code === "dreamina_login_required";
if (state === "login_pending") return installed && !authenticated && code === "dreamina_login_pending";
if (state === "authenticated") return installed && authenticated && code === undefined;
return installed && !authenticated && code === "dreamina_version_failed";
}
function isDreaminaState(value: unknown): value is DreaminaCliState {
return value === "missing" || value === "installed" || value === "login_pending" || value === "authenticated" || value === "error";
}
function isOfficialVerificationUri(value: unknown): value is string {
if (typeof value !== "string" || value.length > 2_048) return false;
try {
const url = new URL(value);
return url.protocol === "https:" && url.hostname === "jimeng.jianying.com" && url.pathname === "/ai-tool/cli-auth" && !url.username && !url.password && !url.hash;
} catch {
return false;
}
}
function publicStatusMessage(state: DreaminaCliState) {
if (state === "missing") return "未检测到 Dreamina CLI,请先完成本机安装";
if (state === "installed") return "Dreamina CLI 已安装,需要登录";
if (state === "login_pending") return "请在官方页面确认 Dreamina 登录";
if (state === "authenticated") return "Dreamina CLI 已登录";
return "已找到 Dreamina CLI,但版本检测失败";
}
function publicError(code: string, status: number) {
const messages: Record<string, string> = {
...STABLE_ERROR_MESSAGES,
dreamina_cancelled: "Dreamina 操作已取消",
dreamina_response_invalid: "Dreamina CLI 响应无效",
dreamina_runtime_required: "请先连接本机运行时",
dreamina_runtime_unreachable: "本机运行时暂时不可用",
dreamina_timeout: "Dreamina CLI 请求超时",
};
return new DreaminaAgentError(code, status, messages[code] ?? "Dreamina CLI 请求失败");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}

View File

@@ -0,0 +1,816 @@
import type { LocalRuntimeTransport } from "@/services/local-runtime";
import { LocalRuntimeClientError, type LocalRuntimeConnection } from "@/services/local-runtime-session";
import { getLocalRuntimeSessionClient } from "@/stores/use-local-runtime-store";
import type { GenerationTaskEffectClaim, GenerationTaskEffectResult, GenerationTaskEffectStore } from "@/services/generation-task-materializer";
const MAX_RESPONSE_BYTES = 256 * 1024 * 1024;
const MAX_REFERENCE_BYTES = 20 * 1024 * 1024;
const MAX_TASK_LIST_RESPONSE_BYTES = 1024 * 1024;
const MAX_EFFECT_RESPONSE_BYTES = 16 * 1024;
const GENERATION_EFFECT_CONSUMER_ID = "web-generation-materializer";
const MAX_TASK_LIST_ITEMS = 100;
export const LOCAL_DREAMINA_WAIT_STOPPED_CODE = "dreamina_local_wait_stopped";
export const LOCAL_DREAMINA_WAIT_STOPPED_MESSAGE = "仅停止本机等待,官方任务仍可能继续。";
export const LOCAL_DREAMINA_OFFICIAL_INCOMPLETE_CODE = "dreamina_official_incomplete";
export const LOCAL_DREAMINA_OFFICIAL_INCOMPLETE_MESSAGE = "官方任务未完成;可能已在官方取消或生成失败。";
export const LOCAL_DREAMINA_OFFICIAL_FAILED_CODE = "dreamina_official_failed";
export const LOCAL_DREAMINA_FAILED_OR_CANCELLED_MESSAGE = "任务未成功。当前 Dreamina CLI 无法可靠判断是官网取消还是生成失败。";
export const LOCAL_DREAMINA_OFFICIAL_FAILED_MESSAGE = LOCAL_DREAMINA_FAILED_OR_CANCELLED_MESSAGE;
export type LocalDreaminaTaskLifecycle = "QUEUED_LOCAL" | "SUBMITTING" | "SUBMISSION_UNCERTAIN" | "ACCEPTED" | "RUNNING" | "TERMINAL";
export type LocalDreaminaTerminalOutcome = "SUCCEEDED" | "REJECTED" | "FAILED" | "CANCELLED" | "FAILED_OR_CANCELLED";
export type LocalDreaminaTaskSyncState = "SYNC_OK" | "SYNC_RETRY_WAIT" | "SYNC_BLOCKED_ACCOUNT" | "SYNC_UNCERTAIN" | "SYNC_CONFLICT";
export type LocalDreaminaTaskResultState = "NOT_AVAILABLE" | "PENDING_MATERIALIZATION" | "MATERIALIZING" | "READY" | "FAILED_RETRYABLE" | "FAILED_PERMANENT";
export type LocalDreaminaTaskContext =
| {
scope: "scoped";
projectId?: string;
nodeId?: string;
conversationId?: string;
messageId?: string;
batchIndex?: number;
batchCount?: number;
retryOf?: string;
attemptGroupId?: string;
}
| { scope: "legacy_unscoped" };
export type LocalDreaminaTaskOutput = {
outputIndex: number;
mediaType: "image" | "video" | "audio";
providerArtifactRef?: string;
materializedAssetId?: string;
materializationErrorCode?: string;
};
export type LocalDreaminaProviderObservation = {
source: "submit_receipt" | "query_result" | "list_task";
observedAt: string;
accountBinding?: string;
fenceEpoch?: number;
status: "pending" | "processing" | "completed" | "failed" | "cancelled";
};
export type LocalDreaminaProviderCapability = {
adapterSupported: boolean;
cancelSupported: boolean;
pushStatusSupported: boolean;
statusConsistency: "eventual_polling";
accountEntitlement: "yes" | "no" | "unknown";
currentlyObservedAvailable: "yes" | "no" | "unknown";
references: { images: boolean; videos: boolean; audios: boolean; firstLastFrames: boolean };
};
export type LocalDreaminaReference =
| { kind?: "image"; mimeType: "image/png" | "image/jpeg" | "image/webp"; bytes: Uint8Array; metadata?: { name?: string; width?: number; height?: number } }
| { kind: "video"; mimeType: "video/mp4" | "video/quicktime" | "video/webm"; bytes: Uint8Array; metadata?: { name?: string; width?: number; height?: number; durationMs?: number } }
| { kind: "audio"; mimeType: "audio/mpeg" | "audio/wav" | "audio/mp4" | "audio/aac" | "audio/flac"; bytes: Uint8Array; metadata?: { name?: string; durationMs?: number } };
export type LocalDreaminaGenerationInput = {
model: `local:dreamina-cli:${string}`;
mode: "image" | "video";
prompt: string;
settings: { aspect?: string; resolution?: string; duration?: number; count?: number };
references: LocalDreaminaReference[];
resumeOnly?: boolean;
idempotencyKey?: string;
clientOperationId?: string;
context?: LocalDreaminaTaskContext;
};
export type LocalDreaminaGenerationResult = {
mode: "image" | "video";
images?: Array<{ dataUrl: string; mimeType: string; bytes: number }>;
video?: { dataUrl: string; mimeType: string; bytes: number };
};
export type LocalDreaminaGenerationTask = {
id: string;
clientOperationId?: string;
provider: "dreamina-cli";
mode: "image" | "video";
operation: string;
model: string;
status: "queued" | "running" | "succeeded" | "failed" | "cancelled";
stage: "queued" | "submitting" | "submitted" | "generating" | "succeeded" | "failed" | "cancelled" | "submission_unknown";
progress?: number;
receiptRecorded: boolean;
errorCode?: string;
officialStatus?: "pending" | "processing" | "completed" | "failed" | "cancelled";
lifecycle?: LocalDreaminaTaskLifecycle;
terminalOutcome?: LocalDreaminaTerminalOutcome;
syncState?: LocalDreaminaTaskSyncState;
resultState?: LocalDreaminaTaskResultState;
outputs?: LocalDreaminaTaskOutput[];
accountBinding?: string;
context?: LocalDreaminaTaskContext;
providerObservation?: LocalDreaminaProviderObservation;
createdAt: string;
updatedAt: string;
result?: LocalDreaminaGenerationResult;
};
export class LocalDreaminaGenerationClientError extends Error {
constructor(
readonly code: string,
message: string,
readonly status = 500,
) {
super(message);
this.name = "LocalDreaminaGenerationClientError";
}
}
type RuntimeClient = LocalRuntimeTransport & {
connect(signal?: AbortSignal): Promise<LocalRuntimeConnection>;
revokeLocalSession?(): void;
};
type Dependencies = {
client?: RuntimeClient;
idempotencyKey?: () => string;
onTaskUpdate?: (task: LocalDreaminaGenerationTask) => void;
};
type ParsedInput = Omit<LocalDreaminaGenerationInput, "model"> & { model: string };
export function createLocalDreaminaTaskEffectStore(dependencies: Pick<Dependencies, "client"> = {}): GenerationTaskEffectStore {
const client = dependencies.client ?? getLocalRuntimeSessionClient();
type EffectLease = {
taskId: string;
effectKey: string;
leaseToken: string;
expiresAt: string;
fence: number;
};
const leases = new Map<string, EffectLease>();
const leaseKey = (taskId: string, effectKey: string) => `${taskId}\0${effectKey}`;
const call = async (path: string, body: Record<string, unknown>) => {
const request = async () => {
await requireConnection(client);
const response = await client.request(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
return { response, value: await readBoundedJson(response, MAX_EFFECT_RESPONSE_BYTES) };
};
let result = await request();
if (result.response.status === 401 || result.response.status === 403) {
client.revokeLocalSession?.();
result = await request();
}
if (!result.response.ok) throw runtimeError(result.response.status, result.value);
const envelope = record(result.value);
const value = record(envelope?.result);
if (envelope?.ok !== true || !value) {
throw new LocalDreaminaGenerationClientError("local_runtime_response_invalid", "本机任务副作用响应无效", 502);
}
return value;
};
return {
async claim(effectKey, taskId): Promise<GenerationTaskEffectClaim> {
const value = await call("/dreamina/generate/effects/claim", {
consumerId: GENERATION_EFFECT_CONSUMER_ID,
taskId,
effectKey,
});
if (value.status === "busy") {
if (typeof value.retryAt !== "string" || !Number.isFinite(Date.parse(value.retryAt))) {
throw new LocalDreaminaGenerationClientError("local_runtime_response_invalid", "本机任务副作用响应无效", 502);
}
return { status: "busy", retryAt: value.retryAt };
}
if (value.status === "completed") {
const result = parseEffectResult(value.result);
return { status: "completed", result };
}
if (value.status !== "claimed" || typeof value.leaseToken !== "string" || typeof value.leaseExpiresAt !== "string" || !Number.isFinite(Date.parse(value.leaseExpiresAt)) || !Number.isSafeInteger(value.fence) || (value.fence as number) < 1) {
throw new LocalDreaminaGenerationClientError("local_runtime_response_invalid", "本机任务副作用响应无效", 502);
}
const lease = {
taskId,
effectKey,
leaseToken: value.leaseToken,
expiresAt: value.leaseExpiresAt,
fence: value.fence as number,
};
leases.set(leaseKey(taskId, effectKey), lease);
return { status: "claimed", fence: lease.fence };
},
async renew(effectKey, taskId) {
const key = leaseKey(taskId, effectKey);
const lease = leases.get(key);
if (!lease) throw new LocalDreaminaGenerationClientError("local_runtime_effect_lease_missing", "本机任务副作用租约缺失", 409);
const value = await call("/dreamina/generate/effects/renew", {
consumerId: GENERATION_EFFECT_CONSUMER_ID,
taskId: lease.taskId,
effectKey: lease.effectKey,
leaseToken: lease.leaseToken,
fence: lease.fence,
});
if (typeof value.leaseExpiresAt !== "string" || !Number.isFinite(Date.parse(value.leaseExpiresAt)) || value.fence !== lease.fence) {
throw new LocalDreaminaGenerationClientError("local_runtime_effect_lease_lost", "本机任务副作用租约已失效", 409);
}
lease.expiresAt = value.leaseExpiresAt;
return { fence: lease.fence };
},
async complete(effectKey, taskId, result) {
const key = leaseKey(taskId, effectKey);
const lease = leases.get(key);
if (!lease) throw new LocalDreaminaGenerationClientError("local_runtime_effect_lease_missing", "本机任务副作用租约缺失", 409);
const value = await call("/dreamina/generate/effects/complete", {
consumerId: GENERATION_EFFECT_CONSUMER_ID,
taskId: lease.taskId,
effectKey: lease.effectKey,
leaseToken: lease.leaseToken,
fence: lease.fence,
result,
});
if (value.completed !== true) throw new LocalDreaminaGenerationClientError("local_runtime_effect_lease_lost", "本机任务副作用租约已失效", 409);
leases.delete(key);
},
async release(effectKey, taskId) {
const key = leaseKey(taskId, effectKey);
const lease = leases.get(key);
if (!lease) throw new LocalDreaminaGenerationClientError("local_runtime_effect_lease_missing", "本机任务副作用租约缺失", 409);
const value = await call("/dreamina/generate/effects/release", {
consumerId: GENERATION_EFFECT_CONSUMER_ID,
taskId: lease.taskId,
effectKey: lease.effectKey,
leaseToken: lease.leaseToken,
fence: lease.fence,
});
if (value.released !== true) {
throw new LocalDreaminaGenerationClientError("local_runtime_effect_lease_lost", "本机任务副作用租约已失效", 409);
}
leases.delete(key);
},
};
}
function parseEffectResult(value: unknown): GenerationTaskEffectResult {
const result = record(value);
if (!result || Object.keys(result).some((key) => key !== "materializedAssetId") || (result.materializedAssetId !== undefined && typeof result.materializedAssetId !== "string")) {
throw new LocalDreaminaGenerationClientError("local_runtime_response_invalid", "本机任务副作用响应无效", 502);
}
return typeof result.materializedAssetId === "string" ? { materializedAssetId: result.materializedAssetId } : {};
}
// Local CLI work goes straight to the signed Runtime; it must never create a backend task.
export async function runLocalDreaminaGenerationTask(input: LocalDreaminaGenerationInput, dependencies: Dependencies = {}, signal?: AbortSignal): Promise<LocalDreaminaGenerationResult> {
const parsed = parseInput(input);
const idempotencyKey = parsed.idempotencyKey ?? dependencies.idempotencyKey?.() ?? crypto.randomUUID();
validateTaskIdentity(idempotencyKey, parsed.mode);
const client = dependencies.client ?? getLocalRuntimeSessionClient();
let task = parsed.resumeOnly ? await queryLocalDreaminaGenerationTask(idempotencyKey, parsed.mode, { client }, signal) : await submitParsedTask(parsed, idempotencyKey, client, signal);
dependencies.onTaskUpdate?.(task);
if (task.status === "queued" || task.status === "running") {
task = await waitForLocalDreaminaGenerationTask(idempotencyKey, parsed.mode, { client }, signal);
dependencies.onTaskUpdate?.(task);
}
if (task.status === "succeeded" && task.result) return task.result;
if (task.status === "cancelled") {
if (task.errorCode === LOCAL_DREAMINA_WAIT_STOPPED_CODE) {
throw new LocalDreaminaGenerationClientError(LOCAL_DREAMINA_WAIT_STOPPED_CODE, LOCAL_DREAMINA_WAIT_STOPPED_MESSAGE, 409);
}
throw new DOMException("Aborted", "AbortError");
}
if (task.errorCode === LOCAL_DREAMINA_OFFICIAL_INCOMPLETE_CODE) {
throw new LocalDreaminaGenerationClientError(task.errorCode, LOCAL_DREAMINA_OFFICIAL_INCOMPLETE_MESSAGE, 502);
}
if (task.errorCode === LOCAL_DREAMINA_OFFICIAL_FAILED_CODE) {
throw new LocalDreaminaGenerationClientError(task.errorCode, LOCAL_DREAMINA_OFFICIAL_FAILED_MESSAGE, 502);
}
throw new LocalDreaminaGenerationClientError(task.errorCode ?? "local_generation_unknown", "本机即梦生成未完成", 502);
}
export async function queryLocalDreaminaGenerationTask(idempotencyKey: string, mode: "image" | "video" | undefined, dependencies: Pick<Dependencies, "client"> = {}, signal?: AbortSignal) {
validateTaskIdentity(idempotencyKey, mode);
return queryWithOneReconnect(dependencies.client ?? getLocalRuntimeSessionClient(), idempotencyKey, mode, signal);
}
export async function waitForLocalDreaminaGenerationTask(idempotencyKey: string, mode: "image" | "video" | undefined, dependencies: Pick<Dependencies, "client"> = {}, signal?: AbortSignal) {
validateTaskIdentity(idempotencyKey, mode);
const client = dependencies.client ?? getLocalRuntimeSessionClient();
const request = async () => {
await requireConnection(client, signal);
const response = await client.request("/dreamina/generate/wait", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ idempotencyKey, ...(mode ? { mode } : {}) }),
signal,
});
return { response, value: await readBoundedJson(response) };
};
let result = await request();
if (result.response.status === 401 || result.response.status === 403) {
client.revokeLocalSession?.();
result = await request();
}
if (!result.response.ok) throw runtimeError(result.response.status, result.value);
return parseTask(result.value, mode);
}
export async function listLocalDreaminaGenerationTaskPage(page: { limit: number; cursor?: string; projectId?: string; activeOnly?: boolean }, dependencies: Pick<Dependencies, "client"> = {}, signal?: AbortSignal) {
const client = dependencies.client ?? getLocalRuntimeSessionClient();
const params = new URLSearchParams({ limit: String(Math.max(1, Math.min(100, Math.trunc(page.limit)))) });
if (page.cursor) params.set("cursor", page.cursor);
if (page.projectId) params.set("projectId", page.projectId);
if (page.activeOnly) params.set("activeOnly", "true");
const request = async () => {
await requireConnection(client, signal);
const response = await client.request(`/dreamina/generate/tasks?${params.toString()}`, { method: "GET", signal });
return { response, value: await readBoundedJson(response, MAX_TASK_LIST_RESPONSE_BYTES) };
};
let result = await request();
if (result.response.status === 401 || result.response.status === 403) {
client.revokeLocalSession?.();
result = await request();
}
if (!result.response.ok) throw runtimeError(result.response.status, result.value);
return parseTaskPage(result.value);
}
export async function listLocalDreaminaGenerationTasks(dependencies: Pick<Dependencies, "client"> = {}, signal?: AbortSignal) {
const tasks: LocalDreaminaGenerationTask[] = [];
const seenCursors = new Set<string>();
let cursor: string | undefined;
do {
const page = await listLocalDreaminaGenerationTaskPage({ limit: 100, ...(cursor ? { cursor } : {}) }, dependencies, signal);
tasks.push(...page.tasks);
if (!page.nextCursor || seenCursors.has(page.nextCursor)) break;
seenCursors.add(page.nextCursor);
cursor = page.nextCursor;
} while (true);
return tasks;
}
export async function cancelLocalDreaminaGenerationTask(idempotencyKey: string, dependencies: Pick<Dependencies, "client"> = {}, signal?: AbortSignal) {
validateTaskIdentity(idempotencyKey, "image");
const client = dependencies.client ?? getLocalRuntimeSessionClient();
await requireConnection(client, signal);
const response = await client.request("/dreamina/generate/cancel", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ idempotencyKey }),
signal,
});
const value = await readBoundedJson(response);
if (!response.ok) throw runtimeError(response.status, value);
return parseTask(value);
}
export async function deleteLocalDreaminaGenerationTask(idempotencyKey: string, dependencies: Pick<Dependencies, "client"> = {}, signal?: AbortSignal) {
validateTaskIdentity(idempotencyKey, "image");
const client = dependencies.client ?? getLocalRuntimeSessionClient();
await requireConnection(client, signal);
const response = await client.request("/dreamina/generate/delete", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ idempotencyKey }),
signal,
});
const value = await readBoundedJson(response);
if (!response.ok) throw runtimeError(response.status, value);
const envelope = record(value);
const result = envelope?.ok === true ? record(envelope.result) : undefined;
if (!result || typeof result !== "object" || Array.isArray(result) || (result as { deleted?: unknown }).deleted !== true) {
throw new LocalDreaminaGenerationClientError("dreamina_response_invalid", "本机即梦任务响应无效", 502);
}
return { deleted: true as const };
}
export async function refreshLocalDreaminaGenerationTask(idempotencyKey: string, dependencies: Pick<Dependencies, "client"> = {}, signal?: AbortSignal) {
validateTaskIdentity(idempotencyKey, "image");
const client = dependencies.client ?? getLocalRuntimeSessionClient();
await requireConnection(client, signal);
const response = await client.request("/dreamina/generate/refresh", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ idempotencyKey }),
signal,
});
const value = await readBoundedJson(response);
if (!response.ok) throw runtimeError(response.status, value);
return parseTask(value);
}
async function submitParsedTask(parsed: ParsedInput, idempotencyKey: string, client: RuntimeClient, signal?: AbortSignal) {
// Mutation retries are forbidden: establish the side-effect-free session before the only paid POST.
await requireConnection(client, signal);
let response: Response;
let value: unknown;
try {
response = await client.request("/dreamina/generate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
idempotencyKey,
clientOperationId: parsed.clientOperationId ?? idempotencyKey,
operation: operationFor(parsed.mode, parsed.references),
model: parsed.model,
prompt: parsed.prompt,
settings: parsed.settings,
references: parsed.references.map((reference) => ({
kind: reference.kind ?? "image",
mimeType: reference.mimeType,
contentBase64: bytesToBase64(reference.bytes),
...(reference.metadata ? { metadata: reference.metadata } : {}),
})),
context: parsed.context ?? { scope: "scoped" },
}),
signal,
});
value = await readBoundedJson(response);
} catch (error) {
if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) throw submissionUnknown();
throw error;
}
if (!response.ok) throw runtimeError(response.status, value);
return parseTask(value, parsed.mode);
}
async function queryWithOneReconnect(client: RuntimeClient, idempotencyKey: string, mode: "image" | "video" | undefined, signal?: AbortSignal) {
const request = async () => {
await requireConnection(client, signal);
const response = await client.request("/dreamina/generate/query", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ idempotencyKey, ...(mode ? { mode } : {}) }),
signal,
});
return { response, value: await readBoundedJson(response) };
};
let result = await request();
if (result.response.status === 401 || result.response.status === 403) {
client.revokeLocalSession?.();
result = await request();
}
if (!result.response.ok) throw runtimeError(result.response.status, result.value);
return parseTask(result.value, mode);
}
async function requireConnection(client: RuntimeClient, signal?: AbortSignal) {
const connection = await client.connect(signal);
if (connection.state !== "connected") throw new LocalRuntimeClientError("origin_not_trusted", "本机连接需要重新建立", 403);
}
function parseInput(value: LocalDreaminaGenerationInput): ParsedInput {
if (
!value ||
typeof value !== "object" ||
!/^local:dreamina-cli:[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(value.model) ||
(value.mode !== "image" && value.mode !== "video") ||
typeof value.prompt !== "string" ||
!value.prompt.trim() ||
value.prompt.length > 20_000 ||
!Array.isArray(value.references) ||
!validReferenceCounts(value.model, value.references) ||
(value.idempotencyKey !== undefined && !/^[A-Za-z0-9._:-]{16,120}$/.test(value.idempotencyKey)) ||
(value.clientOperationId !== undefined && !/^[A-Za-z0-9._:-]{16,120}$/.test(value.clientOperationId))
)
throw invalidRequest();
const model = value.model.slice("local:dreamina-cli:".length);
const settings = value.settings ?? {};
if (!settings || typeof settings !== "object" || Array.isArray(settings) || Object.keys(settings).some((key) => !["aspect", "resolution", "duration", "count"].includes(key))) throw invalidRequest();
if (settings.duration !== undefined && (!Number.isInteger(settings.duration) || settings.duration < 1 || settings.duration > 60)) throw invalidRequest();
if (settings.count !== undefined && (!Number.isInteger(settings.count) || settings.count < 1 || settings.count > 4)) throw invalidRequest();
if (value.mode === "video" && settings.count !== undefined && settings.count !== 1) throw invalidRequest();
if (value.mode === "image" && settings.duration !== undefined) throw invalidRequest();
if (model === "seedance2.0mini" && ((settings.duration ?? 4) < 4 || (settings.duration ?? 4) > 15)) throw unavailable();
if (value.context !== undefined) parseInputContext(value.context);
let bytes = 0;
for (const reference of value.references) {
const kind = reference?.kind ?? "image";
const validMime =
kind === "image"
? ["image/png", "image/jpeg", "image/webp"].includes(reference?.mimeType)
: kind === "video"
? ["video/mp4", "video/quicktime", "video/webm"].includes(reference?.mimeType)
: kind === "audio" && ["audio/mpeg", "audio/wav", "audio/mp4", "audio/aac", "audio/flac"].includes(reference?.mimeType);
if (!reference || !validMime || !(reference.bytes instanceof Uint8Array) || !reference.bytes.byteLength || !safeReferenceMetadata(reference.metadata, kind)) throw invalidRequest();
bytes += reference.bytes.byteLength;
if (bytes > MAX_REFERENCE_BYTES) throw invalidRequest();
}
return { ...value, model, prompt: value.prompt.trim(), settings };
}
function validReferenceCounts(model: string, references: LocalDreaminaReference[]) {
if (model !== "local:dreamina-cli:seedance2.5") return references.length <= 30;
if (references.length > 50) return false;
const counts = { image: 0, video: 0, audio: 0 };
for (const reference of references) {
const kind = reference?.kind ?? "image";
if (kind === "image" || kind === "video" || kind === "audio") counts[kind] += 1;
}
return counts.image <= 30 && counts.video <= 10 && counts.audio <= 10;
}
function operationFor(mode: "image" | "video", references: LocalDreaminaReference[]) {
if (mode === "image") return references.length ? "image-to-image" : "text-to-image";
const imageCount = references.filter((reference) => (reference.kind ?? "image") === "image").length;
const hasMultimodal = references.some((reference) => reference.kind === "video" || reference.kind === "audio");
if (!references.length) return "text-to-video";
return hasMultimodal || imageCount > 2 ? "reference-to-video" : "image-to-video";
}
function parseInputContext(value: LocalDreaminaTaskContext) {
const context = value as unknown as Record<string, unknown>;
if (!context || (context.scope !== "scoped" && context.scope !== "legacy_unscoped")) throw invalidRequest();
if (context.scope === "legacy_unscoped") {
if (Object.keys(context).some((key) => key !== "scope")) throw invalidRequest();
return;
}
const allowed = ["scope", "projectId", "nodeId", "conversationId", "messageId", "batchIndex", "batchCount", "retryOf", "attemptGroupId"];
if (Object.keys(context).some((key) => !allowed.includes(key))) throw invalidRequest();
for (const key of ["projectId", "nodeId", "conversationId", "messageId", "retryOf", "attemptGroupId"]) {
if (context[key] !== undefined && !safeCorrelationId(context[key])) throw invalidRequest();
}
if (context.batchIndex !== undefined && (!Number.isSafeInteger(context.batchIndex) || (context.batchIndex as number) < 0)) throw invalidRequest();
if (context.batchCount !== undefined && (!Number.isSafeInteger(context.batchCount) || (context.batchCount as number) < 1)) throw invalidRequest();
}
function safeReferenceMetadata(value: unknown, kind: "image" | "video" | "audio") {
if (value === undefined) return true;
const metadata = record(value);
if (!metadata) return false;
const allowed = kind === "image" ? ["name", "width", "height"] : kind === "video" ? ["name", "width", "height", "durationMs"] : ["name", "durationMs"];
if (Object.keys(metadata).some((key) => !allowed.includes(key))) return false;
if (metadata.name !== undefined && (typeof metadata.name !== "string" || metadata.name.length > 200 || /[\u0000-\u001f\u007f]/.test(metadata.name))) return false;
for (const key of ["width", "height", "durationMs"]) {
if (metadata[key] !== undefined && (!Number.isFinite(metadata[key]) || (metadata[key] as number) <= 0)) return false;
}
return true;
}
async function readBoundedJson(response: Response, maxBytes = MAX_RESPONSE_BYTES): Promise<unknown> {
const declared = Number(response.headers.get("content-length"));
if (Number.isFinite(declared) && declared > maxBytes) throw invalidResponse();
if (!response.body) throw invalidResponse();
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
const item = await reader.read();
if (item.done) break;
total += item.value.byteLength;
if (total > maxBytes) {
await reader.cancel();
throw invalidResponse();
}
chunks.push(item.value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
try {
return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
} catch {
throw invalidResponse();
}
}
function parseTask(value: unknown, expectedMode?: "image" | "video", allowMissingResult = false): LocalDreaminaGenerationTask {
const envelope = record(value);
const source = envelope?.ok === true ? record(envelope.result) : undefined;
const status = source?.status;
const stage = source?.stage;
if (
!source ||
typeof source.id !== "string" ||
!/^[A-Za-z0-9._:-]{8,160}$/.test(source.id) ||
source.provider !== "dreamina-cli" ||
(source.mode !== "image" && source.mode !== "video") ||
(expectedMode && source.mode !== expectedMode) ||
typeof source.operation !== "string" ||
typeof source.model !== "string" ||
!["queued", "running", "succeeded", "failed", "cancelled"].includes(String(status)) ||
!["queued", "submitting", "submitted", "generating", "succeeded", "failed", "cancelled", "submission_unknown"].includes(String(stage)) ||
typeof source.receiptRecorded !== "boolean" ||
typeof source.createdAt !== "string" ||
!Number.isFinite(Date.parse(source.createdAt)) ||
typeof source.updatedAt !== "string" ||
!Number.isFinite(Date.parse(source.updatedAt))
)
throw invalidResponse();
const result = source.result === undefined ? undefined : parseResult(source.result, source.mode);
if (status === "succeeded" && !result && !allowMissingResult) throw invalidResponse();
return {
id: source.id,
...(typeof source.clientOperationId === "string" && /^[A-Za-z0-9._:-]{16,120}$/.test(source.clientOperationId) ? { clientOperationId: source.clientOperationId } : {}),
provider: "dreamina-cli",
mode: source.mode,
operation: source.operation,
model: source.model,
status: status as LocalDreaminaGenerationTask["status"],
stage: stage as LocalDreaminaGenerationTask["stage"],
...(typeof source.progress === "number" && source.progress >= 0 && source.progress <= 100 ? { progress: source.progress } : {}),
receiptRecorded: source.receiptRecorded,
...(typeof source.errorCode === "string" ? { errorCode: source.errorCode } : {}),
...(typeof source.officialStatus === "string" && ["pending", "processing", "completed", "failed", "cancelled"].includes(source.officialStatus) ? { officialStatus: source.officialStatus as LocalDreaminaGenerationTask["officialStatus"] } : {}),
...parseTaskContract(source),
createdAt: source.createdAt,
updatedAt: source.updatedAt,
...(result ? { result } : {}),
};
}
function parseTaskContract(source: Record<string, unknown>): Partial<LocalDreaminaGenerationTask> {
const contractKeys = ["lifecycle", "terminalOutcome", "syncState", "resultState", "outputs", "accountBinding", "context", "providerObservation"];
if (!contractKeys.some((key) => source[key] !== undefined)) return {};
const lifecycle = source.lifecycle;
const terminalOutcome = source.terminalOutcome;
const syncState = source.syncState;
const resultState = source.resultState;
if (
!["QUEUED_LOCAL", "SUBMITTING", "SUBMISSION_UNCERTAIN", "ACCEPTED", "RUNNING", "TERMINAL"].includes(String(lifecycle)) ||
!["SYNC_OK", "SYNC_RETRY_WAIT", "SYNC_BLOCKED_ACCOUNT", "SYNC_UNCERTAIN", "SYNC_CONFLICT"].includes(String(syncState)) ||
!["NOT_AVAILABLE", "PENDING_MATERIALIZATION", "MATERIALIZING", "READY", "FAILED_RETRYABLE", "FAILED_PERMANENT"].includes(String(resultState)) ||
(lifecycle === "TERMINAL") !== (typeof terminalOutcome === "string") ||
(terminalOutcome !== undefined && !["SUCCEEDED", "REJECTED", "FAILED", "CANCELLED", "FAILED_OR_CANCELLED"].includes(String(terminalOutcome)))
) {
throw invalidResponse();
}
const accountBinding = source.accountBinding === undefined ? undefined : parseAccountBinding(source.accountBinding);
const context = parseTaskContext(source.context);
const outputs = parseTaskOutputs(source.outputs);
const providerObservation = source.providerObservation === undefined ? undefined : parseProviderObservation(source.providerObservation);
return {
lifecycle: lifecycle as LocalDreaminaTaskLifecycle,
...(terminalOutcome === undefined ? {} : { terminalOutcome: terminalOutcome as LocalDreaminaTerminalOutcome }),
syncState: syncState as LocalDreaminaTaskSyncState,
resultState: resultState as LocalDreaminaTaskResultState,
outputs,
...(accountBinding ? { accountBinding } : {}),
context,
...(providerObservation ? { providerObservation } : {}),
};
}
function parseTaskContext(value: unknown): LocalDreaminaTaskContext {
const context = record(value);
if (!context || (context.scope !== "scoped" && context.scope !== "legacy_unscoped")) throw invalidResponse();
if (context.scope === "legacy_unscoped") {
if (Object.keys(context).some((key) => key !== "scope")) throw invalidResponse();
return { scope: "legacy_unscoped" };
}
const allowed = ["scope", "projectId", "nodeId", "conversationId", "messageId", "batchIndex", "batchCount", "retryOf", "attemptGroupId"];
if (Object.keys(context).some((key) => !allowed.includes(key))) throw invalidResponse();
const parsed: Extract<LocalDreaminaTaskContext, { scope: "scoped" }> = { scope: "scoped" };
for (const key of ["projectId", "nodeId", "conversationId", "messageId", "retryOf", "attemptGroupId"] as const) {
if (context[key] === undefined) continue;
if (!safeCorrelationId(context[key])) throw invalidResponse();
parsed[key] = context[key] as string;
}
if (context.batchIndex !== undefined) {
if (!Number.isSafeInteger(context.batchIndex) || (context.batchIndex as number) < 0) throw invalidResponse();
parsed.batchIndex = context.batchIndex as number;
}
if (context.batchCount !== undefined) {
if (!Number.isSafeInteger(context.batchCount) || (context.batchCount as number) < 1) throw invalidResponse();
parsed.batchCount = context.batchCount as number;
}
return parsed;
}
function parseTaskOutputs(value: unknown): LocalDreaminaTaskOutput[] {
if (!Array.isArray(value) || value.length > 32) throw invalidResponse();
const indexes = new Set<number>();
return value.map((candidate) => {
const output = record(candidate);
if (
!output ||
!Number.isSafeInteger(output.outputIndex) ||
(output.outputIndex as number) < 0 ||
(output.outputIndex as number) > 999 ||
indexes.has(output.outputIndex as number) ||
!["image", "video", "audio"].includes(String(output.mediaType)) ||
(output.providerArtifactRef !== undefined && !safeOpaqueRef(output.providerArtifactRef)) ||
(output.materializedAssetId !== undefined && !safeOpaqueRef(output.materializedAssetId)) ||
(output.materializationErrorCode !== undefined && !safeErrorCode(output.materializationErrorCode))
)
throw invalidResponse();
indexes.add(output.outputIndex as number);
return {
outputIndex: output.outputIndex as number,
mediaType: output.mediaType as LocalDreaminaTaskOutput["mediaType"],
...(typeof output.providerArtifactRef === "string" ? { providerArtifactRef: output.providerArtifactRef } : {}),
...(typeof output.materializedAssetId === "string" ? { materializedAssetId: output.materializedAssetId } : {}),
...(typeof output.materializationErrorCode === "string" ? { materializationErrorCode: output.materializationErrorCode } : {}),
};
});
}
function parseProviderObservation(value: unknown): LocalDreaminaProviderObservation {
const observation = record(value);
if (
!observation ||
!["submit_receipt", "query_result", "list_task"].includes(String(observation.source)) ||
typeof observation.observedAt !== "string" ||
!Number.isFinite(Date.parse(observation.observedAt)) ||
!["pending", "processing", "completed", "failed", "cancelled"].includes(String(observation.status)) ||
(observation.accountBinding !== undefined && !safeAccountBinding(observation.accountBinding)) ||
(observation.fenceEpoch !== undefined && (!Number.isSafeInteger(observation.fenceEpoch) || (observation.fenceEpoch as number) < 0))
) {
throw invalidResponse();
}
return {
source: observation.source as LocalDreaminaProviderObservation["source"],
observedAt: observation.observedAt,
...(typeof observation.accountBinding === "string" ? { accountBinding: observation.accountBinding } : {}),
...(typeof observation.fenceEpoch === "number" ? { fenceEpoch: observation.fenceEpoch } : {}),
status: observation.status as LocalDreaminaProviderObservation["status"],
};
}
function parseAccountBinding(value: unknown) {
if (!safeAccountBinding(value)) throw invalidResponse();
return value as string;
}
function safeAccountBinding(value: unknown): value is string {
return typeof value === "string" && /^[A-Za-z0-9._:-]{8,160}$/.test(value);
}
function safeCorrelationId(value: unknown): value is string {
return typeof value === "string" && value.length >= 1 && value.length <= 200 && !/[\u0000-\u001f\u007f]/.test(value);
}
function safeOpaqueRef(value: unknown): value is string {
return typeof value === "string" && /^[A-Za-z0-9._:-]{1,256}$/.test(value);
}
function safeErrorCode(value: unknown): value is string {
return typeof value === "string" && /^[a-z][a-z0-9_]{2,80}$/.test(value);
}
function parseTaskPage(value: unknown) {
const envelope = record(value);
const rawResult = envelope?.ok === true ? envelope.result : undefined;
const result = record(rawResult);
const items = Array.isArray(rawResult) ? rawResult : result?.tasks;
const nextCursor = result?.nextCursor;
if (!Array.isArray(items) || items.length > MAX_TASK_LIST_ITEMS || (nextCursor !== undefined && (typeof nextCursor !== "string" || !/^[A-Za-z0-9_-]{1,512}$/.test(nextCursor)))) throw invalidResponse();
return {
tasks: items.map((item) => parseTask({ ok: true, result: item }, undefined, true)),
...(typeof nextCursor === "string" ? { nextCursor } : {}),
};
}
function parseResult(value: unknown, mode: "image" | "video"): LocalDreaminaGenerationResult {
const result = record(value);
if (!result || result.mode !== mode) throw invalidResponse();
if (mode === "video") return { mode, video: parseMedia(result.video, "video/") };
if (!Array.isArray(result.images) || !result.images.length || result.images.length > 4) throw invalidResponse();
return { mode, images: result.images.map((image) => parseMedia(image, "image/")) };
}
function parseMedia(value: unknown, prefix: "image/" | "video/") {
const media = record(value);
const bytes = media?.bytes;
if (!media || typeof media.dataUrl !== "string" || typeof media.mimeType !== "string" || !media.mimeType.startsWith(prefix) || !Number.isInteger(bytes) || (bytes as number) <= 0 || !media.dataUrl.startsWith(`data:${media.mimeType};base64,`))
throw invalidResponse();
return { dataUrl: media.dataUrl, mimeType: media.mimeType, bytes: bytes as number };
}
function runtimeError(status: number, value: unknown) {
const source = record(value);
const code = typeof source?.code === "string" ? source.code : "local_generation_request_failed";
const allowed = /^(?:local_generation|dreamina)_[a-z0-9_]{2,80}$/.test(code);
throw new LocalDreaminaGenerationClientError(allowed ? code : "local_generation_request_failed", "本机即梦生成请求失败", status || 502);
}
function bytesToBase64(bytes: Uint8Array) {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function record(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
}
function submissionUnknown() {
return new LocalDreaminaGenerationClientError("dreamina_submission_unknown", "Dreamina submission result is unknown", 502);
}
function validateTaskIdentity(idempotencyKey: string, mode: "image" | "video" | undefined) {
if (!/^[A-Za-z0-9._:-]{16,120}$/.test(idempotencyKey) || (mode !== undefined && mode !== "image" && mode !== "video")) throw invalidRequest();
}
function invalidRequest() {
return new LocalDreaminaGenerationClientError("local_generation_request_invalid", "本机即梦生成参数无效", 400);
}
function unavailable() {
return new LocalDreaminaGenerationClientError("local_generation_model_unavailable", "所选本机即梦模型或操作不可用", 409);
}
function invalidResponse() {
return new LocalDreaminaGenerationClientError("local_generation_response_invalid", "本机即梦生成响应无效", 502);
}

View File

@@ -0,0 +1,181 @@
import type { LocalRuntimeTransport } from "@/services/local-runtime";
import { LocalRuntimeClientError } from "@/services/local-runtime-session";
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
export type DreaminaLocalModel = {
provider: "dreamina-cli";
id: string;
displayName: string;
modality: "image" | "video";
adapterSupported: boolean;
accountEntitlement: "yes" | "no" | "unknown";
currentlyObservedAvailable: "yes" | "no" | "unknown";
operations: Array<"text-to-image" | "image-to-image" | "text-to-video" | "image-to-video" | "reference-to-video">;
settings: { aliases: string[]; aspects: string[]; maxReferenceImages: number; minDuration?: number; maxDuration?: number; tiers?: string[] };
source: "runtime-execution-contract";
};
export class DreaminaModelCatalogError extends Error {
constructor(
readonly code: "session_required" | "scope_denied" | "catalog_unavailable",
readonly status: number,
) {
super("Dreamina model catalog is unavailable");
this.name = "DreaminaModelCatalogError";
}
}
type RecoverableSessionTransport = LocalRuntimeTransport & {
connect(signal?: AbortSignal): Promise<{ state: string }>;
revokeLocalSession(): void;
};
export async function getDreaminaModelCatalog(client: LocalRuntimeTransport, signal?: AbortSignal): Promise<DreaminaLocalModel[]> {
return (await getDreaminaModelCatalogSnapshot(client, signal)).models;
}
export async function getDreaminaModelCatalogSnapshot(client: LocalRuntimeTransport, signal?: AbortSignal) {
const response = await client.request("/dreamina/models", { method: "GET", signal });
if (!response.ok) {
await response.body?.cancel().catch(() => undefined);
throw new DreaminaModelCatalogError(response.status === 401 ? "session_required" : response.status === 403 ? "scope_denied" : "catalog_unavailable", response.status);
}
const value = await readBoundedJson(response);
return parseCatalogSnapshot(value);
}
export async function getDreaminaModelCatalogWithSessionRecovery(client: RecoverableSessionTransport, signal?: AbortSignal): Promise<DreaminaLocalModel[]> {
return (await getDreaminaModelCatalogSnapshotWithSessionRecovery(client, signal)).models;
}
export async function getDreaminaModelCatalogSnapshotWithSessionRecovery(client: RecoverableSessionTransport, signal?: AbortSignal) {
try {
return await getDreaminaModelCatalogSnapshot(client, signal);
} catch (error) {
if (signal?.aborted || !isRecoverableSessionFailure(error)) throw error;
client.revokeLocalSession();
const connection = await client.connect(signal);
if (connection.state !== "connected") throw error;
return await getDreaminaModelCatalogSnapshot(client, signal);
}
}
function isRecoverableSessionFailure(error: unknown) {
return (error instanceof DreaminaModelCatalogError && (error.code === "session_required" || error.code === "scope_denied")) || (error instanceof LocalRuntimeClientError && error.code === "session_required");
}
async function readBoundedJson(response: Response): Promise<unknown> {
const declared = Number(response.headers.get("content-length"));
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) throw new Error("Dreamina model catalog is invalid");
if (!response.body) throw new Error("Dreamina model catalog is invalid");
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
const part = await reader.read();
if (part.done) break;
total += part.value.byteLength;
if (total > MAX_RESPONSE_BYTES) {
await reader.cancel();
throw new Error("Dreamina model catalog is invalid");
}
chunks.push(part.value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
try {
return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
} catch {
throw new Error("Dreamina model catalog is invalid");
}
}
function parseCatalog(value: unknown): DreaminaLocalModel[] {
return parseCatalogSnapshot(value).models;
}
function parseCatalogSnapshot(value: unknown) {
const root = record(value);
if (
!root ||
root.ok !== true ||
root.provider !== "dreamina-cli" ||
typeof root.accountBinding !== "string" ||
!/^[A-Za-z0-9._:-]{8,160}$/.test(root.accountBinding) ||
!Number.isSafeInteger(root.sessionEpoch) ||
(root.sessionEpoch as number) < 0 ||
!Array.isArray(root.models) ||
root.models.length > 128
)
throw new Error("Dreamina model catalog is invalid");
return { accountBinding: root.accountBinding, sessionEpoch: root.sessionEpoch as number, models: root.models.map(parseModel) };
}
function parseModel(value: unknown): DreaminaLocalModel {
const model = record(value);
const settings = model && record(model.settings);
const maxReferenceImages = settings?.maxReferenceImages;
const minDuration = settings?.minDuration;
const maxDuration = settings?.maxDuration;
if (
!model ||
!settings ||
model.provider !== "dreamina-cli" ||
model.source !== "runtime-execution-contract" ||
typeof model.id !== "string" ||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(model.id) ||
typeof model.displayName !== "string" ||
model.displayName !== model.displayName.trim() ||
(model.modality !== "image" && model.modality !== "video") ||
typeof model.adapterSupported !== "boolean" ||
!["yes", "no", "unknown"].includes(String(model.accountEntitlement)) ||
!["yes", "no", "unknown"].includes(String(model.currentlyObservedAvailable)) ||
!Array.isArray(model.operations) ||
!Array.isArray(settings.aliases) ||
!Array.isArray(settings.aspects) ||
!Number.isInteger(maxReferenceImages) ||
(maxReferenceImages as number) < 0 ||
(maxReferenceImages as number) > 30 ||
(minDuration !== undefined && (!Number.isInteger(minDuration) || (minDuration as number) < 1)) ||
(maxDuration !== undefined && (!Number.isInteger(maxDuration) || (maxDuration as number) < ((minDuration as number) ?? 1))) ||
(model.modality === "video" && (minDuration === undefined || maxDuration === undefined))
)
throw new Error("Dreamina model catalog is invalid");
const allowed = model.modality === "image" ? ["text-to-image", "image-to-image"] : ["text-to-video", "image-to-video", "reference-to-video"];
if (!model.operations.length || model.operations.some((operation) => typeof operation !== "string" || !allowed.includes(operation))) throw new Error("Dreamina model catalog is invalid");
return {
provider: "dreamina-cli",
id: model.id,
displayName: model.displayName,
modality: model.modality,
adapterSupported: model.adapterSupported,
accountEntitlement: model.accountEntitlement as DreaminaLocalModel["accountEntitlement"],
currentlyObservedAvailable: model.currentlyObservedAvailable as DreaminaLocalModel["currentlyObservedAvailable"],
operations: [...model.operations] as DreaminaLocalModel["operations"],
settings: {
aliases: [...settings.aliases] as string[],
aspects: [...settings.aspects] as string[],
maxReferenceImages: maxReferenceImages as number,
...(minDuration === undefined ? {} : { minDuration: minDuration as number }),
...(maxDuration === undefined ? {} : { maxDuration: maxDuration as number }),
...(settings.tiers === undefined ? {} : { tiers: Array.isArray(settings.tiers) ? ([...settings.tiers] as string[]) : invalid() }),
},
source: "runtime-execution-contract",
};
}
function record(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
}
function invalid(): never {
throw new Error("Dreamina model catalog is invalid");
}

View File

@@ -0,0 +1,193 @@
import type { GenerationTask } from "@/services/api/task-center";
import { DREAMINA_SUBMIT_ERROR_MESSAGES } from "@/lib/generation-error";
import {
LOCAL_DREAMINA_FAILED_OR_CANCELLED_MESSAGE,
LOCAL_DREAMINA_OFFICIAL_INCOMPLETE_CODE,
LOCAL_DREAMINA_OFFICIAL_INCOMPLETE_MESSAGE,
LOCAL_DREAMINA_WAIT_STOPPED_CODE,
LOCAL_DREAMINA_WAIT_STOPPED_MESSAGE,
type LocalDreaminaGenerationTask,
} from "@/services/local-dreamina-generation";
export type LocalDreaminaTaskContext = Pick<GenerationTask, "prompt" | "type" | "attempts"> & Partial<Pick<GenerationTask, "projectId" | "startedAt">>;
export type LocalDreaminaActionTarget = Pick<GenerationTask, "id" | "status"> & Partial<Pick<GenerationTask, "provider" | "stage" | "receiptRecorded" | "errorCode" | "error">>;
export const LOCAL_DREAMINA_BACKGROUND_MESSAGE = "任务已转入后台,官方状态会继续同步。";
export type LocalDreaminaDiagnosticLog = {
level: "info" | "warn" | "error";
stage: string;
observedAt: string;
provenance: "task_state" | "provider_observation" | "background_reconcile" | "manual_refresh";
errorCode?: string;
};
export function projectLocalDreaminaDiagnosticLog(input: Record<string, unknown>): LocalDreaminaDiagnosticLog {
const level = input.level === "warn" || input.level === "error" ? input.level : "info";
const stage = typeof input.stage === "string" && /^[a-z][a-z0-9_]{1,80}$/.test(input.stage) ? input.stage : "unknown";
const provenance = input.provenance === "provider_observation" || input.provenance === "background_reconcile" || input.provenance === "manual_refresh" ? input.provenance : "task_state";
const observedAt = typeof input.observedAt === "string" && Number.isFinite(Date.parse(input.observedAt)) ? input.observedAt : new Date(0).toISOString();
return {
level,
stage,
...(typeof input.errorCode === "string" && /^[a-z][a-z0-9_]{2,80}$/.test(input.errorCode) ? { errorCode: input.errorCode } : {}),
provenance,
observedAt,
};
}
export function projectLocalDreaminaTask(task: LocalDreaminaGenerationTask, context?: LocalDreaminaTaskContext): GenerationTask {
const status = projectedStatus(task);
const terminal = task.lifecycle ? task.lifecycle === "TERMINAL" : status === "succeeded" || status === "failed" || status === "cancelled";
const projectId = projectedProjectId(task, context);
const localWaitStopped =
task.errorCode === LOCAL_DREAMINA_WAIT_STOPPED_CODE &&
!isLocalDreaminaBackgroundTask({
id: localDreaminaTaskId(task.id),
provider: "dreamina-cli",
status,
stage: task.stage,
receiptRecorded: task.receiptRecorded,
});
const officialIncomplete = task.errorCode === LOCAL_DREAMINA_OFFICIAL_INCOMPLETE_CODE;
const failedOrCancelled = task.terminalOutcome === "FAILED_OR_CANCELLED";
const submitFailure = task.errorCode ? DREAMINA_SUBMIT_ERROR_MESSAGES[task.errorCode] : undefined;
const scopedContext = task.context?.scope === "scoped" ? task.context : undefined;
const clientContext = scopedContext
? {
...(scopedContext.nodeId ? { nodeId: scopedContext.nodeId } : {}),
...(scopedContext.conversationId ? { conversationId: scopedContext.conversationId } : {}),
...(scopedContext.messageId ? { messageId: scopedContext.messageId } : {}),
...(scopedContext.batchIndex !== undefined ? { batchIndex: scopedContext.batchIndex } : {}),
...(scopedContext.batchCount !== undefined ? { batchCount: scopedContext.batchCount } : {}),
}
: undefined;
return {
id: localDreaminaTaskId(task.id),
...(task.clientOperationId ? { clientOperationId: task.clientOperationId } : {}),
...(scopedContext?.retryOf ? { retryOf: scopedContext.retryOf } : {}),
...(scopedContext?.attemptGroupId ? { attemptGroupId: scopedContext.attemptGroupId } : {}),
...(Object.keys(clientContext ?? {}).length ? { clientContext } : {}),
...(projectId ? { projectId } : {}),
type: context?.type ?? `canvas_${task.mode}`,
status,
progress: task.progress,
stage: task.stage,
prompt: context?.prompt ?? "",
operation: task.operation,
provider: "dreamina-cli",
model: `local:dreamina-cli:${task.model}`,
errorCode: task.errorCode,
officialStatus: task.officialStatus,
receiptRecorded: task.receiptRecorded,
...(localWaitStopped
? {
error: LOCAL_DREAMINA_WAIT_STOPPED_MESSAGE,
providerCancelStatus: "uncertain" as const,
providerCancelError: LOCAL_DREAMINA_WAIT_STOPPED_MESSAGE,
}
: failedOrCancelled
? { error: LOCAL_DREAMINA_FAILED_OR_CANCELLED_MESSAGE }
: task.officialStatus === "failed"
? { error: LOCAL_DREAMINA_FAILED_OR_CANCELLED_MESSAGE }
: task.officialStatus === "cancelled"
? { error: "官方返回状态:cancelled" }
: officialIncomplete
? { error: LOCAL_DREAMINA_OFFICIAL_INCOMPLETE_MESSAGE }
: submitFailure
? { error: submitFailure }
: {}),
...(task.result ? { resultJson: JSON.stringify(task.result) } : {}),
...(task.resultState ? { resultState: task.resultState } : {}),
...(task.outputs ? { outputs: task.outputs.map((output) => ({ ...output })) } : {}),
attempts: context?.attempts ?? 1,
...(context?.startedAt ? { startedAt: context.startedAt } : {}),
...(terminal ? { completedAt: task.updatedAt } : {}),
createdAt: task.createdAt,
updatedAt: task.updatedAt,
};
}
function projectedStatus(task: LocalDreaminaGenerationTask): GenerationTask["status"] {
if (!task.lifecycle) return task.status;
if (task.lifecycle === "QUEUED_LOCAL") return "queued";
if (task.lifecycle === "SUBMITTING" || task.lifecycle === "ACCEPTED" || task.lifecycle === "RUNNING") return "running";
if (task.lifecycle === "SUBMISSION_UNCERTAIN") return "failed";
if (task.terminalOutcome === "SUCCEEDED") return "succeeded";
if (task.terminalOutcome === "CANCELLED") return "cancelled";
return "failed";
}
function projectedProjectId(task: LocalDreaminaGenerationTask, context?: LocalDreaminaTaskContext) {
if (!task.context) return context?.projectId;
if (task.context.scope === "legacy_unscoped") return undefined;
return task.context.projectId;
}
export function isLocalDreaminaWaitStopped(task: Pick<GenerationTask, "errorCode"> & Partial<Pick<GenerationTask, "provider">>) {
return task.provider === "dreamina-cli" && task.errorCode === LOCAL_DREAMINA_WAIT_STOPPED_CODE;
}
export function isLocalDreaminaSubmissionUncertain(task: LocalDreaminaActionTarget) {
return isLocalDreaminaTaskId(task.id) && task.receiptRecorded !== true && (task.stage === "submission_unknown" || task.errorCode === "dreamina_submission_unknown");
}
export function isLocalDreaminaBackgroundTask(task: LocalDreaminaActionTarget) {
return isLocalDreaminaTaskId(task.id) && task.status === "running" && task.receiptRecorded === true;
}
export function localDreaminaCancellationMessage(task: LocalDreaminaActionTarget) {
if (isLocalDreaminaSubmissionUncertain(task)) return task.error || "提交结果待确认";
if (isLocalDreaminaBackgroundTask(task)) return LOCAL_DREAMINA_BACKGROUND_MESSAGE;
return isLocalDreaminaWaitStopped(task) ? LOCAL_DREAMINA_WAIT_STOPPED_MESSAGE : task.error || "任务已取消";
}
export function localDreaminaCancellationCopy(task: LocalDreaminaActionTarget) {
if (!isLocalDreaminaTaskId(task.id) || isLocalDreaminaSubmissionUncertain(task)) return undefined;
if (isLocalDreaminaBackgroundTask(task)) {
return {
kind: "background",
action: "转入后台",
confirmation: "任务已由官方接受;转入后台后仍会继续同步官方状态。",
} as const;
}
return {
kind: "cancel",
action: "取消任务",
confirmation: "任务尚未提交官方,可以安全取消本机任务。",
} as const;
}
export function localDreaminaDetachOutcome(task: LocalDreaminaActionTarget) {
if (!isLocalDreaminaTaskId(task.id) || isLocalDreaminaSubmissionUncertain(task)) return undefined;
if (isLocalDreaminaBackgroundTask(task)) {
return {
kind: "background",
message: LOCAL_DREAMINA_BACKGROUND_MESSAGE,
taskStatus: "running",
creationStatus: "pending",
canvasNodeStatus: "loading",
batchItemStatus: "running",
} as const;
}
return {
kind: "cancel",
message: isLocalDreaminaWaitStopped(task) ? LOCAL_DREAMINA_WAIT_STOPPED_MESSAGE : task.error || "任务已取消",
taskStatus: task.status,
creationStatus: "cancelled",
canvasNodeStatus: "error",
batchItemStatus: "cancelled",
} as const;
}
export function localDreaminaTaskId(idempotencyKey: string) {
return `dreamina:${stripLocalDreaminaTaskPrefix(idempotencyKey)}`;
}
export function stripLocalDreaminaTaskPrefix(taskId: string) {
return taskId.startsWith("dreamina:") ? taskId.slice("dreamina:".length) : taskId;
}
export function isLocalDreaminaTaskId(taskId: string) {
return /^dreamina:[A-Za-z0-9._:-]{16,120}$/.test(taskId);
}

View File

@@ -0,0 +1,33 @@
const LEGACY_STORAGE_KEYS = ["canvas-agent-url", "canvas-agent-token"] as const;
const LEGACY_QUERY_KEYS = ["agentUrl", "agentToken"] as const;
export type LocalRuntimeBootstrapState = {
legacyDeepLinkRejected: boolean;
};
let currentState: LocalRuntimeBootstrapState = { legacyDeepLinkRejected: false };
export function readLocalRuntimeBootstrapState() {
return { ...currentState };
}
type LocalRuntimeBootstrapEnvironment = {
readonly href: string;
replaceUrl(url: string): void;
removeStorageItem(key: string): void;
};
export function runLocalRuntimeBootstrap(environment: LocalRuntimeBootstrapEnvironment, loadApplication: (state: LocalRuntimeBootstrapState) => void) {
const url = new URL(environment.href);
const legacyDeepLinkRejected = LEGACY_QUERY_KEYS.some((key) => url.searchParams.has(key));
if (legacyDeepLinkRejected) {
for (const key of LEGACY_QUERY_KEYS) url.searchParams.delete(key);
environment.replaceUrl(`${url.pathname}${url.search}${url.hash}`);
}
for (const key of LEGACY_STORAGE_KEYS) environment.removeStorageItem(key);
const state = { legacyDeepLinkRejected };
currentState = state;
loadApplication(state);
return state;
}

View File

@@ -0,0 +1,463 @@
import { openDB } from "idb";
import { canonicalize } from "json-canonicalize";
const configuredRuntimeEndpoint = typeof import.meta.env === "object" ? import.meta.env.VITE_FRAMEFIELD_LOCAL_RUNTIME_ENDPOINT : undefined;
export const LOCAL_RUNTIME_ENDPOINT = resolveLocalRuntimeEndpoint(configuredRuntimeEndpoint);
const KEY_DATABASE = "framefield-local-runtime";
const KEY_STORE = "browser-keys";
const KEY_RECORD_ID = "default";
export function resolveLocalRuntimeEndpoint(value: string | undefined) {
if (value === undefined) return "http://127.0.0.1:17371";
if (!value || value !== value.trim() || value.includes(",")) {
throw new Error("Local Runtime endpoint must be one exact loopback origin");
}
const url = new URL(value);
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/" || url.username || url.password || url.search || url.hash || url.origin !== value) {
throw new Error("Local Runtime endpoint must be one exact loopback origin");
}
return url.origin;
}
export type RuntimeBrowserKeyRecord = {
id: typeof KEY_RECORD_ID;
privateKey: CryptoKey;
publicKeyJwk: JsonWebKey;
keyId: string;
registered: boolean;
};
export type RuntimeBrowserKeyStore = {
load(): Promise<RuntimeBrowserKeyRecord | undefined>;
save(record: RuntimeBrowserKeyRecord): Promise<void>;
clear(): Promise<void>;
};
export type RuntimePublicSession = {
sessionId: string;
keyId: string;
scopes: string[];
expiresAt: string;
};
export type LocalRuntimeConnection = { state: "connected"; session: RuntimePublicSession; runtimeVersion: number } | { state: "origin_not_trusted"; runtimeVersion: number };
type RuntimeInfo = {
runtime: "framefield-local-runtime";
apiVersion: 2;
protocolVersion: "framefield-runtime-session-v1";
runtimeInstanceId: string;
originTrusted: boolean;
};
type RuntimeChallenge = {
state: "challenge";
challengeId: string;
nonce: string;
runtimeInstanceId: string;
expiresAt: string;
keyId: string;
};
type LocalRuntimeSessionClientOptions = {
origin?: string;
keyStore?: RuntimeBrowserKeyStore;
fetch?: typeof fetch;
crypto?: Crypto;
now?: () => number;
};
export class LocalRuntimeClientError extends Error {
constructor(
readonly code: string,
message: string,
readonly status = 0,
) {
super(message);
this.name = "LocalRuntimeClientError";
}
}
export class LocalRuntimeSessionClient {
private readonly origin: string;
private readonly keyStore: RuntimeBrowserKeyStore;
private readonly fetchImpl: typeof fetch;
private readonly cryptoImpl: Crypto;
private readonly now: () => number;
private session?: RuntimePublicSession;
private runtimeInstanceId?: string;
private pending?: RuntimeChallenge;
private connectPromise?: Promise<LocalRuntimeConnection>;
constructor(options: LocalRuntimeSessionClientOptions = {}) {
this.origin = exactOrigin(options.origin ?? globalThis.location?.origin ?? "");
this.keyStore = options.keyStore ?? createIndexedDbRuntimeKeyStore();
this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
this.cryptoImpl = options.crypto ?? globalThis.crypto;
this.now = options.now ?? Date.now;
if (!this.cryptoImpl?.subtle || typeof this.cryptoImpl.getRandomValues !== "function") {
throw new LocalRuntimeClientError("webcrypto_unavailable", "浏览器不支持本机安全连接");
}
}
currentSession() {
return this.session ? { ...this.session, scopes: [...this.session.scopes] } : undefined;
}
connect(signal?: AbortSignal) {
if (this.session && Date.parse(this.session.expiresAt) > this.now()) {
return Promise.resolve<LocalRuntimeConnection>({
state: "connected",
session: this.currentSession()!,
runtimeVersion: 2,
});
}
this.connectPromise ??= this.connectOnce(signal).finally(() => {
this.connectPromise = undefined;
});
return this.connectPromise;
}
async request(pathAndQuery: string, init: RequestInit = {}) {
const session = this.session;
if (!session || Date.parse(session.expiresAt) <= this.now()) {
this.session = undefined;
throw new LocalRuntimeClientError("session_required", "本机会话尚未建立", 401);
}
const url = exactRuntimeUrl(pathAndQuery);
const method = String(init.method ?? "GET").toUpperCase();
const bodyBytes = requestBodyBytes(init.body);
const headers = new Headers(init.headers);
if (headers.has("authorization") || headers.has("x-canvas-agent-token")) {
throw new LocalRuntimeClientError("legacy_bearer_rejected", "正常本机请求不能携带旧版凭据");
}
const timestamp = this.now();
const requestNonce = randomBase64Url(this.cryptoImpl, 16);
const lastEventId = headers.get("last-event-id");
const payload = {
protocol: "framefield-runtime-request-v1",
sessionId: session.sessionId,
keyId: session.keyId,
method,
pathAndQuery: `${url.pathname}${url.search}`,
bodySha256: await sha256Base64Url(this.cryptoImpl, bodyBytes),
lastEventId,
origin: this.origin,
endpoint: LOCAL_RUNTIME_ENDPOINT,
runtimeInstanceId: this.runtimeInstanceId!,
requestNonce,
timestamp,
sessionExpiresAt: session.expiresAt,
};
const key = await this.requireKey();
const proof = await signP1363(this.cryptoImpl, key.privateKey, canonicalize(payload));
headers.set("X-Framefield-Runtime-Session", session.sessionId);
headers.set("X-Framefield-Runtime-Timestamp", String(timestamp));
headers.set("X-Framefield-Runtime-Nonce", requestNonce);
headers.set("X-Framefield-Runtime-Proof", proof);
const response = await this.fetchImpl(url, {
...init,
method,
headers,
credentials: "omit",
redirect: "error",
cache: "no-store",
});
if (response.status === 401) this.session = undefined;
return response;
}
revokeLocalSession() {
this.session = undefined;
this.pending = undefined;
}
private async connectOnce(signal?: AbortSignal): Promise<LocalRuntimeConnection> {
const info = await this.readInfo(signal);
if (!info.originTrusted) {
this.session = undefined;
this.pending = undefined;
return { state: "origin_not_trusted", runtimeVersion: info.apiVersion };
}
if (this.runtimeInstanceId && this.runtimeInstanceId !== info.runtimeInstanceId) {
this.session = undefined;
this.pending = undefined;
}
this.runtimeInstanceId = info.runtimeInstanceId;
const key = await this.requireKey();
let challenge = this.pending;
if (!challenge || Date.parse(challenge.expiresAt) <= this.now()) {
challenge = await this.createChallenge(key, signal);
this.pending = challenge;
}
if (challenge.runtimeInstanceId !== info.runtimeInstanceId || challenge.keyId !== key.keyId) {
this.pending = undefined;
throw new LocalRuntimeClientError("challenge_invalid", "本机会话挑战无效");
}
const signature = await signP1363(
this.cryptoImpl,
key.privateKey,
canonicalize({
protocol: "framefield-runtime-session-v1",
challengeId: challenge.challengeId,
nonce: challenge.nonce,
origin: this.origin,
endpoint: LOCAL_RUNTIME_ENDPOINT,
runtimeInstanceId: challenge.runtimeInstanceId,
expiresAt: challenge.expiresAt,
}),
);
const exchange = await this.jsonFetch("/runtime/session/exchange", { challengeId: challenge.challengeId, signature }, signal, true);
if (!exchange.response.ok) throw responseError(exchange.response, exchange.body);
const session = parseSession(exchange.body, key.keyId);
this.pending = undefined;
this.session = session;
if (!key.registered) {
key.registered = true;
await this.keyStore.save(key);
}
return { state: "connected", session: this.currentSession()!, runtimeVersion: info.apiVersion };
}
private async readInfo(signal?: AbortSignal) {
const response = await this.fetchImpl(`${LOCAL_RUNTIME_ENDPOINT}/runtime/info`, {
method: "GET",
credentials: "omit",
redirect: "error",
cache: "no-store",
signal,
});
const body = await safeJson(response);
if (!response.ok) throw responseError(response, body);
return parseInfo(body);
}
private async createChallenge(key: RuntimeBrowserKeyRecord, signal?: AbortSignal) {
const payload = key.registered ? { keyId: key.keyId } : { publicKeyJwk: key.publicKeyJwk };
const result = await this.jsonFetch("/runtime/session/challenge", payload, signal, true);
if (result.response.status === 404 && key.registered) {
key.registered = false;
await this.keyStore.save(key);
const retry = await this.jsonFetch("/runtime/session/challenge", { publicKeyJwk: key.publicKeyJwk }, signal, true);
if (!retry.response.ok) throw responseError(retry.response, retry.body);
return parseChallenge(retry.body, key.keyId);
}
if (!result.response.ok) throw responseError(result.response, result.body);
return parseChallenge(result.body, key.keyId);
}
private async jsonFetch(path: string, body: unknown, signal?: AbortSignal, allowError = false) {
const response = await this.fetchImpl(`${LOCAL_RUNTIME_ENDPOINT}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
credentials: "omit",
redirect: "error",
cache: "no-store",
signal,
});
const parsed = await safeJson(response);
if (!allowError && !response.ok) throw responseError(response, parsed);
return { response, body: parsed };
}
private async requireKey() {
const existing = await this.keyStore.load();
if (existing) {
validateKeyRecord(existing);
return existing;
}
const pair = await this.cryptoImpl.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"]);
const publicKeyJwk = await this.cryptoImpl.subtle.exportKey("jwk", pair.publicKey);
validatePublicJwk(publicKeyJwk);
const keyId = await browserKeyId(this.cryptoImpl, publicKeyJwk);
const record: RuntimeBrowserKeyRecord = {
id: KEY_RECORD_ID,
privateKey: pair.privateKey,
publicKeyJwk,
keyId,
registered: false,
};
await this.keyStore.save(record);
return record;
}
}
export function createIndexedDbRuntimeKeyStore(): RuntimeBrowserKeyStore {
const database = openDB(KEY_DATABASE, 1, {
upgrade(db) {
if (!db.objectStoreNames.contains(KEY_STORE)) db.createObjectStore(KEY_STORE);
},
});
return {
async load() {
return (await (await database).get(KEY_STORE, KEY_RECORD_ID)) as RuntimeBrowserKeyRecord | undefined;
},
async save(record) {
await (await database).put(KEY_STORE, record, KEY_RECORD_ID);
},
async clear() {
await (await database).delete(KEY_STORE, KEY_RECORD_ID);
},
};
}
async function browserKeyId(cryptoImpl: Crypto, jwk: JsonWebKey) {
return await sha256Base64Url(
cryptoImpl,
new TextEncoder().encode(
canonicalize({
crv: jwk.crv,
kty: jwk.kty,
x: jwk.x,
y: jwk.y,
}),
),
);
}
async function signP1363(cryptoImpl: Crypto, privateKey: CryptoKey, payload: string) {
const signature = new Uint8Array(await cryptoImpl.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, new TextEncoder().encode(payload)));
if (signature.byteLength !== 64) {
throw new LocalRuntimeClientError("signature_invalid", "浏览器签名格式不受支持");
}
return base64Url(signature);
}
async function sha256Base64Url(cryptoImpl: Crypto, value: Uint8Array) {
const bytes = new Uint8Array(value.byteLength);
bytes.set(value);
return base64Url(new Uint8Array(await cryptoImpl.subtle.digest("SHA-256", bytes.buffer)));
}
function randomBase64Url(cryptoImpl: Crypto, bytes: number) {
return base64Url(cryptoImpl.getRandomValues(new Uint8Array(bytes)));
}
function base64Url(value: Uint8Array) {
let binary = "";
for (const byte of value) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function requestBodyBytes(body: BodyInit | null | undefined) {
if (body === undefined || body === null) return new Uint8Array();
if (typeof body === "string") return new TextEncoder().encode(body);
if (body instanceof Uint8Array) return body;
throw new LocalRuntimeClientError("request_body_invalid", "本机请求体格式无效");
}
function exactRuntimeUrl(pathAndQuery: string) {
if (!pathAndQuery.startsWith("/") || pathAndQuery.includes("#")) {
throw new LocalRuntimeClientError("request_target_invalid", "本机请求路径无效");
}
const url = new URL(pathAndQuery, LOCAL_RUNTIME_ENDPOINT);
if (url.origin !== LOCAL_RUNTIME_ENDPOINT || `${url.pathname}${url.search}` !== pathAndQuery) {
throw new LocalRuntimeClientError("request_target_invalid", "本机请求路径无效");
}
return url;
}
function exactOrigin(value: string) {
try {
const url = new URL(value);
if (!["http:", "https:"].includes(url.protocol) || url.pathname !== "/" || url.search || url.hash || url.origin !== value) throw new Error();
return url.origin;
} catch {
throw new LocalRuntimeClientError("origin_invalid", "当前页面来源无效");
}
}
function validatePublicJwk(jwk: JsonWebKey) {
if (
jwk.kty !== "EC" ||
jwk.crv !== "P-256" ||
!/^[A-Za-z0-9_-]{43}$/.test(jwk.x ?? "") ||
!/^[A-Za-z0-9_-]{43}$/.test(jwk.y ?? "") ||
jwk.d !== undefined ||
jwk.ext !== true ||
!Array.isArray(jwk.key_ops) ||
jwk.key_ops.length !== 1 ||
jwk.key_ops[0] !== "verify"
) {
throw new LocalRuntimeClientError("browser_key_invalid", "浏览器公钥无效");
}
}
function validateKeyRecord(record: RuntimeBrowserKeyRecord) {
validatePublicJwk(record.publicKeyJwk);
if (
record.id !== KEY_RECORD_ID ||
record.privateKey.type !== "private" ||
record.privateKey.extractable ||
record.privateKey.algorithm.name !== "ECDSA" ||
record.privateKey.usages.length !== 1 ||
record.privateKey.usages[0] !== "sign" ||
!/^[A-Za-z0-9_-]{43}$/.test(record.keyId)
) {
throw new LocalRuntimeClientError("browser_key_invalid", "浏览器密钥无效");
}
}
function parseInfo(value: Record<string, unknown>): RuntimeInfo {
assertExactKeys(value, ["runtime", "apiVersion", "protocolVersion", "runtimeInstanceId", "originTrusted"]);
if (
value.runtime !== "framefield-local-runtime" ||
value.apiVersion !== 2 ||
value.protocolVersion !== "framefield-runtime-session-v1" ||
typeof value.runtimeInstanceId !== "string" ||
!/^[A-Za-z0-9_-]{8,160}$/.test(value.runtimeInstanceId) ||
typeof value.originTrusted !== "boolean"
) {
throw new LocalRuntimeClientError("runtime_incompatible", "本机运行时版本不兼容");
}
return value as RuntimeInfo;
}
function parseChallenge(value: Record<string, unknown>, keyId: string): RuntimeChallenge {
assertExactKeys(value, ["state", "challengeId", "nonce", "runtimeInstanceId", "expiresAt", "keyId"]);
if (value.state !== "challenge" || typeof value.challengeId !== "string" || typeof value.nonce !== "string" || typeof value.runtimeInstanceId !== "string" || typeof value.expiresAt !== "string" || value.keyId !== keyId) {
throw new LocalRuntimeClientError("challenge_invalid", "本机会话挑战无效");
}
return value as RuntimeChallenge;
}
function parseSession(value: Record<string, unknown>, keyId: string): RuntimePublicSession {
assertExactKeys(value, ["sessionId", "keyId", "scopes", "expiresAt"]);
if (typeof value.sessionId !== "string" || value.sessionId.length < 16 || value.keyId !== keyId || !Array.isArray(value.scopes) || value.scopes.some((scope) => typeof scope !== "string") || typeof value.expiresAt !== "string") {
throw new LocalRuntimeClientError("session_invalid", "本机会话响应无效");
}
return value as RuntimePublicSession;
}
function assertExactKeys(value: Record<string, unknown>, keys: readonly string[]) {
const allowed = new Set(keys);
if (Object.keys(value).some((key) => !allowed.has(key))) {
throw new LocalRuntimeClientError("runtime_response_invalid", "本机运行时响应无效");
}
}
async function safeJson(response: Response) {
try {
const value = (await response.json()) as unknown;
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
return value as Record<string, unknown>;
} catch {
throw new LocalRuntimeClientError("runtime_response_invalid", "本机运行时响应无效", response.status);
}
}
function responseError(response: Response, body: Record<string, unknown>) {
const code = typeof body.code === "string" && body.code in PUBLIC_RUNTIME_ERROR_MESSAGES ? body.code : "runtime_request_failed";
return new LocalRuntimeClientError(code, PUBLIC_RUNTIME_ERROR_MESSAGES[code], response.status);
}
const PUBLIC_RUNTIME_ERROR_MESSAGES: Record<string, string> = {
challenge_invalid: "本机会话挑战无效",
invalid_public_key: "浏览器公钥无效",
origin_not_trusted: "当前页面来源未获本机授权",
rate_limited: "本机连接请求过多,请稍后重试",
registration_not_found: "浏览器密钥尚未注册",
runtime_internal_error: "本机运行时请求失败",
runtime_request_failed: "本机运行时请求失败",
};

View File

@@ -0,0 +1,149 @@
import { LocalRuntimeClientError } from "@/services/local-runtime-session";
export type LocalRuntimeModuleId = "canvas-agent" | "dreamina";
export type LocalRuntimeScope = "runtime:status" | "runtime:revoke" | "canvas:connect" | "dreamina:status" | "dreamina:login" | "dreamina:logout" | "dreamina:run" | "dreamina:models" | "dreamina:generate";
export type LocalRuntimeModuleDescriptor = {
id: LocalRuntimeModuleId;
displayName: string;
apiVersion: 1;
scopes: LocalRuntimeScope[];
};
export type LocalRuntimeStatus = {
runtime: {
id: "framefield-local-runtime";
version: string;
apiVersion: 2;
};
modules: LocalRuntimeModuleDescriptor[];
};
export type LocalRuntimeTransport = {
request(pathAndQuery: string, init?: RequestInit): Promise<Response>;
};
const MAX_RESPONSE_BYTES = 64 * 1024;
const MODULE_SCOPES: Record<LocalRuntimeModuleId, ReadonlySet<LocalRuntimeScope>> = {
"canvas-agent": new Set(["canvas:connect"]),
dreamina: new Set(["dreamina:status", "dreamina:login", "dreamina:logout", "dreamina:run", "dreamina:models", "dreamina:generate"]),
};
export async function readLocalRuntimeStatus(client: LocalRuntimeTransport, signal?: AbortSignal): Promise<LocalRuntimeStatus> {
const response = await client.request("/runtime/status", { method: "GET", signal });
if (response.redirected || response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) {
throw invalidResponse(response.status);
}
const body = await readBoundedJson(response);
if (!response.ok) {
const code = response.status === 401 ? "session_required" : response.status === 403 ? "scope_denied" : "runtime_request_failed";
throw new LocalRuntimeClientError(code, publicRequestError(code), response.status);
}
return parseRuntimeStatus(body, response.status);
}
async function readBoundedJson(response: Response): Promise<unknown> {
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
throw invalidResponse(response.status);
}
if (!response.body) throw invalidResponse(response.status);
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
const item = await reader.read();
if (item.done) break;
total += item.value.byteLength;
if (total > MAX_RESPONSE_BYTES) {
await reader.cancel();
throw invalidResponse(response.status);
}
chunks.push(item.value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
try {
return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
} catch {
throw invalidResponse(response.status);
}
}
function parseRuntimeStatus(value: unknown, status: number): LocalRuntimeStatus {
if (!isRecord(value) || !hasExactKeys(value, ["modules", "ok", "runtime"]) || value.ok !== true || !isRecord(value.runtime) || !Array.isArray(value.modules)) {
throw invalidResponse(status);
}
const runtime = value.runtime;
if (!hasExactKeys(runtime, ["apiVersion", "id", "version"]) || runtime.id !== "framefield-local-runtime" || runtime.apiVersion !== 2 || typeof runtime.version !== "string" || !/^[A-Za-z0-9._+:-]{1,80}$/.test(runtime.version)) {
throw invalidResponse(status);
}
const ids = new Set<LocalRuntimeModuleId>();
const modules = value.modules.map((item) => {
if (
!isRecord(item) ||
!hasExactKeys(item, ["apiVersion", "displayName", "id", "scopes"]) ||
!isModuleId(item.id) ||
ids.has(item.id) ||
item.apiVersion !== 1 ||
typeof item.displayName !== "string" ||
item.displayName.trim() !== item.displayName ||
item.displayName.length < 1 ||
item.displayName.length > 80 ||
!Array.isArray(item.scopes)
) {
throw invalidResponse(status);
}
const scopes = item.scopes as unknown[];
const allowedScopes = MODULE_SCOPES[item.id];
if (scopes.some((scope) => typeof scope !== "string" || !allowedScopes.has(scope as LocalRuntimeScope)) || new Set(scopes).size !== scopes.length) {
throw invalidResponse(status);
}
ids.add(item.id);
return {
id: item.id,
displayName: item.displayName,
apiVersion: 1 as const,
scopes: [...scopes] as LocalRuntimeScope[],
};
});
return {
runtime: { id: "framefield-local-runtime", version: runtime.version, apiVersion: 2 },
modules,
};
}
function hasExactKeys(value: Record<string, unknown>, expected: string[]) {
const keys = Object.keys(value).sort();
return keys.length === expected.length && keys.every((key, index) => key === expected[index]);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function isModuleId(value: unknown): value is LocalRuntimeModuleId {
return value === "canvas-agent" || value === "dreamina";
}
function invalidResponse(status: number) {
return new LocalRuntimeClientError("runtime_response_invalid", "本机运行时响应无效", status);
}
function publicRequestError(code: string) {
if (code === "session_required") return "本机会话已失效,请重新连接";
if (code === "scope_denied") return "本机会话权限不足";
return "本机运行时请求失败";
}

View File

@@ -1,9 +1,25 @@
import { canvasNodeToAsset, declaredCanvasNodeAssetCategory, findCanvasNodeAsset, type CanvasAssetSource } from "@/lib/canvas/canvas-node-asset";
import { readImageMeta } from "@/lib/image-utils";
import { parseBackendGenerationResult, type BackendGenerationResult } from "@/services/api/generation-task";
import { linkProjectAsset, updateProjectAssetCategory } from "@/services/api/projects";
import type { GenerationTask, GenerationTaskOutput } from "@/services/api/task-center";
import { getMediaBlob, resolveMediaUrl, setMediaBlob } from "@/services/file-storage";
import { createGenerationTaskMaterializer, createIdempotentMaterializeOutput, type MaterializeGenerationTaskOutput } from "@/services/generation-task-materializer";
import { withGenerationArtifactCommitLock } from "@/services/generation-asset-repository";
import { getImageBlob, resolveImageUrl, setImageBlob } from "@/services/image-storage";
import { generationArtifactStorageKey, loadOrStoreGenerationArtifact } from "@/services/generation-artifact-sink";
import { createLocalDreaminaTaskEffectStore } from "@/services/local-dreamina-generation";
import { createProviderNeutralGenerationTaskEffectStore } from "@/services/provider-neutral-generation-effects";
import { saveRemoteUserDataNow } from "@/services/user-data-sync";
import { useAssetStore, type AssetCategory, type AssetStatus } from "@/stores/use-asset-store";
import { getActiveUserScope } from "@/lib/user-scope";
import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
import { useAssetStore, type AssetCategory, type AssetStatus, type NewAsset } from "@/stores/use-asset-store";
import type { CanvasNodeData } from "@/types/canvas";
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw new DOMException("The operation was aborted", "AbortError");
}
type EnsureCanvasNodeAssetOptions = {
canvasId: string;
domainProjectId?: string;
@@ -11,6 +27,7 @@ type EnsureCanvasNodeAssetOptions = {
source: CanvasAssetSource;
taskId?: string;
category?: AssetCategory;
signal?: AbortSignal;
};
export type CanvasNodeAssetResult = {
@@ -22,16 +39,18 @@ export type CanvasNodeAssetResult = {
const pendingAssetSyncs = new Map<string, Promise<CanvasNodeAssetResult>>();
export function ensureCanvasNodeAsset(options: EnsureCanvasNodeAssetOptions) {
const scope = getActiveUserScope();
const identity = options.taskId || options.node.metadata?.taskId || options.node.metadata?.storageKey || options.node.id;
const key = [options.domainProjectId || "personal", options.canvasId, options.node.id, identity].join(":");
const key = [scope, options.domainProjectId || "personal", options.canvasId, options.node.id, identity].join(":");
const pending = pendingAssetSyncs.get(key);
if (pending) return pending;
const request = persistCanvasNodeAsset(options).finally(() => pendingAssetSyncs.delete(key));
const request = runGenerationConsumer(options.signal, (signal) => persistCanvasNodeAsset({ ...options, signal })).finally(() => pendingAssetSyncs.delete(key));
pendingAssetSyncs.set(key, request);
return request;
}
async function persistCanvasNodeAsset(options: EnsureCanvasNodeAssetOptions): Promise<CanvasNodeAssetResult> {
throwIfAborted(options.signal);
const store = useAssetStore.getState();
let asset = findCanvasNodeAsset(store.assets, options.node, options.canvasId, options.taskId);
const declaredCategory = options.category || declaredCanvasNodeAssetCategory(options.node);
@@ -49,23 +68,432 @@ async function persistCanvasNodeAsset(options: EnsureCanvasNodeAssetOptions): Pr
asset = useAssetStore.getState().assets.find((item) => item.id === asset?.id) || asset;
}
if (!options.domainProjectId) return { assetId: asset.id, created, linkedToProject: false };
await syncAssetToProject(asset.id, options.domainProjectId, declaredCategory, options.signal);
return { assetId: asset.id, created, linkedToProject: true };
}
async function syncAssetToProject(assetId: string, domainProjectId: string, category?: AssetCategory, signal?: AbortSignal) {
throwIfAborted(signal);
const asset = useAssetStore.getState().assets.find((candidate) => candidate.id === assetId);
if (!asset) throw new Error("素材写入本地失败");
const linkedProjectIds = Array.isArray(asset.metadata?.projectIds) ? asset.metadata.projectIds.filter((id): id is string => typeof id === "string") : [];
if (linkedProjectIds.includes(domainProjectId)) return;
// 项目关联依赖后端 assets 记录,先强制完成素材同步,不能依赖延迟自动同步的时序。
await saveRemoteUserDataNow();
const { asset: linkedAsset } = await linkProjectAsset(options.domainProjectId, {
assetId: asset.id,
category: declaredCategory || asset.category || "other",
});
const linked = declaredCategory && linkedAsset.category !== declaredCategory
? (await updateProjectAssetCategory(options.domainProjectId, asset.id, declaredCategory)).asset
: linkedAsset;
throwIfAborted(signal);
const { asset: linkedAsset } = await linkProjectAsset(
domainProjectId,
{
assetId: asset.id,
category: category || asset.category || "other",
},
signal,
);
throwIfAborted(signal);
const linked = category && linkedAsset.category !== category ? (await updateProjectAssetCategory(domainProjectId, asset.id, category, signal)).asset : linkedAsset;
throwIfAborted(signal);
useAssetStore.getState().updateAsset(asset.id, {
category: linked.category as AssetCategory,
status: linked.status as AssetStatus,
primaryVersionId: linked.primaryVersionId,
metadata: { ...asset.metadata, projectIds: [...new Set([...linkedProjectIds, options.domainProjectId])] },
metadata: { ...asset.metadata, projectIds: [...new Set([...linkedProjectIds, domainProjectId])] },
});
await saveRemoteUserDataNow();
return { assetId: asset.id, created, linkedToProject: true };
throwIfAborted(signal);
}
function generationTaskResult(task: GenerationTask): BackendGenerationResult {
if (task.resultJson) return parseBackendGenerationResult(task);
if (!task.previewUrl) return {};
return task.previewKind === "video" ? { mode: "video", video: { dataUrl: task.previewUrl } } : { mode: "image", images: [{ dataUrl: task.previewUrl }] };
}
export function projectGenerationTaskResult(task: GenerationTask, result?: BackendGenerationResult): GenerationTask {
const projectedResult = result ?? generationTaskResult(task);
const outputs: GenerationTaskOutput[] = projectedResult.images?.length
? projectedResult.images.map((image, outputIndex) => ({
outputIndex,
mediaType: "image" as const,
...(image.storageKey ? { providerArtifactRef: image.storageKey } : {}),
}))
: projectedResult.video
? [
{
outputIndex: 0,
mediaType: "video" as const,
...(projectedResult.video.storageKey ? { providerArtifactRef: projectedResult.video.storageKey } : {}),
},
]
: projectedResult.audio
? [
{
outputIndex: 0,
mediaType: "audio" as const,
...(projectedResult.audio.storageKey ? { providerArtifactRef: projectedResult.audio.storageKey } : {}),
},
]
: (task.outputs?.map((output) => ({ ...output })) ?? []);
return {
...task,
...(result ? { resultJson: JSON.stringify(result) } : {}),
outputs,
...(task.status === "succeeded" && outputs.length && !outputs.every((output) => output.materializedAssetId) ? { resultState: "PENDING_MATERIALIZATION" as const } : {}),
};
}
async function storedGenerationImage(result: NonNullable<BackendGenerationResult["images"]>[number], effectKey: string, scope: string, signal?: AbortSignal) {
throwIfAborted(signal);
if (result.storageKey) {
const url = await resolveImageUrl(result.storageKey, result.dataUrl);
if (!url) throw new Error("图片结果资源不可用");
const meta = result.width && result.height ? undefined : await readImageMeta(url, signal);
throwIfAborted(signal);
return {
url,
storageKey: result.storageKey,
width: result.width || meta?.width || 1024,
height: result.height || meta?.height || 1024,
bytes: result.bytes || 0,
mimeType: result.mimeType || "image/png",
};
}
const storageKey = generationArtifactStorageKey(effectKey, "image", scope);
const blob = await loadOrStoreGenerationArtifact({
effectKey: storageKey,
read: (key) => getImageBlob(key),
materialize: async () => (await fetch(result.dataUrl, { signal })).blob(),
write: async (key, artifact) => {
await setImageBlob(key, artifact);
},
});
throwIfAborted(signal);
const url = await resolveImageUrl(storageKey);
throwIfAborted(signal);
if (!url) throw new Error("图片结果资源不可用");
const meta = result.width && result.height ? undefined : await readImageMeta(url, signal);
throwIfAborted(signal);
return {
url,
storageKey,
width: result.width || meta?.width || 1024,
height: result.height || meta?.height || 1024,
bytes: result.bytes || blob.size,
mimeType: result.mimeType || blob.type || "image/png",
};
}
async function storedGenerationMedia(dataUrl: string, effectKey: string, mediaType: "video" | "audio", metadata: { width?: number; height?: number; durationMs?: number; bytes?: number; mimeType: string }, scope: string, signal?: AbortSignal) {
throwIfAborted(signal);
const storageKey = generationArtifactStorageKey(effectKey, mediaType, scope);
const blob = await loadOrStoreGenerationArtifact({
effectKey: storageKey,
read: (key) => getMediaBlob(key),
materialize: async () => (await fetch(dataUrl, { signal })).blob(),
write: async (key, artifact) => {
await setMediaBlob(key, artifact);
},
});
throwIfAborted(signal);
const url = await resolveMediaUrl(storageKey);
throwIfAborted(signal);
if (!url) throw new Error(`${mediaType === "video" ? "视频" : "音频"}结果资源不可用`);
return {
url,
storageKey,
width: metadata.width,
height: metadata.height,
durationMs: metadata.durationMs,
bytes: metadata.bytes || blob.size,
mimeType: metadata.mimeType || blob.type,
};
}
async function generationOutputAsset(input: Parameters<MaterializeGenerationTaskOutput>[0], scope: string): Promise<NewAsset> {
throwIfAborted(input.signal);
const result = generationTaskResult(input.task);
const metadata = {
source: "generation-task",
generationEffectKey: input.effectKey,
taskId: input.task.id,
outputIndex: input.output.outputIndex,
conversationId: input.task.clientContext?.conversationId,
messageId: input.task.clientContext?.messageId,
batchIndex: input.task.clientContext?.batchIndex,
};
if (input.output.mediaType === "image") {
const image = result.images?.[input.output.outputIndex];
if (!image) throw new Error("生成任务缺少图片输出");
const stored = await storedGenerationImage(image, input.effectKey, scope, input.signal);
return {
kind: "image",
title: "生成图片",
coverUrl: stored.url,
tags: ["生成"],
status: "confirmed",
source: "生成任务",
metadata,
data: {
dataUrl: stored.url,
storageKey: stored.storageKey,
width: stored.width,
height: stored.height,
bytes: stored.bytes,
mimeType: stored.mimeType,
},
};
}
if (input.output.mediaType === "video") {
const video = result.video;
if (!video) throw new Error("生成任务缺少视频输出");
const stored = video.storageKey
? {
url: await resolveMediaUrl(video.storageKey, video.dataUrl),
storageKey: video.storageKey,
width: video.width || 0,
height: video.height || 0,
durationMs: video.durationMs,
bytes: video.bytes || 0,
mimeType: video.mimeType || "video/mp4",
}
: await storedGenerationMedia(
video.dataUrl,
input.effectKey,
"video",
{
width: video.width,
height: video.height,
durationMs: video.durationMs,
bytes: video.bytes,
mimeType: video.mimeType || "video/mp4",
},
scope,
input.signal,
);
if (!stored.url) throw new Error("视频结果资源不可用");
return {
kind: "video",
title: "生成视频",
coverUrl: stored.url,
tags: ["生成"],
status: "confirmed",
source: "生成任务",
metadata,
data: {
url: stored.url,
storageKey: stored.storageKey,
width: stored.width || 0,
height: stored.height || 0,
durationMs: stored.durationMs,
bytes: stored.bytes,
mimeType: stored.mimeType || "video/mp4",
},
};
}
const audio = result.audio;
if (!audio) throw new Error("生成任务缺少音频输出");
const stored = audio.storageKey
? {
url: await resolveMediaUrl(audio.storageKey, audio.dataUrl),
storageKey: audio.storageKey,
durationMs: audio.durationMs,
bytes: audio.bytes || 0,
mimeType: audio.mimeType || "audio/mpeg",
}
: await storedGenerationMedia(
audio.dataUrl,
input.effectKey,
"audio",
{
durationMs: audio.durationMs,
bytes: audio.bytes,
mimeType: audio.mimeType || "audio/mpeg",
},
scope,
input.signal,
);
if (!stored.url) throw new Error("音频结果资源不可用");
return {
kind: "audio",
title: "生成音频",
coverUrl: "",
tags: ["生成"],
status: "confirmed",
source: "生成任务",
metadata,
data: {
url: stored.url,
storageKey: stored.storageKey,
durationMs: stored.durationMs,
bytes: stored.bytes,
mimeType: stored.mimeType || "audio/mpeg",
},
};
}
let generationTaskEffectStoreInstance: ReturnType<typeof createLocalDreaminaTaskEffectStore> | undefined;
function generationTaskEffectStore() {
generationTaskEffectStoreInstance ??= createLocalDreaminaTaskEffectStore();
return generationTaskEffectStoreInstance;
}
const materializeGenerationOutput: MaterializeGenerationTaskOutput = createIdempotentMaterializeOutput({
async insertOrReturnAsset(input) {
const scope = getActiveUserScope();
return withGenerationArtifactCommitLock(
scope,
async () => {
throwIfAborted(input.signal);
const asset = await generationOutputAsset(input, scope);
throwIfAborted(input.signal);
const assetId = await useAssetStore.getState().addGenerationAsset(input.effectKey, asset, input.signal);
throwIfAborted(input.signal);
return assetId;
},
{ requireCrossRealmLock: true },
);
},
});
const dreaminaGenerationTaskMaterializer = createGenerationTaskMaterializer({
effects: {
claim: (effectKey, taskId) => generationTaskEffectStore().claim(effectKey, taskId),
renew: (effectKey, taskId) => generationTaskEffectStore().renew(effectKey, taskId),
complete: (effectKey, taskId, result) => generationTaskEffectStore().complete(effectKey, taskId, result),
release: (effectKey, taskId) => generationTaskEffectStore().release(effectKey, taskId),
},
materializeOutput: materializeGenerationOutput,
});
function remoteGenerationTaskMaterializer() {
return createGenerationTaskMaterializer({
// 每个页面实例持有自己的租约;共享浏览器 storage + Web Lock 提供跨标签原子权威。
effects: createProviderNeutralGenerationTaskEffectStore(),
materializeOutput: materializeGenerationOutput,
});
}
function generationTaskMaterializer(task: GenerationTask) {
return task.provider === "dreamina-cli" ? dreaminaGenerationTaskMaterializer : remoteGenerationTaskMaterializer();
}
export async function materializeGenerationTaskAssets(task: GenerationTask, signal?: AbortSignal): Promise<GenerationTask> {
return generationTaskMaterializer(task).materialize(projectGenerationTaskResult(task), signal);
}
export function attachGenerationTaskNode(task: GenerationTask, nodeId: string, outputIndex: number, consumer: Parameters<typeof dreaminaGenerationTaskMaterializer.attachNode>[3], signal?: AbortSignal) {
return generationTaskMaterializer(task).attachNode(task, nodeId, outputIndex, consumer, signal);
}
export function attachGenerationTaskMessage(task: GenerationTask, messageId: string, outputIndex: number, consumer: Parameters<typeof dreaminaGenerationTaskMaterializer.attachMessage>[3], signal?: AbortSignal) {
return generationTaskMaterializer(task).attachMessage(task, messageId, outputIndex, consumer, signal);
}
export function resumeGenerationTaskAgent(task: GenerationTask, continuationId: string, consumer: Parameters<typeof dreaminaGenerationTaskMaterializer.resumeAgent>[2], signal?: AbortSignal) {
return generationTaskMaterializer(task).resumeAgent(task, continuationId, consumer, signal);
}
export async function consumeGenerationTaskNode(
task: GenerationTask,
nodeId: string,
outputIndex: number,
consumer: (input: { task: GenerationTask; output: GenerationTaskOutput; effectKey: string; signal?: AbortSignal }) => Promise<void> | void,
dependencies: {
signal?: AbortSignal;
managed?: true;
materialize?: typeof materializeGenerationTaskAssets;
attachNode?: typeof attachGenerationTaskNode;
} = {},
): Promise<GenerationTask> {
if (!dependencies.managed) {
return runGenerationConsumer(dependencies.signal, (signal) => consumeGenerationTaskNode(task, nodeId, outputIndex, consumer, { ...dependencies, signal, managed: true }));
}
const materialized = await (dependencies.materialize ?? materializeGenerationTaskAssets)(task, dependencies.signal);
const output = materialized.outputs?.find((candidate) => candidate.outputIndex === outputIndex);
if (!output?.materializedAssetId) throw new Error("生成任务输出尚未物化");
await (dependencies.attachNode ?? attachGenerationTaskNode)(
materialized,
nodeId,
outputIndex,
async ({ effectKey, signal }) => {
await consumer({ task: materialized, output, effectKey, signal });
},
dependencies.signal,
);
return materialized;
}
export async function consumeGenerationTaskAgent(
task: GenerationTask,
continuationId: string,
consumer: (input: { task: GenerationTask; effectKey: string; signal?: AbortSignal }) => Promise<void> | void,
dependencies: {
signal?: AbortSignal;
managed?: true;
materialize?: typeof materializeGenerationTaskAssets;
resumeAgent?: typeof resumeGenerationTaskAgent;
} = {},
): Promise<GenerationTask> {
if (!dependencies.managed) {
return runGenerationConsumer(dependencies.signal, (signal) => consumeGenerationTaskAgent(task, continuationId, consumer, { ...dependencies, signal, managed: true }));
}
const materialized = task.outputs?.length ? await (dependencies.materialize ?? materializeGenerationTaskAssets)(task, dependencies.signal) : task;
await (dependencies.resumeAgent ?? resumeGenerationTaskAgent)(
materialized,
continuationId,
async ({ effectKey, signal }) => {
await consumer({ task: materialized, effectKey, signal });
},
dependencies.signal,
);
return materialized;
}
export async function consumeGenerationTaskMessage(
task: GenerationTask,
messageId: string,
consumer: (input: { task: GenerationTask; resultUrls: string[]; effectKey: string; signal?: AbortSignal }) => Promise<void> | void,
dependencies: {
signal?: AbortSignal;
managed?: true;
materialize?: typeof materializeGenerationTaskAssets;
materializedUrls?: typeof generationTaskMaterializedUrls;
attachMessage?: typeof attachGenerationTaskMessage;
} = {},
): Promise<GenerationTask> {
if (!dependencies.managed) {
return runGenerationConsumer(dependencies.signal, (signal) => consumeGenerationTaskMessage(task, messageId, consumer, { ...dependencies, signal, managed: true }));
}
const materialized = await (dependencies.materialize ?? materializeGenerationTaskAssets)(task, dependencies.signal);
const resultUrls = (dependencies.materializedUrls ?? generationTaskMaterializedUrls)(materialized);
const attach = dependencies.attachMessage ?? attachGenerationTaskMessage;
const outputs = materialized.outputs?.filter((output) => output.materializedAssetId) ?? [];
for (const output of outputs) {
await attach(
materialized,
messageId,
output.outputIndex,
async ({ effectKey, signal }) => {
await consumer({ task: materialized, resultUrls, effectKey, signal });
},
dependencies.signal,
);
}
return materialized;
}
export function generationTaskMaterializedUrls(task: GenerationTask): string[] {
const assets = useAssetStore.getState().assets;
return (task.outputs || []).flatMap((output) => {
const asset = output.materializedAssetId ? assets.find((candidate) => candidate.id === output.materializedAssetId) : undefined;
if (!asset) return [];
if (asset.kind === "image") return [asset.data.dataUrl || asset.coverUrl];
if (asset.kind === "video" || asset.kind === "audio") return [asset.data.url];
return [];
});
}

View File

@@ -0,0 +1,265 @@
import { localForageStorageForScope } from "@/lib/localforage-storage";
import { getActiveUserScope } from "@/lib/user-scope";
import type { GenerationTaskEffectClaim, GenerationTaskEffectResult, GenerationTaskEffectStore } from "@/services/generation-task-materializer";
type EffectRecord = {
version: 1;
taskId: string;
effectKey: string;
state: "pending" | "completed" | "released";
fence: number;
leaseToken?: string;
leaseExpiresAt?: string;
completedAt?: string;
releasedAt?: string;
result?: GenerationTaskEffectResult;
};
type EffectLease = {
scope: string;
taskId: string;
effectKey: string;
leaseToken: string;
expiresAt: string;
fence: number;
};
type AsyncLock = {
request<T>(name: string, callback: () => Promise<T>): Promise<T>;
};
type EffectStorage = {
getItem(name: string): string | null | Promise<string | null>;
setItem(name: string, value: string): unknown | Promise<unknown>;
};
const EFFECT_STORAGE_PREFIX = "infinite-canvas:generation-effect:";
const EFFECT_LOCK_PREFIX = "infinite-canvas:generation-effect-lock:";
const DEFAULT_LEASE_MS = 30_000;
const inProcessRecords = new Map<string, string>();
const inProcessLockTails = new Map<string, Promise<void>>();
function effectStorage(scope: string): EffectStorage {
if (typeof window !== "undefined") return localForageStorageForScope(scope);
return {
getItem: async (name) => inProcessRecords.get(name) ?? null,
setItem: async (name, value) => {
inProcessRecords.set(name, value);
},
};
}
function effectLock(): AsyncLock {
if (typeof window !== "undefined") {
const locks = navigator.locks;
if (!locks) throw new Error("当前浏览器不支持跨页面生成副作用互斥");
return locks;
}
return {
async request<T>(name: string, callback: () => Promise<T>) {
const prior = inProcessLockTails.get(name) ?? Promise.resolve();
let release!: () => void;
const tail = new Promise<void>((resolve) => {
release = resolve;
});
const queued = prior.then(() => tail);
inProcessLockTails.set(name, queued);
await prior;
try {
return await callback();
} finally {
release();
if (inProcessLockTails.get(name) === queued) inProcessLockTails.delete(name);
}
},
};
}
function recordKey(scope: string, effectKey: string) {
return `${EFFECT_STORAGE_PREFIX}${scope}:${effectKey}`;
}
function lockKey(scope: string, effectKey: string) {
return `${EFFECT_LOCK_PREFIX}${scope}:${effectKey}`;
}
function leaseKey(scope: string, taskId: string, effectKey: string) {
return `${scope}\0${taskId}\0${effectKey}`;
}
function validResult(value: unknown): value is GenerationTaskEffectResult {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const result = value as Record<string, unknown>;
return Object.keys(result).every((key) => key === "materializedAssetId") && (result.materializedAssetId === undefined || typeof result.materializedAssetId === "string");
}
function parseRecord(value: string | null, taskId: string, effectKey: string): EffectRecord | undefined {
if (!value) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new Error("生成副作用持久状态无效");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("生成副作用持久状态无效");
const record = parsed as EffectRecord;
const allowed = new Set(["version", "taskId", "effectKey", "state", "fence", "leaseToken", "leaseExpiresAt", "completedAt", "releasedAt", "result"]);
if (
Object.keys(record).some((key) => !allowed.has(key)) ||
record.version !== 1 ||
record.taskId !== taskId ||
record.effectKey !== effectKey ||
!Number.isSafeInteger(record.fence) ||
record.fence < 1 ||
!["pending", "completed", "released"].includes(record.state)
) {
throw new Error("生成副作用持久状态无效");
}
if (record.state === "pending") {
if (typeof record.leaseToken !== "string" || typeof record.leaseExpiresAt !== "string" || !Number.isFinite(Date.parse(record.leaseExpiresAt)) || record.completedAt !== undefined || record.releasedAt !== undefined || record.result !== undefined) {
throw new Error("生成副作用持久状态无效");
}
} else if (record.state === "completed") {
if (typeof record.completedAt !== "string" || !Number.isFinite(Date.parse(record.completedAt)) || record.leaseToken !== undefined || record.leaseExpiresAt !== undefined || record.releasedAt !== undefined || !validResult(record.result ?? {})) {
throw new Error("生成副作用持久状态无效");
}
} else if (typeof record.releasedAt !== "string" || !Number.isFinite(Date.parse(record.releasedAt)) || record.leaseToken !== undefined || record.leaseExpiresAt !== undefined || record.completedAt !== undefined || record.result !== undefined) {
throw new Error("生成副作用持久状态无效");
}
return record;
}
export function createProviderNeutralGenerationTaskEffectStore(
dependencies: {
now?: () => Date;
leaseMs?: number;
} = {},
): GenerationTaskEffectStore {
const now = dependencies.now ?? (() => new Date());
const leaseMs = dependencies.leaseMs ?? DEFAULT_LEASE_MS;
if (!Number.isSafeInteger(leaseMs) || leaseMs < 100 || leaseMs > 3_600_000) {
throw new Error("生成副作用租约时长无效");
}
const leases = new Map<string, EffectLease>();
const leasesByBinding = new Map<string, EffectLease>();
function ownedLease(effectKey: string, taskId: string, binding?: string) {
if (binding) {
const owned = leasesByBinding.get(binding);
if (owned?.taskId === taskId && owned.effectKey === effectKey) return owned;
return undefined;
}
const matches = [...leases.values()].filter((lease) => lease.taskId === taskId && lease.effectKey === effectKey);
return matches.length === 1 ? matches[0] : undefined;
}
function forgetLease(owned: EffectLease) {
leases.delete(leaseKey(owned.scope, owned.taskId, owned.effectKey));
leasesByBinding.delete(owned.leaseToken);
}
async function withRecord<T>(scope: string, taskId: string, effectKey: string, action: (record: EffectRecord | undefined, storage: EffectStorage, key: string) => Promise<T>) {
return effectLock().request(lockKey(scope, effectKey), async () => {
const storage = effectStorage(scope);
const key = recordKey(scope, effectKey);
return action(parseRecord(await storage.getItem(key), taskId, effectKey), storage, key);
});
}
return {
async claim(effectKey, taskId): Promise<GenerationTaskEffectClaim> {
const scope = getActiveUserScope();
return withRecord(scope, taskId, effectKey, async (record, storage, key) => {
if (record?.state === "completed") {
return { status: "completed", result: { ...(record.result ?? {}) } };
}
const current = now();
if (record?.state === "pending" && Date.parse(record.leaseExpiresAt!) > current.getTime()) {
return { status: "busy", retryAt: record.leaseExpiresAt };
}
const lease: EffectLease = {
scope,
taskId,
effectKey,
leaseToken: crypto.randomUUID(),
expiresAt: new Date(current.getTime() + leaseMs).toISOString(),
fence: (record?.fence ?? 0) + 1,
};
await storage.setItem(
key,
JSON.stringify({
version: 1,
taskId,
effectKey,
state: "pending",
fence: lease.fence,
leaseToken: lease.leaseToken,
leaseExpiresAt: lease.expiresAt,
} satisfies EffectRecord),
);
leases.set(leaseKey(scope, taskId, effectKey), lease);
leasesByBinding.set(lease.leaseToken, lease);
return { status: "claimed", fence: lease.fence, binding: lease.leaseToken };
});
},
async renew(effectKey, taskId, binding) {
const owned = ownedLease(effectKey, taskId, binding);
if (!owned) throw new Error("生成副作用租约缺失");
return withRecord(owned.scope, taskId, effectKey, async (record, storage, key) => {
const current = now();
if (record?.state !== "pending" || record.leaseToken !== owned.leaseToken || record.fence !== owned.fence || Date.parse(record.leaseExpiresAt!) <= current.getTime()) {
throw new Error("生成副作用租约已失效");
}
owned.expiresAt = new Date(current.getTime() + leaseMs).toISOString();
await storage.setItem(key, JSON.stringify({ ...record, leaseExpiresAt: owned.expiresAt } satisfies EffectRecord));
return { fence: owned.fence };
});
},
async complete(effectKey, taskId, result, binding) {
if (!validResult(result)) throw new Error("生成副作用结果无效");
const owned = ownedLease(effectKey, taskId, binding);
if (!owned) throw new Error("生成副作用租约缺失");
await withRecord(owned.scope, taskId, effectKey, async (record, storage, key) => {
const current = now();
if (record?.state !== "pending" || record.leaseToken !== owned.leaseToken || record.fence !== owned.fence || Date.parse(record.leaseExpiresAt!) <= current.getTime()) {
throw new Error("生成副作用租约已失效");
}
await storage.setItem(
key,
JSON.stringify({
version: 1,
taskId,
effectKey,
state: "completed",
fence: owned.fence,
completedAt: current.toISOString(),
result: { ...result },
} satisfies EffectRecord),
);
forgetLease(owned);
});
},
async release(effectKey, taskId, binding) {
const owned = ownedLease(effectKey, taskId, binding);
if (!owned) throw new Error("生成副作用租约缺失");
await withRecord(owned.scope, taskId, effectKey, async (record, storage, key) => {
const current = now();
if (record?.state !== "pending" || record.leaseToken !== owned.leaseToken || record.fence !== owned.fence || Date.parse(record.leaseExpiresAt!) <= current.getTime()) {
throw new Error("生成副作用租约已失效");
}
await storage.setItem(
key,
JSON.stringify({
version: 1,
taskId,
effectKey,
state: "released",
fence: owned.fence,
releasedAt: current.toISOString(),
} satisfies EffectRecord),
);
forgetLease(owned);
});
},
};
}

View File

@@ -1,6 +1,6 @@
import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob, resolveImageUrl } from "@/services/image-storage";
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteUserDataSnapshot, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteCanvasProject, listRemoteAssets, listRemoteCanvasProjects, upsertRemoteAsset, upsertRemoteCanvasProject, type RemoteUserDataSummary } from "@/services/api/user-data";
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import type { Asset } from "@/stores/use-asset-store";
import { useAssetStore } from "@/stores/use-asset-store";
@@ -12,6 +12,11 @@ let applyingRemoteState = false;
let syncTimer: number | null = null;
let syncPromise: Promise<void> | null = null;
let syncQueued = false;
let syncPauseCount = 0;
let syncPauseTail: Promise<void> = Promise.resolve();
let resumeSync: (() => void) | null = null;
let syncResumed: Promise<void> | null = null;
const activeRemoteSyncOperations = new Set<Promise<void>>();
let subscriptionsInstalled = false;
let remoteAssetVersions = new Map<string, string>();
let remoteProjectVersions = new Map<string, string>();
@@ -19,22 +24,28 @@ let remoteProjectVersions = new Map<string, string>();
const LOCAL_STORAGE_KEY_PATTERN = /^(image|video|audio|file|video-reference|audio-reference):/;
export async function syncRemoteUserData(userId?: string | null) {
activeRemoteUserId = userId || "";
if (!activeRemoteUserId) return;
applyingRemoteState = true;
try {
const remoteSnapshot = await getRemoteUserDataSnapshot();
remoteProjectVersions = versionMap(remoteSnapshot.projects);
remoteAssetVersions = versionMap(remoteSnapshot.assets);
const localProjects = useCanvasStore.getState().projects;
const localAssets = useAssetStore.getState().assets;
const mergedProjects = mergeById(localProjects, remoteSnapshot.projects);
const mergedAssets = mergeById(localAssets, await hydrateAssets(remoteSnapshot.assets));
useCanvasStore.getState().replaceProjects(mergedProjects);
useAssetStore.getState().replaceAssets(mergedAssets);
} finally {
applyingRemoteState = false;
}
await runRemoteUserDataSyncOperation(async () => {
activeRemoteUserId = userId || "";
if (!activeRemoteUserId) return;
applyingRemoteState = true;
try {
const [remoteCanvas, remoteAssets] = await Promise.all([listRemoteCanvasProjects(), listRemoteAssets()]);
remoteProjectVersions = versionMap(remoteCanvas.projects);
remoteAssetVersions = versionMap(remoteAssets.assets);
const localProjects = useCanvasStore.getState().projects;
const localAssets = useAssetStore.getState().assets;
const [changedProjects, changedAssets] = await Promise.all([
fetchNewerRemoteItems(localProjects, remoteCanvas.projects, async (id) => (await getRemoteCanvasProject(id)).project),
fetchNewerRemoteItems(localAssets, remoteAssets.assets, async (id) => (await getRemoteAsset(id)).asset),
]);
const mergedProjects = mergeById(localProjects, changedProjects);
const mergedAssets = mergeById(localAssets, await hydrateAssets(changedAssets));
useCanvasStore.getState().replaceProjects(mergedProjects);
useAssetStore.getState().replaceAssets(mergedAssets);
} finally {
applyingRemoteState = false;
}
});
// 首次登录可能带有尚未创建到云端的本地画布;先完成一次 upsert,避免详情页保存/分享先于项目创建。
try {
await saveRemoteUserDataNow();
@@ -63,10 +74,76 @@ export function resetRemoteUserDataSync() {
window.clearTimeout(syncTimer);
syncTimer = null;
}
syncQueued = false;
}
function waitForRemoteUserDataSyncResume() {
if (!syncPauseCount) return Promise.resolve();
if (!syncResumed) {
syncResumed = new Promise<void>((resolve) => {
resumeSync = resolve;
});
}
return syncResumed;
}
async function runRemoteUserDataSyncOperation<T>(operation: () => Promise<T>): Promise<T> {
while (syncPauseCount) await waitForRemoteUserDataSyncResume();
let finish!: () => void;
const active = new Promise<void>((resolve) => {
finish = resolve;
});
activeRemoteSyncOperations.add(active);
try {
return await operation();
} finally {
activeRemoteSyncOperations.delete(active);
finish();
}
}
export async function withRemoteUserDataSyncPaused<T>(operation: () => Promise<T>): Promise<T> {
syncPauseCount += 1;
if (syncTimer) {
window.clearTimeout(syncTimer);
syncTimer = null;
syncQueued = true;
}
let releaseTurn!: () => void;
const previousTurn = syncPauseTail;
syncPauseTail = new Promise<void>((resolve) => {
releaseTurn = resolve;
});
await previousTurn;
try {
if (activeRemoteSyncOperations.size) {
await Promise.allSettled([...activeRemoteSyncOperations]);
}
if (syncPromise) await syncPromise;
return await operation();
} finally {
releaseTurn();
syncPauseCount -= 1;
if (!syncPauseCount) {
const resolve = resumeSync;
resumeSync = null;
syncResumed = null;
resolve?.();
if (syncQueued) scheduleRemoteUserDataSync();
}
}
}
export function scheduleRemoteUserDataSync() {
if (!activeRemoteUserId || applyingRemoteState) return;
if (syncPauseCount) {
syncQueued = true;
if (syncTimer) {
window.clearTimeout(syncTimer);
syncTimer = null;
}
return;
}
if (syncPromise) {
syncQueued = true;
return;
@@ -92,15 +169,22 @@ export async function createCanvasProjectWithRemoteSync(title: string, projectId
}
export async function deleteAssetWithRemoteSync(id: string) {
if (activeRemoteUserId) {
await deleteRemoteAsset(id);
remoteAssetVersions.delete(id);
}
useAssetStore.getState().removeAsset(id);
await runRemoteUserDataSyncOperation(async () => {
if (activeRemoteUserId) {
await deleteRemoteAsset(id);
remoteAssetVersions.delete(id);
}
useAssetStore.getState().removeAsset(id);
});
}
export async function saveRemoteUserDataNow() {
if (!activeRemoteUserId) return;
if (syncPauseCount) {
syncQueued = true;
await waitForRemoteUserDataSyncResume();
return saveRemoteUserDataNow();
}
if (syncPromise) {
syncQueued = true;
return syncPromise;
@@ -274,6 +358,15 @@ function mergeById<T extends { id?: string; updatedAt?: string }>(local: T[], re
return Array.from(items.values()).sort((a, b) => timeValue(b.updatedAt) - timeValue(a.updatedAt));
}
async function fetchNewerRemoteItems<T extends { id: string; updatedAt?: string }>(local: T[], remote: RemoteUserDataSummary[], fetchItem: (id: string) => Promise<T>) {
const localById = new Map(local.map((item) => [item.id, item]));
const pending = remote.filter((item) => {
const current = localById.get(item.id);
return !current || timeValue(item.updatedAt) > timeValue(current.updatedAt);
});
return Promise.all(pending.map((item) => fetchItem(item.id)));
}
function versionMap(items: Array<{ id: string; updatedAt?: string }>) {
return new Map<string, string>(items.map((item) => [item.id, item.updatedAt || ""]));
}