183 lines
13 KiB
TypeScript
183 lines
13 KiB
TypeScript
import "server-only";
|
||
import { asSchema, type ToolSet } from "ai";
|
||
import { join } from "node:path";
|
||
import { runNativeZcode, type NativeEvent, type NativeResult } from "@/services/zcode-agent/runner.mjs";
|
||
import type { CanvasAgentTool } from "@/lib/ai/canvas-agent-runtime/loop";
|
||
import { publicAgentError, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol";
|
||
|
||
type Ports = {
|
||
native?: typeof runNativeZcode;
|
||
runId: string;
|
||
snapshot: AgentRunSnapshot;
|
||
definitions: ToolSet;
|
||
tools: Record<string, CanvasAgentTool>;
|
||
instructions: string;
|
||
context: unknown;
|
||
gateway: { modelId: string; apiType: string; forward: (body: Record<string, unknown>, path: string, signal: AbortSignal) => Promise<Response> };
|
||
signal: AbortSignal;
|
||
checkpoint: (snapshot: AgentRunSnapshot, status: CanvasAgentRunStatus) => Promise<void>;
|
||
consumeInputs: (snapshot: AgentRunSnapshot) => Promise<{ cancelled: boolean }>;
|
||
isCancelled: () => Promise<boolean>;
|
||
verify: (snapshot: AgentRunSnapshot) => Promise<NonNullable<AgentRunSnapshot["verification"]>>;
|
||
};
|
||
|
||
export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunStatus> {
|
||
const { snapshot } = ports;
|
||
let serial: Promise<unknown> = Promise.resolve();
|
||
function exclusive<T>(action: () => Promise<T>): Promise<T> {
|
||
const next = serial.then(action); serial = next.catch(() => undefined); return next;
|
||
}
|
||
async function save(message: string, status: CanvasAgentRunStatus = "running") {
|
||
snapshot.message = message;
|
||
await ports.checkpoint(structuredClone(snapshot), status); return status;
|
||
}
|
||
let modelError = "";
|
||
try {
|
||
if (snapshot.calls.some(call => call.status === "started" || call.uncertain))
|
||
return await save("存在结果未确认的工具调用,请先核对画布,避免重复生成。", "needs_review");
|
||
snapshot.nativeRuntime = { ...snapshot.nativeRuntime, engine: "zcode", state: "starting" };
|
||
const schemas = new Map(Object.entries(ports.definitions).map(([name, definition]) => [name, asSchema(definition.inputSchema!)]));
|
||
const toolDescriptors = Object.entries(ports.tools).map(([name, entry]) => ({ name,
|
||
description: ports.definitions[name]?.description || entry.title,
|
||
inputSchema: schemas.get(name)!.jsonSchema, readOnly: entry.readOnly }));
|
||
toolDescriptors.push({ name: "requestCanvasUserInput", description: "需要用户补充必要信息时提出问题,保存任务并等待用户回答。不要用文件、shell 或虚构数据代替用户输入。",
|
||
inputSchema: { type: "object", properties: { question: { type: "string", minLength: 1, maxLength: 1000 } }, required: ["question"], additionalProperties: false }, readOnly: true });
|
||
let question = "";
|
||
const tools = Object.keys(ports.tools);
|
||
const instructions = [ports.instructions,
|
||
"你正在独立沙箱内运行原生 ZCode 核心。本站图片/视频/节点/界面能力已通过 canvas MCP 全部授权;先读实际画布,不猜测节点 ID。",
|
||
`可用业务工具:${tools.join("、")}。工具名称带 mcp__canvas__ 前缀。`,
|
||
"原生文件、Bash、记忆及子任务能力仅作用于此任务的私有工作区,不能通过 shell 直接访问模型服务商或本站后台;业务操作使用 MCP。",
|
||
"需要澄清时调用 requestCanvasUserInput;不要使用终端交互。工具返回的 userUpdates 是用户的新补充,必须从下一步纳入。",
|
||
"工具错误涉及结果未确认时停止收费动作,不通过更换调用 ID 或另一个工具重复生成。用户明确要求修改的目标才能删除或覆盖。",
|
||
"只有真实工具返回成功才可声称完成;打开面板、等待用户选文件、后台缺少处理服务都不能声称媒体已处理。",
|
||
].join("\n");
|
||
let prompt = snapshot.nativeRuntime.sessionId
|
||
? ["继续当前任务,保留所有已完成结果。", ...snapshot.inputs.filter(input => input.applied).slice(-4).map(input => input.text)].join("\n")
|
||
: `${snapshot.objective}\n\n当前画布与历史对话上下文:${JSON.stringify(ports.context)}`;
|
||
while (true) {
|
||
const control = await exclusive(() => ports.consumeInputs(snapshot));
|
||
if (control.cancelled || ports.signal.aborted) return await save("已停止后续处理,完成的结果保留在画布。", "cancelled");
|
||
await save("正在启动 ZCode 独立沙箱");
|
||
const startedInputs = new Set(snapshot.inputs.map(input => input.id));
|
||
const result = await (ports.native || runNativeZcode)({
|
||
runId: ports.runId, attemptId: crypto.randomUUID(),
|
||
runtimeDir: process.env.CANVAS_ZCODE_RUNTIME_DIR || "/opt/yedir-canvas-zcode/current",
|
||
stateDir: process.env.CANVAS_ZCODE_STATE_DIR || "/var/lib/yedir-canvas-agent",
|
||
nodePath: process.env.CANVAS_ZCODE_NODE_PATH || join(process.env.CANVAS_ZCODE_RUNTIME_DIR || "/opt/yedir-canvas-zcode/current", "bin/node"),
|
||
modelId: ports.gateway.modelId, apiType: ports.gateway.apiType,
|
||
instructions, prompt, context: ports.context, sessionId: snapshot.nativeRuntime.sessionId,
|
||
signal: ports.signal, tools: toolDescriptors, formatToolError: publicAgentError,
|
||
model: async (body, path, signal) => {
|
||
const response = await ports.gateway.forward(body, path, signal);
|
||
if (!response.ok) modelError = response.status === 401 ? "当前 Agent 模型服务商鉴权失败,请检查模型配置"
|
||
: response.status === 429 ? "Agent 模型通道额度不足或请求过多" : "Agent 模型连接中断,已保留执行记录";
|
||
return response;
|
||
},
|
||
onSandbox: async metadata => exclusive(async () => {
|
||
snapshot.nativeRuntime = { ...snapshot.nativeRuntime!, sandbox: metadata.kind, commit: metadata.commit, state: "starting" };
|
||
await save("正在启动独立沙箱中的 ZCode 核心");
|
||
}),
|
||
onEvent: async (event: NativeEvent | NativeResult) => {
|
||
if (!["turn.started", "result", "session.created", "session.resumed"].includes(event.type)) return;
|
||
await exclusive(async () => {
|
||
if (event.sessionId) snapshot.nativeRuntime!.sessionId = event.sessionId;
|
||
if (event.traceId) snapshot.nativeRuntime!.traceId = event.traceId;
|
||
snapshot.nativeRuntime!.state = "running";
|
||
if (event.type === "turn.started") snapshot.steps++;
|
||
await save("ZCode 正在规划并执行画布任务");
|
||
});
|
||
},
|
||
execute: async (name, input, callId) => {
|
||
if (name === "requestCanvasUserInput") {
|
||
if (typeof input.question !== "string" || !input.question.trim() || input.question.length > 1000) throw new Error("请提供明确的问题");
|
||
question = input.question; return { status: "waiting_for_user", question };
|
||
}
|
||
const entry = ports.tools[name]; if (!entry) throw new Error("此画布工具未授权");
|
||
const schema = schemas.get(name)!;
|
||
const validated = schema.validate ? await schema.validate(input) : { success: true as const, value: input };
|
||
if (!validated.success) throw new Error("画布工具参数无效,未提交业务操作");
|
||
const previous = await exclusive(async () => {
|
||
if ((await ports.consumeInputs(snapshot)).cancelled || ports.signal.aborted) throw new Error("任务已停止,未提交新操作");
|
||
const old = snapshot.calls.find(call => call.id === callId);
|
||
if (old) {
|
||
if (old.name !== name || JSON.stringify(old.input) !== JSON.stringify(input)) throw new Error("工具调用标识与参数不一致");
|
||
if (old.status === "succeeded") return old.output!;
|
||
throw new Error("此工具结果待确认,禁止自动重复执行");
|
||
}
|
||
if (!entry.readOnly && snapshot.calls.some(call => call.uncertain)) throw new Error("先核对未确认的操作,禁止重复提交收费任务");
|
||
if (!entry.readOnly && !snapshot.calls.some(call => call.name === "planCanvasTask" && call.status === "succeeded"))
|
||
throw new Error("请先制定任务计划再执行画布修改");
|
||
if (!entry.readOnly && snapshot.verification?.retryable && snapshot.verification.failedNodeIds?.length) {
|
||
const targets = [input.nodeId, ...(Array.isArray(input.nodeIds) ? input.nodeIds : []),
|
||
...(Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds : [])].filter((id): id is string => typeof id === "string");
|
||
if (!targets.length || targets.some(id => !snapshot.verification!.failedNodeIds!.includes(id)))
|
||
throw new Error("修复只能处理未通过验收的节点,禁止重复生成已通过的结果");
|
||
}
|
||
snapshot.calls.push({ id: callId, name, input, status: "started" });
|
||
await save(`正在执行:${entry.title}`); return undefined;
|
||
});
|
||
if (previous) return previous;
|
||
try {
|
||
const output = await entry.execute(validated.value as Record<string, unknown>, callId);
|
||
await exclusive(async () => {
|
||
const call = snapshot.calls.find(call => call.id === callId)!;
|
||
call.status = "succeeded"; call.output = output;
|
||
if (name === "planCanvasTask") snapshot.requirements = { ...snapshot.requirements,
|
||
...(typeof output.expectedImageCount === "number" ? { expectedImageCount: output.expectedImageCount } : {}),
|
||
...(typeof output.aspectRatio === "string" ? { aspectRatio: output.aspectRatio } : {}) };
|
||
const ids = Array.isArray(output.createdNodeIds) ? output.createdNodeIds.filter((id): id is string => typeof id === "string") : [];
|
||
snapshot.resultNodeIds = [...new Set([...snapshot.resultNodeIds, ...ids])];
|
||
await save(`已完成:${entry.title}`);
|
||
});
|
||
return { ...output, userUpdates: snapshot.inputs.filter(input => !startedInputs.has(input.id)).map(input => input.text) };
|
||
} catch (error) {
|
||
await exclusive(async () => {
|
||
const call = snapshot.calls.find(call => call.id === callId)!;
|
||
call.status = "failed"; call.error = publicAgentError(error); call.uncertain = !entry.readOnly;
|
||
await save(call.error);
|
||
});
|
||
throw error;
|
||
}
|
||
},
|
||
});
|
||
await exclusive(async () => {
|
||
snapshot.nativeRuntime!.sessionId = result.sessionId;
|
||
snapshot.nativeRuntime!.state = "stopped";
|
||
snapshot.inputTokens += Number(result.usage?.inputTokens || 0);
|
||
snapshot.outputTokens += Number(result.usage?.outputTokens || 0);
|
||
snapshot.messages.push({ role: "assistant", content: result.response || "" });
|
||
await ports.consumeInputs(snapshot);
|
||
});
|
||
if (await ports.isCancelled()) return await save("已停止后续处理,完成的结果保留在画布。", "cancelled");
|
||
if (snapshot.calls.some(call => call.status === "failed" || call.uncertain))
|
||
return await save("部分工具结果待确认,已保留执行记录,请核对后继续。", "needs_review");
|
||
if (question || snapshot.calls.some(call => call.output?.mode === "interactive"))
|
||
return await save(question || "请在已打开的工具面板中完成操作,然后继续任务。", "needs_review");
|
||
const supplements = snapshot.inputs.filter(input => !startedInputs.has(input.id));
|
||
if (supplements.length) { prompt = supplements.map(input => input.text).join("\n"); continue; }
|
||
// Text/analysis tasks have no generated media to inspect. Mutating tasks
|
||
// still use the existing independent checks against actual saved nodes.
|
||
const mutated = snapshot.calls.some(call => !ports.tools[call.name]?.readOnly);
|
||
if (!mutated && !snapshot.requirements?.expectedImageCount) {
|
||
const expectsChange = /生成|抠图|去背景|换背景|删除|移除|替换|改名|移动|放大|裁剪|旋转|导出|下载|制作|添加|上传/.test(snapshot.objective)
|
||
&& !/只(?:要|需)?(?:分析|讨论|建议|提示词)|不要(?:执行|生成|修改)/.test(snapshot.objective);
|
||
return await save(expectsChange ? "尚未执行画布操作,请核对任务计划后继续。" : result.response || "已完成画布分析。", expectsChange ? "needs_review" : "completed");
|
||
}
|
||
await save("正在核对画布中的实际结果");
|
||
const verification = await ports.verify(snapshot); snapshot.verification = verification;
|
||
if (verification.passed) return await save(result.response || "任务完成,结果已写入画布。", "completed");
|
||
if (verification.retryable && snapshot.repairs < 2) {
|
||
snapshot.repairs++;
|
||
prompt = `验收需要定点修复:${JSON.stringify(verification)}。只处理失败结果,禁止整批重新生成;保留所有通过结果。`;
|
||
continue;
|
||
}
|
||
return await save(`结果待核对:${verification.issues.join(";")}`, "needs_review");
|
||
}
|
||
} catch (error) {
|
||
if (snapshot.nativeRuntime) snapshot.nativeRuntime.state = "stopped";
|
||
const cancelled = await ports.isCancelled().catch(() => false);
|
||
return await save(cancelled ? "已停止后续步骤,完成的结果保留在画布。" : modelError || publicAgentError(error), cancelled ? "cancelled" : "needs_review");
|
||
}
|
||
}
|