import type { AgentImagePreferences } from "./image-preferences"; 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: { imagePreferences?: AgentImagePreferences; session: Session; projectId: string; request: Request; objective: string; definitions: ToolSet; executable: Record; 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)[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, callId: string, readOnly: boolean): Promise> { 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 (params.imagePreferences?.modelId && ["style-generate.post", "wash-generate.post"].includes(operation.id)) throw new AgentToolRejectedError("用户已指定生图模型,请用 generateCanvasImages 执行并保留当前图片参数;不能通过另一条生成接口换用默认模型。"); 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 }; } }; } }