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;
|
||||
|
||||
Reference in New Issue
Block a user