// 遗留兼容: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; runTool: (toolId: string, toolArgs: Record) => Promise; 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 | 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; return null; } catch { return null; } }; const buildSystemPrompt = (allowedTools: Set, systemContextText?: string) => { const toolLines: string[] = []; const guideLines: string[] = []; if (allowedTools.has("search_web")) { toolLines.push("- search_web:{\"query\":\"...\",\"count\":6}"); } if (allowedTools.has("rag_lightrag_query")) { toolLines.push( "- rag_lightrag_query:{\"query\":\"...\",\"mode\":\"mix\",\"topK\":12,\"chunkTopK\":12}", ); } if (allowedTools.has("docs_search")) { toolLines.push("- docs_search:{\"query\":\"...\",\"limit\":12}"); } if (allowedTools.has("docs_read")) { toolLines.push("- docs_read:{\"documentId\":\"...\",\"maxChars\":2500}"); } if (allowedTools.has("image_read")) { toolLines.push("- image_read:{\"attachmentRef\":\"...\"}"); } if (allowedTools.has("asset_extract_outline")) { toolLines.push( "- asset_extract_outline:{\"attachmentRef\":\"...\"}", ); } if (allowedTools.has("asset_to_mindmap")) { toolLines.push( "- asset_to_mindmap:{\"mindmapId\":\"...\",\"assetId\":\"...\",\"maxItems\":120,\"reason\":\"...\"}", ); } if (allowedTools.has("oo_get_selection")) { toolLines.push("- oo_get_selection:{\"format\":\"text\"}"); } if (allowedTools.has("oo_replace_selection")) { toolLines.push( "- oo_replace_selection:{\"text\":\"...\",\"format\":\"text\",\"reason\":\"...\"}", ); } if (allowedTools.has("oo_insert_text")) { toolLines.push("- oo_insert_text:{\"text\":\"...\",\"reason\":\"...\"}"); } if (allowedTools.has("oo_insert_html")) { toolLines.push("- oo_insert_html:{\"html\":\"

...

\",\"reason\":\"...\"}
"); } if (allowedTools.has("oo_insert_image")) { toolLines.push( "- oo_insert_image:{\"imageRef\":\"...\",\"width\":320,\"height\":180,\"reason\":\"...\"}", ); } 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:{\"text\":\"/new 新页面标题\"}"); } if (allowedTools.has("doc_get")) { toolLines.push("- doc_get:{\"maxBlocks\":80}"); } if (allowedTools.has("doc_find")) { toolLines.push("- doc_find:{\"query\":\"...\",\"maxResults\":8}"); } if (allowedTools.has("doc_insert_blocks")) { toolLines.push( "- doc_insert_blocks:{\"afterBlockId\":\"...\",\"blocks\":[{\"type\":\"heading\",\"level\":2,\"text\":\"...\"},{\"type\":\"paragraph\",\"text\":\"...\"}]}", ); } if (allowedTools.has("doc_replace_range")) { toolLines.push( "- doc_replace_range:{\"blockId\":\"...\",\"text\":\"...\",\"mode\":\"replace\"}", ); } if (allowedTools.has("mindmap_get")) { toolLines.push("- mindmap_get:{\"maxNodes\":120}"); } if (allowedTools.has("mindmap_get_subtree")) { toolLines.push("- mindmap_get_subtree:{\"uid\":\"...\",\"depth\":2,\"maxNodes\":60}"); } if (allowedTools.has("mindmap_apply_ops")) { toolLines.push("- mindmap_apply_ops:{\"ops\":[{\"op\":\"updateText\",\"uid\":\"...\",\"text\":\"...\"}],\"reason\":\"...\"}"); } if (allowedTools.has("mindmap_add_child")) { toolLines.push( "- mindmap_add_child:{\"parentUid\":\"...\",\"text\":\"...\",\"hyperlink\":\"https://...\",\"note\":\"...\",\"refs\":[{\"kind\":\"url\",\"fileUrl\":\"https://...\"}]}", ); } if (allowedTools.has("mindmap_add_sibling_after")) { toolLines.push("- mindmap_add_sibling_after:{\"targetUid\":\"...\",\"text\":\"...\",\"hyperlink\":\"https://...\"}"); } if (allowedTools.has("mindmap_update_node_text")) { toolLines.push("- mindmap_update_node_text:{\"uid\":\"...\",\"text\":\"...\"}"); } if (allowedTools.has("mindmap_set_hyperlink")) { toolLines.push("- mindmap_set_hyperlink:{\"uid\":\"...\",\"hyperlink\":\"https://...\"}(清除传 null)"); } if (allowedTools.has("mindmap_append_note")) { toolLines.push("- mindmap_append_note:{\"uid\":\"...\",\"markdown\":\"...\"}"); } if (allowedTools.has("mindmap_set_refs")) { toolLines.push("- mindmap_set_refs:{\"uid\":\"...\",\"refs\":[{\"kind\":\"url\",\"fileUrl\":\"https://...\"}]}"); } if (allowedTools.has("mindmap_delete_node")) { toolLines.push("- mindmap_delete_node:{\"uid\":\"...\"}"); } if (allowedTools.has("mindmap_add_attachment_ref")) { toolLines.push("- mindmap_add_attachment_ref:{\"uid\":\"...\",\"attachmentId\":\"...\",\"page\":12,\"mode\":\"append\"}"); } if (allowedTools.has("mindmap_add_attachment_child")) { toolLines.push("- mindmap_add_attachment_child:{\"parentUid\":\"...\",\"attachmentId\":\"...\",\"page\":12}"); } if (allowedTools.has("mindmap_add_image_child")) { toolLines.push("- mindmap_add_image_child:{\"parentUid\":\"...\",\"imageRef\":\"...\",\"caption\":\"...\"}"); } if (allowedTools.has("mindmap_append_image_note")) { toolLines.push("- mindmap_append_image_note:{\"uid\":\"...\",\"imageRef\":\"...\"}"); } if (allowedTools.has("mindmap_expand_node")) { toolLines.push("- mindmap_expand_node:{\"targetUid\":\"...\",\"instruction\":\"...\"}"); } 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) 标签格式:{...JSON...},JSON 必须是严格 JSON(双引号)。", "3) 你将收到工具结果:{...JSON...},再继续。", "", "工具列表(允许集):", ...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(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)}` }); 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)}` }); 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)}` }); 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})` }; };