feat(canvas): 创作助手 - 合并创作对话、能力发现与画布接续

This commit is contained in:
ddCat
2026-09-09 22:46:45 +08:00
97 changed files with 8434 additions and 326 deletions

View File

@@ -0,0 +1,52 @@
import { getNodeDefinition, listNodeDefinitions } from "@/lib/canvas/node-registry";
import { getRegisteredPlugin, listRegisteredPlugins } from "@/lib/plugins/plugin-registry";
import { isPluginEffectivelyEnabled } from "@/stores/use-plugin-store";
import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
// Query live registries on every discovery; installation is not execution availability.
export function listAgentCapabilities(query = "") {
const nodes = listNodeDefinitions().filter((node) => !node.plugin || isPluginEffectivelyEnabled(node.plugin.pluginId)).map((node) => ({
id: node.type, name: node.label, source: node.plugin?.pluginId || "builtin",
scope: "canvas", actions: node.type === "script" ? ["create", "canvas_read_storyboard", "canvas_edit_storyboard"] : ["create", "read", "update"],
defaultMetadata: node.defaultMetadata, defaultSize: node.defaultSize,
inputKind: node.acceptsInputKind, schema: node.plugin?.schema,
execution: node.plugin ? "仅开放节点创建和数据更新;插件专用执行需提供动作适配器,不能用媒体生成替代。" : "通过已有画布工具操作;专业节点不等同于普通媒体生成。",
}));
const plugins = listRegisteredPlugins().filter((plugin) => isPluginEffectivelyEnabled(plugin.manifest.id)).map(({ manifest, agentActions, readAgentNode }) => ({
id: manifest.id, name: manifest.name, source: manifest.id, version: manifest.version,
description: manifest.description, contributions: manifest.contributes,
actions: (agentActions || []).map(({ id, description, inputSchema }) => ({ id, description, inputSchema })),
readTool: readAgentNode ? "canvas_read_plugin_node" : undefined,
execution: agentActions?.length ? "使用 canvas_plugin_action 调用 actions 中的真实动作。" : "贡献声明是发现信息,不是任意命令执行入口。只调用已挂载工具。",
}));
const terms = query.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean);
return [...nodes, ...plugins, { id: "project-style", name: "项目画风", source: "builtin", actions: ["canvas_list_styles", "canvas_apply_style"], scope: "canvas", description: "读取系统或个人风格,通过同一保存入口应用画风并同步关联项目。" }].filter((item) => terms.every((term) => JSON.stringify(item).toLocaleLowerCase().includes(term)));
}
export function readAgentPluginNode(snapshot: CanvasAgentSnapshot, nodeId: string) {
const node = snapshot.nodes.find((item) => item.id === nodeId);
const owner = node && getNodeDefinition(node.type)?.plugin?.pluginId;
const plugin = owner && isPluginEffectivelyEnabled(owner) ? getRegisteredPlugin(owner) : undefined;
if (!node || !plugin?.readAgentNode || !plugin.manifest.permissions.includes("canvas.read")) throw new Error("当前插件未启用或未提供节点读取能力");
return { nodeId, pluginId: owner, version: plugin.manifest.version, data: plugin.readAgentNode(structuredClone(node), structuredClone(snapshot)) };
}
export function readAgentPluginDocumentation(pluginId: string) {
const plugin = getRegisteredPlugin(pluginId);
if (!plugin || !isPluginEffectivelyEnabled(pluginId)) throw new Error("插件不可用,请重新发现能力");
return { id: pluginId, version: plugin.manifest.version, documentation: plugin.manifest.documentation || plugin.manifest.description };
}
export function buildAgentPluginOperations(pluginId: string, actionId: string, input: Record<string, unknown>, snapshot: CanvasAgentSnapshot) {
const plugin = getRegisteredPlugin(pluginId);
if (!plugin || !isPluginEffectivelyEnabled(pluginId)) throw new Error("插件已停用或不可用");
const action = plugin.agentActions?.find((item) => item.id === actionId);
if (!action) throw new Error("插件未提供此 Agent 动作,请先读取能力目录");
if (!plugin.manifest.permissions.includes("canvas.write")) throw new Error("插件未声明画布写入权限");
const operations = action.buildOperations(structuredClone(input), structuredClone(snapshot));
if (!Array.isArray(operations) || !operations.length || operations.length > 100) throw new Error("插件必须返回 1 至 100 个画布操作");
if (operations.some((op) => op.type === "run_generation") && !plugin.manifest.permissions.includes("generation.run")) throw new Error("插件未声明生成权限");
return operations;
}
export const AGENT_CAPABILITY_GUIDANCE = "处理专业任务前先调用 canvas_list_capabilities 查找系统现有节点和已启用插件,并用 canvas_list_skills 检索相关专业技能、读取 SKILL.md 后按需使用;用户不必手动选择技能。不要用普通文本模拟分镜表或插件结果。发现信息不代表已经执行,未挂载的专用动作须如实说明。已授权范围内自主推进,不重复征求技能读取或普通操作许可。";

View File

@@ -0,0 +1,89 @@
import { imageToDataUrl } from "@/services/image-storage";
import { resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { getActiveUserScope } from "@/lib/user-scope";
// 预算只作用于视觉分析副本,不修改原素材。
export async function agentImagePreviews(images: Parameters<typeof imageToDataUrl>[0][]): Promise<string[]> {
if (!images.length) return [];
const limit = Math.min(256 * 1024, Math.floor(2 * 1024 * 1024 / images.length));
if (limit < 16 * 1024) throw new Error("本次参考图片过多,请分批选择图片后继续。");
const previews: string[] = [];
// 顺序解码,避免多张原图同时占用内存。
for (const source of images) {
const url = await imageToDataUrl(source);
if (!url) throw new Error("参考图片无法读取,请重新选择图片。");
const image = new Image();
image.src = url;
try { await image.decode(); }
catch { throw new Error("参考图片无法解码,请重新选择图片。"); }
const canvas = document.createElement("canvas");
try {
const context = canvas.getContext("2d");
if (!context) throw new Error("无法准备看图预览,请刷新后重试。");
let edge = Math.min(1600, Math.max(image.naturalWidth, image.naturalHeight));
let preview = "";
while (edge > 0) {
const scale = Math.min(1, edge / Math.max(image.naturalWidth, image.naturalHeight));
canvas.width = Math.max(1, Math.round(image.naturalWidth * scale));
canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
preview = canvas.toDataURL("image/jpeg", 0.82);
if (preview.length <= limit) break;
if (edge <= 128) break;
edge = Math.floor(edge * 0.75);
}
if (!preview || preview.length > limit) throw new Error("参考图片体积过大,请减少本次选择的图片后继续。");
previews.push(preview);
} finally {
canvas.width = 0;
canvas.height = 0;
image.src = "";
}
}
return previews;
}
type AgentImageCrop = { x: number; y: number; width: number; height: number };
export async function inspectAgentImage(source: Parameters<typeof imageToDataUrl>[0], detail: "preview" | "original" | "crop", crop?: AgentImageCrop) {
const scope = getActiveUserScope();
const dataUrl = await encodeAgentImage(source, detail, crop);
if (scope !== getActiveUserScope()) throw new DOMException("账号已切换", "AbortError");
if (detail === "original" && resourceIdFromStorageKey(source.storageKey)) return { storageKey: source.storageKey!, encodedBytes: dataUrl.length };
const response = await fetch(dataUrl);
if (!response.ok) throw new Error("读取看图素材失败");
const blob = await response.blob();
const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
if (scope !== getActiveUserScope()) throw new DOMException("账号已切换", "AbortError");
const resource = await uploadResourceFile(blob, "image", { fileName: `agent-inspect-${hash.slice(0, 12)}.${blob.type === "image/png" ? "png" : "jpg"}`, idempotencyKey: `agent-inspect:${hash}` });
if (scope !== getActiveUserScope()) throw new DOMException("账号已切换", "AbortError");
return { storageKey: resourceStorageKey(resource.id), encodedBytes: dataUrl.length };
}
async function encodeAgentImage(source: Parameters<typeof imageToDataUrl>[0], detail: "preview" | "original" | "crop", crop?: AgentImageCrop) {
if (detail === "preview") return (await agentImagePreviews([source]))[0];
const url = await imageToDataUrl(source);
if (!url) throw new Error("参考图片无法读取,请重新选择图片。");
if (detail === "original") {
if (url.length > 6 * 1024 * 1024) throw new Error("原图超过本次看图预算,请改用 crop 分区检查,不要降低细节精度。");
return url;
}
if (!crop || ![crop.x, crop.y, crop.width, crop.height].every((value) => typeof value === "number" && Number.isFinite(value)) || crop.x < 0 || crop.y < 0 || crop.width <= 0 || crop.height <= 0 || crop.x + crop.width > 1 || crop.y + crop.height > 1) throw new Error("裁剪范围必须是 0 到 1 的归一化坐标,且完整位于原图内。");
const image = new Image();
image.src = url;
await image.decode();
const canvas = document.createElement("canvas");
try {
canvas.width = Math.max(1, Math.round(image.naturalWidth * crop.width));
canvas.height = Math.max(1, Math.round(image.naturalHeight * crop.height));
const context = canvas.getContext("2d");
if (!context) throw new Error("无法读取图片局部,请刷新后重试。");
context.drawImage(image, image.naturalWidth * crop.x, image.naturalHeight * crop.y, image.naturalWidth * crop.width, image.naturalHeight * crop.height, 0, 0, canvas.width, canvas.height);
const result = canvas.toDataURL("image/png");
if (result.length > 6 * 1024 * 1024) throw new Error("该区域超过本次看图预算,请缩小裁剪范围后继续检查。");
return result;
} finally { canvas.width = 0; canvas.height = 0; image.src = ""; }
}

View File

@@ -0,0 +1,43 @@
import { http } from "./request";
import type { CanvasAgentOp } from "@/lib/canvas/canvas-agent-ops";
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import type { CreateTaskInput, GenerationTask } from "./task-center";
export type CreationStatus = "idle" | "running" | "waiting_answer" | "waiting_proposal" | "waiting_canvas" | "waiting_payment" | "waiting_task" | "paused" | "completed" | "cancelled";
export type CreationGuard = { executionEpoch: number; owner: string };
export type CreationRun = {
id: string; userId: string; canvasId?: string; revision: number;
executionEpoch: number; executionOwner: string; leaseExpiresAt?: string;
status: CreationStatus; state: Record<string, unknown>;
approvedProposalVersion?: number; approvedProposalHash?: string;
createdAt: string; updatedAt: string;
};
export type CreationSubmission = {
id: string; runId: string; itemKey: string; requestHash: string;
quote: { model: string; billingMode: string; quantity: number; amountMicrocredits: number; estimated: boolean; expiresAt: string; quoteHash: string; options?: Record<string, unknown> };
approvedAt?: string; revokedAt?: string; taskId?: string;
};
export type CreationRunDetail = { run: CreationRun; submissions: CreationSubmission[] };
const path = (id: string) => `/creation-runs/${encodeURIComponent(id)}`;
export const creationRuns = {
create: (input: { clientKey: string; canvasId?: string; state: Record<string, unknown> }, signal?: AbortSignal) => http.post<CreationRunDetail>("/creation-runs", input, { signal }),
get: (id: string, signal?: AbortSignal) => http.get<CreationRunDetail>(path(id), { signal }),
list: (signal?: AbortSignal) => http.get<{ runs: CreationRun[] }>("/creation-runs", { signal }),
save: (id: string, input: CreationGuard & { revision: number; state: Record<string, unknown>; status: CreationStatus }, signal?: AbortSignal) => http.patch<CreationRun>(path(id), input, { signal }),
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 }),
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 }),
approve: (id: string, input: CreationGuard & { submissionIds: string[] }, signal?: AbortSignal) => http.post<{ submissions: CreationSubmission[] }>(`${path(id)}/submissions/approve`, input, { signal }),
refreshQuote: (id: string, input: CreationGuard & { submissionId: string }, signal?: AbortSignal) => http.post<CreationSubmission>(`${path(id)}/submissions/refresh`, input, { signal }),
execute: (id: string, input: CreationGuard & { submissionId: string }, signal?: AbortSignal) => http.post<GenerationTask>(`${path(id)}/execute`, input, { signal }).then((task) => {
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("wallet:updated"));
return task;
}),
canvasSnapshot: (id: string, signal?: AbortSignal) => http.get<{ document: CanvasProject; snapshotHash: string }>(`${path(id)}/canvas-snapshot`, { signal }),
commitCanvas: (id: string, input: CreationGuard & { expectedSnapshotHash: string; document: CanvasProject }, signal?: AbortSignal) => http.post<{ snapshotHash: string }>(`${path(id)}/canvas-commit`, input, { signal }),
};;

View File

@@ -1,7 +1,7 @@
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 { 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";
@@ -13,6 +13,7 @@ import { useLocalDreaminaModelStore } from "@/stores/use-local-dreamina-model-st
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";
export { logicalModelIDForConfig };
@@ -134,7 +135,7 @@ export async function submitBackendGenerationTask(
return createBackendGenerationTask(options, prepared, dependencies);
}
export async function runBackendToolGenerationTask(options: {
type BackendToolGenerationOptions = {
prompt: string;
config: AiConfig;
messages: ResponseInputMessage[];
@@ -142,12 +143,28 @@ export async function runBackendToolGenerationTask(options: {
toolChoice: ToolChoice;
signal?: AbortSignal;
onDelta?: (text: string) => void;
}): Promise<ToolResponseResult> {
onTaskCreated?: (task: GenerationTask) => void;
metadata?: { source: string; nodeId?: string; runId?: string; stage?: string };
};
// 报价和执行复用完全相同的任务协议,准备阶段不提交模型任务。
export function prepareBackendToolGenerationTask(options: BackendToolGenerationOptions): CreateTaskInput {
throwIfAborted(options.signal);
assertAgentExchangeBudget(options.messages, options.tools, options.config.systemPrompt || "");
const imageKeys = new Set<string>();
for (const message of options.messages) {
if ("type" in message || message.role === "tool" || !Array.isArray(message.content)) continue;
for (const part of message.content) {
if (part.type !== "image_url") continue;
const key = part.image_url.url;
if (!resourceIdFromStorageKey(key)) throw new Error("看图素材尚未保存为资源,请让助手重新读取图片后继续。");
imageKeys.add(key);
}
}
const logicalModelId = logicalModelIDForConfig(options.config);
const requestConfig = resolveModelRequestConfig(options.config, options.config.model);
if (!logicalModelId && !requestConfig.channelId && !requestConfig.interfaceType) throw new Error("当前模型未选择可用请求协议");
const task = await createGenerationTask({
const task: CreateTaskInput = {
type: "canvas_text",
operation: "text",
prompt: options.prompt,
@@ -156,11 +173,21 @@ export async function runBackendToolGenerationTask(options: {
input: {
mode: "text",
prompt: options.prompt,
config: backendProviderConfig(options.config),
config: backendProviderConfig(options.config, "text"),
agentRequests: buildBackendToolRequests(options.messages, options.tools, options.toolChoice, options.config),
metadata: { source: "canvas-online-agent" },
referenceImages: [...imageKeys].map((storageKey) => ({ storageKey })),
metadata: options.metadata || { source: "canvas-online-agent" },
},
});
};
if (new Blob([JSON.stringify(task)]).size > 15 * 1024 * 1024) {
throw new Error("本次图片或对话内容过多,请减少参考图片,或新建会话后继续。已有作品会保留。");
}
return task;
}
export async function runBackendToolGenerationTask(options: BackendToolGenerationOptions): Promise<ToolResponseResult> {
const task = await createGenerationTask(prepareBackendToolGenerationTask(options));
options.onTaskCreated?.(task);
const completed = await waitForGenerationTask(task.id, { signal: options.signal, initialTask: task, onTextDelta: options.onDelta });
const result = parseBackendGenerationResult(completed);
return {
@@ -425,11 +452,27 @@ async function createAndWaitGenerationTask(options: BackendGenerationTaskOptions
}
async function createBackendGenerationTask(options: BackendGenerationTaskOptions, prepared: PreparedGenerationReferences, dependencies: GenerationTaskDependencies) {
const { projectId, mode, prompt, config, metadata, onTaskUpdate } = options;
const task = await dependencies.createTask(backendGenerationTaskInput(options, prepared));
options.onTaskUpdate?.(task);
return task;
}
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);
throwIfAborted(options.signal);
return backendGenerationTaskInput(options, prepared);
}
function backendGenerationTaskInput(options: BackendGenerationTaskOptions, prepared: PreparedGenerationReferences): CreateTaskInput {
const { projectId, mode, prompt, config, metadata } = options;
const videoOperation = generationOperation(options);
const workflow = resolveGenerationWorkflowExecution(config, mode);
const logicalModelId = workflow ? "" : logicalModelIDForConfig(config);
const task = await dependencies.createTask({
return {
...(projectId ? { projectId } : {}),
type: `canvas_${mode}`,
operation: mode === "video" ? videoOperation : mode,
@@ -451,9 +494,7 @@ async function createBackendGenerationTask(options: BackendGenerationTaskOptions
mask: prepared.mask,
metadata: generationMetadata(config, metadata),
},
});
onTaskUpdate?.(task);
return task;
};
}
function generationMetadata(config: AiConfig, metadata?: Record<string, unknown>) {

View File

@@ -33,10 +33,12 @@ export type ToolResponseResult = {
export type ToolChoice = "auto" | "required" | { type: "function"; name: string };
export type BackendToolRequests = {
responses: Record<string, unknown>;
chatCompletion: Record<string, unknown>;
claude?: Record<string, unknown>;
gemini?: Record<string, unknown>;
canonical: {
messages: ResponseInputMessage[];
tools: ResponseFunctionTool[];
toolChoice: ToolChoice;
systemPrompt: string;
};
};
export type ResponseMessageContent = AiTextMessage["content"] | string;

View File

@@ -102,28 +102,14 @@ export function toChatCompletionToolChoice(toolChoice: ToolChoice) {
}
export function buildBackendToolRequests(messages: ResponseInputMessage[], tools: ResponseFunctionTool[], toolChoice: ToolChoice, config?: AiConfig): BackendToolRequests {
const requests: BackendToolRequests = {
responses: {
input: toResponseInput(messages),
tools: tools.map(toResponseTool),
tool_choice: toolChoice,
parallel_tool_calls: false,
},
chatCompletion: {
messages: toChatCompletionMessages(messages),
return {
canonical: {
messages,
tools,
tool_choice: toChatCompletionToolChoice(toolChoice),
parallel_tool_calls: false,
toolChoice,
systemPrompt: config?.systemPrompt || "",
},
};
if (config) {
requests.claude = {
...toClaudeBody(config, messages, tools),
tool_choice: typeof toolChoice === "object" ? { type: "tool", name: toolChoice.name } : { type: toolChoice === "required" ? "any" : "auto" },
};
requests.gemini = toGeminiBody(config, messages, toGeminiToolOptions(tools, toolChoice));
}
return requests;
}
export function toGeminiBody(config: AiConfig, messages: ResponseInputMessage[], extra?: Record<string, unknown>) {

View File

@@ -0,0 +1,169 @@
import { nanoid } from "nanoid";
import { creationRuns, type CreationRun, type CreationSubmission } from "./api/creation-runs";
import { prepareBackendToolGenerationTask, parseBackendGenerationResult, runBackendToolGenerationTask } from "./api/generation-task";
import { waitForGenerationTask } from "./api/task-center";
import type { CreativeQuote } from "@/lib/creation/creative-agent-contract";
import { getActiveUserScope } from "@/lib/user-scope";
import { logicalModelIDForConfig, resolveModelRequestConfig } from "@/stores/use-config-store";
type Request = Parameters<typeof runBackendToolGenerationTask>[0];
type Pending = { submission: CreationSubmission; externalBilling: boolean; resolve: () => void; reject: (error: Error) => void };
export type ApprovedToolExecutionState = { quote?: CreativeQuote; busy: boolean; error?: string };
// Only this boundary approves and executes quoted tasks. Pipeline code never grants approval.
export class ApprovedToolExecution {
private run?: CreationRun;
private owner = `plugin:${nanoid()}`;
private scope = getActiveUserScope();
private pending = new Map<string, Pending>();
private heartbeat?: ReturnType<typeof setInterval>;
private disposed = false;
private busy = false;
private error?: string;
private stopReason?: string;
private ready?: Promise<void>;
private controller = new AbortController();
constructor(private options: {
clientKey: string; runId?: string; identity: Record<string, string>;
signal: AbortSignal; onRun: (id: string) => void;
onState: (state: ApprovedToolExecutionState) => void;
}, private api = creationRuns, private waitTask = waitForGenerationTask) {
options.signal.addEventListener("abort", this.abort, { once: true });
}
private assertLive() {
if (this.disposed || this.options.signal.aborted || this.scope !== getActiveUserScope() || this.controller.signal.aborted) throw new DOMException(this.stopReason || "分析已停止,请重新打开后继续", "AbortError");
}
private guard() { return { owner: this.owner, executionEpoch: this.run!.executionEpoch }; }
private initialize() {
return this.ready ||= (async () => {
this.assertLive();
const detail = this.options.runId ? await this.api.get(this.options.runId, this.controller.signal) : await this.api.create({ clientKey: this.options.clientKey, state: this.options.identity }, this.controller.signal);
this.assertLive();
if (Object.entries(this.options.identity).some(([key, value]) => detail.run.state[key] !== value)) throw new Error("原分析的图片、节点或模型已变化,请开始新的分析");
this.run = await this.api.claim(detail.run.id, { expectedEpoch: detail.run.executionEpoch, owner: this.owner }, this.controller.signal);
this.assertLive();
this.options.onRun(this.run.id);
this.heartbeat = setInterval(() => {
if (this.disposed) return;
void (async () => { this.assertLive(); await this.api.heartbeat(this.run!.id, this.guard(), this.controller.signal); })().catch(() => this.abort(new Error("分析连接已中断,请重新打开后继续")));
}, 15_000);
})();
}
execute = async (request: Request) => {
// A control/quote failure must also unblock sibling stages waiting for approval.
// Model task failures below keep the pipeline's existing partial-result handling.
const task = await this.submit(request).catch((error: unknown) => {
this.abort(error);
throw new DOMException(error instanceof Error ? error.message : "分析提交失败,请重新打开后继续", "AbortError");
});
if (this.scope === getActiveUserScope()) request.onTaskCreated?.(task);
this.assertLive();
const completed = await this.waitTask(task.id, { initialTask: task, signal: this.controller.signal, onTextDelta: request.onDelta });
this.assertLive();
const result = parseBackendGenerationResult(completed);
return { content: result.text || "", toolCalls: result.toolCalls || [], ...(result.reasoning ? { reasoning: result.reasoning } : {}) };
};
private async submit(request: Request) {
await this.initialize();
this.assertLive();
const stage = request.metadata?.stage;
if (!stage) throw new Error("分析阶段缺少稳定标识");
let submission = await this.api.prepare(this.run!.id, { ...this.guard(), itemKey: `stage:${stage}`, request: prepareBackendToolGenerationTask(request) }, this.controller.signal);
this.assertLive();
if (submission.revokedAt) {
const detail = await this.api.get(this.run!.id, this.controller.signal);
submission = detail.submissions.find((item) => item.requestHash === submission.requestHash && !item.revokedAt) || submission;
if (submission.revokedAt) throw new Error("原报价已失效,无法恢复此阶段");
}
if (!submission.taskId && Date.parse(submission.quote.expiresAt) <= Date.now()) submission = await this.api.refreshQuote(this.run!.id, { ...this.guard(), submissionId: submission.id }, this.controller.signal);
this.assertLive();
if (!submission.taskId && !submission.approvedAt) {
await new Promise<void>((resolve, reject) => {
const externalBilling = !logicalModelIDForConfig(request.config) && !resolveModelRequestConfig(request.config, request.config.model).channelId;
this.pending.set(stage, { submission, externalBilling, resolve, reject });
this.emit();
});
submission = this.pending.get(stage)!.submission;
this.pending.delete(stage);
this.emit();
}
this.assertLive();
return this.api.execute(this.run!.id, { ...this.guard(), submissionId: submission.id }, this.controller.signal);
}
async approve(submissionIds: string[]) {
if (this.busy || this.disposed || this.controller.signal.aborted) return;
const ids = new Set(submissionIds);
const batch = [...this.pending.values()].filter((item) => ids.has(item.submission.id) && !item.submission.approvedAt);
if (batch.length !== ids.size) { this.error = "报价已变化,请核对当前费用卡后确认"; this.emit(); return; }
if (!batch.length) return;
this.busy = true; this.error = undefined; this.emit();
try {
this.assertLive();
const result = await this.api.approve(this.run!.id, { ...this.guard(), submissionIds: batch.map((item) => item.submission.id) }, this.controller.signal);
this.assertLive();
if (batch.some((item) => !result.submissions.some((candidate) => candidate.id === item.submission.id && candidate.approvedAt))) {
const error = new Error("费用确认回执不完整,请继续上次分析以读取实际状态");
this.abort(error);
throw error;
}
for (const item of batch) {
const approved = result.submissions.find((candidate) => candidate.id === item.submission.id && candidate.approvedAt);
if (!approved) throw new Error("费用确认结果不完整,请重新核对");
item.submission = approved;
}
batch.forEach((item) => item.resolve());
} catch (error) { this.error = error instanceof Error ? error.message : "费用确认失败"; }
finally { this.busy = false; this.emit(); }
}
async refresh() {
if (this.busy || this.disposed || this.controller.signal.aborted) return;
this.busy = true; this.error = undefined; this.emit();
try {
this.assertLive();
for (const item of [...this.pending.values()]) {
if (item.submission.approvedAt) continue;
item.submission = await this.api.refreshQuote(this.run!.id, { ...this.guard(), submissionId: item.submission.id }, this.controller.signal);
this.assertLive();
}
} catch (error) { this.error = error instanceof Error ? error.message : "报价刷新失败"; }
finally { this.busy = false; this.emit(); }
}
private emit() {
if (this.disposed || this.scope !== getActiveUserScope()) return;
const items = [...this.pending.entries()].filter(([, item]) => !item.submission.approvedAt).map(([stage, item]) => ({ ...item.submission, stage }));
const externalBilling = [...this.pending.values()].some((item) => !item.submission.approvedAt && item.externalBilling);
const estimated = items.some((item) => item.quote.estimated);
this.options.onState({ busy: this.busy, error: this.error, quote: items.length ? {
id: items.map((item) => item.id).join(":"), title: "确认本批分析费用",
items: items.map((item) => ({ id: item.id, label: stageLabel(item.stage), model: item.quote.model, quantity: item.quote.quantity, specification: `${item.quote.estimated ? "预计" : ""} ${item.quote.amountMicrocredits / 1_000_000} 积分` })),
amountLabel: `${externalBilling ? "平台 " : ""}${estimated ? "预计 " : ""}${items.reduce((sum, item) => sum + item.quote.amountMicrocredits, 0) / 1_000_000} 积分${externalBilling ? " + 外部渠道费用" : ""}`,
externalBilling,
basis: "仅批准本批列出的模型调用;按用量计费时按实际结算,估算不是费用上限。后续阶段根据分析结果另行报价。",
expiresAt: items.map((item) => item.quote.expiresAt).sort()[0],
} : undefined });
}
private abort = (reason?: unknown) => {
if (!this.controller.signal.aborted && reason instanceof Error) this.stopReason = reason.message;
this.controller.abort();
if (this.heartbeat) clearInterval(this.heartbeat);
for (const item of this.pending.values()) item.reject(new DOMException(this.stopReason || "分析已停止", "AbortError"));
};
dispose() {
this.disposed = true; this.abort();
if (this.heartbeat) clearInterval(this.heartbeat);
this.options.signal.removeEventListener("abort", this.abort);
if (this.run && this.scope === getActiveUserScope()) void this.api.release(this.run.id, this.guard()).catch(() => { /* Lease expiry releases an offline owner. */ });
}
}
function stageLabel(key: string) {
return ({ analyze_art_scene: "理解画面", review_art_composition: "分析构图", review_art_color: "分析色彩", review_art_lighting: "分析光线", review_art_structure: "分析结构", aggregate_art_critique: "整理问题", ground_art_critique_issues: "定位问题", verify_art_critique: "复核结论", generate_art_edit_prompts: "编写修改建议" } as Record<string, string>)[key.replace(/^stage:/, "")] || "分析阶段";
}

View File

@@ -0,0 +1,53 @@
import { inspectAgentImage } from "./agent-image-preview";
import { runBackendToolGenerationTask } from "./api/generation-task";
import { runArtCritiquePipeline, type ArtCritiquePipelineOptions } from "@/lib/art-critique/pipeline";
import { artCritiqueSourceFingerprint, isArtCritiqueImageInput } from "@/lib/art-critique/contracts";
import type { CanvasNodeData } from "@/types/canvas";
import type { AiConfig } from "@/stores/use-config-store";
import { getActiveUserScope } from "@/lib/user-scope";
export const ART_CRITIQUE_MAX_MODEL_CALLS = 9;
// One execution boundary for manual UI and future approved Agent runs.
// The executor may be replaced by an approval-aware task submitter; parsing stays in the pipeline.
export async function executeArtCritique(input: {
nodeId: string;
runId: string;
source: CanvasNodeData;
config: AiConfig;
signal: AbortSignal;
onTaskCreated: (stage: string, taskId: string) => void;
onStage?: ArtCritiquePipelineOptions["onStage"];
onDraftReport?: ArtCritiquePipelineOptions["onDraftReport"];
submitStage?: typeof runBackendToolGenerationTask;
}, dependencies = { inspectImage: inspectAgentImage, runTask: runBackendToolGenerationTask, runPipeline: runArtCritiquePipeline }) {
if (!input.nodeId || !input.runId || !isArtCritiqueImageInput(input.source)) throw new Error("分析需要真实目标节点、执行标识和已有图片");
const scope = getActiveUserScope();
const assertLive = () => {
if (input.signal.aborted || getActiveUserScope() !== scope) throw new DOMException("分析已停止或账号已切换", "AbortError");
};
assertLive();
const content = input.source.metadata?.content || input.source.metadata?.previewContent || "";
const image = await dependencies.inspectImage({ storageKey: input.source.metadata?.storageKey, ...(content.startsWith("data:") ? { dataUrl: content } : { url: content }), name: input.source.title }, "original");
assertLive();
const calledStages = new Set<string>();
return dependencies.runPipeline(input.config, { dataUrl: image.storageKey, title: input.source.title, sourceFingerprint: artCritiqueSourceFingerprint(input.source) }, {
signal: input.signal,
onStage: (stage) => { assertLive(); input.onStage?.(stage); },
onDraftReport: (report) => { assertLive(); input.onDraftReport?.(report); },
requestStage: async ({ config, messages, tool, toolName, signal }) => {
assertLive();
if (calledStages.has(toolName) || calledStages.size >= ART_CRITIQUE_MAX_MODEL_CALLS) throw new Error("分析阶段重复或超过本次调用数量上限");
calledStages.add(toolName);
const result = await (input.submitStage || dependencies.runTask)({
config, messages, tools: [tool], toolChoice: { type: "function", name: toolName },
prompt: `图片审美分析:${toolName}`, signal,
metadata: { source: "art-critique", nodeId: input.nodeId, runId: input.runId, stage: toolName },
// A task can be accepted while the user stops observing; retain that receipt.
onTaskCreated: (task) => { if (getActiveUserScope() === scope) input.onTaskCreated(toolName, task.id); },
});
assertLive();
return result;
},
});
}

View File

@@ -0,0 +1,67 @@
import type { CanvasAssistantMessage, CanvasAssistantSession } from "@/types/canvas";
import { getActiveUserScope } from "@/lib/user-scope";
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { createCanvasProjectWithRemoteSync, hasRemoteUserDataSyncSession, loadCanvasProjectForEditing, saveRemoteUserDataNow } from "@/services/user-data-sync";
type SourceMessage = {
id: string; role: "user" | "assistant"; content: string; createdAt: string;
mode?: string; status?: string; taskIds?: string[]; error?: string; model?: string;
settings?: unknown; attachments?: Array<{ name: string; storageKey?: string }>;
references?: Array<{ id: string; label: string; kind: string }>;
};
type SourceConversation = { id: string; title: string; updatedAt: string; canvasId?: string; messages: SourceMessage[] };
// Same source IDs merge into the same canvas conversation, without replaying tasks
// or replacing messages produced by the canvas Agent after the handoff.
export async function continueCreationConversationOnCanvas(source: SourceConversation) {
const scope = getActiveUserScope();
const assertScope = () => { if (scope !== getActiveUserScope()) throw new DOMException("账号已切换,请重新打开创作", "AbortError"); };
if (!source.id || !source.messages.length) throw new Error("请先开始一段创作对话");
if (source.messages.some((item) => item.status === "pending" || item.status === "streaming")) throw new Error("当前创作仍在生成,请完成或停止后再转入画布。");
if (!useCanvasStore.getState().hydrated) throw new Error("画布资料正在加载,请稍后继续。");
const sessionId = `creation:${source.id}`;
const messages: CanvasAssistantMessage[] = source.messages.map((item) => ({
id: `creation:${source.id}:${item.id}`, role: item.role,
text: item.content || (item.status === "done" ? "首页作品已生成,可在画布中继续创作。" : item.status === "error" ? "首页生成未完成。" : item.status === "cancelled" ? "首页生成已停止。" : "来自首页的创作记录"),
meta: "来自首页",
detail: { kind: "creation-handoff", sourceConversationId: source.id, sourceMessageId: item.id, createdAt: item.createdAt,
status: item.status, mode: item.mode, model: item.model, taskIds: item.taskIds, error: item.error, settings: item.settings,
references: item.references?.map(({ id, label, kind }) => ({ id, label, kind })),
attachments: item.attachments?.map(({ name, storageKey }) => ({ name, storageKey })),
},
}));
const local = useCanvasStore.getState().projects.find((item) => item.chatSessions?.some((session) => session.id === sessionId));
const existingId = source.canvasId || local?.id;
let id: string;
let syncError: unknown;
if (existingId) {
const project = await loadCanvasProjectForEditing(existingId);
assertScope();
if (!project) throw new Error("关联画布已不存在,请先恢复画布后继续。");
const sessions = project.chatSessions || [];
const previous = sessions.find((item) => item.id === sessionId);
const incoming = new Map(messages.map((item) => [item.id, item]));
const merged = (previous?.messages || []).map((item) => {
const replacement = incoming.get(item.id);
incoming.delete(item.id);
return replacement || item;
});
const session: CanvasAssistantSession = { ...previous, id: sessionId, title: source.title, createdAt: previous?.createdAt || source.messages[0].createdAt, updatedAt: new Date().toISOString(), messages: [...merged, ...incoming.values()] };
useCanvasStore.getState().updateProject(project.id, { chatSessions: [...sessions.filter((item) => item.id !== sessionId), session], activeChatId: sessionId });
id = project.id;
await flushCanvasStorePersistence();
assertScope();
try {
if (!hasRemoteUserDataSyncSession()) throw new Error("尚未建立云端同步会话");
await saveRemoteUserDataNow();
} catch (cause) { syncError = cause; }
} else {
const session: CanvasAssistantSession = { id: sessionId, title: source.title, createdAt: source.messages[0].createdAt, updatedAt: source.updatedAt, messages };
const created = await createCanvasProjectWithRemoteSync(source.title || "创作画布", undefined, { chatSessions: [session], activeChatId: sessionId });
id = created.id;
syncError = created.syncError;
await flushCanvasStorePersistence();
}
assertScope();
return { id, sessionId, syncError };
}

View File

@@ -0,0 +1,558 @@
import { nanoid } from "nanoid";
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { buildCanvasAgentContext } from "@/lib/canvas/canvas-agent-context";
import { applyCreativeAnswers, creativeScenarioPrompt, normalizeCreativeQuestions, CREATIVE_SCENARIOS, type CreativeAnswers, type CreativeQuote } from "@/lib/creation/creative-agent-contract";
import { assertCreativeBriefSpecifications, creativeNodeId, creativeProposalOps, initialCreativeState, mergeCreativeBrief, normalizeCreativeProposal, readCreativeState, type CreativeAgentState, type CreativeMediaState } from "@/lib/creation/creative-agent-state";
import { CREATIVE_AGENT_SYSTEM_PROMPT, CREATIVE_AGENT_TOOLS, parseCreativeToolArguments } from "@/lib/creation/creative-agent-tools";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { getActiveUserScope } from "@/lib/user-scope";
import { logicalModelIDForConfig, modelDisplayName, resolveModelRequestConfig, selectableModelsByCapability, type AiConfig } from "@/stores/use-config-store";
import { creationRuns, type CreationGuard, type CreationRun, type CreationRunDetail, type CreationStatus, type CreationSubmission } from "./api/creation-runs";
import { parseBackendGenerationResult, prepareBackendGenerationTask, prepareBackendToolGenerationTask } from "./api/generation-task";
import { queryGenerationTask, waitForGenerationTask, type GenerationTask } from "./api/task-center";
import { resourceFileUrl, resourceIdFromStorageKey } from "./api/resources";
import type { ResponseInputMessage, ResponseToolCall } from "./api/image";
import { listAddedSkills } from "./api/skills";
import { skillRuntime } from "./skill-runtime";
import { ensureCanvasNodeAsset } from "./project-asset-sync";
import { withRemoteUserDataSyncExclusive } from "./user-data-sync";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { creativeVideoSpecificationError } from "@/lib/creation/creative-agent-state";
import { updateCreativePlan } from "@/lib/creation/creative-plan";
export type CreativeCanvasAdapter = { canvasId: string; read: () => CanvasAgentSnapshot; apply: (ops: CanvasAgentOp[]) => Promise<CanvasAgentSnapshot> };
export type CreativeControllerView = { run?: CreationRun; state: CreativeAgentState; busy: boolean; hasControl: boolean; error?: string; quote?: CreativeQuote };
type ControllerOptions = { clientKey?: string; config: () => AiConfig; canvas: () => CreativeCanvasAdapter | undefined; onChange: (view: CreativeControllerView) => void; onOpenCanvas: (canvasId: string, runId: string) => void; api?: typeof creationRuns; waitTask?: typeof waitForGenerationTask; queryTask?: typeof queryGenerationTask; ensureAsset?: typeof ensureCanvasNodeAsset };
// 单一浏览器执行器;运行事实和费用批准以服务端记录为准,页面只提供就绪的画布适配器。
export class CreativeAgentController {
private run?: CreationRun;
private state = initialCreativeState();
private submissions: CreationSubmission[] = [];
private owner = nanoid();
private scope = getActiveUserScope();
private abort = new AbortController();
private busy = false;
private hasControl = false;
private disposed = false;
private heartbeat?: ReturnType<typeof setInterval>;
private error?: string;
private api: typeof creationRuns;
private waitTask: typeof waitForGenerationTask;
private queryTask: typeof queryGenerationTask;
private getAsset() { return this.options.ensureAsset || ensureCanvasNodeAsset; }
constructor(private options: ControllerOptions) { this.api = options.api || creationRuns; this.waitTask = options.waitTask || waitForGenerationTask; this.queryTask = options.queryTask || queryGenerationTask; }
private emit() { if (!this.disposed) this.options.onChange({ run: this.run, state: this.state, busy: this.busy, hasControl: this.hasControl, error: this.error, quote: this.quote() }); }
private assertLive() { if (this.disposed || this.abort.signal.aborted || this.scope !== getActiveUserScope()) throw new DOMException("执行已停止或账号已切换", "AbortError"); }
private guard(): CreationGuard { this.assertLive(); if (!this.run || !this.hasControl) throw new Error("请先接管当前任务,再继续操作"); return { owner: this.owner, executionEpoch: this.run.executionEpoch }; }
private accept(detail: CreationRunDetail) { this.run = detail.run; this.state = readCreativeState(detail.run.state); this.submissions = detail.submissions || []; }
private async save(status: CreationStatus = this.run?.status || "idle") {
const guard = this.guard();
this.run = await this.api.save(this.run!.id, { ...guard, revision: this.run!.revision, status, state: this.state as unknown as Record<string, unknown> }, this.abort.signal);
this.assertLive(); this.emit();
}
private async action(operation: () => Promise<void>, takeOver = false) {
if (this.busy) throw new Error("上一项操作仍在处理,请稍候");
if (this.disposed || this.scope !== getActiveUserScope()) throw new DOMException("页面已关闭", "AbortError");
this.busy = true; this.error = undefined; this.emit();
try { if (this.run) await this.ensureControl(takeOver); await operation(); }
catch (error) {
if (!this.disposed && !(error instanceof DOMException && error.name === "AbortError")) { this.error = error instanceof Error ? error.message : "操作未完成"; this.emit(); }
throw error;
} finally { this.busy = false; this.emit(); }
}
async load(runId?: string, seed?: Partial<CreativeAgentState>, readOnly = false) {
await this.action(async () => {
if (runId) this.accept(await this.api.get(runId, this.abort.signal));
else this.accept(await this.api.create({ clientKey: this.options.clientKey || nanoid(), canvasId: this.options.canvas()?.canvasId, state: { ...initialCreativeState(), ...seed } }, this.abort.signal));
this.assertLive();
if (!readOnly) {
this.startHeartbeat();
await this.ensureControl(true);
}
this.emit();
});
}
private async claim() {
this.run = await this.api.claim(this.run!.id, { expectedEpoch: this.run!.executionEpoch, owner: this.owner }, this.abort.signal);
this.assertLive(); this.hasControl = true;
this.startHeartbeat();
}
private startHeartbeat() {
if (this.heartbeat) return;
this.heartbeat = setInterval(() => {
if (this.disposed || !this.run) return;
if (!this.hasControl) {
if (!this.busy) void this.ensureControl().then(() => { this.error = undefined; this.emit(); }).catch((error) => {
if (this.disposed) return;
this.error = error instanceof Error ? error.message : "连接恢复失败,请重试";
this.emit();
});
return;
}
if (this.abort.signal.aborted) return;
void this.api.heartbeat(this.run.id, this.guard(), this.abort.signal).then((result) => {
if (!this.disposed && this.run) this.run = { ...this.run, leaseExpiresAt: result.leaseExpiresAt };
}).catch(() => { if (this.disposed) return; this.hasControl = false; this.abort.abort(); this.error = "连接暂时中断,正在恢复。已有作品会保留。"; this.emit(); });
}, 15_000);
}
private restoring?: Promise<void>;
private async ensureControl(takeOver = false): Promise<void> {
if (this.hasControl && !this.abort.signal.aborted && Date.parse(this.run?.leaseExpiresAt || "") > Date.now() + 3000) return;
if (this.restoring) {
if (!takeOver) return this.restoring;
try { await this.restoring; } catch { /* Explicit reconnect retries after the passive recovery attempt. */ }
return this.ensureControl(true);
}
this.restoring = (async () => {
if (this.disposed || this.scope !== getActiveUserScope()) throw new DOMException("页面已关闭", "AbortError");
if (this.abort.signal.aborted) this.abort = new AbortController();
const detail = await this.api.get(this.run!.id, this.abort.signal);
this.assertLive();
const run = detail.run;
if (!takeOver && run.executionOwner && run.executionOwner !== this.owner && Date.parse(run.leaseExpiresAt || "") > Date.now()) throw new Error("当前创作连接已切换,请点击重试连接继续。");
// Only replace the lease here; never overwrite local edits or unknown submission results.
this.run = { ...this.run!, executionOwner: run.executionOwner, executionEpoch: run.executionEpoch, leaseExpiresAt: run.leaseExpiresAt };
await this.claim();
})();
try { await this.restoring; } finally { this.restoring = undefined; }
}
async takeControl() {
if (this.disposed) return;
await this.action(async () => { this.accept(await this.api.get(this.run!.id, this.abort.signal)); }, true);
}
async refresh() { await this.action(async () => { this.accept(await this.api.get(this.run!.id, this.abort.signal)); }); }
dispose() {
this.disposed = true; this.abort.abort(); if (this.heartbeat) clearInterval(this.heartbeat);
if (this.hasControl && this.run) void this.api.release(this.run.id, { executionEpoch: this.run.executionEpoch, owner: this.owner }).catch(() => undefined);
this.hasControl = false;
}
async pause() {
if (!this.hasControl || !this.run) return;
const guard = this.guard();
this.abort.abort();
this.state = { ...this.state, error: "已暂停后续操作。已提交的生成任务仍可在任务中心继续运行。" };
try {
this.run = await this.api.save(this.run.id, { ...guard, revision: this.run.revision, status: "paused", state: this.state as unknown as Record<string, unknown> });
} finally {
try { await this.api.release(this.run.id, guard); }
finally { this.hasControl = false; this.startHeartbeat(); this.emit(); }
}
}
async configure(patch: Pick<CreativeAgentState, "scene" | "references" | "selectedSkillIds" | "textModel">) { await this.action(async () => { this.state = { ...this.state, ...patch }; await this.save(); }); }
async requestModification() {
await this.action(async () => {
if (this.state.media.some((item) => ["queued", "running"].includes(item.status))) throw new Error("已有生成还在处理,请继续处理以同步结果,完成后再修改方案。");
this.run = await this.api.invalidateProposal(this.run!.id, { ...this.guard(), revision: this.run!.revision }, this.abort.signal);
this.state = { ...this.state, modificationRequested: true, pendingPayment: undefined, pendingEdits: undefined, pendingRedo: undefined };
await this.save("idle");
});
}
async send(text: string) {
if (!text.trim()) return;
await this.action(async () => {
this.guard();
if (this.state.media.some((item) => ["queued", "running"].includes(item.status))) throw new Error("请先暂停或等当前生成完成,再调整需求;已提交任务不会被文字修改取消");
if (this.run?.approvedProposalVersion) this.run = await this.api.invalidateProposal(this.run.id, { ...this.guard(), revision: this.run.revision }, this.abort.signal);
this.state = { ...this.state, questions: undefined, answers: undefined, pendingPayment: undefined, planning: undefined, pendingEdits: undefined, pendingRedo: undefined, error: undefined, messages: [...this.state.messages, { id: nanoid(), role: "user", text: text.trim() }] };
await this.save("running");
await this.preparePlanning(text.trim());
});
}
async answer(answers: CreativeAnswers, continuePlanning = true) {
await this.action(async () => {
const question = this.state.questions;
if (!question) throw new Error("当前没有待回答问题");
const brief = applyCreativeAnswers(question, question, answers, this.state.brief, this.assets());
const summary = question.questions.map((item) => `${item.title}:${JSON.stringify(brief[item.field]?.value ?? "由助手建议")}`).join("\n");
this.state = { ...this.state, brief, questions: { ...question, status: "submitted" }, answers, messages: [...this.state.messages, { id: nanoid(), role: "user", text: summary, question: { ...question, status: "submitted" }, answers }] };
await this.save(continuePlanning ? "running" : "idle");
if (continuePlanning) await this.preparePlanning(summary);
});
}
// 原在线 Agent 工具循环已完成模型请求;此入口仅校验并持久化交互,不再调用第二个模型。
async presentInteraction(data: Record<string, unknown>, revalidate = false) {
await this.action(async () => {
this.guard();
if (revalidate && (this.state.proposal || this.state.canvasApplied || this.run?.approvedProposalVersion || this.state.pendingPayment?.length || this.state.media.some((item) => item.taskId))) throw new Error("此方案已有批准或执行记录,不能重复呈现,请修改方案后重新确认");
if (this.state.externalInteractionPresented && !revalidate) return;
this.state = { ...this.state, externalInteractionPresented: true };
await this.consumePlanning({ resultJson: JSON.stringify({ toolCalls: [{ id: "interaction", type: "function", function: { name: "creative_respond", arguments: JSON.stringify(data) } }] }) } as GenerationTask);
});
}
private assets() { return this.state.references.map((item) => ({ id: item.id, label: item.title })); }
private requestConfig(model?: string): AiConfig {
const config = this.options.config(), selected = model || this.state.textModel || config.textModel || config.model;
const next: AiConfig = { ...config, model: selected, count: "1", taskWorkflowProvider: "model" };
const requestConfig = resolveModelRequestConfig(next, selected);
if (!logicalModelIDForConfig(next) && !requestConfig.channelId) throw new Error("智能创作需要系统受管模型,请选择系统模型;自定义渠道和本机模型仍可使用原直接生成入口");
return next;
}
private async preparePlanning(prompt: string) {
if (this.state.modelCalls >= 32) throw new Error("此任务已达到 32 次分析调用上限,请新建任务并沿用当前方案");
const config = this.requestConfig();
const skills = await listAddedSkills();
const prepared = await skillRuntime.prepare({ profile: "creation", prompt, skills: skills.skills || [], selectedSkillIds: this.state.selectedSkillIds });
this.assertLive();
const catalogue = (["image", "video"] as const).flatMap((mode) => selectableModelsByCapability(config, mode).slice(0, 20).map((model) => ({ mode, model, name: modelDisplayName(config, model), capability: modelCapabilityConfigFor(config, model) })));
const canvas = this.options.canvas();
const imageReferences = this.state.references.filter((reference) => reference.kind === "image" && reference.storageKey);
const protocol: ResponseInputMessage[] = [
{ role: "system", content: `${CREATIVE_AGENT_SYSTEM_PROMPT}\n${creativeScenarioPrompt(this.state.scene)}\n用户系统提示:${config.systemPrompt || ""}` },
...this.state.messages.slice(-20).map((message) => ({ role: message.role, content: message.text })),
{ role: "user", content: [{ type: "text", text: JSON.stringify({ brief: this.state.brief, proposal: this.state.proposal, availableModels: catalogue, references: this.state.references, canvas: canvas ? buildCanvasAgentContext(canvas.read()) : { available: false, instruction: "首页不能操作画布" }, currentRequest: prepared.prompt }) }, ...imageReferences.map((reference) => ({ type: "image_url" as const, image_url: { url: reference.storageKey! } }))] },
];
const itemKey = `planning:${nanoid()}`;
this.state = { ...this.state, planning: { itemKey, protocol, model: config.model, prompt }, pendingPayment: undefined };
await this.save("running");
await this.ensurePlanningSubmission();
}
private async ensurePlanningSubmission() {
const planning = this.state.planning;
if (!planning) throw new Error("缺少待恢复的规划内容");
let submission = this.submissions.find((item) => item.itemKey === planning.itemKey);
if (!submission) {
const config = this.requestConfig(planning.model);
const request = prepareBackendToolGenerationTask({ prompt: planning.prompt || "继续创作", config: { ...config, systemPrompt: "" }, messages: planning.protocol, tools: CREATIVE_AGENT_TOOLS, toolChoice: "auto", signal: this.abort.signal });
request.input = { ...request.input, referenceImages: this.state.references.filter((reference) => reference.kind === "image" && reference.storageKey).map((reference) => ({ id: reference.id, name: reference.title, storageKey: reference.storageKey })) };
submission = await this.api.prepare(this.run!.id, { ...this.guard(), itemKey: planning.itemKey, request }, this.abort.signal);
}
this.assertLive(); this.upsertSubmission(submission);
this.state = { ...this.state, planning: { ...planning, submissionId: submission.id }, pendingPayment: [submission.id] };
await this.save("waiting_payment");
}
private upsertSubmission(submission: CreationSubmission) { this.submissions = [...this.submissions.filter((item) => item.id !== submission.id), submission]; }
private quote(): CreativeQuote | undefined {
// pendingPayment also retains submitted IDs for recovery; only unsubmitted items need a fee card.
const pending = this.state.pendingPayment?.map((id) => this.submissions.find((item) => item.id === id)).filter((item): item is CreationSubmission => Boolean(item && !item.taskId && !item.revokedAt));
if (!pending?.length) return undefined;
const amount = pending.reduce((sum, item) => sum + item.quote.amountMicrocredits, 0);
const estimated = pending.some((item) => item.quote.estimated);
return { id: pending.map((item) => item.id).join(":"), title: this.state.planning && !this.state.planning.consumed ? "确认本次理解与规划费用" : "确认本批生成费用", items: pending.map((item) => { const media = this.state.media.find((media) => media.submissionId === item.id); const node = this.state.proposal?.workflow.nodes.find((node) => node.ref === media?.ref); return { id: item.id, label: node?.title || "理解需求并生成问答或方案", model: item.quote.model, quantity: 1, specification: [...Object.entries(item.quote.options || {}).filter(([, value]) => value !== undefined && value !== "").map(([key, value]) => `${({ size: "比例/尺寸", videoSeconds: "时长(秒)", vquality: "清晰度", quality: "质量", count: "数量" } as Record<string, string>)[key] || key}:${String(value)}`), `${item.quote.billingMode === "token" ? "按用量" : item.quote.billingMode === "per_second" ? "按秒" : "按次"}计费,本项${item.quote.estimated ? "预计" : ""} ${(item.quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`].join(" · ") }; }), amountLabel: `${estimated ? "预计 " : ""}${(amount / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`, basis: estimated ? "按实际用量结算,此金额为估算,不是费用上限。仅批准列出的调用。" : "按当前报价,仅批准本批列出的生成项。", expiresAt: pending.map((item) => item.quote.expiresAt).sort()[0], approvedQuantity: pending.filter((item) => item.approvedAt).length };
}
async approvePayment() {
const expiresAt = this.quote()?.expiresAt;
if (expiresAt && Date.parse(expiresAt) <= Date.now()) {
await this.refreshQuotes();
return;
}
await this.action(async () => {
const ids = this.state.pendingPayment;
if (!ids?.length) throw new Error("当前没有待批准费用");
const unapproved = ids.filter((id) => { const item = this.submissions.find((item) => item.id === id); return item && !item.taskId && !item.approvedAt && !item.revokedAt; });
if (this.run!.status === "paused") await this.save("waiting_payment");
if (unapproved.length) {
const result = await this.api.approve(this.run!.id, { ...this.guard(), submissionIds: unapproved }, this.abort.signal);
result.submissions.forEach((item) => this.upsertSubmission(item));
}
await this.executeApproved(ids);
});
}
async refreshQuotes() {
await this.action(async () => {
if (!this.state.pendingPayment?.length) throw new Error("当前没有待更新报价");
for (const id of [...this.state.pendingPayment]) {
if (this.submissions.find((item) => item.id === id)?.taskId) continue;
const next = await this.api.refreshQuote(this.run!.id, { ...this.guard(), submissionId: id }, this.abort.signal);
this.guard(); this.upsertSubmission(next);
this.state = { ...this.state,
pendingPayment: this.state.pendingPayment!.map((item) => item === id ? next.id : item),
planning: this.state.planning?.submissionId === id ? { ...this.state.planning, submissionId: next.id, itemKey: next.itemKey } : this.state.planning,
media: this.state.media.map((item) => item.submissionId === id ? { ...item, submissionId: next.id } : item),
};
await this.save("waiting_payment");
}
});
}
private async executeApproved(ids: string[]) {
const planning = this.state.planning;
if (planning?.submissionId && ids.includes(planning.submissionId) && !planning.consumed) {
await this.save("waiting_task");
const task = await this.api.execute(this.run!.id, { ...this.guard(), submissionId: planning.submissionId }, this.abort.signal);
this.upsertSubmission({ ...this.submissions.find((item) => item.id === planning.submissionId)!, taskId: task.id });
const completed = await this.waitTask(task.id, { initialTask: task, signal: this.abort.signal });
this.assertLive();
await this.consumePlanning(completed);
return;
}
if (!this.options.canvas()) throw new Error("媒体生成只能在对应画布页面继续");
await this.save("running");
const errors: string[] = [];
for (const id of ids) {
this.guard();
const media = this.state.media.find((item) => item.submissionId === id);
if (!media || media.status === "ready") continue;
try {
const task = await this.api.execute(this.run!.id, { ...this.guard(), submissionId: id }, this.abort.signal);
this.upsertSubmission({ ...this.submissions.find((item) => item.id === id)!, taskId: task.id });
this.setMedia(media.ref, { taskId: task.id, status: task.status === "succeeded" ? "running" : "queued", error: undefined });
await this.save("waiting_task");
} catch (error) { this.assertLive(); errors.push(error instanceof Error ? error.message : "提交未确认"); }
}
// 等待已提交项,不会因观察失败创建新的任务。
for (const id of ids) {
const media = this.state.media.find((item) => item.submissionId === id);
if (media?.taskId && media.status !== "ready") {
try { await this.observeMedia(media); } catch (error) { this.assertLive(); errors.push(error instanceof Error ? error.message : "生成未完成"); }
}
}
if (errors.length) { await this.save("paused"); throw new Error([...new Set(errors)].join(";")); }
this.state = { ...this.state, pendingPayment: undefined };
await this.prepareMediaBatch();
}
private async consumePlanning(task: GenerationTask) {
const output = parseBackendGenerationResult(task);
const calls = output.toolCalls || [];
const first = calls.find((call) => call.function.name === "creative_respond");
const results = calls.map((call) => ({ role: "tool" as const, tool_call_id: call.id, content: JSON.stringify({ status: call === first ? "presented_to_user" : "cancelled", reason: call === first ? "内容待程序校验和用户确认" : "交互形成等待屏障,后续调用必须重新规划" }) }));
const protocol = [...(this.state.planning?.protocol || []), ...calls.map(toolInput), ...results];
this.state = { ...this.state, modelCalls: this.state.modelCalls + 1, pendingPayment: undefined, planning: this.state.planning ? { ...this.state.planning, consumed: true, protocol } : undefined };
if (!first) {
if (!output.text?.trim()) throw new Error("助手没有返回可展示的内容");
this.state = { ...this.state, messages: [...this.state.messages, { id: nanoid(), role: "assistant", text: output.text }] };
await this.save("idle"); return;
}
const data = parseCreativeToolArguments(first.function.arguments);
const scene = typeof data.scenario === "string" && Object.hasOwn(CREATIVE_SCENARIOS, data.scenario) ? data.scenario as CreativeAgentState["scene"] : this.state.scene;
const brief = mergeCreativeBrief(this.state.brief, data.brief, scene, this.state.messages.findLast((message) => message.role === "user")?.text || "");
const questions = normalizeCreativeQuestions(data.questions, scene, brief, { interactionId: nanoid(), revision: 1 }, this.assets());
const message = typeof data.message === "string" ? data.message : output.text || "已整理你的要求。";
this.state = { ...this.state, scene, brief, dynamicPlan: updateCreativePlan(data.plan, this.state.dynamicPlan, nanoid()), messages: [...this.state.messages, { id: nanoid(), role: "assistant", text: message }], questions: undefined, answers: undefined };
if (questions.questions.length) {
this.state = { ...this.state, questions };
await this.save("waiting_answer"); return;
}
if (data.proposal) {
const proposal = normalizeCreativeProposal(data.proposal, nanoid(), Math.max(this.state.proposal?.version || 0, this.run?.approvedProposalVersion || 0) + 1, this.options.config(), this.state.references);
assertCreativeBriefSpecifications(proposal, brief);
this.state = { ...this.state, proposal, operations: undefined, canvasApplied: false, media: proposal.generationItems.map((item) => ({ ref: item.ref, nodeId: creativeNodeId(this.run!.id, proposal.version, item.ref), attempt: 1, status: "pending" })) };
await this.save("waiting_proposal"); return;
}
if (Array.isArray(data.edits) && data.edits.length) {
const canvas = this.options.canvas();
if (!canvas) throw new Error("请进入画布后再调整节点");
const current = canvas.read();
const ops: CanvasAgentOp[] = data.edits.map((raw) => {
const edit = raw as Record<string, unknown>;
const id = typeof edit.nodeId === "string" ? edit.nodeId : "";
if (!current.nodes.some((node) => node.id === id)) throw new Error("助手建议修改的节点不存在");
return { type: "update_node", id, patch: { ...(typeof edit.title === "string" ? { title: edit.title } : {}) }, metadata: { ...(typeof edit.prompt === "string" ? { prompt: edit.prompt, composerContent: edit.prompt } : {}), ...(typeof edit.content === "string" && current.nodes.find((node) => node.id === id)?.type === "text" ? { content: edit.content } : {}) } };
});
this.state = { ...this.state, pendingEdits: ops };
await this.save("waiting_proposal"); return;
}
await this.save("idle");
}
async approveProposal() {
await this.action(async () => {
const proposal = this.state.proposal;
if (!proposal || this.run?.status !== "waiting_proposal") throw new Error("当前方案已过期或无需确认");
assertCreativeBriefSpecifications(proposal, this.state.brief);
const snapshot = this.options.canvas()?.read() || { projectId: "", title: "", nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
const ops = creativeProposalOps(this.run!.id, proposal, snapshot, this.options.config());
proposal.extra = { ...(proposal.extra as Record<string, unknown> || {}), nodeIds: Object.fromEntries(proposal.workflow.nodes.map((node) => [node.ref, creativeNodeId(this.run!.id, proposal.version, node.ref)])) };
this.state = { ...this.state, proposal, operations: ops, messages: [...this.state.messages, { id: nanoid(), role: "assistant", text: `方案 v${proposal.version}:${proposal.title}`, proposal }] };
await this.save("waiting_proposal");
this.run = await this.api.approveProposal(this.run!.id, { ...this.guard(), revision: this.run!.revision, proposalVersion: proposal.version, proposal, ops }, this.abort.signal);
const linked = await this.api.canvas(this.run.id, this.guard(), this.abort.signal);
this.run = linked.run;
await this.save("waiting_canvas");
if (this.options.canvas()?.canvasId === linked.canvasId) await this.applyProposal();
else { await this.api.release(this.run.id, this.guard()); this.hasControl = false; this.options.onOpenCanvas(linked.canvasId, this.run.id); }
});
}
async approveEdits() {
await this.action(async () => {
const ops = this.state.pendingEdits;
if (!ops?.length) throw new Error("当前没有待确认节点调整");
const version = Math.max(this.state.proposal?.version || 0, this.run?.approvedProposalVersion || 0) + 1;
this.run = await this.api.approveProposal(this.run!.id, { ...this.guard(), revision: this.run!.revision, proposalVersion: version, proposal: { title: "局部节点调整", ops }, ops }, this.abort.signal);
await this.commitOps(ops);
this.state = { ...this.state, pendingEdits: undefined, messages: [...this.state.messages, { id: nanoid(), role: "assistant", text: "已更新指定节点字段。" }] };
await this.save("idle");
});
}
private async applyProposal() {
if (this.state.canvasApplied) return;
if (!this.state.operations || !this.state.proposal) throw new Error("缺少已批准方案的操作记录,请重新核对");
await this.commitOps(this.state.operations);
this.state = { ...this.state, canvasApplied: true };
await this.save("running");
await this.prepareMediaBatch();
}
private async commitOps(ops: CanvasAgentOp[]) {
return withRemoteUserDataSyncExclusive(() => this.commitOpsExclusive(ops));
}
private async commitOpsExclusive(ops: CanvasAgentOp[]) {
const adapter = this.options.canvas();
if (!adapter || adapter.canvasId !== this.run?.canvasId) throw new Error("对应画布尚未加载,不能执行画布操作");
this.guard();
const remote = await this.api.canvasSnapshot(this.run.id, this.abort.signal);
const remoteSnapshot = { ...adapter.read(), projectId: remote.document.id, nodes: remote.document.nodes, connections: remote.document.connections };
const missing = reconcileOps(ops, remoteSnapshot);
if (missing.length) {
const next = applyCanvasAgentOps(remoteSnapshot, missing);
await this.api.commitCanvas(this.run.id, { ...this.guard(), expectedSnapshotHash: remote.snapshotHash, document: { ...remote.document, nodes: next.nodes, connections: next.connections } }, this.abort.signal);
}
this.guard();
const localOps = reconcileOps(ops, adapter.read());
if (localOps.length) {
const applied = await adapter.apply(localOps);
// 同一保存队列释放前更新持久化源,避免排队的自动保存仍提交操作前快照。
if (useCanvasStore.getState().projects.some((project) => project.id === adapter.canvasId)) {
useCanvasStore.getState().updateProject(adapter.canvasId, { nodes: applied.nodes, connections: applied.connections });
}
}
this.assertLive();
}
private setMedia(ref: string, patch: Partial<CreativeMediaState>) { this.state = { ...this.state, media: this.state.media.map((item) => item.ref === ref ? { ...item, ...patch } : item) }; this.emit(); }
private async prepareMediaBatch() {
const proposal = this.state.proposal;
const adapter = this.options.canvas();
if (!proposal || !adapter || !this.state.canvasApplied) throw new Error("请先在画布中完成方案接续");
const existingRefs = Object.keys((proposal.extra as { existingAssets?: Record<string, unknown> } | undefined)?.existingAssets || {});
const ready = new Set([...existingRefs, ...this.state.media.filter((item) => item.status === "ready").map((item) => item.ref)]);
if (this.state.media.every((item) => item.status === "ready")) {
this.state = { ...this.state, pendingPayment: undefined, messages: [...this.state.messages, { id: nanoid(), role: "assistant", text: "本次产物已生成并保存,可以在会话和画布查看。" }] };
await this.save("completed"); return;
}
const candidates = this.state.media.filter((media) => media.status === "pending" && proposal.generationItems.find((item) => item.ref === media.ref)?.referenceRefs?.every((ref) => ready.has(ref)) !== false);
const ids: string[] = [];
for (const media of candidates) {
const item = proposal.generationItems.find((item) => item.ref === media.ref)!;
const snapshot = adapter.read(), node = snapshot.nodes.find((node) => node.id === media.nodeId);
if (!node) throw new Error(`节点 ${media.ref} 已被删除,请核对后继续,不会重新创建`);
const nodeIds = (proposal.extra as { nodeIds?: Record<string, string> } | undefined)?.nodeIds;
const referenceNodes = [...(item.referenceRefs || []).map((ref) => nodeIds?.[ref] || this.state.media.find((media) => media.ref === ref)?.nodeId || creativeNodeId(this.run!.id, proposal.version, ref)), ...(proposal.workflow.nodes.find((node) => node.ref === item.ref)?.referenceNodeIds || [])].map((id) => snapshot.nodes.find((node) => node.id === id));
const referenceImages = referenceNodes.map((reference) => {
const storageKey = typeof reference?.metadata?.storageKey === "string" ? reference.metadata.storageKey : "";
const resourceId = resourceIdFromStorageKey(storageKey);
if (!reference || !resourceId || reference.metadata?.status !== "success") throw new Error("引用素材尚未就绪,请选择实际可用的资源");
return { id: reference.id, name: reference.title || "参考图", type: reference.metadata?.mimeType || "image/png", storageKey, dataUrl: resourceFileUrl(resourceId) };
});
const config = { ...this.requestConfig(item.model), count: "1", ...(item.size ? { size: item.size } : {}), ...(item.seconds !== undefined ? { videoSeconds: String(item.seconds) } : {}), ...(item.quality ? item.mode === "video" ? { vquality: item.quality } : { quality: item.quality } : {}) };
const request = await prepareBackendGenerationTask({ projectId: this.run!.canvasId, mode: item.mode, prompt: node.metadata?.prompt || "", config, referenceImages, signal: this.abort.signal, metadata: { source: "creative-agent", creationRunId: this.run!.id, nodeId: node.id, conversationId: this.run!.id } });
request.input = { ...request.input, nodeId: node.id };
const submission = await this.api.prepare(this.run!.id, { ...this.guard(), itemKey: `media:v${proposal.version}:${media.ref}:${media.attempt}`, proposalVersion: proposal.version, request }, this.abort.signal);
this.upsertSubmission(submission); this.setMedia(media.ref, { submissionId: submission.id }); ids.push(submission.id);
this.state = { ...this.state, pendingPayment: [...ids] }; await this.save("waiting_payment");
}
if (!ids.length) { await this.save("paused"); throw new Error("当前没有可执行项,请先处理失败任务或核对素材依赖"); }
}
private async observeMedia(media: CreativeMediaState) {
let task: GenerationTask;
let observedStatus: GenerationTask["status"] | undefined;
try {
task = await this.waitTask(media.taskId!, { signal: this.abort.signal, onTaskUpdate: (task) => { observedStatus = task.status; if (!this.disposed) this.setMedia(media.ref, { status: task.status === "queued" ? "queued" : "running" }); } });
} catch (error) {
if (this.abort.signal.aborted) throw error;
this.setMedia(media.ref, { status: "failed", failureKind: observedStatus === "failed" || observedStatus === "cancelled" ? "generation" : "observation", error: error instanceof Error ? error.message : "生成失败" }); await this.save("paused"); throw error;
}
this.guard();
try {
const result = parseBackendGenerationResult(task), output = result.video || result.images?.[0];
if (!output?.storageKey || !resourceIdFromStorageKey(output.storageKey)) throw new Error("任务已完成,但资源尚未就绪;请恢复回写,不要重新生成");
const adapter = this.options.canvas();
const node = adapter?.read().nodes.find((node) => node.id === media.nodeId);
if (!node) throw new Error("目标节点已删除,产物仍保留在任务中心和素材库");
const metadata = { content: resourceFileUrl(resourceIdFromStorageKey(output.storageKey)!), storageKey: output.storageKey, status: "success" as const, naturalWidth: output.width, naturalHeight: output.height, bytes: output.bytes, mimeType: output.mimeType, ...(result.video ? { durationMs: result.video.durationMs } : {}), generationTaskId: task.id };
const asset = await this.getAsset()({ canvasId: this.run!.canvasId!, node: { ...node, metadata: { ...node.metadata, ...metadata } }, source: "canvas-generation", taskId: task.id, signal: this.abort.signal });
this.guard();
await this.commitOps([{ type: "update_node", id: media.nodeId, metadata: { ...metadata, assetId: asset.assetId } }]);
const specificationError = result.video && creativeVideoSpecificationError(this.state.proposal?.generationItems.find((item) => item.ref === media.ref), result.video);
if (specificationError) {
this.setMedia(media.ref, { status: "failed", storageKey: output.storageKey, error: specificationError });
throw new Error(specificationError);
}
this.setMedia(media.ref, { status: "ready", storageKey: output.storageKey, error: undefined }); await this.save("waiting_task");
} catch (error) { if (this.abort.signal.aborted) throw error; this.setMedia(media.ref, { status: this.state.media.find((item) => item.ref === media.ref)?.status === "failed" ? "failed" : "write_failed", error: error instanceof Error ? error.message : "资源回写失败" }); await this.save("paused"); throw error; }
}
async resume() {
await this.action(async () => {
this.guard(); this.accept(await this.api.get(this.run!.id, this.abort.signal));
if (this.state.modificationRequested) throw new Error("请先说明想修改的内容,调整后的方案需要重新确认。");
this.state = { ...this.state, error: undefined };
await this.save(this.state.questions?.status === "pending" ? "waiting_answer" : "running");
if (this.state.pendingRedo) { await this.continueRedo(); return; }
if (!this.state.planning && this.state.messages.at(-1)?.role === "user") {
if (this.state.externalInteractionPresented) { await this.save("idle"); return; }
await this.preparePlanning(this.state.messages.at(-1)!.text); return;
}
if (this.state.planning && !this.state.planning.consumed && !this.state.planning.submissionId) await this.ensurePlanningSubmission();
const planning = this.state.planning;
if (planning?.submissionId && !planning.consumed) {
const submission = this.submissions.find((item) => item.id === planning.submissionId);
if (submission?.taskId) { const task = await this.waitTask(submission.taskId, { signal: this.abort.signal }); await this.consumePlanning(task); return; }
if (submission?.approvedAt) { await this.executeApproved([submission.id]); return; }
this.state = { ...this.state, pendingPayment: [planning.submissionId] }; await this.save("waiting_payment"); return;
}
if (!this.state.canvasApplied && this.state.proposal && this.run!.approvedProposalHash && this.run!.approvedProposalVersion === this.state.proposal.version) {
const linked = await this.api.canvas(this.run!.id, this.guard(), this.abort.signal); this.run = linked.run;
if (this.options.canvas()?.canvasId !== linked.canvasId) { await this.save("waiting_canvas"); await this.api.release(this.run.id, this.guard()); this.hasControl = false; this.options.onOpenCanvas(linked.canvasId, this.run.id); return; }
await this.applyProposal(); return;
}
if (this.state.questions?.status === "pending") return;
if (this.state.pendingEdits) { await this.save("waiting_proposal"); return; }
if (this.state.proposal && !this.state.canvasApplied) { await this.save("waiting_proposal"); return; }
const errors: string[] = [];
for (const media of this.state.media) {
const submission = this.submissions.find((item) => item.id === media.submissionId);
if (submission?.taskId && media.status !== "ready") { this.setMedia(media.ref, { taskId: submission.taskId }); try { await this.observeMedia({ ...media, taskId: submission.taskId }); } catch (error) { this.assertLive(); errors.push(error instanceof Error ? error.message : "生成未完成"); } }
}
if (errors.length) { await this.save("paused"); throw new Error([...new Set(errors)].join(";")); }
const approved = this.state.pendingPayment?.filter((id) => this.submissions.some((item) => item.id === id && item.approvedAt));
if (approved?.length) await this.executeApproved(approved);
else if (this.state.pendingPayment?.length) await this.save("waiting_payment");
else if (this.state.proposal && this.state.canvasApplied) await this.prepareMediaBatch();
});
}
async redo(ref: string) {
await this.action(async () => {
if (this.state.pendingRedo) throw new Error("已有待恢复的重做操作,请先核对状态并继续");
const media = this.state.media.find((item) => item.ref === ref);
if (!media) throw new Error("生成项不存在");
if (media.taskId) { const task = await this.queryTask(media.taskId, { signal: this.abort.signal }); if (["running", "queued"].includes(task.status) || task.billing?.status === "uncertain") throw new Error("原任务仍在执行或账务待核对,不能重新扣费"); }
const canvas = this.options.canvas();
const proposal = this.state.proposal;
if (!canvas || !proposal) throw new Error("请先打开对应画布");
const snapshot = canvas.read();
const node = snapshot.nodes.find((node) => node.id === media.nodeId);
if (!node) throw new Error("目标节点已删除,不能自动重建");
const version = Math.max(proposal.version, this.run!.approvedProposalVersion || 0) + 1;
const nodeIds = (proposal.extra as { nodeIds?: Record<string, string> } | undefined)?.nodeIds || Object.fromEntries(proposal.workflow.nodes.map((node) => [node.ref, creativeNodeId(this.run!.id, proposal.version, node.ref)]));
const operations: CanvasAgentOp[] = Object.values(nodeIds).map((id) => { const current = snapshot.nodes.find((node) => node.id === id); if (!current) throw new Error("原方案节点已被删除,请核对方案后继续"); return { type: "update_node", id, metadata: { ...current.metadata } }; });
const nextProposal = { ...proposal, version, extra: { ...(proposal.extra as Record<string, unknown> || {}), nodeIds }, generationItems: proposal.generationItems.map((item) => item.ref === ref ? { ...item, model: String(node.metadata?.model || item.model), size: node.metadata?.size || item.size, seconds: node.metadata?.seconds ? Number(node.metadata.seconds) : item.seconds, quality: (item.mode === "video" ? node.metadata?.vquality : node.metadata?.quality) || item.quality } : item) };
assertCreativeBriefSpecifications(nextProposal, this.state.brief);
this.state = { ...this.state, proposal: nextProposal, operations, pendingPayment: undefined, planning: undefined, pendingRedo: { ref, attempt: media.attempt + 1, proposalVersion: version } };
await this.save("running");
await this.continueRedo();
});
}
private async continueRedo() {
const intent = this.state.pendingRedo, proposal = this.state.proposal, operations = this.state.operations;
if (!intent || !proposal || proposal.version !== intent.proposalVersion || !operations || !this.state.media.some((media) => media.ref === intent.ref)) throw new Error("重做记录不完整,请重新核对方案");
if (this.run!.approvedProposalVersion !== intent.proposalVersion || !this.run!.approvedProposalHash) {
this.run = await this.api.approveProposal(this.run!.id, { ...this.guard(), revision: this.run!.revision, proposalVersion: intent.proposalVersion, proposal, ops: operations }, this.abort.signal);
}
this.guard();
this.setMedia(intent.ref, { status: "pending", attempt: intent.attempt, submissionId: undefined, taskId: undefined, error: undefined });
this.state = { ...this.state, pendingRedo: undefined };
await this.save("running");
await this.prepareMediaBatch();
}
}
function reconcileOps(ops: CanvasAgentOp[], snapshot: CanvasAgentSnapshot): CanvasAgentOp[] {
return ops.filter((op) => {
if (op.type === "add_node") return !snapshot.nodes.some((node) => node.id === op.id);
if (op.type === "connect_nodes") return !snapshot.connections.some((edge) => edge.id === op.id || edge.fromNodeId === op.fromNodeId && edge.toNodeId === op.toNodeId);
if (op.type === "run_generation") throw new Error("画布写入不能直接提交收费生成");
return true;
});
}
function toolInput(call: ResponseToolCall): ResponseInputMessage { return { type: "function_call", call_id: call.id, name: call.function.name, arguments: call.function.arguments, ...(call.thoughtSignature ? { thoughtSignature: call.thoughtSignature } : {}) }; }

View File

@@ -0,0 +1,68 @@
import { nanoid } from "nanoid";
import type { AiConfig } from "@/stores/use-config-store";
import type { ResponseInputMessage, ResponseToolCall } from "./api/image";
import { CREATIVE_SCENARIOS, normalizeCreativeQuestions, type CreativeScenarioId } from "@/lib/creation/creative-agent-contract";
import { assertCreativeBriefSpecifications, mergeCreativeBrief, normalizeCreativeProposal, type CreativeAgentState } from "@/lib/creation/creative-agent-state";
import { parseCreativeToolArguments } from "@/lib/creation/creative-agent-tools";
type Reply = { content: string; toolCalls: ResponseToolCall[] };
type Context = { config: AiConfig; state: CreativeAgentState; latestInput: string };
function validate(reply: Reply, context: Context) {
const call = reply.toolCalls.find((item) => item.function.name === "creative_respond");
if (!call) return;
const input = parseCreativeToolArguments(call.function.arguments);
const scene = typeof input.scenario === "string" && Object.hasOwn(CREATIVE_SCENARIOS, input.scenario) ? input.scenario as CreativeScenarioId : context.state.scene;
const brief = mergeCreativeBrief(context.state.brief, input.brief, scene, context.latestInput);
const questions = normalizeCreativeQuestions(input.questions, scene, brief, { interactionId: "validation", revision: 1 }, context.state.references.map((asset) => ({ id: asset.id, label: asset.title })));
if (questions.questions.length) return;
if (Array.isArray(input.questions) && input.questions.length && !input.proposal) throw new Error("问题均为已知或无效字段。请使用已知答案继续规划,不要重复提问;若缺少其他关键信息,请提出具体的新问题。");
if (input.proposal) {
const proposal = normalizeCreativeProposal(input.proposal, "validation", 1, context.config, context.state.references);
assertCreativeBriefSpecifications(proposal, brief);
}
}
function recoveryQuestion(context: Context, error: string): Reply {
const assetIssue = /引用|素材|图片节点/.test(error);
const modelIssue = /模型|能力|尺寸|比例|时长/.test(error);
const field = `recovery.choice-${nanoid(8)}`;
const message = assetIssue ? "这份方案的素材依赖仍未能可靠绑定。已有作品保留,请选择下一步,我会按你的选择继续规划。"
: modelIssue ? "当前可用生成能力还不能可靠满足这份方案。请决定接下来如何安排,已确认的目标会保留。"
: "我尝试修正了方案,但它仍未达到可执行条件。已有内容保留,你可以先完善创意,或补充调整要求后继续。";
const choices = assetIssue
? [{ id: "choose", label: "先让我选择参考素材", description: "助手下一轮列出当前真实可用素材供选择。" }, { id: "text", label: "先完善文字方案", description: "先讨论内容,不创建或生成媒体。" }]
: [{ id: "text", label: "先完善文字方案", description: "保留创意和已确认需求,暂不进入媒体制作。" }, { id: "alternatives", label: "说明可行替代方案", description: "列出差异,由我决定是否修改目标。" }];
const input = { scenario: context.state.scene, message, brief: [], questions: [{ field, type: "single", title: "接下来希望怎样推进?", required: true, allowCustom: true, options: choices }] };
return { content: message, toolCalls: [{ id: `recovery-${nanoid()}`, type: "function", function: { name: "creative_respond", arguments: JSON.stringify(input) } }] };
}
// Correct rejected arguments before presentation. This loop never executes canvas or media tools.
export async function recoverCreativeResponse(initial: Reply, messages: ResponseInputMessage[], context: Context,
request: (messages: ResponseInputMessage[]) => Promise<Reply>, onProgress: (attempt: number, error: string) => void): Promise<Reply> {
let reply = initial;
let protocol = [...messages];
for (let attempt = 0; attempt <= 2; attempt++) {
let failure: string;
try { validate(reply, context); return reply; }
catch (error) { failure = error instanceof Error ? error.message : "交互格式无效"; }
onProgress(attempt + 1, failure);
if (attempt === 2) return recoveryQuestion(context, failure);
protocol = [...protocol,
...reply.toolCalls.map((call): ResponseInputMessage => ({ type: "function_call", call_id: call.id, name: call.function.name, arguments: call.function.arguments, ...(call.thoughtSignature ? { thoughtSignature: call.thoughtSignature } : {}) })),
...reply.toolCalls.map((call): ResponseInputMessage => ({ role: "tool", tool_call_id: call.id, content: JSON.stringify({ status: call.function.name === "creative_respond" ? "validation_failed" : "cancelled", error: failure, executed: false }) })),
{ role: "user", content: "这是程序校验反馈,不是用户修改需求。未创建节点或消费媒体报价。请自行修复参数,保留已知要求与真实引用;不让用户填写技术字段。缺少真实素材、事实或能力时,用creative_respond.questions主动提问,选项来自当前上下文且允许自由补充。不再仅说‘已修复’或‘请确认’,必须返回可校验的完整方案或可回答问题。仍无法组成媒体方案时可先提供纯文字创意并提问下一步,不虚构媒体配置。" },
];
// Only proposal/question tools are supplied to the repair model; there are no writable tools.
try {
reply = await request(protocol);
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw error;
onProgress(3, error instanceof Error ? error.message : "修正请求失败");
return recoveryQuestion(context, failure);
}
reply = { ...reply, toolCalls: reply.toolCalls.filter((call) => call.function.name === "creative_respond") };
if (!reply.toolCalls.length && !reply.content.trim()) return recoveryQuestion(context, failure);
}
return reply;
}

View File

@@ -130,7 +130,7 @@ function functionTool(name: string, description: string, properties: Record<stri
}
const PROGRESSIVE_SKILL_TOOLS: ResponseFunctionTool[] = [
functionTool("canvas_list_skills", "列出当前用户已加入、可按需加载的画布技能;只返回元数据,不返回完整指令。", {}),
functionTool("canvas_list_skills", "自动发现当前用户已加入的技能;可按 query 检索名称、描述或标签,offset/limit 分页;只返回元数据。", { query: { type: "string" }, offset: { type: "number" }, limit: { type: "number" } }),
functionTool("canvas_get_skill", "按 skillId 或名称读取技能元数据和入口 SKILL.md;不会加载 references、scripts、assets 等其他文件。", { skillId: { type: "string" }, name: { type: "string" } }),
functionTool("canvas_list_skill_files", "列出一个技能包的文件路径、类型和大小,不读取文件正文。", { skillId: { type: "string" }, name: { type: "string" } }),
functionTool("canvas_read_skill_file", "按路径读取技能包中的一个文本文件。先从入口引用或文件清单确定路径,禁止猜测路径。", { skillId: { type: "string" }, name: { type: "string" }, path: { type: "string" } }, ["path"]),
@@ -343,9 +343,37 @@ function nativePackage(skill: Skill, bundle: SkillPackageBundle): NativeSkillPac
function createProgressiveToolsAdapter(dependencies: SkillRuntimeDependencies): SkillToolAdapter {
const handlers: Record<string, (args: Record<string, unknown>, skills: Skill[]) => Promise<SkillRuntimeToolResult>> = {
canvas_list_skills: async (_args, skills) => {
const data = skills.filter((skill) => skill.isAdded).map((skill) => ({ skillId: skill.skillId, name: skill.skillName, description: skill.description, tag: skill.tag, version: skill.version, fileCount: skill.fileCount, sourceType: skill.sourceType }));
return { ok: true, message: data.length ? "已列出当前可用技能。" : "当前没有已加入技能。", data };
canvas_list_skills: async (args, skills) => {
const terms = typeof args.query === "string"
? args.query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
: [];
const matches = skills.filter((skill) => skill.isAdded && terms.every((term) =>
`${skill.skillName} ${skill.description} ${skill.tag}`.toLocaleLowerCase().includes(term),
));
const offset = typeof args.offset === "number" && Number.isFinite(args.offset)
? Math.max(0, Math.floor(args.offset))
: 0;
const limit = typeof args.limit === "number" && Number.isFinite(args.limit)
? Math.max(1, Math.min(100, Math.floor(args.limit)))
: 40;
const items = matches.slice(offset, offset + limit).map((skill) => ({
skillId: skill.skillId,
name: skill.skillName,
description: skill.description,
tag: skill.tag,
version: skill.version,
fileCount: skill.fileCount,
sourceType: skill.sourceType,
}));
return {
ok: true,
message: items.length ? "已发现相关技能,请按需读取入口;无匹配时可缩短关键词或查询完整目录。" : "没有匹配技能,可调整关键词。",
data: {
items,
total: matches.length,
nextOffset: offset + items.length < matches.length ? offset + items.length : null,
},
};
},
canvas_get_skill: async (args, skills) => {
const skill = requireAddedSkill(skills, args);

View File

@@ -0,0 +1,35 @@
import { nanoid } from "nanoid";
import { creationRuns, type CreationSubmission } from "./api/creation-runs";
import type { CreateTaskInput } from "./api/task-center";
// Both the node composer and Agent submit the same quoted professional task.
export async function submitStoryboardTask(request: CreateTaskInput, options: {
signal: AbortSignal;
assertCurrent: () => void;
confirm: (submission: CreationSubmission) => Promise<boolean>;
}, api = creationRuns) {
const { signal, assertCurrent } = options;
const detail = await api.create({ clientKey: `storyboard:${nanoid()}`, canvasId: request.projectId, state: { kind: "storyboard" } }, signal);
const run = await api.claim(detail.run.id, { expectedEpoch: detail.run.executionEpoch, owner: `storyboard:${nanoid()}` }, signal);
const guard = { executionEpoch: run.executionEpoch, owner: run.executionOwner };
let connectionError: unknown;
const heartbeat = setInterval(() => {
void api.heartbeat(run.id, guard, signal).catch((error) => { connectionError = error; });
}, 15_000);
try {
const submission = await api.prepare(run.id, { ...guard, itemKey: "storyboard", request }, signal);
if (!await options.confirm(submission)) return undefined;
signal.throwIfAborted();
if (connectionError) throw connectionError;
assertCurrent();
if (Date.parse(submission.quote.expiresAt) <= Date.now()) throw new Error("分镜报价已过期,请重新发起拆镜并确认最新费用");
const approved = await api.approve(run.id, { ...guard, submissionIds: [submission.id] }, signal);
if (!approved.submissions.some((item) => item.id === submission.id && item.approvedAt)) throw new Error("费用确认回执不完整,尚未提交拆镜任务");
signal.throwIfAborted();
assertCurrent();
return await api.execute(run.id, { ...guard, submissionId: submission.id }, signal);
} finally {
clearInterval(heartbeat);
void api.release(run.id, guard).catch(() => { /* Offline leases expire on the server. */ });
}
}

View File

@@ -224,7 +224,7 @@ export function localSavedRemotePendingMessage(localAction: string, error: unkno
return formatLocalSavedRemotePending(localAction, error);
}
export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string, initialContent?: Partial<Pick<CanvasProject, "nodes" | "connections">>) {
export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string, initialContent?: Partial<Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId">>) {
const id = useCanvasStore.getState().createProject(title, projectId);
if (initialContent) useCanvasStore.getState().updateProject(id, initialContent);
if (!activeRemoteUserId) return { id, syncError: new Error("尚未建立云端同步会话") };