0.1.13 AI功能大改
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
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})` };
|
||||
};
|
||||
Reference in New Issue
Block a user