112 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import "server-only";
import { AgentToolRejectedError } from "./protocol";
import { tool, type ToolSet } from "ai";
import type { Session } from "next-auth";
import { z } from "zod";
import { LEGACY_AGENT_TOOL_MAP } from "./legacy-tool-map";
import contracts from "./project-operation-contracts.json";
import { getAccessibleAgentPresetById, getDocumentById } from "@/lib/db/queries";
import { getEnabledTools } from "@/lib/ai/tools-registry";
import { getWeather } from "@/lib/ai/tools/get-weather";
import { createDocument } from "@/lib/ai/tools/create-document";
import { updateDocument } from "@/lib/ai/tools/update-document";
import { requestSuggestions } from "@/lib/ai/tools/request-suggestions";
import { PROJECT_AGENT_OPERATIONS, projectOperationAuthorized, projectOperationUrl, sanitizeProjectToolOutput, validateCanvasMetadataInput } from "./project-operation-catalog";
import { isExplicitDestructiveIntent } from "./capability-access";
import type { CanvasAgentTool } from "./loop";
export async function registerProjectAgentTools(params: {
session: Session; projectId: string; request: Request; objective: string;
definitions: ToolSet; executable: Record<string, CanvasAgentTool>;
emit: (type: string, data: unknown) => void;
}) {
const userId = params.session.user!.id!;
function register(name: string, description: string, schema: z.ZodTypeAny, execute: CanvasAgentTool["execute"], readOnly = true) {
params.definitions[name] = tool({ description, inputSchema: schema });
params.executable[name] = { title: description.slice(0, 32), readOnly, concurrentSafe: readOnly, execute };
}
register("listProjectServices", "列出图片画布之外的全部项目业务工具、真实接口及输入提示。可按分组筛选;authorized=false的分镜/视频功能暂不授权。", z.object({ group: z.string().max(100).optional(), operationId: z.string().max(120).optional() }),
async input => ({ services: PROJECT_AGENT_OPERATIONS.filter(item => (!input.group || item.group === input.group) && (!input.operationId || item.id === input.operationId))
.map(item => ({ ...item, authorized: projectOperationAuthorized(item), ...(input.operationId ? { requestContract: (contracts as Record<string, unknown>)[item.id] } : {}) })), legacyTools: ["getWeather", "createDocument", "updateDocument", "requestSuggestions"],
limits: "仅当前用户权限;无后台管理、密钥、支付、系统任务和Claw控制权限;文件选择用浏览器入口;异步任务必须轮询真实状态。" }));
const invokeSchema = z.object({ operationId: z.string().min(1).max(120), pathParams: z.record(z.string().max(180)).default({}),
query: z.record(z.union([z.string().max(4000), z.number(), z.boolean()])).default({}), body: z.record(z.unknown()).optional() });
async function invoke(input: Record<string, unknown>, callId: string, readOnly: boolean): Promise<Record<string, unknown>> {
const args = invokeSchema.parse(input);
const operation = PROJECT_AGENT_OPERATIONS.find(item => item.id === args.operationId);
if (!operation || operation.readOnly !== readOnly) throw new Error("请使用清单中对应读写类别的固定业务工具");
if (!projectOperationAuthorized(operation)) throw new Error("用户暂未授权视频画布和分镜工具");
if (JSON.stringify(args.body || {}).length > 2 * 1024 * 1024) throw new Error("业务工具输入超过大小限制");
if (args.body?.workspaceType === "video" || args.query.workspaceType === "video" || args.body?.type === "video") throw new Error("视频画布暂未授权");
if (operation.path.startsWith("/api/canvas/[id]")) {
if (args.pathParams.id !== params.projectId) throw new Error("画布业务接口仅授权当前画布,不能跨画布读取或写入");
}
if (operation.id === "canvas-metadata.put") {
try { validateCanvasMetadataInput(args.body || {}); }
catch { throw new AgentToolRejectedError("画布元数据格式无效,未提交操作;只允许名称、描述与封面,不能覆盖节点或更改工作区类型"); }
}
if (operation.id.startsWith("skill-item.") && !operation.readOnly) {
const preset = await getAccessibleAgentPresetById({ id: args.pathParams.id, userId });
if (!preset || preset.ownerUserId !== userId) throw new Error("仅授权修改本人技能,公共技能管理请使用管理员界面");
}
if ((args.body?.visibility === "public" || args.body?.isPublic === true) && !/发布|公开/.test(params.objective))
throw new Error("公开发布需要用户明确要求,不能把创建草稿当作发布授权");
if (operation.method === "DELETE" && (!isExplicitDestructiveIntent(params.objective) || !Object.values(args.pathParams).some(id => params.objective.includes(id))))
throw new Error("删除项目文件需要用户明确提出删除,并在要求中指定文件编号;模型不能自行授权");
if (operation.id === "canvas-list.post" && !/新建|创建|新增/.test(params.objective)) throw new Error("请先确认需要新建画布");
if (args.body?.userId && args.body.userId !== userId) throw new Error("不能指定其他用户身份");
const route = projectOperationUrl(operation, args.pathParams, args.query);
const incoming = new URL(params.request.url);
const port = incoming.port || process.env.PORT || "3125";
if (!/^\d{2,5}$/.test(port) || Number(port) > 65535) throw new Error("业务网关端口未配置");
const headers = new Headers({ "Content-Type": "application/json", "x-canvas-agent-call-id": callId });
// Auth is inherited from the original request; models cannot supply headers,
// cookies, another host, URL, user identity or provider credentials.
for (const key of ["cookie", "host", "x-forwarded-host", "x-forwarded-proto"])
if (params.request.headers.has(key)) headers.set(key, params.request.headers.get(key)!);
const response = await fetch(`http://127.0.0.1:${port}${route}`, { method: operation.method, headers, redirect: "error", cache: "no-store",
...(operation.method !== "GET" ? { body: JSON.stringify(args.body || {}) } : {}), signal: AbortSignal.timeout(300000) });
if (!response.headers.get("content-type")?.includes("application/json")) { await response.body?.cancel(); throw new Error(`此工具未返回可核验JSON结果(${response.status});请使用真实界面处理文件或流式操作`); }
const raw = await response.text(); if (raw.length > 2 * 1024 * 1024) throw new Error("业务返回过大,请缩小查询范围");
const payload = JSON.parse(raw);
if ([400,401,403,404,405,422].includes(response.status)) throw new AgentToolRejectedError(`业务工具参数或权限未通过(${response.status}),未提交操作。${typeof payload?.error === "string" ? payload.error.slice(0,200) : "请先读取该操作的真实请求契约"}`);
if (!response.ok || payload?.success === false || payload?.error) throw new Error(`业务工具 ${operation.title} 未成功(${response.status}):${typeof payload?.error === "string" ? payload.error.slice(0, 200) : "请核对输入参数或底层服务权限"}`);
if (operation.id === "canvas-list.get" && Array.isArray(payload?.data)) payload.data = payload.data.filter((row: { workspaceType?: string }) => row.workspaceType !== "video");
const envelope = payload?.data && typeof payload.data === "object" && !Array.isArray(payload.data) ? payload.data : payload?.batch || payload;
const state = payload?.status || envelope?.status;
const status = payload?.processing || envelope?.processing || ["pending", "running", "queued", "processing"].includes(state) ? "pending" : state === "failed" ? "failed" : "succeeded";
if (operation.id === "canvas-metadata.put" && typeof payload?.data?.title === "string")
params.emit("data-canvasProjectMetadata", { projectId: params.projectId, title: payload.data.title });
return { projectService: true, operationId: operation.id, status, taskId: payload?.taskId || envelope?.taskId || (payload?.batch ? envelope.id : undefined),
result: sanitizeProjectToolOutput(payload), message: status === "pending" ? "仅已受理,必须轮询状态确认实际结果;禁止重复创建任务。" : "已获得真实业务接口结果" };
}
register("readProjectService", "读取项目业务数据。先 listProjectServices 查看 operationId、pathParams和query要求;仅执行已授权GET接口。", invokeSchema, (input, id) => invoke(input, id, true));
register("runProjectService", "执行固定项目业务接口。先查清单和真实数据、制定计划;仅当前用户权限。pending表示任务已受理,不能声称完成或重复收费。", invokeSchema, (input, id) => invoke(input, id, false), false);
register("loadProjectSkill", "读取用户可访问的技能完整提示词、槽位和默认参数,由当前ZCode核心执行;不会调用另一个旧Agent或自动生成收费内容。", z.object({ presetId: z.string().uuid() }), async input => {
const preset = await getAccessibleAgentPresetById({ id: String(input.presetId), userId });
if (!preset || preset.status !== "active") throw new Error("此技能不可用或无权访问");
if (preset.type === "video") throw new Error("视频技能暂未授权给 ZCode Agent");
return { toolAliases: LEGACY_AGENT_TOOL_MAP, id: preset.id, name: preset.name, type: preset.type, promptTemplate: preset.promptTemplate, defaultModelId: preset.defaultModelId,
defaultParams: sanitizeProjectToolOutput(preset.defaultParams), slots: preset.slots.map(slot => ({ title: slot.title, promptTemplate: slot.promptTemplate, defaultParams: sanitizeProjectToolOutput(slot.defaultParams) })),
message: "请按用户当前任务采用技能指令,并使用真实已授权工具执行;不得虚构结果。" };
});
const enabled = new Set(await getEnabledTools());
const dataStream = { write: (part: { type: string; data?: unknown }) => params.emit(part.type, part.data) };
const legacy: ToolSet = { getWeather, createDocument: createDocument({ session: params.session, dataStream: dataStream as never }),
updateDocument: updateDocument({ session: params.session, dataStream: dataStream as never }), requestSuggestions: requestSuggestions({ session: params.session, dataStream: dataStream as never }) };
for (const [name, definition] of Object.entries(legacy)) {
params.definitions[name] = { ...definition, execute: undefined };
params.executable[name] = { title: name, readOnly: name === "getWeather", concurrentSafe: name === "getWeather", execute: async (input, callId) => {
if (!enabled.has(name as never)) throw new Error("管理员尚未启用此工具;授权清单不绕过底层服务开关");
const id = input.id || input.documentId;
if (name !== "getWeather" && name !== "createDocument") { const doc = await getDocumentById({ id: String(id) });
if (!doc || doc.userId !== userId) throw new Error("文档不存在或不属于当前用户"); }
const output = await definition.execute!(input as never, { toolCallId: callId, messages: [] });
if (!output || typeof output !== "object" || "error" in output) throw new Error("业务工具未返回有效结果");
if (name !== "getWeather") { const doc = await getDocumentById({ id: String((output as { id?: string }).id) });
if (!doc || doc.userId !== userId || !doc.content) throw new Error("文档尚未保存,不能声称完成"); }
return { projectService: true, status: "succeeded", ...sanitizeProjectToolOutput(output) as Record<string, unknown> };
} };
}
}