2026-04-16 15:24:37 +08:00
|
|
|
|
// 遗留兼容:runAiAgent 已退出 /api/ai-agent/run 主链,保留此文件仅用于历史测试与渐进清理。
|
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";
|
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
|
export const AI_AGENT_LEGACY_RUNTIME_CONTRACT = {
|
|
|
|
|
|
runtimeRole: "legacy_compat",
|
|
|
|
|
|
mainBridgeOwner: "rust-web-hermes",
|
|
|
|
|
|
legacyEndpoint: "/api/ai-agent/run",
|
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
2026-04-13 19:21:42 +08:00
|
|
|
|
export type AgentChatFn = (
|
|
|
|
|
|
messages: OpenAiCompatibleChatMessage[],
|
|
|
|
|
|
cfg: OpenAiCompatibleChatOptions,
|
|
|
|
|
|
) => Promise<{ text: string; raw: unknown }>;
|
|
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
export type RunAiAgentArgs = {
|
|
|
|
|
|
userMessages: Array<{ role: "user" | "assistant"; content: string }>;
|
|
|
|
|
|
cfg: OpenAiCompatibleChatOptions;
|
2026-04-13 19:21:42 +08:00
|
|
|
|
chat?: AgentChatFn;
|
2026-01-10 23:08:56 +08:00
|
|
|
|
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[] = [];
|
2026-01-11 12:35:53 +08:00
|
|
|
|
const guideLines: string[] = [];
|
2026-01-10 23:08:56 +08:00
|
|
|
|
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>");
|
|
|
|
|
|
}
|
2026-01-11 12:35:53 +08:00
|
|
|
|
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 文档的选区/光标位置。");
|
|
|
|
|
|
}
|
2026-01-10 23:08:56 +08:00
|
|
|
|
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 片段)。");
|
|
|
|
|
|
}
|
2026-01-11 12:35:53 +08:00
|
|
|
|
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 是写工具,只有在用户明确要求“生成/写入思维导图”时才调用。");
|
|
|
|
|
|
}
|
2026-01-10 23:08:56 +08:00
|
|
|
|
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 属于写工具:只有在用户明确要求“创建/改名/执行斜杠命令”时才调用;否则不要擅自创建新文档。");
|
|
|
|
|
|
}
|
2026-01-11 12:35:53 +08:00
|
|
|
|
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 传空字符串。");
|
|
|
|
|
|
}
|
2026-01-10 23:08:56 +08:00
|
|
|
|
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));
|
2026-04-13 19:21:42 +08:00
|
|
|
|
const chat = args.chat ?? openAiCompatibleChat;
|
2026-01-10 23:08:56 +08:00
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-04-13 19:21:42 +08:00
|
|
|
|
const { text } = await chat(messages, args.cfg);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
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})` };
|
|
|
|
|
|
};
|