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

333 lines
16 KiB
TypeScript
Raw Normal View History

2026-01-10 23:08:56 +08:00
import type { OpenAiCompatibleChatMessage, OpenAiCompatibleChatOptions } from "@/lib/ai/openaiCompatibleChat";
import { openAiCompatibleChat } from "@/lib/ai/openaiCompatibleChat";
import { parseToolTagCalls, formatToolResultTag } from "../protocol/toolTagProtocol";
export type RunAiAgentArgs = {
userMessages: Array<{ role: "user" | "assistant"; content: string }>;
cfg: OpenAiCompatibleChatOptions;
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[] = [];
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("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>");
}
const guideLines: string[] = [];
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("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("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 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 openAiCompatibleChat(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}` };
};