feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道
Refs #225 Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
525
web/src/services/canvas-generation-consumer.ts
Normal file
525
web/src/services/canvas-generation-consumer.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
56
web/src/services/creation-conversation-store.ts
Normal file
56
web/src/services/creation-conversation-store.ts
Normal 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));
|
||||
}
|
||||
@@ -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(":");
|
||||
|
||||
16
web/src/services/generation-artifact-sink.ts
Normal file
16
web/src/services/generation-artifact-sink.ts
Normal 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)}`;
|
||||
}
|
||||
138
web/src/services/generation-asset-repository.ts
Normal file
138
web/src/services/generation-asset-repository.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
19
web/src/services/generation-consumer-dedupe.ts
Normal file
19
web/src/services/generation-consumer-dedupe.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
97
web/src/services/generation-consumer-lifecycle.ts
Normal file
97
web/src/services/generation-consumer-lifecycle.ts
Normal 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);
|
||||
}
|
||||
228
web/src/services/generation-task-materializer.ts
Normal file
228
web/src/services/generation-task-materializer.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
298
web/src/services/local-dreamina-cli.ts
Normal file
298
web/src/services/local-dreamina-cli.ts
Normal 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));
|
||||
}
|
||||
816
web/src/services/local-dreamina-generation.ts
Normal file
816
web/src/services/local-dreamina-generation.ts
Normal 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);
|
||||
}
|
||||
181
web/src/services/local-dreamina-model-catalog.ts
Normal file
181
web/src/services/local-dreamina-model-catalog.ts
Normal 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");
|
||||
}
|
||||
193
web/src/services/local-dreamina-task-projection.ts
Normal file
193
web/src/services/local-dreamina-task-projection.ts
Normal 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);
|
||||
}
|
||||
33
web/src/services/local-runtime-bootstrap.ts
Normal file
33
web/src/services/local-runtime-bootstrap.ts
Normal 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;
|
||||
}
|
||||
463
web/src/services/local-runtime-session.ts
Normal file
463
web/src/services/local-runtime-session.ts
Normal 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: "本机运行时请求失败",
|
||||
};
|
||||
149
web/src/services/local-runtime.ts
Normal file
149
web/src/services/local-runtime.ts
Normal 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 "本机运行时请求失败";
|
||||
}
|
||||
@@ -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 [];
|
||||
});
|
||||
}
|
||||
|
||||
265
web/src/services/provider-neutral-generation-effects.ts
Normal file
265
web/src/services/provider-neutral-generation-effects.ts
Normal 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);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 || ""]));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user