Files
mnote/wolai-frontend/src/lib/ai-agent/runtime/runAgent.ts
T

401 lines
20 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.
// 遗留兼容:runAiAgent 已退出 /api/ai-agent/run 主链,保留此文件仅用于历史测试与渐进清理。
import type { OpenAiCompatibleChatMessage, OpenAiCompatibleChatOptions } from "@/lib/ai/openaiCompatibleChat";
import { openAiCompatibleChat } from "@/lib/ai/openaiCompatibleChat";
import { parseToolTagCalls, formatToolResultTag } from "../protocol/toolTagProtocol";
export const AI_AGENT_LEGACY_RUNTIME_CONTRACT = {
runtimeRole: "legacy_compat",
mainBridgeOwner: "rust-web-hermes",
legacyEndpoint: "/api/ai-agent/run",
} as const;
export type AgentChatFn = (
messages: OpenAiCompatibleChatMessage[],
cfg: OpenAiCompatibleChatOptions,
) => Promise<{ text: string; raw: unknown }>;
export type RunAiAgentArgs = {
userMessages: Array<{ role: "user" | "assistant"; content: string }>;
cfg: OpenAiCompatibleChatOptions;
chat?: AgentChatFn;
allowedToolIds: Set<string>;
runTool: (toolId: string, toolArgs: Record<string, unknown>) => Promise<unknown>;
maxSteps?: number;
onEvent?: (event: { type: string; data: unknown }) => void;
systemContextText?: string;
defaultMindmapTargetUid?: string;
};
const nowMs = () => Date.now();
const DEFAULT_MAX_STEPS = 10;
const safeParseJsonObject = (raw: string): Record<string, unknown> | null => {
const s = String(raw ?? "").trim();
if (!s) return null;
try {
const parsed = JSON.parse(s);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
return null;
} catch {
return null;
}
};
const buildSystemPrompt = (allowedTools: Set<string>, systemContextText?: string) => {
const toolLines: string[] = [];
const guideLines: string[] = [];
if (allowedTools.has("search_web")) {
toolLines.push("- search_web<search_web>{\"query\":\"...\",\"count\":6}</search_web>");
}
if (allowedTools.has("rag_lightrag_query")) {
toolLines.push(
"- rag_lightrag_query<rag_lightrag_query>{\"query\":\"...\",\"mode\":\"mix\",\"topK\":12,\"chunkTopK\":12}</rag_lightrag_query>",
);
}
if (allowedTools.has("docs_search")) {
toolLines.push("- docs_search<docs_search>{\"query\":\"...\",\"limit\":12}</docs_search>");
}
if (allowedTools.has("docs_read")) {
toolLines.push("- docs_read<docs_read>{\"documentId\":\"...\",\"maxChars\":2500}</docs_read>");
}
if (allowedTools.has("image_read")) {
toolLines.push("- image_read<image_read>{\"attachmentRef\":\"...\"}</image_read>");
}
if (allowedTools.has("asset_extract_outline")) {
toolLines.push(
"- asset_extract_outline<asset_extract_outline>{\"attachmentRef\":\"...\"}</asset_extract_outline>",
);
}
if (allowedTools.has("asset_to_mindmap")) {
toolLines.push(
"- asset_to_mindmap<asset_to_mindmap>{\"mindmapId\":\"...\",\"assetId\":\"...\",\"maxItems\":120,\"reason\":\"...\"}</asset_to_mindmap>",
);
}
if (allowedTools.has("oo_get_selection")) {
toolLines.push("- oo_get_selection<oo_get_selection>{\"format\":\"text\"}</oo_get_selection>");
}
if (allowedTools.has("oo_replace_selection")) {
toolLines.push(
"- oo_replace_selection<oo_replace_selection>{\"text\":\"...\",\"format\":\"text\",\"reason\":\"...\"}</oo_replace_selection>",
);
}
if (allowedTools.has("oo_insert_text")) {
toolLines.push("- oo_insert_text<oo_insert_text>{\"text\":\"...\",\"reason\":\"...\"}</oo_insert_text>");
}
if (allowedTools.has("oo_insert_html")) {
toolLines.push("- oo_insert_html<oo_insert_html>{\"html\":\"<p>...</p>\",\"reason\":\"...\"}</oo_insert_html>");
}
if (allowedTools.has("oo_insert_image")) {
toolLines.push(
"- oo_insert_image<oo_insert_image>{\"imageRef\":\"...\",\"width\":320,\"height\":180,\"reason\":\"...\"}</oo_insert_image>",
);
}
if (
allowedTools.has("oo_get_selection") ||
allowedTools.has("oo_replace_selection") ||
allowedTools.has("oo_insert_text") ||
allowedTools.has("oo_insert_html") ||
allowedTools.has("oo_insert_image")
) {
guideLines.push("- OnlyOffice 增/删/改:先 oo_get_selection 读选区,再用 oo_replace_selection / oo_insert_* 写回。");
guideLines.push("- oo_* 属于客户端插件工具:会直接修改当前 OnlyOffice 文档的选区/光标位置。");
}
if (allowedTools.has("slash_run")) {
toolLines.push("- slash_run<slash_run>{\"text\":\"/new 新页面标题\"}</slash_run>");
}
if (allowedTools.has("doc_get")) {
toolLines.push("- doc_get<doc_get>{\"maxBlocks\":80}</doc_get>");
}
if (allowedTools.has("doc_find")) {
toolLines.push("- doc_find<doc_find>{\"query\":\"...\",\"maxResults\":8}</doc_find>");
}
if (allowedTools.has("doc_insert_blocks")) {
toolLines.push(
"- doc_insert_blocks<doc_insert_blocks>{\"afterBlockId\":\"...\",\"blocks\":[{\"type\":\"heading\",\"level\":2,\"text\":\"...\"},{\"type\":\"paragraph\",\"text\":\"...\"}]}</doc_insert_blocks>",
);
}
if (allowedTools.has("doc_replace_range")) {
toolLines.push(
"- doc_replace_range<doc_replace_range>{\"blockId\":\"...\",\"text\":\"...\",\"mode\":\"replace\"}</doc_replace_range>",
);
}
if (allowedTools.has("mindmap_get")) {
toolLines.push("- mindmap_get<mindmap_get>{\"maxNodes\":120}</mindmap_get>");
}
if (allowedTools.has("mindmap_get_subtree")) {
toolLines.push("- mindmap_get_subtree<mindmap_get_subtree>{\"uid\":\"...\",\"depth\":2,\"maxNodes\":60}</mindmap_get_subtree>");
}
if (allowedTools.has("mindmap_apply_ops")) {
toolLines.push("- mindmap_apply_ops<mindmap_apply_ops>{\"ops\":[{\"op\":\"updateText\",\"uid\":\"...\",\"text\":\"...\"}],\"reason\":\"...\"}</mindmap_apply_ops>");
}
if (allowedTools.has("mindmap_add_child")) {
toolLines.push(
"- mindmap_add_child<mindmap_add_child>{\"parentUid\":\"...\",\"text\":\"...\",\"hyperlink\":\"https://...\",\"note\":\"...\",\"refs\":[{\"kind\":\"url\",\"fileUrl\":\"https://...\"}]}</mindmap_add_child>",
);
}
if (allowedTools.has("mindmap_add_sibling_after")) {
toolLines.push("- mindmap_add_sibling_after<mindmap_add_sibling_after>{\"targetUid\":\"...\",\"text\":\"...\",\"hyperlink\":\"https://...\"}</mindmap_add_sibling_after>");
}
if (allowedTools.has("mindmap_update_node_text")) {
toolLines.push("- mindmap_update_node_text<mindmap_update_node_text>{\"uid\":\"...\",\"text\":\"...\"}</mindmap_update_node_text>");
}
if (allowedTools.has("mindmap_set_hyperlink")) {
toolLines.push("- mindmap_set_hyperlink<mindmap_set_hyperlink>{\"uid\":\"...\",\"hyperlink\":\"https://...\"}</mindmap_set_hyperlink>(清除传 null");
}
if (allowedTools.has("mindmap_append_note")) {
toolLines.push("- mindmap_append_note<mindmap_append_note>{\"uid\":\"...\",\"markdown\":\"...\"}</mindmap_append_note>");
}
if (allowedTools.has("mindmap_set_refs")) {
toolLines.push("- mindmap_set_refs<mindmap_set_refs>{\"uid\":\"...\",\"refs\":[{\"kind\":\"url\",\"fileUrl\":\"https://...\"}]}</mindmap_set_refs>");
}
if (allowedTools.has("mindmap_delete_node")) {
toolLines.push("- mindmap_delete_node<mindmap_delete_node>{\"uid\":\"...\"}</mindmap_delete_node>");
}
if (allowedTools.has("mindmap_add_attachment_ref")) {
toolLines.push("- mindmap_add_attachment_ref<mindmap_add_attachment_ref>{\"uid\":\"...\",\"attachmentId\":\"...\",\"page\":12,\"mode\":\"append\"}</mindmap_add_attachment_ref>");
}
if (allowedTools.has("mindmap_add_attachment_child")) {
toolLines.push("- mindmap_add_attachment_child<mindmap_add_attachment_child>{\"parentUid\":\"...\",\"attachmentId\":\"...\",\"page\":12}</mindmap_add_attachment_child>");
}
if (allowedTools.has("mindmap_add_image_child")) {
toolLines.push("- mindmap_add_image_child<mindmap_add_image_child>{\"parentUid\":\"...\",\"imageRef\":\"...\",\"caption\":\"...\"}</mindmap_add_image_child>");
}
if (allowedTools.has("mindmap_append_image_note")) {
toolLines.push("- mindmap_append_image_note<mindmap_append_image_note>{\"uid\":\"...\",\"imageRef\":\"...\"}</mindmap_append_image_note>");
}
if (allowedTools.has("mindmap_expand_node")) {
toolLines.push("- mindmap_expand_node<mindmap_expand_node>{\"targetUid\":\"...\",\"instruction\":\"...\"}</mindmap_expand_node>");
}
guideLines.push("- 先用只读工具定位(需要时再检索),再做最小范围写入。");
if (allowedTools.has("search_web")) {
guideLines.push("- 需要来源时先 search_web,再把 URL 放进最终回答或写入引用字段。");
}
if (allowedTools.has("rag_lightrag_query")) {
guideLines.push("- 需要从本地知识库语义检索/汇总:优先 rag_lightrag_query;若还需要网页来源,再额外 search_web。");
}
if (allowedTools.has("docs_search") || allowedTools.has("docs_read")) {
guideLines.push("- 跨页面找内容:先 docs_search 找到 documentId,再 docs_read 读取原文片段(需要时再到对应页面使用 doc_* 写入)。");
}
if (allowedTools.has("image_read")) {
guideLines.push("- 需要读图:用 image_read 从 media_assets.ocr_text 获取文字(attachmentRef 可用附件 id/title/url 片段)。");
}
if (allowedTools.has("asset_extract_outline") || allowedTools.has("asset_to_mindmap")) {
guideLines.push("- PDF→导图(M3):先 asset_extract_outline 提取标题层级/页码,再根据需要调用 asset_to_mindmap 落盘到指定 mindmap。");
guideLines.push("- 注意:asset_to_mindmap 是写工具,只有在用户明确要求“生成/写入思维导图”时才调用。");
}
if (allowedTools.has("slash_run")) {
guideLines.push("- 需要创建/改名:用 slash_run 执行 /new 或 /rename(这是写工具,只有在用户明确要求时才调用)。");
}
if (
allowedTools.has("doc_get") ||
allowedTools.has("doc_find") ||
allowedTools.has("doc_insert_blocks") ||
allowedTools.has("doc_replace_range")
) {
guideLines.push("- 写页面前:先 doc_get 或 doc_find 确认 blockId;再用 doc_* 写工具插入/替换。");
guideLines.push("- doc_insert_blocks 适合新增标题/段落;doc_replace_range 适合改写某个段落/标题的文本。");
}
if (
allowedTools.has("mindmap_get") ||
allowedTools.has("mindmap_get_subtree") ||
allowedTools.has("mindmap_apply_ops") ||
allowedTools.has("mindmap_expand_node")
) {
guideLines.push("- 思维导图小改动优先用细粒度 mindmap_* 写工具(add_child/update/set_hyperlink/append_note/set_refs 等)。");
guideLines.push("- 只有当需要一次性执行多条混合操作时才用 mindmap_apply_ops。");
guideLines.push("- 附件的 id 与 url 在“当前上下文”里(attachments 列表)。");
guideLines.push("- 挂到已有节点:mindmap_add_attachment_ref;把附件变成节点:mindmap_add_attachment_child。");
guideLines.push("- 插入图片:mindmap_add_image_child 或 mindmap_append_image_note(图片 URL 可来自 attachments 或 https 链接)。");
}
const hardRules: string[] = [];
hardRules.push("- 当用户询问价格/版本/配置信息并要求“最新/准确/带来源”时:优先调用 search_web 获取来源,再回答。");
hardRules.push("- 如果你调用了 search_web:最终回答需给出“来源”列表(至少 2 条 URL);未调用 search_web 时不要编造来源。");
hardRules.push("- 最终回答不要再输出任何工具标签。");
if (allowedTools.has("slash_run")) {
hardRules.push("- slash_run 属于写工具:只有在用户明确要求“创建/改名/执行斜杠命令”时才调用;否则不要擅自创建新文档。");
}
if (allowedTools.has("asset_to_mindmap")) {
hardRules.push("- asset_to_mindmap 属于写工具:只有在用户明确要求“从附件生成/写入思维导图”时才调用;否则不要擅自写入导图。");
}
if (
allowedTools.has("oo_replace_selection") ||
allowedTools.has("oo_insert_text") ||
allowedTools.has("oo_insert_html") ||
allowedTools.has("oo_insert_image")
) {
hardRules.push("- oo_* 属于写工具:只有在用户明确要求“修改/补全/插入 OnlyOffice 文档内容”时才调用;否则不要擅自改文档。");
hardRules.push("- 删除选区:用 oo_replace_selection 且 text 传空字符串。");
}
if (allowedTools.has("mindmap_apply_ops") || allowedTools.has("mindmap_expand_node")) {
hardRules.push("- 涉及思维导图写入时:只能通过 mindmap_* 写工具落盘;不要输出整棵树覆盖。");
}
if (allowedTools.has("doc_insert_blocks") || allowedTools.has("doc_replace_range")) {
hardRules.push("- 涉及页面写入时:只能通过 doc_* 写工具落入页面;不要让用户复制粘贴,也不要输出整篇 blocks JSON 让用户手动替换。");
}
hardRules.push("- 若上下文包含 selectedUids:默认使用第一个 uid 作为目标节点(除非用户明确给出 targetUid)。");
return [
"你是一个“AI Agent”(类似 Cline/VSCode Chat)。你可以通过工具完成任务,并把步骤展示给用户。",
systemContextText ? `\n当前上下文:\n${systemContextText.trim()}\n` : "",
"",
"工具调用协议(硬性):",
"1) 当你需要调用工具时,必须只输出一个工具标签(不要输出其它文字)。",
"2) 标签格式:<tool_name>{...JSON...}</tool_name>JSON 必须是严格 JSON(双引号)。",
"3) 你将收到工具结果:<tool_result tool=\"tool_name\">{...JSON...}</tool_result>,再继续。",
"",
"工具列表(允许集):",
...toolLines,
"",
"如何选择工具(建议):",
...guideLines,
"",
"行为要求(硬性):",
...hardRules,
].join("\n");
};
export const runAiAgent = async (args: RunAiAgentArgs): Promise<{ ok: true; text: string; steps: number } | { ok: false; error: string }> => {
const maxSteps = Math.max(1, Math.min(24, Math.floor(args.maxSteps ?? DEFAULT_MAX_STEPS)));
const allowedToolIds = args.allowedToolIds;
const allowedToolNames = new Set<string>(Array.from(allowedToolIds));
const chat = args.chat ?? openAiCompatibleChat;
const messages: OpenAiCompatibleChatMessage[] = [
{ role: "system", content: buildSystemPrompt(allowedToolIds, args.systemContextText) },
...args.userMessages.map((m) => ({ role: m.role, content: m.content })),
];
const emit = (type: string, data: unknown) => args.onEvent?.({ type, data });
const stepId = (step: number) => `step_${step}_${Math.random().toString(16).slice(2, 10)}`;
let steps = 0;
let usedAnyTool = false;
const latestUserQuestion =
[...args.userMessages]
.reverse()
.find((m) => m.role === "user")
?.content?.trim() ?? "";
const shouldForceSearchWeb = (question: string) => {
const q = String(question || "").trim();
if (!q) return false;
// v1:非常粗粒度的启发式,避免“问价格/最新信息”却不检索
return /价格|多少钱|价位|收费|pricing|price|token|tokens|最新版|最新|更新|版本|费率/i.test(q);
};
const looksLikeHasSources = (text: string) => {
const s = String(text || "");
const urls = s.match(/https?:\/\/\S+/g) ?? [];
return urls.length >= 2;
};
const shouldForceMindmapExpand = (question: string) => {
const q = String(question || "").trim();
if (!q) return false;
// v1:仅在明确“要改导图”的意图时才兜底触发写工具,避免误操作
const wantsMindmap = /导图|思维导图|节点|子节点/i.test(q);
const wantsWrite = /写入|保存|落盘|应用|修改|更新|补完|扩展|完善|新增|添加/i.test(q);
return wantsMindmap && wantsWrite;
};
for (; steps < maxSteps; steps += 1) {
const { text } = await chat(messages, args.cfg);
const parsed = parseToolTagCalls(text, allowedToolNames);
if (parsed.calls.length === 0) {
const finalText = String(text ?? "").trim();
// 若模型没调用工具且看起来也没给足来源,而问题又强依赖“最新/可追溯”,则自动补一次检索再让模型回答
if (!usedAnyTool && allowedToolIds.has("search_web") && shouldForceSearchWeb(latestUserQuestion) && !looksLikeHasSources(finalText)) {
const id = stepId(steps + 1);
const tool = "search_web";
const toolArgs = { query: latestUserQuestion, count: 6 };
emit("tool_call", { id, tool, args: toolArgs });
const t0 = nowMs();
try {
const result = await args.runTool(tool, toolArgs);
const ms = nowMs() - t0;
usedAnyTool = true;
emit("tool_result", { id, tool, ok: true, ms, result });
messages.push({ role: "assistant", content: `<${tool}>${JSON.stringify(toolArgs)}</${tool}>` });
messages.push({ role: "user", content: formatToolResultTag(tool, JSON.stringify(result)) });
continue;
} catch (e) {
const ms = nowMs() - t0;
const msg = e instanceof Error ? e.message : String(e);
usedAnyTool = true;
emit("tool_result", { id, tool, ok: false, ms, result: { error: msg } });
messages.push({ role: "user", content: formatToolResultTag(tool, JSON.stringify({ error: msg })) });
continue;
}
}
// 若用户明确要“补完/写入思维导图”,但模型没触发工具,则兜底执行一次 mindmap_expand_node
if (
!usedAnyTool &&
allowedToolIds.has("mindmap_expand_node") &&
args.defaultMindmapTargetUid &&
shouldForceMindmapExpand(latestUserQuestion)
) {
const id = stepId(steps + 1);
const tool = "mindmap_expand_node";
const toolArgs = { targetUid: args.defaultMindmapTargetUid, instruction: latestUserQuestion };
emit("tool_call", { id, tool, args: toolArgs });
const t0 = nowMs();
try {
const result = await args.runTool(tool, toolArgs);
const ms = nowMs() - t0;
usedAnyTool = true;
emit("tool_result", { id, tool, ok: true, ms, result });
messages.push({ role: "assistant", content: `<${tool}>${JSON.stringify(toolArgs)}</${tool}>` });
messages.push({ role: "user", content: formatToolResultTag(tool, JSON.stringify(result)) });
continue;
} catch (e) {
const ms = nowMs() - t0;
const msg = e instanceof Error ? e.message : String(e);
usedAnyTool = true;
emit("tool_result", { id, tool, ok: false, ms, result: { error: msg } });
messages.push({ role: "user", content: formatToolResultTag(tool, JSON.stringify({ error: msg })) });
continue;
}
}
emit("assistant_message", { text: finalText });
return { ok: true, text: finalText, steps: steps + 1 };
}
// v1:一次只允许执行第一个工具调用,避免模型一口气输出多次工具导致不可控
const call = parsed.calls[0];
const tool = call.name;
if (!allowedToolIds.has(tool)) {
return { ok: false, error: `工具未被允许:${tool}` };
}
const toolArgs = safeParseJsonObject(call.rawInput) ?? {};
const id = stepId(steps + 1);
emit("tool_call", { id, tool, args: toolArgs });
const t0 = nowMs();
try {
const result = await args.runTool(tool, toolArgs);
const ms = nowMs() - t0;
usedAnyTool = true;
emit("tool_result", { id, tool, ok: true, ms, result });
messages.push({ role: "assistant", content: `<${tool}>${JSON.stringify(toolArgs)}</${tool}>` });
messages.push({ role: "user", content: formatToolResultTag(tool, JSON.stringify(result)) });
} catch (e) {
const ms = nowMs() - t0;
const msg = e instanceof Error ? e.message : String(e);
usedAnyTool = true;
emit("tool_result", { id, tool, ok: false, ms, result: { error: msg } });
messages.push({ role: "user", content: formatToolResultTag(tool, JSON.stringify({ error: msg })) });
}
}
return { ok: false, error: `已达到最大步数(${maxSteps}` };
};