chore: commit workspace updates
This commit is contained in:
236
web/src/services/api/agent.ts
Normal file
236
web/src/services/api/agent.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { http, apiBaseURL } from "@/services/api/request";
|
||||
import { consumeTaskTextStream, createTaskTextStreamParser } from "@/services/api/task-text-stream";
|
||||
|
||||
export type AgentPermissionMode = "read_only" | "auto" | "request_approval";
|
||||
export type AgentPersonality = "务实" | "简洁" | "详细" | "温和";
|
||||
|
||||
export type AgentApproval = {
|
||||
modelName?: string;
|
||||
approvalId: string;
|
||||
call: { id: string; function: { name: string; arguments: string } };
|
||||
decision?: "approve" | "reject";
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type AgentRun = {
|
||||
id: string;
|
||||
canvasId: string;
|
||||
status: "queued" | "running" | "waiting_approval" | "completed" | "failed" | "cancelled";
|
||||
permissionMode: AgentPermissionMode;
|
||||
revision?: number;
|
||||
cleanupPending?: boolean;
|
||||
failureMessage?: string;
|
||||
model?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
skills?: Array<{ id: string; name: string; version: string; hash: string }>;
|
||||
events?: AgentEvent[];
|
||||
spentCredits?: number;
|
||||
step?: number;
|
||||
activeMessage?: { messageId: string; text: string };
|
||||
approval?: AgentApproval;
|
||||
};
|
||||
|
||||
export type AgentEvent = {
|
||||
eventId: string;
|
||||
runId: string;
|
||||
seq: number;
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
/** Local delivery order for synthetic snapshot events; never used as a resume cursor. */
|
||||
localSeq?: number;
|
||||
};
|
||||
|
||||
/** 事件流本身不是 http 封装请求,单独保留状态码供 UI 区分旧后端/失效轮次。 */
|
||||
export class AgentStreamError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number) {
|
||||
super(`Agent 事件流不可用 (${status})`);
|
||||
this.name = "AgentStreamError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export type CreateAgentRunInput = {
|
||||
thinking?: boolean;
|
||||
personality?: AgentPersonality;
|
||||
canvasId: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
logicalModelId?: string;
|
||||
channelId?: string;
|
||||
channelModelKey?: string;
|
||||
skillIds?: string[];
|
||||
permissionMode?: AgentPermissionMode;
|
||||
contextScope?: string[];
|
||||
budget?: { maxCredits?: number; maxGenerationTasks?: number; maxVideoSeconds?: number };
|
||||
idempotencyKey: string;
|
||||
};
|
||||
|
||||
// Only Agent admission endpoints have server-side fingerprint/idempotency protection.
|
||||
// Never apply this policy to arbitrary provider generation POSTs.
|
||||
async function submitAgentRequest(path: string, input: CreateAgentRunInput) {
|
||||
const body = JSON.parse(JSON.stringify(input)) as CreateAgentRunInput;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await http.post<{ run: AgentRun }>(path, body, { timeout: 15_000 });
|
||||
} catch (cause) {
|
||||
const error = cause as { retryable?: boolean; retryAfterMs?: number };
|
||||
if (!error.retryable || attempt >= 2) throw cause;
|
||||
const delay = Math.max(500 * 2 ** attempt * (0.75 + Math.random() * 0.5), error.retryAfterMs || 0);
|
||||
// Long rate-limit waits return control to the user with the original
|
||||
// pending request intact instead of keeping the composer hung.
|
||||
if (delay > 10_000) throw cause;
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAgentRun(input: CreateAgentRunInput) {
|
||||
return submitAgentRequest("/agent/runs", input);
|
||||
}
|
||||
|
||||
export async function sendAgentMessage(runId: string, input: CreateAgentRunInput) {
|
||||
return submitAgentRequest(`/agent/runs/${encodeURIComponent(runId)}/messages`, input);
|
||||
}
|
||||
|
||||
export function getAgentCapabilities() {
|
||||
return http.get<{ version: number; permissionModes: AgentPermissionMode[]; contextScopes: string[]; skills: boolean; writeTools: boolean }>("/agent/capabilities", { timeout: 15_000 });
|
||||
}
|
||||
|
||||
export function getAgentRun(runId: string, signal?: AbortSignal) {
|
||||
return http.get<{ run: AgentRun }>(`/agent/runs/${encodeURIComponent(runId)}`, { signal });
|
||||
}
|
||||
|
||||
export function cancelAgentRun(runId: string) {
|
||||
return http.post<{ accepted: boolean }>(`/agent/runs/${encodeURIComponent(runId)}/cancel`, undefined, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
export function undoAgentCanvasRun(runId: string, input: { stepId?: string; expectedSnapshotHash: string; reason?: string }, signal?: AbortSignal) {
|
||||
return http.post<{ accepted: boolean; snapshotHash: string }>(`/agent/runs/${encodeURIComponent(runId)}/undo`, input, { signal });
|
||||
}
|
||||
|
||||
export async function decideAgentApproval(runId: string, approvalId: string, decision: "approve" | "reject", reason?: string, signal?: AbortSignal) {
|
||||
return http.post<{ accepted: boolean }>(`/agent/runs/${encodeURIComponent(runId)}/approvals/${encodeURIComponent(approvalId)}/decision`, { decision, reason: reason?.trim() || undefined }, { signal });
|
||||
}
|
||||
|
||||
export function subscribeAgentEvents(runId: string, onEvent: (event: AgentEvent) => void, options: { after?: number; onError?: (error: unknown) => void; onConnectionChange?: (status: "connecting" | "connected" | "reconnecting" | "disconnected") => void; timeoutMs?: number } = {}) {
|
||||
const controller = new AbortController();
|
||||
let localSeq = 0;
|
||||
let cursor = Number.isSafeInteger(options.after) && (options.after as number) > 0 ? options.after as number : 0;
|
||||
let lastActiveMessageKey = "";
|
||||
let lastStatusKey = "";
|
||||
const emit = (type: string, payload: Record<string, unknown>) => {
|
||||
if (controller.signal.aborted) return;
|
||||
onEvent({ eventId: `${runId}:snapshot:${++localSeq}`, runId, seq: 0, type, payload, localSeq, createdAt: new Date().toISOString() });
|
||||
};
|
||||
void (async () => {
|
||||
let failures = 0;
|
||||
while (!controller.signal.aborted) {
|
||||
let terminal = false;
|
||||
let retryAfterMs = 0;
|
||||
const attempt = new AbortController();
|
||||
const abortAttempt = () => attempt.abort();
|
||||
controller.signal.addEventListener("abort", abortAttempt, { once: true });
|
||||
let watchdog: ReturnType<typeof setTimeout>;
|
||||
const touch = () => { clearTimeout(watchdog); watchdog = setTimeout(abortAttempt, options.timeoutMs ?? 45_000); };
|
||||
touch();
|
||||
options.onConnectionChange?.(failures ? "reconnecting" : "connecting");
|
||||
try {
|
||||
const response = await fetch(`${String(apiBaseURL).replace(/\/+$/, "")}/agent/runs/${encodeURIComponent(runId)}/events?after=${cursor}`, { credentials: "include", signal: attempt.signal, headers: { Accept: "text/event-stream", "Last-Event-ID": String(cursor) } });
|
||||
if (!response.ok) {
|
||||
const error = new AgentStreamError(response.status);
|
||||
const header = response.headers.get("retry-after");
|
||||
if (header) retryAfterMs = Math.max(0, /^\d+(\.\d+)?$/.test(header) ? Number(header) * 1000 : Date.parse(header) - Date.now()) || 0;
|
||||
if (retryAfterMs > 300_000) {
|
||||
await response.body?.cancel();
|
||||
options.onConnectionChange?.("disconnected");
|
||||
options.onError?.(error);
|
||||
return;
|
||||
}
|
||||
await response.body?.cancel();
|
||||
if (response.status >= 400 && response.status < 500 && ![408, 429].includes(response.status)) {
|
||||
options.onConnectionChange?.("disconnected");
|
||||
options.onError?.(error);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!response.body || !response.headers.get("content-type")?.includes("text/event-stream")) throw new AgentStreamError(response.status);
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const parser = createTaskTextStreamParser();
|
||||
const connectedAt = Date.now();
|
||||
touch();
|
||||
const cancelReader = () => { void reader.cancel().catch(() => undefined); };
|
||||
attempt.signal.addEventListener("abort", cancelReader, { once: true });
|
||||
try {
|
||||
while (!controller.signal.aborted && !attempt.signal.aborted) {
|
||||
const { value, done } = await reader.read();
|
||||
if (value?.length) touch();
|
||||
consumeTaskTextStream(parser, decoder.decode(value, { stream: !done }), (item) => {
|
||||
if (item.event === "agent_event") {
|
||||
const event = item.data as AgentEvent;
|
||||
if (event.runId !== runId || !Number.isSafeInteger(event.seq) || event.seq <= cursor) return;
|
||||
cursor = event.seq;
|
||||
failures = 0;
|
||||
options.onConnectionChange?.("connected");
|
||||
onEvent({ ...event, localSeq: ++localSeq });
|
||||
} else if (item.event === "run_snapshot") {
|
||||
const run = item.data as AgentRun;
|
||||
if (run.id !== runId) return;
|
||||
options.onConnectionChange?.("connected");
|
||||
if (run.activeMessage) {
|
||||
const messageKey = `${run.activeMessage.messageId}\u0000${run.activeMessage.text}`;
|
||||
if (messageKey !== lastActiveMessageKey) {
|
||||
lastActiveMessageKey = messageKey;
|
||||
emit("assistant_message", run.activeMessage);
|
||||
}
|
||||
}
|
||||
const statusPayload = { status: run.status, revision: run.revision, cleanupPending: run.cleanupPending, failureMessage: run.failureMessage, skills: run.skills, spentCredits: run.spentCredits, step: run.step, approval: run.approval };
|
||||
const statusKey = JSON.stringify(statusPayload);
|
||||
if (statusKey !== lastStatusKey) {
|
||||
lastStatusKey = statusKey;
|
||||
emit("run_status", statusPayload);
|
||||
}
|
||||
terminal = !run.cleanupPending && ["completed", "failed", "cancelled"].includes(run.status);
|
||||
} else if (item.event === "error") throw new Error("Agent 状态读取失败");
|
||||
}, done);
|
||||
// A repeated initial snapshot is not a healthy connection.
|
||||
// Sustained heartbeat traffic or advancing events reset the budget.
|
||||
if (Date.now() - connectedAt >= 30_000 && value?.length) failures = 0;
|
||||
if (done || terminal) break;
|
||||
}
|
||||
} finally {
|
||||
attempt.signal.removeEventListener("abort", cancelReader);
|
||||
await reader.cancel().catch(() => undefined);
|
||||
reader.releaseLock();
|
||||
}
|
||||
if (terminal) return;
|
||||
throw new Error("Agent 连接已中断,运行仍保留,可重新连接");
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (++failures >= 5) {
|
||||
options.onConnectionChange?.("disconnected");
|
||||
options.onError?.(error);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(watchdog!);
|
||||
controller.signal.removeEventListener("abort", abortAttempt);
|
||||
attempt.abort();
|
||||
}
|
||||
options.onConnectionChange?.("reconnecting");
|
||||
await new Promise<void>((resolve) => {
|
||||
const finish = () => { clearTimeout(timer); controller.signal.removeEventListener("abort", finish); resolve(); };
|
||||
const backoff = Math.min(1000 * 2 ** failures, 15000) * (0.75 + Math.random() * 0.5);
|
||||
const timer = setTimeout(finish, Math.max(backoff, Math.min(retryAfterMs, 300_000)));
|
||||
controller.signal.addEventListener("abort", finish, { once: true });
|
||||
if (controller.signal.aborted) finish();
|
||||
});
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}
|
||||
@@ -47,7 +47,6 @@ export type AuthSessionPayload = {
|
||||
export type RuntimeLimits = {
|
||||
activeTaskLimit: number;
|
||||
resourceUploadMB: number;
|
||||
sessionUploadMB: number;
|
||||
recycleBinRetentionDays?: number;
|
||||
};
|
||||
|
||||
@@ -128,8 +127,6 @@ export type AdminUserDetail = {
|
||||
assetBytes: number;
|
||||
canvasCount: number;
|
||||
canvasBytes: number;
|
||||
sessionCount: number;
|
||||
sessionBytes: number;
|
||||
taskCount: number;
|
||||
taskBytes: number;
|
||||
apiCallCount: number;
|
||||
@@ -307,7 +304,6 @@ export type AdminArkPrivateAssetSetting = {
|
||||
|
||||
export type RuntimeResourcePolicy = {
|
||||
resourceUploadMB: number;
|
||||
sessionUploadMB: number;
|
||||
generatedFileMB: number;
|
||||
dailyUploadMB: number;
|
||||
storedFileGB: number;
|
||||
@@ -315,7 +311,6 @@ export type RuntimeResourcePolicy = {
|
||||
taskDataGB: number;
|
||||
assetCount: number;
|
||||
canvasCount: number;
|
||||
sessionCount: number;
|
||||
taskCount: number;
|
||||
apiCallLogCount: number;
|
||||
recycleBinRetentionDays?: number;
|
||||
@@ -335,10 +330,8 @@ export type RuntimeTaskPolicy = {
|
||||
|
||||
export type RuntimeRequestPolicy = {
|
||||
taskCreatePerMinute: number;
|
||||
sessionCreatePerMinute: number;
|
||||
resourceUploadPerMinute: number;
|
||||
resourceImportPerMinute: number;
|
||||
sessionFilePerMinute: number;
|
||||
assetWritePerMinute: number;
|
||||
canvasWritePerMinute: number;
|
||||
registerPerHour: number;
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { http } from "@/services/api/request";
|
||||
|
||||
export type ComfyBridgeSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
online: boolean;
|
||||
lastSeenAt?: string;
|
||||
lastTaskAt?: string;
|
||||
capabilities?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ComfyBridgeRegistration = {
|
||||
bridge: ComfyBridgeSummary;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export function listComfyBridges() {
|
||||
return http.get<ComfyBridgeSummary[]>("/comfy-bridges");
|
||||
}
|
||||
|
||||
export function createComfyBridge(name: string, capabilities?: Record<string, unknown>) {
|
||||
return http.post<ComfyBridgeRegistration>("/comfy-bridges", { name, capabilities });
|
||||
}
|
||||
|
||||
export function revokeComfyBridge(id: string) {
|
||||
// 部分生产代理默认拦截 DELETE;后端保留 DELETE 兼容,但管理端统一走显式撤销动作。
|
||||
return http.post<{ revoked: boolean }>(`/comfy-bridges/${encodeURIComponent(id)}/revoke`);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { http } from "./request";
|
||||
import type { CanvasAgentOp } from "@/lib/canvas/canvas-agent-ops";
|
||||
import type { CanvasOperation } from "@/lib/canvas/canvas-operation-contract";
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import type { CreateTaskInput, GenerationTask } from "./task-center";
|
||||
|
||||
@@ -28,7 +28,7 @@ export const creationRuns = {
|
||||
claim: (id: string, input: { expectedEpoch: number; owner: string }, signal?: AbortSignal) => http.post<CreationRun>(`${path(id)}/claim`, input, { signal }),
|
||||
heartbeat: (id: string, guard: CreationGuard, signal?: AbortSignal) => http.post<{ leaseExpiresAt: string }>(`${path(id)}/heartbeat`, guard, { signal }),
|
||||
release: (id: string, guard: CreationGuard) => http.post<{ released: boolean }>(`${path(id)}/release`, guard),
|
||||
approveProposal: (id: string, input: CreationGuard & { revision: number; proposalVersion: number; proposal: unknown; ops: CanvasAgentOp[] }, signal?: AbortSignal) => http.post<CreationRun>(`${path(id)}/proposal-approve`, input, { signal }),
|
||||
approveProposal: (id: string, input: CreationGuard & { revision: number; proposalVersion: number; proposal: unknown; ops: CanvasOperation[] }, signal?: AbortSignal) => http.post<CreationRun>(`${path(id)}/proposal-approve`, input, { signal }),
|
||||
invalidateProposal: (id: string, input: CreationGuard & { revision: number }, signal?: AbortSignal) => http.post<CreationRun>(`${path(id)}/proposal-invalidate`, input, { signal }),
|
||||
canvas: (id: string, guard: CreationGuard, signal?: AbortSignal) => http.post<{ run: CreationRun; canvasId: string }>(`${path(id)}/canvas`, guard, { signal }),
|
||||
prepare: (id: string, input: CreationGuard & { itemKey: string; proposalVersion?: number; request: CreateTaskInput }, signal?: AbortSignal) => http.post<CreationSubmission>(`${path(id)}/submissions/prepare`, input, { signal }),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { projectDesktopLocalChannelRuntime } from "@/lib/desktop-local-channel";
|
||||
import { isSystemProxyBaseUrl, resolveBackendApiUrl, type AiConfig, type ChannelHeader } from "@/stores/use-config-store";
|
||||
import { apiBaseURL } from "@/services/api/request";
|
||||
import { isSystemProxyBaseUrl, type AiConfig, type ChannelHeader } from "@/stores/use-config-store";
|
||||
|
||||
type RelayConfig = Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat"> & { allowLocalChannel?: boolean; headers?: ChannelHeader[] };
|
||||
type RelayConfig = Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat"> & { headers?: ChannelHeader[] };
|
||||
|
||||
export type ChannelRequest = {
|
||||
url: string;
|
||||
@@ -11,29 +11,22 @@ 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(runtimeConfig.baseUrl)) {
|
||||
if (isSystemProxyBaseUrl(config.baseUrl)) {
|
||||
return { url: upstreamUrl, headers: Object.fromEntries(normalizedHeaders.entries()), credentials: "include" };
|
||||
}
|
||||
|
||||
const normalizedBaseUrl = requireHttpUrl(runtimeConfig.baseUrl, "当前模型渠道 Base URL");
|
||||
const normalizedBaseUrl = requireHttpUrl(config.baseUrl, "当前模型渠道 Base URL");
|
||||
const normalizedUpstreamUrl = requireHttpUrl(upstreamUrl, "当前模型请求地址");
|
||||
normalizedHeaders.delete("X-Canvas-Upstream-Headers");
|
||||
normalizedHeaders.delete("x-goog-api-key");
|
||||
normalizedHeaders.set("Authorization", `Bearer ${runtimeConfig.apiKey}`);
|
||||
normalizedHeaders.set("Authorization", `Bearer ${config.apiKey}`);
|
||||
normalizedHeaders.set("X-Canvas-Upstream-URL", normalizedUpstreamUrl);
|
||||
normalizedHeaders.set("X-Canvas-Upstream-Format", runtimeConfig.apiFormat === "gemini" ? "gemini" : runtimeConfig.apiFormat === "claude" ? "claude" : "openai");
|
||||
if (runtimeConfig.allowLocalChannel === true) {
|
||||
normalizedHeaders.set("X-Canvas-Allow-Local-Channel", "1");
|
||||
normalizedHeaders.set("X-Canvas-Upstream-Base-URL", normalizedBaseUrl);
|
||||
} 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));
|
||||
normalizedHeaders.set("X-Canvas-Upstream-Format", config.apiFormat === "gemini" ? "gemini" : config.apiFormat === "claude" ? "claude" : "openai");
|
||||
normalizedHeaders.set("X-Canvas-Upstream-Base-URL", normalizedBaseUrl);
|
||||
if (config.headers?.length) normalizedHeaders.set("X-Canvas-Upstream-Headers", encodeChannelHeaders(config.headers));
|
||||
return {
|
||||
url: resolveBackendApiUrl("/api/ai/custom"),
|
||||
url: `${apiBaseURL.replace(/\/+$/u, "")}/ai/custom`,
|
||||
headers: Object.fromEntries(normalizedHeaders.entries()),
|
||||
credentials: "include",
|
||||
};
|
||||
|
||||
@@ -2,19 +2,16 @@ 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, type CreateTaskInput } 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 { grokImagePromptLimitError } from "@/lib/grok-image-prompt-limit";
|
||||
import { resolveGenerationWorkflowExecution, type GenerationWorkflowExecution } from "@/lib/generation-workflow-execution";
|
||||
import { isArkPlanBaseUrl } from "@/lib/seedance-video";
|
||||
import { resolveVideoOperation } from "@/lib/model-selection";
|
||||
import { logicalModelIDForConfig, modelOptionName, resolveModelChannel, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { useLocalDreaminaModelStore } from "@/stores/use-local-dreamina-model-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
import { buildBackendToolRequests, type ResponseFunctionTool, type ResponseInputMessage, type ToolChoice, type ToolResponseResult } from "@/services/api/image";
|
||||
import { assertAgentExchangeBudget } from "@/lib/canvas/canvas-agent-context-budget";
|
||||
import { assertAgentExchangeBudget } from "@/lib/canvas/agent-context-budget";
|
||||
|
||||
export { logicalModelIDForConfig };
|
||||
|
||||
@@ -46,8 +43,6 @@ type BackendGenerationTaskOptions = {
|
||||
onTextDelta?: (text: string) => void;
|
||||
streamText?: boolean;
|
||||
enableThinking?: boolean;
|
||||
localIdempotencyKey?: string;
|
||||
localResumeOnly?: boolean;
|
||||
clientOperationId?: string;
|
||||
retryOf?: string;
|
||||
retryContextsByBatchIndex?: Array<{ retryOf: string; attemptGroupId: string; clientOperationId: string }>;
|
||||
@@ -57,19 +52,13 @@ type BackendGenerationTaskOptions = {
|
||||
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;
|
||||
ensureLocalDreaminaReady?: (signal?: AbortSignal) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const defaultDependencies: GenerationTaskDependencies = {
|
||||
createTask: createGenerationTask,
|
||||
waitTask: waitForGenerationTask,
|
||||
runLocal: (input, signal, onTaskUpdate) => runLocalDreaminaGenerationTask(input, { onTaskUpdate }, signal),
|
||||
createId: () => crypto.randomUUID(),
|
||||
now: () => new Date().toISOString(),
|
||||
ensureLocalDreaminaReady: (signal) => useLocalDreaminaModelStore.getState().ensureReady(signal),
|
||||
};
|
||||
|
||||
type PreparedGenerationReferences = {
|
||||
@@ -97,8 +86,6 @@ export async function runBackendGenerationTask(
|
||||
onTextDelta,
|
||||
streamText,
|
||||
enableThinking,
|
||||
localIdempotencyKey,
|
||||
localResumeOnly,
|
||||
clientOperationId,
|
||||
retryOf,
|
||||
attemptGroupId,
|
||||
@@ -107,14 +94,6 @@ export async function runBackendGenerationTask(
|
||||
) {
|
||||
throwIfAborted(signal);
|
||||
assertClientPromptLimit(mode, prompt, config, metadata);
|
||||
if (usesLocalDreamina(config)) {
|
||||
await dependencies.ensureLocalDreaminaReady?.(signal);
|
||||
throwIfAborted(signal);
|
||||
return await runLocalDreaminaGeneration(
|
||||
{ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, textHistory, mask, signal, metadata, onTaskUpdate, onTextDelta, streamText, enableThinking, localIdempotencyKey, localResumeOnly, clientOperationId, retryOf, attemptGroupId },
|
||||
dependencies,
|
||||
);
|
||||
}
|
||||
assertBackendRuntimeConfigured(config, mode);
|
||||
const prepared = await prepareGenerationReferences({ config, referenceImages, referenceVideos, referenceAudios, mask });
|
||||
throwIfAborted(signal);
|
||||
@@ -129,7 +108,6 @@ export async function submitBackendGenerationTask(
|
||||
): Promise<GenerationTask> {
|
||||
throwIfAborted(options.signal);
|
||||
assertClientPromptLimit(options.mode, options.prompt, options.config, options.metadata);
|
||||
if (usesLocalDreamina(options.config)) throw new Error("本机即梦任务暂不支持后台提交");
|
||||
assertBackendRuntimeConfigured(options.config, options.mode);
|
||||
const prepared = await prepareGenerationReferences(options);
|
||||
throwIfAborted(options.signal);
|
||||
@@ -176,6 +154,7 @@ export function prepareBackendToolGenerationTask(options: BackendToolGenerationO
|
||||
prompt: options.prompt,
|
||||
config: backendProviderConfig(options.config, "text"),
|
||||
agentRequests: buildBackendToolRequests(options.messages, options.tools, options.toolChoice, options.config),
|
||||
textOptions: { stream: modelCapabilityConfigFor(options.config, requestConfig.model).text?.streaming !== false },
|
||||
referenceImages: [...imageKeys].map((storageKey) => ({ storageKey })),
|
||||
metadata: options.metadata || { source: "canvas-online-agent" },
|
||||
},
|
||||
@@ -203,27 +182,6 @@ export async function runBackendGenerationTaskBatch(options: BackendGenerationTa
|
||||
throwIfAborted(options.signal);
|
||||
assertClientPromptLimit(options.mode, options.prompt, options.config, options.metadata);
|
||||
if (options.retryContextsByBatchIndex && options.retryContextsByBatchIndex.length !== count) throw new Error("生成重试批次任务数量不匹配");
|
||||
if (usesLocalDreamina(options.config)) {
|
||||
await dependencies.ensureLocalDreaminaReady?.(options.signal);
|
||||
throwIfAborted(options.signal);
|
||||
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(
|
||||
@@ -240,88 +198,6 @@ export async function runBackendGenerationTaskBatch(options: BackendGenerationTa
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
latestPublicTask = { ...latestPublicTask, status: "succeeded", progress: 100, stage: "local_cli_succeeded", resultJson: JSON.stringify(result), completedAt, updatedAt: completedAt };
|
||||
options.onTaskUpdate?.(latestPublicTask);
|
||||
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?.({
|
||||
...latestPublicTask,
|
||||
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;
|
||||
return resolveVideoOperation({
|
||||
@@ -334,84 +210,7 @@ function generationOperation(options: BackendGenerationTaskOptions) {
|
||||
}
|
||||
|
||||
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 usesLocalDreamina(config: AiConfig) {
|
||||
return (config.taskWorkflowProvider || "model") === "model" && isLocalDreaminaModel(config.model);
|
||||
return signal?.aborted === true || (error instanceof Error && error.name === "AbortError");
|
||||
}
|
||||
|
||||
function assertBackendRuntimeConfigured(config: AiConfig, mode: BackendGenerationMode) {
|
||||
@@ -468,7 +267,6 @@ async function createBackendGenerationTask(options: BackendGenerationTaskOptions
|
||||
|
||||
export async function prepareBackendGenerationTask(options: BackendGenerationTaskOptions): Promise<CreateTaskInput> {
|
||||
throwIfAborted(options.signal);
|
||||
if (usesLocalDreamina(options.config)) throw new Error("智能创作的在线授权暂不支持本机即梦,请使用现有本机入口");
|
||||
assertClientPromptLimit(options.mode, options.prompt, options.config, options.metadata);
|
||||
assertBackendRuntimeConfigured(options.config, options.mode);
|
||||
const prepared = await prepareGenerationReferences(options);
|
||||
@@ -614,7 +412,6 @@ export function backendProviderConfig(config: AiConfig, mode: BackendGenerationM
|
||||
apiFormat: requestConfig.apiFormat,
|
||||
interfaceType: requestConfig.interfaceType,
|
||||
baseUrl: requestConfig.baseUrl,
|
||||
allowLocalChannel: requestConfig.allowLocalChannel === true,
|
||||
apiKey: requestConfig.apiKey,
|
||||
secretKey: requestConfig.secretKey,
|
||||
model: requestConfig.model,
|
||||
@@ -626,13 +423,11 @@ export function backendProviderConfig(config: AiConfig, mode: BackendGenerationM
|
||||
|
||||
function workflowProviderConfig(config: AiConfig, requestConfig: ReturnType<typeof resolveModelRequestConfig>, workflow: GenerationWorkflowExecution) {
|
||||
const runningHubActive = workflow.provider === "runninghub";
|
||||
const comfyBridgeActive = workflow.provider === "comfyui-bridge";
|
||||
return {
|
||||
channelId: "",
|
||||
apiFormat: requestConfig.apiFormat,
|
||||
interfaceType: workflow.interfaceType,
|
||||
baseUrl: comfyBridgeActive ? "bridge://local" : config.runningHub.baseUrl,
|
||||
allowLocalChannel: false,
|
||||
baseUrl: config.runningHub.baseUrl,
|
||||
apiKey: runningHubActive ? config.runningHub.apiKey : "",
|
||||
// 工作流是独立 Provider,不能继承普通模型渠道的密钥和自定义头。
|
||||
secretKey: "",
|
||||
@@ -655,7 +450,6 @@ function workflowProviderConfig(config: AiConfig, requestConfig: ReturnType<type
|
||||
webappId: workflow.webappId,
|
||||
workflowJson: workflow.workflowJson,
|
||||
workflowFields: workflow.workflowFields,
|
||||
bridgeId: workflow.bridgeId,
|
||||
runningHubUseWallet: false,
|
||||
runningHubWalletApiKey: "",
|
||||
runningHubUploadApiKey: runningHubActive ? config.runningHub.uploadApiKey || "" : "",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { sanitizeChannelModelCatalogItem, type ChannelModelCatalogItem } from "@/lib/channel-model-catalog";
|
||||
import { projectDesktopLocalChannelRuntime } from "@/lib/desktop-local-channel";
|
||||
import { createChannelTransport } from "@/services/api/channel-transport";
|
||||
import { readAxiosError, validateGeminiPayload } from "@/services/api/image-response";
|
||||
import { geminiApiUrl, geminiHeaders } from "@/services/api/image-transport";
|
||||
@@ -17,7 +16,7 @@ const defaultGeminiConfig: Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat" | "
|
||||
type GeminiModelPayload = { models?: Array<{ name?: string }> };
|
||||
type OpenAIModelPayload = { data?: Array<{ id?: string }>; error?: { message?: string } };
|
||||
|
||||
export async function fetchImageModels(config: Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat"> & { allowLocalChannel?: boolean }) {
|
||||
export async function fetchImageModels(config: Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat">) {
|
||||
try {
|
||||
if (config.apiFormat === "gemini") {
|
||||
const requestConfig = { ...defaultGeminiConfig, ...config };
|
||||
@@ -41,19 +40,17 @@ export async function fetchImageModels(config: Pick<AiConfig, "baseUrl" | "apiKe
|
||||
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: runtimeChannel.baseUrl, allowLocalChannel: runtimeChannel.allowLocalChannel === true, apiKey: runtimeChannel.apiKey, apiFormat: runtimeChannel.apiFormat });
|
||||
const models = await fetchImageModels({ baseUrl: channel.baseUrl, apiKey: channel.apiKey, apiFormat: channel.apiFormat });
|
||||
return { models, catalog: models.map((id) => ({ id })) };
|
||||
}
|
||||
try {
|
||||
// 登录态由同源后端代取模型目录,避免每个 OpenAI 兼容服务分别维护浏览器 CORS 白名单。
|
||||
const result = await http.post<{ models?: Array<string | ChannelModelCatalogItem> }>("/ai/models", {
|
||||
baseUrl: runtimeChannel.baseUrl,
|
||||
allowLocalChannel: runtimeChannel.allowLocalChannel === true,
|
||||
apiKey: runtimeChannel.apiKey,
|
||||
apiFormat: runtimeChannel.apiFormat,
|
||||
headers: runtimeChannel.headers,
|
||||
baseUrl: channel.baseUrl,
|
||||
apiKey: channel.apiKey,
|
||||
apiFormat: channel.apiFormat,
|
||||
headers: channel.headers,
|
||||
});
|
||||
const catalog = new Map<string, ChannelModelCatalogItem>();
|
||||
for (const item of result.models || []) {
|
||||
|
||||
@@ -71,7 +71,7 @@ function unwrapTransportError(error: unknown): never {
|
||||
if (axios.isAxiosError<BackendEnvelope<unknown>>(error)) {
|
||||
const status = error.response?.status;
|
||||
const code = error.response?.data?.code;
|
||||
throw new ApiError(error.response?.data?.msg || error.message || "请求失败", {
|
||||
throw new ApiError(error.response?.data?.msg || transportFailureMessage(status, error.message), {
|
||||
status,
|
||||
code,
|
||||
reason: error.response?.data?.reason,
|
||||
@@ -83,6 +83,19 @@ function unwrapTransportError(error: unknown): never {
|
||||
throw error;
|
||||
}
|
||||
|
||||
function transportFailureMessage(status?: number, fallback?: string) {
|
||||
switch (status) {
|
||||
case 502:
|
||||
return "后端服务暂时不可用,请稍后重试";
|
||||
case 503:
|
||||
return "服务暂时不可用,请稍后重试";
|
||||
case 504:
|
||||
return "服务响应超时,请稍后重试";
|
||||
default:
|
||||
return fallback || "请求失败";
|
||||
}
|
||||
}
|
||||
|
||||
function isRetryableStatus(status?: number) {
|
||||
return status === 408 || status === 425 || status === 429 || (status !== undefined && status >= 500 && status <= 599);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { http, apiBaseURL, compactApiParams, serializeApiParams, type ApiParams } from "@/services/api/request";
|
||||
import { ApiError, http, apiBaseURL, compactApiParams, serializeApiParams, type ApiParams } from "@/services/api/request";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ export function listAddedSkills() {
|
||||
const now = Date.now();
|
||||
if (addedSkillsCache?.scope === scope && addedSkillsCache.expiresAt > now) return Promise.resolve(addedSkillsCache.value);
|
||||
if (addedSkillsRequest?.scope === scope) return addedSkillsRequest.promise;
|
||||
const promise = http.get<{ skills: Skill[] }>("/skills/added")
|
||||
const promise = readAddedSkillsWithRetry()
|
||||
.then((value) => {
|
||||
addedSkillsCache = { scope, value, expiresAt: Date.now() + 15_000 };
|
||||
return value;
|
||||
@@ -159,8 +159,21 @@ export function listAddedSkills() {
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function readAddedSkillsWithRetry() {
|
||||
const retryDelays = [300, 900, 1800];
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
return await http.get<{ skills: Skill[] }>("/skills/added");
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof ApiError) || !cause.retryable || attempt >= retryDelays.length) throw cause;
|
||||
await new Promise<void>((resolve) => globalThis.setTimeout(resolve, retryDelays[attempt]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateAddedSkillsCache() {
|
||||
addedSkillsCache = null;
|
||||
if (typeof window !== "undefined") window.dispatchEvent(new Event("canvas-skills-changed"));
|
||||
}
|
||||
|
||||
export function createSkill(input: SkillMutationInput) {
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import { DREAMINA_SUBMIT_ERROR_MESSAGES, generationErrorMessage } from "@/lib/generation-error";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
import { http, apiBaseURL, type BackendEnvelope } from "@/services/api/request";
|
||||
import { consumeTaskTextStream, createTaskTextStreamParser, type TaskTextStreamEvent } from "@/services/api/task-text-stream";
|
||||
import { recordDiagnosticEvent } from "@/services/diagnostics/client-diagnostics";
|
||||
import {
|
||||
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";
|
||||
|
||||
export type TaskStatus = "queued" | "running" | "succeeded" | "failed" | "cancelled";
|
||||
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;
|
||||
@@ -32,7 +22,6 @@ export type GenerationTask = {
|
||||
clientOperationId?: string;
|
||||
retryOf?: string;
|
||||
attemptGroupId?: string;
|
||||
sessionId?: string;
|
||||
projectId?: string;
|
||||
type: string;
|
||||
status: TaskStatus;
|
||||
@@ -114,45 +103,6 @@ export type TaskTextReplay = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type AgentSession = {
|
||||
id: string;
|
||||
projectId?: string;
|
||||
status: AgentSessionStatus;
|
||||
prompt: string;
|
||||
canvasSnapshotJson?: string;
|
||||
canvasOpsJson?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AgentMessage = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
role: "user" | "assistant" | "system" | "tool" | string;
|
||||
content: string;
|
||||
payload?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type TaskResult = {
|
||||
id: string;
|
||||
taskId: string;
|
||||
sessionId?: string;
|
||||
kind: string;
|
||||
url?: string;
|
||||
payload?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type SessionFile = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type TaskLog = {
|
||||
id: string;
|
||||
taskId: string;
|
||||
@@ -164,26 +114,7 @@ export type TaskLog = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type AgentSessionDetail = {
|
||||
session: AgentSession;
|
||||
messages: AgentMessage[];
|
||||
tasks: GenerationTask[];
|
||||
results: TaskResult[];
|
||||
};
|
||||
|
||||
export type CreateSessionInput = {
|
||||
projectId?: string;
|
||||
prompt: string;
|
||||
canvasSnapshot?: Record<string, unknown>;
|
||||
references?: string[];
|
||||
projectStyle?: { presetId: string; title: string; prompt: string };
|
||||
characters?: Array<{ assetId: string; versionId: string; name: string; definition: Record<string, unknown> }>;
|
||||
config?: Record<string, unknown>;
|
||||
logicalModelId?: string;
|
||||
};
|
||||
|
||||
export type CreateTaskInput = {
|
||||
sessionId?: string;
|
||||
projectId?: string;
|
||||
type?: string;
|
||||
operation?: string;
|
||||
@@ -193,42 +124,6 @@ export type CreateTaskInput = {
|
||||
logicalModelId?: string;
|
||||
input?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
|
||||
export function createAgentSession(input: CreateSessionInput) {
|
||||
return http.post<AgentSessionDetail>("/sessions", input).then((detail) => {
|
||||
detail.tasks.forEach((task) => notifyCanvasTaskCreated(task));
|
||||
return detail;
|
||||
});
|
||||
}
|
||||
|
||||
export function queryAgentSession(id: string) {
|
||||
return http.get<AgentSessionDetail>(`/sessions/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export function agentSessionFailureMessage(detail: AgentSessionDetail, fallback = "后端影视 Agent 会话失败") {
|
||||
for (let index = detail.tasks.length - 1; index >= 0; index -= 1) {
|
||||
const task = detail.tasks[index];
|
||||
if ((task.status === "failed" || task.status === "cancelled") && task.error?.trim()) return generationErrorMessage(task.error.trim());
|
||||
}
|
||||
for (let index = detail.messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = detail.messages[index];
|
||||
if (message.role === "assistant" && message.content.trim()) return generationErrorMessage(message.content.trim());
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function downloadSessionResults(id: string) {
|
||||
return http.get<TaskResult[]>(`/sessions/${encodeURIComponent(id)}/results`);
|
||||
}
|
||||
|
||||
export function uploadAgentFile(sessionId: string, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("sessionId", sessionId);
|
||||
formData.append("file", file);
|
||||
return http.post<SessionFile>("/files", formData);
|
||||
}
|
||||
|
||||
export function createGenerationTask(input: CreateTaskInput) {
|
||||
return http.post<GenerationTask>("/tasks", input).then((task) => {
|
||||
recordDiagnosticEvent({ level: "info", category: "task", message: "任务已创建", taskId: task.id, projectId: task.projectId });
|
||||
@@ -250,9 +145,7 @@ 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 = {
|
||||
@@ -262,7 +155,6 @@ const defaultGenerationTaskListDependencies: GenerationTaskListDependencies = {
|
||||
signal,
|
||||
}),
|
||||
}),
|
||||
listLocalPage: (page, signal) => listLocalDreaminaGenerationTaskPage(page, {}, signal),
|
||||
};
|
||||
|
||||
export async function listGenerationTasks(limit = 30, options?: { projectId?: string; activeOnly?: boolean }, dependencies: GenerationTaskListDependencies = defaultGenerationTaskListDependencies, signal?: AbortSignal) {
|
||||
@@ -286,21 +178,9 @@ export async function listGenerationTasks(limit = 30, options?: { projectId?: st
|
||||
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(() => [])]);
|
||||
const backendTasks = await collectGenerationTaskPages(backendPageReader, baseRequest, boundedLimit, signal);
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
return [...backendTasks, ...localTasks.map((task) => projectLocalDreaminaTask(task))]
|
||||
return backendTasks
|
||||
.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))
|
||||
@@ -324,24 +204,9 @@ async function collectGenerationTaskPages<T>(readPage: (request: GenerationTaskP
|
||||
}
|
||||
|
||||
export function queryGenerationTask(id: string, options?: { signal?: AbortSignal }) {
|
||||
if (isLocalDreaminaTaskId(id)) {
|
||||
return queryLocalDreaminaGenerationTask(stripLocalDreaminaTaskPrefix(id), undefined, {}, options?.signal).then((task) => projectLocalDreaminaTask(task));
|
||||
}
|
||||
return http.get<GenerationTask>(`/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>;
|
||||
@@ -418,9 +283,6 @@ export function retryGenerationTask(id: string) {
|
||||
}
|
||||
|
||||
export function cancelGenerationTask(id: string) {
|
||||
if (isLocalDreaminaTaskId(id)) {
|
||||
return Promise.reject(new Error("官方即梦 CLI 当前不支持可靠取消"));
|
||||
}
|
||||
return http.post<GenerationTask>(`/tasks/${encodeURIComponent(id)}/cancel`).then((task) => {
|
||||
window.dispatchEvent(new CustomEvent("canvas:task-cancelled", { detail: { task } }));
|
||||
window.dispatchEvent(new CustomEvent("wallet:updated"));
|
||||
@@ -433,20 +295,14 @@ export function queryFailedVideoProviderTask(id: string) {
|
||||
}
|
||||
|
||||
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));
|
||||
return http.get<GenerationTask>(`/tasks/${encodeURIComponent(id)}`, { signal: options?.signal });
|
||||
}
|
||||
|
||||
export function deleteGenerationTask(id: string) {
|
||||
if (!isLocalDreaminaTaskId(id)) return Promise.reject(new Error("当前任务不支持删除"));
|
||||
return deleteLocalDreaminaGenerationTask(stripLocalDreaminaTaskPrefix(id));
|
||||
return http.delete<void>(`/tasks/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function listTaskLogs(id: string) {
|
||||
if (isLocalDreaminaTaskId(id)) {
|
||||
const task = await queryGenerationTask(id);
|
||||
return [projectGenerationTaskSafeLog(task)];
|
||||
}
|
||||
const raw = await http.get<Array<{ level?: unknown; message?: unknown; payload?: unknown; createdAt?: unknown }>>(`/tasks/${encodeURIComponent(id)}/logs`);
|
||||
return raw.map((log, index) => projectBackendSafeTaskLog(id, log, index));
|
||||
}
|
||||
@@ -455,22 +311,6 @@ 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";
|
||||
@@ -493,11 +333,11 @@ function safeTaskLogStage(value: unknown) {
|
||||
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"]);
|
||||
const SAFE_TASK_LOG_ERROR_CODES = new Set(["provider_query_failed", "provider_submission_unknown", "provider_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)) {
|
||||
for (const match of value.matchAll(/(?:model|provider)_[a-z0-9_]{2,80}/gi)) {
|
||||
const errorCode = match[0].toLowerCase();
|
||||
if (SAFE_TASK_LOG_ERROR_CODES.has(errorCode)) return errorCode;
|
||||
}
|
||||
@@ -505,6 +345,7 @@ function safeTaskLogErrorCode(value: unknown) {
|
||||
}
|
||||
|
||||
export type WaitForGenerationTaskOptions = {
|
||||
textEventsSource?: "task" | "agent";
|
||||
signal?: AbortSignal;
|
||||
intervalMs?: number;
|
||||
timeoutMs?: number;
|
||||
@@ -515,20 +356,6 @@ export type WaitForGenerationTaskOptions = {
|
||||
};
|
||||
|
||||
export async function waitForGenerationTask(id: string, options?: WaitForGenerationTaskOptions) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (options?.onTextDelta || options?.useTextEvents) return waitForGenerationTaskTextEvents(id, options);
|
||||
const startedAt = Date.now();
|
||||
const intervalMs = options?.intervalMs || 2000;
|
||||
@@ -581,7 +408,9 @@ async function waitForGenerationTaskTextEvents(id: string, options: WaitForGener
|
||||
const intervalMs = options.intervalMs || 1000;
|
||||
let lastTask = options.initialTask;
|
||||
let lastEventId = 0;
|
||||
let fullText = lastTask?.textDraft || "";
|
||||
// Agent subscriptions replay from cursor 0; an existing draft must not be
|
||||
// prepended to the same persisted deltas when reopening a conversation.
|
||||
let fullText = options.textEventsSource === "agent" ? "" : lastTask?.textDraft || "";
|
||||
let lastStreamError: unknown;
|
||||
if (!lastTask) {
|
||||
lastTask = await queryGenerationTask(id, { signal: options.signal });
|
||||
@@ -594,7 +423,8 @@ async function waitForGenerationTaskTextEvents(id: string, options: WaitForGener
|
||||
try {
|
||||
const base = String(apiBaseURL).replace(/\/+$/, "");
|
||||
const cursor = lastEventId > 0 ? `?after=${encodeURIComponent(String(lastEventId))}` : "";
|
||||
const response = await fetch(`${base}/tasks/${encodeURIComponent(id)}/text-events${cursor}`, {
|
||||
const path = options.textEventsSource === "agent" ? `/agent/runs/${encodeURIComponent(id)}/events` : `/tasks/${encodeURIComponent(id)}/text-events`;
|
||||
const response = await fetch(`${base}${path}${cursor}`, {
|
||||
headers: { Accept: "text/event-stream" },
|
||||
credentials: "include",
|
||||
signal: options.signal,
|
||||
|
||||
@@ -204,6 +204,7 @@ export type BillingOrder = {
|
||||
quantity: number;
|
||||
amountMicrocredits: number;
|
||||
reservedAmountMicrocredits: number;
|
||||
chargeLimitMicrocredits?: number;
|
||||
actualAmountMicrocredits: number;
|
||||
refundedAmountMicrocredits: number;
|
||||
inputTokenPriceMicrocredits: number;
|
||||
|
||||
Reference in New Issue
Block a user