112 lines
11 KiB
TypeScript
112 lines
11 KiB
TypeScript
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> };
|
||
} };
|
||
}
|
||
}
|