0.1.13 AI功能大改
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
export type ToolTagCall = {
|
||||
name: string;
|
||||
rawInput: string;
|
||||
};
|
||||
|
||||
export type ParsedToolTagMessage = {
|
||||
text: string;
|
||||
calls: ToolTagCall[];
|
||||
};
|
||||
|
||||
const isValidTagName = (name: string) => /^[a-zA-Z][a-zA-Z0-9_]*$/.test(name);
|
||||
|
||||
// v1:简单、可预测的“类 XML 标签”协议:
|
||||
// - 工具调用:<tool_name>{...json...}</tool_name>
|
||||
// - 工具结果(回喂模型):<tool_result tool="tool_name">{...json...}</tool_result>
|
||||
//
|
||||
// 解析策略:
|
||||
// - 只解析白名单工具名(避免误把普通 HTML 当工具)
|
||||
// - 不做嵌套/属性解析(足够支撑 v1)
|
||||
export const parseToolTagCalls = (input: string, allowedToolNames: Set<string>): ParsedToolTagMessage => {
|
||||
const s = String(input ?? "");
|
||||
if (!s) return { text: "", calls: [] };
|
||||
|
||||
const calls: ToolTagCall[] = [];
|
||||
let outText = "";
|
||||
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
const lt = s.indexOf("<", i);
|
||||
if (lt === -1) {
|
||||
outText += s.slice(i);
|
||||
break;
|
||||
}
|
||||
outText += s.slice(i, lt);
|
||||
|
||||
const gt = s.indexOf(">", lt + 1);
|
||||
if (gt === -1) {
|
||||
outText += s.slice(lt);
|
||||
break;
|
||||
}
|
||||
|
||||
const tagName = s.slice(lt + 1, gt).trim();
|
||||
if (!isValidTagName(tagName) || !allowedToolNames.has(tagName)) {
|
||||
outText += s.slice(lt, gt + 1);
|
||||
i = gt + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const close = `</${tagName}>`;
|
||||
const closeIdx = s.indexOf(close, gt + 1);
|
||||
if (closeIdx === -1) {
|
||||
outText += s.slice(lt, gt + 1);
|
||||
i = gt + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawInput = s.slice(gt + 1, closeIdx).trim();
|
||||
calls.push({ name: tagName, rawInput });
|
||||
i = closeIdx + close.length;
|
||||
}
|
||||
|
||||
return { text: outText.trim(), calls };
|
||||
};
|
||||
|
||||
export const formatToolResultTag = (toolName: string, jsonText: string) => {
|
||||
const safeName = isValidTagName(toolName) ? toolName : "unknown";
|
||||
const body = String(jsonText ?? "").trim();
|
||||
return `<tool_result tool="${safeName}">${body}</tool_result>`;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export type AiAgentSseEvent =
|
||||
| { event: "assistant_message"; data: { text: string } }
|
||||
| { event: "tool_call"; data: { id: string; tool: string; args: Record<string, unknown> } }
|
||||
| { event: "tool_result"; data: { id: string; tool: string; ok: boolean; ms: number; result: unknown } }
|
||||
| { event: "completion"; data: { ok: true; text: string; steps: number } }
|
||||
| { event: "error"; data: { ok: false; message: string } };
|
||||
|
||||
@@ -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})` };
|
||||
};
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { Json } from "@/types/supabase";
|
||||
|
||||
export type DocToolContext = {
|
||||
documentId: string;
|
||||
userId: string;
|
||||
/**
|
||||
* 来自前端的“最新文档快照”(优先使用,避免覆盖用户尚未落盘的编辑)。
|
||||
* 允许是 blocks 数组,或 { blocks } 结构。
|
||||
*/
|
||||
baseBlocks?: unknown;
|
||||
};
|
||||
|
||||
type SupabaseRouteClient = {
|
||||
from: (table: string) => {
|
||||
select: (columns: string) => SupabaseQuery;
|
||||
update: (values: Record<string, unknown>) => SupabaseUpdateQuery;
|
||||
};
|
||||
};
|
||||
|
||||
type SupabaseQuery = {
|
||||
eq: (column: string, value: unknown) => SupabaseQuery;
|
||||
single: () => Promise<{ data: unknown; error: unknown }>;
|
||||
};
|
||||
|
||||
type SupabaseUpdateQuery = {
|
||||
eq: (column: string, value: unknown) => SupabaseUpdateQuery;
|
||||
};
|
||||
|
||||
export type DocSupabaseClient = SupabaseRouteClient;
|
||||
|
||||
type DocBlockSummary = {
|
||||
id: string;
|
||||
type: string;
|
||||
text: string;
|
||||
depth: number;
|
||||
childCount: number;
|
||||
};
|
||||
|
||||
type DocBlockSpec = {
|
||||
type: "paragraph" | "heading";
|
||||
text: string;
|
||||
level?: number;
|
||||
};
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
const getValue = (obj: unknown, key: string): unknown => (isRecord(obj) ? obj[key] : undefined);
|
||||
|
||||
const normalizeBlocks = (content: unknown): unknown[] => {
|
||||
if (Array.isArray(content)) return content;
|
||||
const blocks = getValue(content, "blocks");
|
||||
if (Array.isArray(blocks)) return blocks;
|
||||
return [];
|
||||
};
|
||||
|
||||
const extractInlineText = (block: unknown): string => {
|
||||
const content = getValue(block, "content");
|
||||
const nodes = Array.isArray(content) ? content : [];
|
||||
const pieces: string[] = [];
|
||||
for (const n of nodes) {
|
||||
const t = getValue(n, "text");
|
||||
if (typeof t === "string") pieces.push(t);
|
||||
}
|
||||
return pieces.join("").trim();
|
||||
};
|
||||
|
||||
const walkSummaries = (rootBlocks: unknown[], maxNodes: number): DocBlockSummary[] => {
|
||||
const list: DocBlockSummary[] = [];
|
||||
const queue: Array<{ block: unknown; depth: number }> = rootBlocks.map((b) => ({ block: b, depth: 0 }));
|
||||
while (queue.length > 0 && list.length < maxNodes) {
|
||||
const item = queue.shift();
|
||||
if (!item) break;
|
||||
const { block, depth } = item;
|
||||
const id = String(getValue(block, "id") ?? "").trim();
|
||||
const type = String(getValue(block, "type") ?? "").trim();
|
||||
const childrenRaw = getValue(block, "children");
|
||||
const children = Array.isArray(childrenRaw) ? childrenRaw : [];
|
||||
const text = extractInlineText(block);
|
||||
if (id) {
|
||||
list.push({ id, type: type || "unknown", text, depth, childCount: children.length });
|
||||
}
|
||||
children.forEach((c) => queue.push({ block: c, depth: depth + 1 }));
|
||||
}
|
||||
return list;
|
||||
};
|
||||
|
||||
const findContainerById = (
|
||||
blocks: unknown[],
|
||||
targetId: string,
|
||||
): { container: unknown[]; index: number } | null => {
|
||||
const id = String(targetId || "").trim();
|
||||
if (!id) return null;
|
||||
for (let i = 0; i < blocks.length; i += 1) {
|
||||
const b = blocks[i];
|
||||
const bid = String(getValue(b, "id") ?? "").trim();
|
||||
if (bid === id) return { container: blocks, index: i };
|
||||
const childrenRaw = getValue(b, "children");
|
||||
const children = Array.isArray(childrenRaw) ? childrenRaw : [];
|
||||
const found = findContainerById(children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const createTextContent = (text: string): unknown[] => [{ type: "text", text }];
|
||||
|
||||
const generateId = () => {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `bn_${Math.random().toString(36).slice(2, 10)}`;
|
||||
};
|
||||
|
||||
const buildBlockFromSpec = (spec: DocBlockSpec): Record<string, unknown> => {
|
||||
const type = spec.type;
|
||||
const text = String(spec.text ?? "").trim();
|
||||
const base: Record<string, unknown> = {
|
||||
id: generateId(),
|
||||
type,
|
||||
props: {},
|
||||
content: createTextContent(text),
|
||||
children: [],
|
||||
};
|
||||
if (type === "heading") {
|
||||
const level = Number(spec.level ?? 2);
|
||||
base.props = { level: Math.max(1, Math.min(5, Number.isFinite(level) ? Math.floor(level) : 2)) };
|
||||
}
|
||||
return base;
|
||||
};
|
||||
|
||||
const loadDocumentBlocks = async (supabase: DocSupabaseClient, ctx: DocToolContext) => {
|
||||
const base = normalizeBlocks(ctx.baseBlocks);
|
||||
if (base.length > 0) {
|
||||
return { blocks: base, source: "client" as const };
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("documents")
|
||||
.select("id,content")
|
||||
.eq("id", ctx.documentId)
|
||||
.eq("user_id", ctx.userId)
|
||||
.single();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取文档失败");
|
||||
}
|
||||
const content = isRecord(data) ? data.content : null;
|
||||
const blocks = normalizeBlocks(content);
|
||||
return { blocks, source: "db" as const };
|
||||
};
|
||||
|
||||
const saveDocumentBlocks = async (supabase: DocSupabaseClient, ctx: DocToolContext, blocks: unknown[]) => {
|
||||
const resp = (await (supabase
|
||||
.from("documents")
|
||||
.update({ content: blocks as unknown as Json })
|
||||
.eq("id", ctx.documentId)
|
||||
.eq("user_id", ctx.userId) as unknown as Promise<{ error: unknown }>)) ?? { error: null };
|
||||
const error = resp.error;
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "保存文档失败");
|
||||
}
|
||||
};
|
||||
|
||||
export const createDocServerTools = (args: {
|
||||
supabase: DocSupabaseClient;
|
||||
ctx: DocToolContext;
|
||||
allowedToolIds: Set<string>;
|
||||
}) => {
|
||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||
if (!args.allowedToolIds.has(toolId)) {
|
||||
throw new Error(`工具未被允许:${toolId}`);
|
||||
}
|
||||
|
||||
if (toolId === "doc_get") {
|
||||
const maxNodesRaw = Number(toolArgs.maxBlocks ?? 80);
|
||||
const maxBlocks = Math.max(10, Math.min(240, Number.isFinite(maxNodesRaw) ? Math.floor(maxNodesRaw) : 80));
|
||||
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
|
||||
const summary = walkSummaries(blocks, maxBlocks);
|
||||
return { ok: true, source, totalTopLevelBlocks: blocks.length, blocks: summary };
|
||||
}
|
||||
|
||||
if (toolId === "doc_find") {
|
||||
const query = String(toolArgs.query ?? "").trim();
|
||||
if (!query) throw new Error("缺少 query");
|
||||
const maxRaw = Number(toolArgs.maxResults ?? 8);
|
||||
const maxResults = Math.max(1, Math.min(30, Number.isFinite(maxRaw) ? Math.floor(maxRaw) : 8));
|
||||
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
|
||||
const summary = walkSummaries(blocks, 400);
|
||||
const q = query.toLowerCase();
|
||||
const hits = summary.filter((x) => x.text.toLowerCase().includes(q)).slice(0, maxResults);
|
||||
return { ok: true, source, query, results: hits };
|
||||
}
|
||||
|
||||
if (toolId === "doc_insert_blocks") {
|
||||
const afterBlockId = String(toolArgs.afterBlockId ?? "").trim();
|
||||
const beforeBlockId = String(toolArgs.beforeBlockId ?? "").trim();
|
||||
const specsRaw = toolArgs.blocks;
|
||||
if (!Array.isArray(specsRaw) || specsRaw.length === 0) throw new Error("缺少 blocks");
|
||||
if (specsRaw.length > 20) throw new Error("blocks 过多(最多 20)");
|
||||
|
||||
const specs: DocBlockSpec[] = specsRaw.map((x) => {
|
||||
const t = isRecord(x) ? String(x.type ?? "paragraph") : "paragraph";
|
||||
const text = isRecord(x) ? String(x.text ?? "") : "";
|
||||
const level = isRecord(x) ? Number(x.level ?? 2) : 2;
|
||||
return { type: t === "heading" ? "heading" : "paragraph", text, level };
|
||||
});
|
||||
|
||||
const created = specs.map(buildBlockFromSpec);
|
||||
|
||||
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
|
||||
const targetId = beforeBlockId || afterBlockId;
|
||||
const found = targetId ? findContainerById(blocks, targetId) : null;
|
||||
if (targetId && !found) {
|
||||
throw new Error(`未找到 blockId:${targetId}`);
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
blocks.push(...created);
|
||||
} else {
|
||||
const insertAt = beforeBlockId ? found.index : found.index + 1;
|
||||
found.container.splice(insertAt, 0, ...created);
|
||||
}
|
||||
|
||||
await saveDocumentBlocks(args.supabase, args.ctx, blocks);
|
||||
return {
|
||||
ok: true,
|
||||
source,
|
||||
inserted: created.map((b) => String(b.id ?? "")),
|
||||
data: blocks,
|
||||
};
|
||||
}
|
||||
|
||||
if (toolId === "doc_replace_range") {
|
||||
const blockId = String(toolArgs.blockId ?? "").trim();
|
||||
const text = String(toolArgs.text ?? "").trim();
|
||||
if (!blockId) throw new Error("缺少 blockId");
|
||||
if (!text) throw new Error("缺少 text");
|
||||
const modeRaw = String(toolArgs.mode ?? "replace").trim();
|
||||
const mode = modeRaw === "append" || modeRaw === "prepend" ? modeRaw : "replace";
|
||||
|
||||
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
|
||||
const found = findContainerById(blocks, blockId);
|
||||
if (!found) throw new Error(`未找到 blockId:${blockId}`);
|
||||
const block = found.container[found.index];
|
||||
if (!isRecord(block)) throw new Error(`block 数据异常:${blockId}`);
|
||||
const prevText = extractInlineText(block);
|
||||
const nextText = mode === "append" ? `${prevText}${text}` : mode === "prepend" ? `${text}${prevText}` : text;
|
||||
found.container[found.index] = { ...block, content: createTextContent(nextText) };
|
||||
|
||||
await saveDocumentBlocks(args.supabase, args.ctx, blocks);
|
||||
return { ok: true, source, blockId, mode, data: blocks };
|
||||
}
|
||||
|
||||
throw new Error(`未知工具:${toolId}`);
|
||||
};
|
||||
|
||||
return { run };
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
type SupabaseRouteClient = {
|
||||
from: (table: string) => any;
|
||||
};
|
||||
|
||||
export type DocsSupabaseClient = SupabaseRouteClient;
|
||||
|
||||
export type DocsToolContext = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
const escapeIlike = (value: string) =>
|
||||
// 注意:PostgREST 的 or(...) 语法里逗号/括号有特殊含义,这里一并转义,避免解析失败
|
||||
value.replace(/[%_,()]/g, (m) => `\\${m}`);
|
||||
|
||||
const snippetAround = (text: string, q: string, maxLen: number) => {
|
||||
const s = String(text || "");
|
||||
const query = String(q || "").trim();
|
||||
if (!s) return "";
|
||||
const limit = Math.max(80, Math.min(2000, Math.floor(maxLen || 0) || 320));
|
||||
if (!query) return s.slice(0, limit);
|
||||
const lower = s.toLowerCase();
|
||||
const ql = query.toLowerCase();
|
||||
const idx = lower.indexOf(ql);
|
||||
if (idx === -1) return s.slice(0, limit);
|
||||
const start = Math.max(0, idx - Math.floor(limit / 3));
|
||||
const end = Math.min(s.length, start + limit);
|
||||
const head = start > 0 ? "…" : "";
|
||||
const tail = end < s.length ? "…" : "";
|
||||
return `${head}${s.slice(start, end)}${tail}`.trim();
|
||||
};
|
||||
|
||||
const loadWorkspaceIds = async (supabase: DocsSupabaseClient, userId: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from("workspace_members")
|
||||
.select("workspace_id")
|
||||
.eq("user_id", userId);
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取工作区失败");
|
||||
}
|
||||
const rows = Array.isArray(data) ? data : [];
|
||||
return rows.map((r) => String((r as any)?.workspace_id ?? "")).filter(Boolean);
|
||||
};
|
||||
|
||||
export const createDocsServerTools = (args: {
|
||||
supabase: DocsSupabaseClient;
|
||||
ctx: DocsToolContext;
|
||||
allowedToolIds: Set<string>;
|
||||
}) => {
|
||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||
if (!args.allowedToolIds.has(toolId)) {
|
||||
throw new Error(`工具未被允许:${toolId}`);
|
||||
}
|
||||
|
||||
if (toolId === "docs_search") {
|
||||
const query = String(toolArgs.query ?? "").trim();
|
||||
if (!query) throw new Error("缺少 query");
|
||||
const limitRaw = Number(toolArgs.limit ?? 12);
|
||||
const limit = Math.max(1, Math.min(30, Number.isFinite(limitRaw) ? Math.floor(limitRaw) : 12));
|
||||
const workspaceId = String(toolArgs.workspaceId ?? "").trim() || null;
|
||||
const includeDeleted = Boolean(toolArgs.includeDeleted ?? false);
|
||||
|
||||
const wsIds = await loadWorkspaceIds(args.supabase, args.ctx.userId);
|
||||
const wsFilter = workspaceId ? [workspaceId] : wsIds;
|
||||
if (wsFilter.length === 0) return { ok: true, query, results: [] };
|
||||
|
||||
const pattern = `%${escapeIlike(query)}%`;
|
||||
const q = args.supabase
|
||||
.from("documents")
|
||||
.select("id,title,workspace_id,parent_id,updated_at,raw_text,deleted_at")
|
||||
.in("workspace_id", wsFilter)
|
||||
.or(`title.ilike.${pattern},raw_text.ilike.${pattern}`)
|
||||
.order("updated_at", { ascending: false })
|
||||
.limit(limit);
|
||||
if (!includeDeleted) q.is("deleted_at", null);
|
||||
|
||||
const { data, error } = await q;
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "搜索文档失败");
|
||||
}
|
||||
const rows = Array.isArray(data) ? data : [];
|
||||
const results = rows.map((r) => {
|
||||
const id = String((r as any)?.id ?? "");
|
||||
const title = String((r as any)?.title ?? "");
|
||||
const raw = String((r as any)?.raw_text ?? "");
|
||||
const updatedAt = (r as any)?.updated_at ?? null;
|
||||
const wid = String((r as any)?.workspace_id ?? "");
|
||||
const pid = (r as any)?.parent_id ? String((r as any)?.parent_id) : null;
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
workspaceId: wid,
|
||||
parentId: pid,
|
||||
updatedAt,
|
||||
snippet: snippetAround(raw, query, 360),
|
||||
};
|
||||
});
|
||||
|
||||
return { ok: true, query, results };
|
||||
}
|
||||
|
||||
if (toolId === "docs_read") {
|
||||
const documentId = String(toolArgs.documentId ?? "").trim();
|
||||
if (!documentId) throw new Error("缺少 documentId");
|
||||
const maxCharsRaw = Number(toolArgs.maxChars ?? 2500);
|
||||
const maxChars = Math.max(200, Math.min(20_000, Number.isFinite(maxCharsRaw) ? Math.floor(maxCharsRaw) : 2500));
|
||||
const includeContent = Boolean(toolArgs.includeContent ?? false);
|
||||
|
||||
const { data, error } = await args.supabase
|
||||
.from("documents")
|
||||
.select(includeContent ? "id,title,raw_text,content,workspace_id,parent_id,updated_at" : "id,title,raw_text,workspace_id,parent_id,updated_at")
|
||||
.eq("id", documentId)
|
||||
.single();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取文档失败");
|
||||
}
|
||||
const title = isRecord(data) ? String(data.title ?? "") : "";
|
||||
const rawText = isRecord(data) ? String(data.raw_text ?? "") : "";
|
||||
const trimmed = rawText.length > maxChars ? `${rawText.slice(0, maxChars)}…` : rawText;
|
||||
const workspaceId = isRecord(data) ? String(data.workspace_id ?? "") : "";
|
||||
const parentId = isRecord(data) && data.parent_id ? String(data.parent_id) : null;
|
||||
const updatedAt = isRecord(data) ? (data as any).updated_at ?? null : null;
|
||||
const content = includeContent && isRecord(data) ? (data as any).content ?? null : null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
documentId,
|
||||
title,
|
||||
workspaceId,
|
||||
parentId,
|
||||
updatedAt,
|
||||
rawTextLength: rawText.length,
|
||||
rawText: trimmed,
|
||||
...(includeContent ? { content } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`未知工具:${toolId}`);
|
||||
};
|
||||
|
||||
return { run };
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
type SupabaseRouteClient = {
|
||||
from: (table: string) => any;
|
||||
};
|
||||
|
||||
export type MediaSupabaseClient = SupabaseRouteClient;
|
||||
|
||||
export type MediaToolContext = {
|
||||
userId: string;
|
||||
attachments?: Array<{ id: string; title: string; fileUrl: string; mimeType?: string | null }>;
|
||||
};
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
type ResolvedAttachment = { id: string; title: string; fileUrl: string; mimeType?: string | null };
|
||||
|
||||
const resolveAttachment = (ctx: MediaToolContext, ref: string): ResolvedAttachment | null => {
|
||||
const s = String(ref || "").trim();
|
||||
if (!s) return null;
|
||||
const list = Array.isArray(ctx.attachments) ? ctx.attachments : [];
|
||||
const byId = list.find((a) => String(a.id) === s);
|
||||
if (byId) return { id: String(byId.id), title: String(byId.title || byId.id), fileUrl: String(byId.fileUrl || ""), mimeType: byId.mimeType ?? null };
|
||||
const exactTitle = list.find((a) => String(a.title) === s);
|
||||
if (exactTitle) return { id: String(exactTitle.id), title: String(exactTitle.title || exactTitle.id), fileUrl: String(exactTitle.fileUrl || ""), mimeType: exactTitle.mimeType ?? null };
|
||||
const fuzzy = list.find((a) => String(a.title || "").includes(s) || String(a.fileUrl || "").includes(s));
|
||||
if (fuzzy) return { id: String(fuzzy.id), title: String(fuzzy.title || fuzzy.id), fileUrl: String(fuzzy.fileUrl || ""), mimeType: fuzzy.mimeType ?? null };
|
||||
return null;
|
||||
};
|
||||
|
||||
const pick = (obj: unknown, key: string) => (isRecord(obj) ? obj[key] : undefined);
|
||||
|
||||
export const createMediaServerTools = (args: {
|
||||
supabase: MediaSupabaseClient;
|
||||
ctx: MediaToolContext;
|
||||
allowedToolIds: Set<string>;
|
||||
}) => {
|
||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
|
||||
|
||||
if (toolId === "image_read") {
|
||||
const assetId = String(toolArgs.assetId ?? "").trim();
|
||||
const fileUrl = String(toolArgs.fileUrl ?? "").trim();
|
||||
const attachmentRef = String(toolArgs.attachmentRef ?? "").trim();
|
||||
|
||||
const resolved = attachmentRef ? resolveAttachment(args.ctx, attachmentRef) : null;
|
||||
const targetAssetId = assetId || resolved?.id || "";
|
||||
const targetUrl = fileUrl || resolved?.fileUrl || "";
|
||||
|
||||
let row: unknown = null;
|
||||
if (targetAssetId) {
|
||||
const { data, error } = await args.supabase
|
||||
.from("media_assets")
|
||||
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
|
||||
.eq("id", targetAssetId)
|
||||
.single();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
|
||||
}
|
||||
row = data;
|
||||
} else if (targetUrl) {
|
||||
const { data, error } = await args.supabase
|
||||
.from("media_assets")
|
||||
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
|
||||
.eq("file_url", targetUrl)
|
||||
.order("updated_at", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
|
||||
}
|
||||
row = data;
|
||||
} else {
|
||||
throw new Error("缺少 assetId / fileUrl / attachmentRef");
|
||||
}
|
||||
|
||||
if (!row) return { ok: true, found: false };
|
||||
|
||||
const ocrText = String(pick(row, "ocr_text") ?? "");
|
||||
const ocrStatus = String(pick(row, "ocr_status") ?? "");
|
||||
const mimeType = String(pick(row, "mime_type") ?? "");
|
||||
const result = {
|
||||
ok: true,
|
||||
found: true,
|
||||
asset: {
|
||||
id: String(pick(row, "id") ?? ""),
|
||||
fileName: String(pick(row, "file_name") ?? ""),
|
||||
fileUrl: String(pick(row, "file_url") ?? ""),
|
||||
mimeType,
|
||||
storagePath: pick(row, "storage_path") ? String(pick(row, "storage_path")) : null,
|
||||
bucket: pick(row, "bucket") ? String(pick(row, "bucket")) : null,
|
||||
documentId: pick(row, "document_id") ? String(pick(row, "document_id")) : null,
|
||||
workspaceId: pick(row, "workspace_id") ? String(pick(row, "workspace_id")) : null,
|
||||
deletedAt: pick(row, "deleted_at") ?? null,
|
||||
purgedAt: pick(row, "purged_at") ?? null,
|
||||
updatedAt: pick(row, "updated_at") ?? null,
|
||||
},
|
||||
ocrStatus,
|
||||
ocrText,
|
||||
hasOcrText: Boolean(ocrText.trim()),
|
||||
note: ocrText.trim()
|
||||
? "已返回 ocr_text。"
|
||||
: "该图片暂未生成 ocr_text(可等待后台 OCR,或后续再补一个 image_ocr 写工具)。",
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new Error(`未知工具:${toolId}`);
|
||||
};
|
||||
|
||||
return { run };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,736 @@
|
||||
import type { OpenAiCompatibleChatOptions } from "@/lib/ai/openaiCompatibleChat";
|
||||
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
||||
import { applyMindmapOps, ensureMindmapUids, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
|
||||
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
||||
import { searchSearxng, type SearxResult } from "../searchWeb";
|
||||
|
||||
export type MindmapToolContext = {
|
||||
documentId: string;
|
||||
mindmapId: string;
|
||||
userId: string;
|
||||
// 仅用于生成更贴近当前选中节点的行为(可选)
|
||||
selectedUids?: string[];
|
||||
// 来自前端(@ 选择/上传)的附件列表,优先使用(避免额外查询)
|
||||
attachments?: Array<{ id: string; title: string; fileUrl: string; mimeType?: string | null }>;
|
||||
};
|
||||
|
||||
type SupabaseRouteClient = {
|
||||
from: (table: string) => {
|
||||
select: (columns: string) => SupabaseQuery;
|
||||
};
|
||||
};
|
||||
|
||||
type SupabaseQuery = {
|
||||
eq: (column: string, value: unknown) => SupabaseQuery;
|
||||
is: (column: string, value: unknown) => SupabaseQuery;
|
||||
maybeSingle: () => Promise<{ data: unknown; error: unknown }>;
|
||||
};
|
||||
|
||||
export type MindmapSupabaseClient = SupabaseRouteClient;
|
||||
|
||||
const defaultMindmapData: MindmapTreeNode = { data: { text: "中心主题" }, children: [] };
|
||||
|
||||
const safeUrlOrNull = (value: unknown) => {
|
||||
const s = typeof value === "string" ? value.trim() : "";
|
||||
if (!s) return null;
|
||||
try {
|
||||
const u = new URL(s);
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||
return u.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const findNodeByUid = (root: MindmapTreeNode, uid: string): MindmapTreeNode | null => {
|
||||
const target = String(uid || "");
|
||||
if (!target) return null;
|
||||
const walk = (n: MindmapTreeNode): MindmapTreeNode | null => {
|
||||
if (String(n?.data?.uid || "") === target) return n;
|
||||
const children = Array.isArray(n.children) ? n.children : [];
|
||||
for (const c of children) {
|
||||
const hit = walk(c);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(root);
|
||||
};
|
||||
|
||||
const walkSummaries = (root: MindmapTreeNode, maxNodes = 120) => {
|
||||
const list: Array<{ uid: string; text: string; parentUid: string | null; depth: number; childCount: number }> = [];
|
||||
const queue: Array<{ node: MindmapTreeNode; parentUid: string | null; depth: number }> = [{ node: root, parentUid: null, depth: 0 }];
|
||||
while (queue.length && list.length < maxNodes) {
|
||||
const { node, parentUid, depth } = queue.shift()!;
|
||||
const uid = String(node?.data?.uid || "");
|
||||
const text = String(node?.data?.text ?? "").replace(/<[^>]+>/g, "").trim();
|
||||
const children = Array.isArray(node?.children) ? node.children : [];
|
||||
if (uid) list.push({ uid, text, parentUid, depth, childCount: children.length });
|
||||
children.forEach((c) => queue.push({ node: c, parentUid: uid || parentUid, depth: depth + 1 }));
|
||||
}
|
||||
return list;
|
||||
};
|
||||
|
||||
const summarizeSubtree = (node: MindmapTreeNode, depthLimit = 2, maxNodes = 60) => {
|
||||
const list: Array<{ uid: string; text: string; depth: number; childCount: number }> = [];
|
||||
const queue: Array<{ node: MindmapTreeNode; depth: number }> = [{ node, depth: 0 }];
|
||||
while (queue.length && list.length < maxNodes) {
|
||||
const { node: n, depth } = queue.shift()!;
|
||||
const uid = String(n?.data?.uid || "");
|
||||
const text = String(n?.data?.text ?? "").replace(/<[^>]+>/g, "").trim();
|
||||
const children = Array.isArray(n.children) ? n.children : [];
|
||||
if (uid) list.push({ uid, text, depth, childCount: children.length });
|
||||
if (depth < depthLimit) children.forEach((c) => queue.push({ node: c, depth: depth + 1 }));
|
||||
}
|
||||
return list;
|
||||
};
|
||||
|
||||
const refsFromSearx = (r: SearxResult): NodeRef[] => [
|
||||
{ kind: "url", fileUrl: r.url, title: r.title, snippet: r.snippet ? r.snippet.slice(0, 300) : undefined },
|
||||
];
|
||||
|
||||
const coerceMimeKind = (mimeType: string) => {
|
||||
const m = String(mimeType || "").toLowerCase();
|
||||
if (m.includes("pdf")) return "pdf" as const;
|
||||
if (m.includes("word") || m.includes("docx")) return "docx" as const;
|
||||
if (m.includes("presentation") || m.includes("ppt")) return "pptx" as const;
|
||||
return "url" as const;
|
||||
};
|
||||
|
||||
type ResolvedAttachment = { id: string; title: string; fileUrl: string; mimeType?: string | null };
|
||||
|
||||
const resolveAttachmentFromContext = (ctx: MindmapToolContext, ref: string): ResolvedAttachment | null => {
|
||||
const s = String(ref || "").trim();
|
||||
if (!s) return null;
|
||||
const list = Array.isArray(ctx.attachments) ? ctx.attachments : [];
|
||||
const byId = list.find((a) => String(a.id) === s);
|
||||
if (byId) return { id: String(byId.id), title: String(byId.title || byId.id), fileUrl: String(byId.fileUrl || ""), mimeType: byId.mimeType ?? null };
|
||||
const exactTitle = list.find((a) => String(a.title) === s);
|
||||
if (exactTitle)
|
||||
return { id: String(exactTitle.id), title: String(exactTitle.title || exactTitle.id), fileUrl: String(exactTitle.fileUrl || ""), mimeType: exactTitle.mimeType ?? null };
|
||||
const fuzzy = list.find((a) => String(a.title || "").includes(s) || String(a.fileUrl || "").includes(s));
|
||||
if (fuzzy) return { id: String(fuzzy.id), title: String(fuzzy.title || fuzzy.id), fileUrl: String(fuzzy.fileUrl || ""), mimeType: fuzzy.mimeType ?? null };
|
||||
return null;
|
||||
};
|
||||
|
||||
const mergeRefsUnique = (a: NodeRef[], b: NodeRef[]) => {
|
||||
const keyOf = (r: NodeRef) => `${r.kind}|${r.assetId ?? ""}|${r.fileUrl ?? ""}|${r.page ?? ""}|${r.slide ?? ""}`;
|
||||
const map = new Map<string, NodeRef>();
|
||||
for (const r of [...a, ...b]) {
|
||||
if (!r || typeof r !== "object") continue;
|
||||
map.set(keyOf(r), r);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
const sanitizeAddChildOps = (args: {
|
||||
targetUid: string;
|
||||
currentChildren: string[];
|
||||
ops: MindmapOp[];
|
||||
searxResults: SearxResult[];
|
||||
}) => {
|
||||
const existed = new Set(args.currentChildren);
|
||||
const fixed: MindmapOp[] = [];
|
||||
for (const raw of args.ops) {
|
||||
if (!raw || typeof raw !== "object" || (raw as { op?: string }).op !== "addChild") continue;
|
||||
const parentUid = String((raw as { parentUid?: string }).parentUid ?? "");
|
||||
if (parentUid !== args.targetUid) continue;
|
||||
const node = (raw as { node?: Record<string, unknown> }).node ?? {};
|
||||
const textVal = String(node.text ?? "").trim();
|
||||
if (!textVal) continue;
|
||||
if (existed.has(textVal)) continue;
|
||||
existed.add(textVal);
|
||||
|
||||
const href = safeUrlOrNull(node.hyperlink) ?? safeUrlOrNull(node.url) ?? null;
|
||||
const refs = Array.isArray(node.refs) ? (node.refs as NodeRef[]) : [];
|
||||
const hasRef = refs.some((x) => {
|
||||
if (!x || x.kind !== "url") return false;
|
||||
const fileUrl = typeof x.fileUrl === "string" ? x.fileUrl : "";
|
||||
return Boolean(safeUrlOrNull(fileUrl));
|
||||
});
|
||||
const finalRefs = hasRef ? refs : args.searxResults[0] ? refsFromSearx(args.searxResults[0]) : [];
|
||||
|
||||
let finalText = textVal;
|
||||
if (!finalRefs.length && !/待核验/.test(finalText)) finalText = `${finalText}(待核验)`;
|
||||
|
||||
fixed.push({
|
||||
op: "addChild",
|
||||
parentUid: args.targetUid,
|
||||
node: {
|
||||
text: finalText,
|
||||
...(href ? { hyperlink: href } : {}),
|
||||
...(finalRefs.length ? { refs: finalRefs } : {}),
|
||||
},
|
||||
});
|
||||
if (fixed.length >= 6) break;
|
||||
}
|
||||
|
||||
if (!fixed.length && args.searxResults.length) {
|
||||
for (const r of args.searxResults.slice(0, 6)) {
|
||||
const title = String(r.title || "").trim();
|
||||
const url = safeUrlOrNull(r.url);
|
||||
if (!title || !url) continue;
|
||||
if (existed.has(title)) continue;
|
||||
existed.add(title);
|
||||
fixed.push({ op: "addChild", parentUid: args.targetUid, node: { text: title, hyperlink: url, refs: refsFromSearx(r) } });
|
||||
if (fixed.length >= 6) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (fixed.length < 3) {
|
||||
const base = args.currentChildren[0] ? "补完" : "补完";
|
||||
for (let i = fixed.length + 1; i <= 3; i += 1) {
|
||||
fixed.push({ op: "addChild", parentUid: args.targetUid, node: { text: `${base}(待核验)${i}` } });
|
||||
}
|
||||
}
|
||||
|
||||
return fixed.slice(0, 6);
|
||||
};
|
||||
|
||||
export const createMindmapServerTools = (args: {
|
||||
supabase: SupabaseRouteClient;
|
||||
ctx: MindmapToolContext;
|
||||
cfg: OpenAiCompatibleChatOptions;
|
||||
allowedToolIds: Set<string>;
|
||||
}) => {
|
||||
const loadDoc = async () => {
|
||||
const { documentId, userId } = args.ctx;
|
||||
const query = args.supabase
|
||||
.from("documents")
|
||||
.select("id,title,workspace_id,mindmap_data")
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", userId);
|
||||
const { data, error } = await query.maybeSingle();
|
||||
if (error && typeof error === "object" && "message" in (error as Record<string, unknown>)) {
|
||||
throw new Error(String((error as Record<string, unknown>).message ?? "读取页面失败"));
|
||||
}
|
||||
if (error) throw new Error("读取页面失败");
|
||||
if (!data) throw new Error("页面不存在");
|
||||
return data as { id: string; title: string | null; workspace_id: string | null; mindmap_data: unknown };
|
||||
};
|
||||
|
||||
const loadMindmap = async () => {
|
||||
const doc = await loadDoc();
|
||||
const local = await readMindmapLocal(args.ctx.documentId, args.ctx.mindmapId);
|
||||
const base = (local.ok ? local.data : (doc.mindmap_data ?? defaultMindmapData)) as MindmapTreeNode;
|
||||
ensureMindmapUids(base);
|
||||
return { doc, base };
|
||||
};
|
||||
|
||||
const mindmap_get = async (toolArgs: Record<string, unknown>) => {
|
||||
const maxNodes = Number(toolArgs.maxNodes ?? 120);
|
||||
const { base } = await loadMindmap();
|
||||
const list = walkSummaries(base, Number.isFinite(maxNodes) ? Math.max(10, Math.min(300, Math.floor(maxNodes))) : 120);
|
||||
return { ok: true, documentId: args.ctx.documentId, mindmapId: args.ctx.mindmapId, nodes: list };
|
||||
};
|
||||
|
||||
const mindmap_get_subtree = async (toolArgs: Record<string, unknown>) => {
|
||||
const uid = String(toolArgs.uid ?? "").trim();
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
const depth = Number(toolArgs.depth ?? 2);
|
||||
const maxNodes = Number(toolArgs.maxNodes ?? 60);
|
||||
const { base } = await loadMindmap();
|
||||
const hit = findNodeByUid(base, uid);
|
||||
if (!hit) throw new Error(`未找到 uid=${uid}`);
|
||||
const list = summarizeSubtree(
|
||||
hit,
|
||||
Number.isFinite(depth) ? Math.max(0, Math.min(6, Math.floor(depth))) : 2,
|
||||
Number.isFinite(maxNodes) ? Math.max(5, Math.min(200, Math.floor(maxNodes))) : 60,
|
||||
);
|
||||
return { ok: true, uid, nodes: list };
|
||||
};
|
||||
|
||||
const mindmap_apply_ops = async (toolArgs: Record<string, unknown>) => {
|
||||
const ops = (toolArgs.ops ?? []) as unknown;
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
if (!Array.isArray(ops) || ops.length === 0) throw new Error("缺少 ops");
|
||||
if (ops.length > 80) throw new Error("ops 过多(最多 80)");
|
||||
|
||||
const normalizeOp = (raw: unknown): MindmapOp | null => {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const o = raw as Record<string, unknown>;
|
||||
const op = String(o.op ?? "").trim();
|
||||
|
||||
// 兼容历史/模型常见写法:update_node/add_child/set_link/append_note/delete_node
|
||||
if (op === "update_node" || op === "updateNode") {
|
||||
const uid = String(o.uid ?? o.id ?? "").trim();
|
||||
const text = String(o.text ?? o.value ?? "").trim();
|
||||
if (!uid || !text) return null;
|
||||
return { op: "updateText", uid, text };
|
||||
}
|
||||
if (op === "add_child" || op === "addChild") {
|
||||
const parentUid = String(o.parentUid ?? o.parent_uid ?? "").trim();
|
||||
const node = (o.node && typeof o.node === "object" ? (o.node as Record<string, unknown>) : {}) as Record<string, unknown>;
|
||||
const text = String(node.text ?? "").trim();
|
||||
if (!parentUid || !text) return null;
|
||||
const hyperlink = safeUrlOrNull(node.hyperlink) ?? null;
|
||||
const note = String(node.note ?? "").trim() || null;
|
||||
const refs = Array.isArray(node.refs) ? (node.refs as NodeRef[]) : [];
|
||||
return { op: "addChild", parentUid, node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) } };
|
||||
}
|
||||
if (op === "set_link" || op === "set_hyperlink" || op === "setHyperlink") {
|
||||
const uid = String(o.uid ?? o.id ?? "").trim();
|
||||
const hyperlinkRaw = o.hyperlink ?? o.url ?? null;
|
||||
const hyperlink = hyperlinkRaw === null ? null : safeUrlOrNull(hyperlinkRaw);
|
||||
if (!uid) return null;
|
||||
return { op: "setHyperlink", uid, hyperlink: hyperlinkRaw === null ? null : hyperlink };
|
||||
}
|
||||
if (op === "append_note" || op === "appendNote") {
|
||||
const uid = String(o.uid ?? o.id ?? "").trim();
|
||||
const markdown = String(o.markdown ?? o.note ?? "").trim();
|
||||
if (!uid || !markdown) return null;
|
||||
return { op: "appendNote", uid, markdown };
|
||||
}
|
||||
if (op === "set_refs" || op === "setRefs") {
|
||||
const uid = String(o.uid ?? o.id ?? "").trim();
|
||||
const refs = Array.isArray(o.refs) ? (o.refs as NodeRef[]) : [];
|
||||
if (!uid || refs.length === 0) return null;
|
||||
return { op: "setRefs", uid, refs };
|
||||
}
|
||||
if (op === "delete_node" || op === "deleteNode") {
|
||||
const uid = String(o.uid ?? o.id ?? "").trim();
|
||||
if (!uid) return null;
|
||||
return { op: "deleteNode", uid };
|
||||
}
|
||||
|
||||
// 原生协议(MindmapOp)
|
||||
if (
|
||||
op === "addChild" ||
|
||||
op === "addSiblingAfter" ||
|
||||
op === "updateText" ||
|
||||
op === "setHyperlink" ||
|
||||
op === "setRefs" ||
|
||||
op === "appendNote" ||
|
||||
op === "deleteNode"
|
||||
) {
|
||||
return o as unknown as MindmapOp;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalized = ops.map((x) => normalizeOp(x)).filter(Boolean) as MindmapOp[];
|
||||
if (normalized.length === 0) throw new Error("ops 无有效操作(请使用 MindmapOp 协议或已支持的别名)");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, normalized);
|
||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
};
|
||||
|
||||
const mindmap_add_child = async (toolArgs: Record<string, unknown>) => {
|
||||
const parentUid = String(toolArgs.parentUid ?? "").trim();
|
||||
const text = String(toolArgs.text ?? "").trim();
|
||||
const hyperlink = safeUrlOrNull(toolArgs.hyperlink) ?? null;
|
||||
const note = String(toolArgs.note ?? "").trim() || null;
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
const refs = Array.isArray(toolArgs.refs) ? (toolArgs.refs as NodeRef[]) : [];
|
||||
if (!parentUid) throw new Error("缺少 parentUid");
|
||||
if (!text) throw new Error("缺少 text");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = {
|
||||
op: "addChild",
|
||||
parentUid,
|
||||
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
|
||||
};
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
};
|
||||
|
||||
const mindmap_add_sibling_after = async (toolArgs: Record<string, unknown>) => {
|
||||
const targetUid = String(toolArgs.targetUid ?? "").trim();
|
||||
const text = String(toolArgs.text ?? "").trim();
|
||||
const hyperlink = safeUrlOrNull(toolArgs.hyperlink) ?? null;
|
||||
const note = String(toolArgs.note ?? "").trim() || null;
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
const refs = Array.isArray(toolArgs.refs) ? (toolArgs.refs as NodeRef[]) : [];
|
||||
if (!targetUid) throw new Error("缺少 targetUid");
|
||||
if (!text) throw new Error("缺少 text");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = {
|
||||
op: "addSiblingAfter",
|
||||
targetUid,
|
||||
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
|
||||
};
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
};
|
||||
|
||||
const mindmap_update_node_text = async (toolArgs: Record<string, unknown>) => {
|
||||
const uid = String(toolArgs.uid ?? "").trim();
|
||||
const text = String(toolArgs.text ?? "").trim();
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
if (!text) throw new Error("缺少 text");
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = { op: "updateText", uid, text };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
};
|
||||
|
||||
const mindmap_set_hyperlink = async (toolArgs: Record<string, unknown>) => {
|
||||
const uid = String(toolArgs.uid ?? "").trim();
|
||||
const hyperlinkRaw = toolArgs.hyperlink;
|
||||
const hyperlink = hyperlinkRaw === null ? null : safeUrlOrNull(hyperlinkRaw);
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
if (hyperlinkRaw !== null && hyperlinkRaw !== undefined && !hyperlink) {
|
||||
throw new Error("hyperlink 必须是 http(s) URL 或 null");
|
||||
}
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = { op: "setHyperlink", uid, hyperlink: hyperlinkRaw === null ? null : hyperlink };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
};
|
||||
|
||||
const mindmap_append_note = async (toolArgs: Record<string, unknown>) => {
|
||||
const uid = String(toolArgs.uid ?? "").trim();
|
||||
const markdown = String(toolArgs.markdown ?? "").trim();
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
if (!markdown) throw new Error("缺少 markdown");
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = { op: "appendNote", uid, markdown };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
};
|
||||
|
||||
const mindmap_set_refs = async (toolArgs: Record<string, unknown>) => {
|
||||
const uid = String(toolArgs.uid ?? "").trim();
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
const refs = Array.isArray(toolArgs.refs) ? (toolArgs.refs as NodeRef[]) : [];
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
if (!refs.length) throw new Error("缺少 refs");
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = { op: "setRefs", uid, refs };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
};
|
||||
|
||||
const mindmap_delete_node = async (toolArgs: Record<string, unknown>) => {
|
||||
const uid = String(toolArgs.uid ?? "").trim();
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = { op: "deleteNode", uid };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
};
|
||||
|
||||
const mindmap_add_attachment_ref = async (toolArgs: Record<string, unknown>) => {
|
||||
const uid = String(toolArgs.uid ?? "").trim();
|
||||
const attachmentId = String(toolArgs.attachmentId ?? toolArgs.attachmentRef ?? "").trim();
|
||||
const fileUrlDirect = String(toolArgs.fileUrl ?? "").trim();
|
||||
const mode = String(toolArgs.mode ?? "append").trim() as "append" | "replace";
|
||||
const page = Number(toolArgs.page ?? 0);
|
||||
const slide = Number(toolArgs.slide ?? 0);
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
const snippet = String(toolArgs.snippet ?? "").trim() || null;
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
if (!attachmentId && !fileUrlDirect) throw new Error("缺少 attachmentId 或 fileUrl");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const node = findNodeByUid(base, uid);
|
||||
if (!node) throw new Error(`未找到 uid=${uid}`);
|
||||
|
||||
let resolved: ResolvedAttachment | null = null;
|
||||
if (attachmentId) resolved = resolveAttachmentFromContext(args.ctx, attachmentId);
|
||||
|
||||
if (!resolved && attachmentId) {
|
||||
const workspaceId = String((doc as any).workspace_id ?? "").trim();
|
||||
if (!workspaceId) throw new Error("缺少 workspace_id,无法解析附件");
|
||||
const query = args.supabase
|
||||
.from("media_assets")
|
||||
.select("id,file_url,file_name,mime_type,document_id,workspace_id,deleted_at")
|
||||
.eq("id", attachmentId)
|
||||
.eq("document_id", args.ctx.documentId)
|
||||
.eq("workspace_id", workspaceId)
|
||||
.is("deleted_at", null);
|
||||
const { data, error } = await query.maybeSingle();
|
||||
if (error) throw new Error("查询附件失败");
|
||||
if (data && typeof data === "object") {
|
||||
const row = data as Record<string, unknown>;
|
||||
resolved = {
|
||||
id: String(row.id ?? attachmentId),
|
||||
title: String(row.file_name ?? row.id ?? attachmentId),
|
||||
fileUrl: String(row.file_url ?? ""),
|
||||
mimeType: (row.mime_type as string | null | undefined) ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const finalUrl = (resolved?.fileUrl || fileUrlDirect || "").trim();
|
||||
const finalTitle = String(resolved?.title || toolArgs.title || attachmentId || "附件").trim();
|
||||
if (!finalUrl) throw new Error("附件缺少 fileUrl");
|
||||
|
||||
const kindOverride = String(toolArgs.kind ?? "").trim();
|
||||
const kind =
|
||||
kindOverride === "pdf" || kindOverride === "docx" || kindOverride === "pptx" || kindOverride === "url"
|
||||
? (kindOverride as NodeRef["kind"])
|
||||
: resolved?.mimeType
|
||||
? coerceMimeKind(resolved.mimeType)
|
||||
: ("url" as const);
|
||||
|
||||
const ref: NodeRef = {
|
||||
kind,
|
||||
...(attachmentId ? { assetId: attachmentId } : {}),
|
||||
fileUrl: finalUrl,
|
||||
...(Number.isFinite(page) && page > 0 ? { page: Math.floor(page) } : {}),
|
||||
...(Number.isFinite(slide) && slide > 0 ? { slide: Math.floor(slide) } : {}),
|
||||
...(finalTitle ? { title: finalTitle } : {}),
|
||||
...(snippet ? { snippet } : {}),
|
||||
};
|
||||
|
||||
const prevRefs = Array.isArray(node.data?.refs) ? (node.data.refs as NodeRef[]) : [];
|
||||
const nextRefs = mode === "replace" ? [ref] : mergeRefsUnique(prevRefs, [ref]);
|
||||
const op: MindmapOp = { op: "setRefs", uid, refs: nextRefs };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
};
|
||||
|
||||
const mindmap_add_attachment_child = async (toolArgs: Record<string, unknown>) => {
|
||||
const parentUid = String(toolArgs.parentUid ?? "").trim();
|
||||
const attachmentId = String(toolArgs.attachmentId ?? toolArgs.attachmentRef ?? "").trim();
|
||||
const fileUrlDirect = String(toolArgs.fileUrl ?? "").trim();
|
||||
const titleOverride = String(toolArgs.title ?? "").trim();
|
||||
const textOverride = String(toolArgs.text ?? "").trim();
|
||||
const note = String(toolArgs.note ?? "").trim() || null;
|
||||
const page = Number(toolArgs.page ?? 0);
|
||||
const slide = Number(toolArgs.slide ?? 0);
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
if (!parentUid) throw new Error("缺少 parentUid");
|
||||
if (!attachmentId && !fileUrlDirect) throw new Error("缺少 attachmentId 或 fileUrl");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const resolved = attachmentId ? resolveAttachmentFromContext(args.ctx, attachmentId) : null;
|
||||
const finalUrl = String(resolved?.fileUrl || fileUrlDirect || "").trim();
|
||||
const finalTitle = String(titleOverride || resolved?.title || attachmentId || "附件").trim();
|
||||
if (!finalUrl) throw new Error("附件缺少 fileUrl");
|
||||
|
||||
const kindOverride = String(toolArgs.kind ?? "").trim();
|
||||
const kind =
|
||||
kindOverride === "pdf" || kindOverride === "docx" || kindOverride === "pptx" || kindOverride === "url"
|
||||
? (kindOverride as NodeRef["kind"])
|
||||
: resolved?.mimeType
|
||||
? coerceMimeKind(resolved.mimeType)
|
||||
: ("url" as const);
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(textOverride || `附件:${finalTitle}`);
|
||||
if (Number.isFinite(page) && page > 0) parts.push(`p.${Math.floor(page)}`);
|
||||
if (Number.isFinite(slide) && slide > 0) parts.push(`slide ${Math.floor(slide)}`);
|
||||
const text = parts.join(" ");
|
||||
|
||||
const ref: NodeRef = {
|
||||
kind,
|
||||
...(attachmentId ? { assetId: attachmentId } : {}),
|
||||
fileUrl: finalUrl,
|
||||
...(Number.isFinite(page) && page > 0 ? { page: Math.floor(page) } : {}),
|
||||
...(Number.isFinite(slide) && slide > 0 ? { slide: Math.floor(slide) } : {}),
|
||||
...(finalTitle ? { title: finalTitle } : {}),
|
||||
};
|
||||
|
||||
const hyperlink = safeUrlOrNull(finalUrl) ?? null;
|
||||
const op: MindmapOp = {
|
||||
op: "addChild",
|
||||
parentUid,
|
||||
node: { text, ...(hyperlink ? { hyperlink } : {}), refs: [ref], ...(note ? { note } : {}) },
|
||||
};
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
};
|
||||
|
||||
const mindmap_add_image_child = async (toolArgs: Record<string, unknown>) => {
|
||||
const parentUid = String(toolArgs.parentUid ?? "").trim();
|
||||
const imageRef = String(toolArgs.imageRef ?? toolArgs.imageId ?? toolArgs.imageUrl ?? "").trim();
|
||||
const alt = String(toolArgs.alt ?? "").trim() || "image";
|
||||
const caption = String(toolArgs.caption ?? "").trim() || "";
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
if (!parentUid) throw new Error("缺少 parentUid");
|
||||
if (!imageRef) throw new Error("缺少 imageRef(可用 attachmentId 或图片 URL)");
|
||||
|
||||
const resolved = resolveAttachmentFromContext(args.ctx, imageRef);
|
||||
const url = String(resolved?.fileUrl || imageRef).trim();
|
||||
const title = String(resolved?.title || caption || "图片").trim();
|
||||
if (!url) throw new Error("图片缺少 URL");
|
||||
|
||||
const markdown = ``;
|
||||
const hyperlink = safeUrlOrNull(url) ?? null;
|
||||
const op: MindmapOp = {
|
||||
op: "addChild",
|
||||
parentUid,
|
||||
node: {
|
||||
text: caption ? caption : `图片:${title}`,
|
||||
...(hyperlink ? { hyperlink } : {}),
|
||||
note: markdown,
|
||||
refs: [{ kind: "url", ...(resolved?.id ? { assetId: resolved.id } : {}), fileUrl: url, title }],
|
||||
},
|
||||
};
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
};
|
||||
|
||||
const mindmap_append_image_note = async (toolArgs: Record<string, unknown>) => {
|
||||
const uid = String(toolArgs.uid ?? "").trim();
|
||||
const imageRef = String(toolArgs.imageRef ?? toolArgs.imageId ?? toolArgs.imageUrl ?? "").trim();
|
||||
const alt = String(toolArgs.alt ?? "").trim() || "image";
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
if (!imageRef) throw new Error("缺少 imageRef(可用 attachmentId 或图片 URL)");
|
||||
|
||||
const resolved = resolveAttachmentFromContext(args.ctx, imageRef);
|
||||
const url = String(resolved?.fileUrl || imageRef).trim();
|
||||
const title = String(resolved?.title || "图片").trim();
|
||||
if (!url) throw new Error("图片缺少 URL");
|
||||
|
||||
const markdown = ``;
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = { op: "appendNote", uid, markdown };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||
return {
|
||||
ok: true,
|
||||
applied,
|
||||
errors,
|
||||
data: nextData,
|
||||
meta: { reason, ref: { kind: "url", ...(resolved?.id ? { assetId: resolved.id } : {}), fileUrl: url, title } },
|
||||
};
|
||||
};
|
||||
|
||||
const mindmap_expand_node = async (toolArgs: Record<string, unknown>) => {
|
||||
const targetUid = String(toolArgs.targetUid ?? "").trim();
|
||||
if (!targetUid) throw new Error("缺少 targetUid");
|
||||
const instruction = String(toolArgs.instruction ?? "").trim();
|
||||
const useSearx = args.allowedToolIds.has("search_web");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const target = findNodeByUid(base, targetUid);
|
||||
if (!target) throw new Error("未找到目标节点(uid 不存在)");
|
||||
|
||||
const targetText = String(target?.data?.text ?? "").trim();
|
||||
const currentChildren = Array.isArray(target?.children)
|
||||
? target.children
|
||||
.map((c) => String(c?.data?.text ?? "").trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 20)
|
||||
: [];
|
||||
|
||||
const query = [targetText, instruction].filter(Boolean).join(" ").trim();
|
||||
const searxResults = useSearx && query ? await searchSearxng(query, 6).catch(() => []) : [];
|
||||
|
||||
const system = [
|
||||
"你是一个“思维导图补完器”。",
|
||||
"你必须输出严格 JSON(不要 Markdown/不要代码块/不要解释)。",
|
||||
"你只能输出 {\"ops\": MindmapOp[]} 这一个对象。",
|
||||
"默认策略:为 targetUid 新增 3~6 个子节点(addChild)。",
|
||||
"每个新增节点必须提供 text,并尽量提供 hyperlink 与 refs(引用来自搜索结果 URL)。",
|
||||
"不要删除/改名已有节点;不要输出 addSiblingAfter/updateText/deleteNode。",
|
||||
].join("\n");
|
||||
|
||||
const user = [
|
||||
`documentId=${args.ctx.documentId}`,
|
||||
`mindmapId=${args.ctx.mindmapId}`,
|
||||
`targetUid=${targetUid}`,
|
||||
"",
|
||||
`目标节点:${targetText || "(empty)"}`,
|
||||
currentChildren.length ? `当前子节点(供去重):${currentChildren.join(";")}` : "",
|
||||
instruction ? `用户要求:${instruction}` : "",
|
||||
"",
|
||||
"可用证据(搜索结果):",
|
||||
...(searxResults.length
|
||||
? searxResults.map((r, idx) => {
|
||||
const snip = (r.snippet || "").replace(/\s+/g, " ").slice(0, 180);
|
||||
return `${idx + 1}. ${r.title}\n url: ${r.url}\n snippet: ${snip}`;
|
||||
})
|
||||
: ["(无)"]),
|
||||
"",
|
||||
"MindmapOp JSON Schema(仅供理解):",
|
||||
'{ "ops": [ { "op": "addChild", "parentUid": string, "node": { "text": string, "hyperlink"?: string, "refs"?: NodeRef[] } } ] }',
|
||||
'NodeRef 示例:{ "kind": "url", "fileUrl": "https://example.com", "title": "来源标题", "snippet": "..." }',
|
||||
"",
|
||||
"硬性约束:",
|
||||
"- 仅输出 addChild;parentUid 必须等于 targetUid。",
|
||||
"- 新增节点 text 不要与当前子节点重复。",
|
||||
"- hyperlink 必须是 http(s) URL。",
|
||||
"- 每个新增节点 refs 至少 1 条(kind=url, fileUrl=来源url);若没有来源,则在 text 末尾追加“(待核验)”。",
|
||||
"- 输出规模控制:最多 6 个节点。",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
let ops: MindmapOp[] = [];
|
||||
let finishReason = "";
|
||||
try {
|
||||
const { text, raw } = await openAiCompatibleChat(
|
||||
[
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
],
|
||||
{
|
||||
...args.cfg,
|
||||
timeoutMs: 40_000,
|
||||
maxTokens: 1800,
|
||||
maxCompletionTokens: 1800,
|
||||
responseFormat: "json_object",
|
||||
},
|
||||
);
|
||||
if (typeof raw === "object" && raw) {
|
||||
const r = raw as Record<string, unknown>;
|
||||
const choices = Array.isArray(r.choices) ? (r.choices as unknown[]) : [];
|
||||
const first = (choices[0] && typeof choices[0] === "object" ? (choices[0] as Record<string, unknown>) : null) ?? null;
|
||||
finishReason = first ? String(first.finish_reason ?? "") : "";
|
||||
}
|
||||
const json = tryExtractJsonObject(text);
|
||||
if (json && Array.isArray((json as Record<string, unknown>).ops)) ops = (json as Record<string, unknown>).ops as MindmapOp[];
|
||||
} catch {
|
||||
ops = [];
|
||||
}
|
||||
|
||||
const fixed = sanitizeAddChildOps({ targetUid, currentChildren, ops, searxResults });
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, fixed);
|
||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
applied,
|
||||
errors,
|
||||
ops: fixed,
|
||||
data: nextData,
|
||||
meta: { finishReason, searched: useSearx, searxCount: searxResults.length },
|
||||
};
|
||||
};
|
||||
|
||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||
if (toolId === "mindmap_get") return await mindmap_get(toolArgs);
|
||||
if (toolId === "mindmap_get_subtree") return await mindmap_get_subtree(toolArgs);
|
||||
if (toolId === "mindmap_apply_ops") return await mindmap_apply_ops(toolArgs);
|
||||
if (toolId === "mindmap_add_child") return await mindmap_add_child(toolArgs);
|
||||
if (toolId === "mindmap_add_sibling_after") return await mindmap_add_sibling_after(toolArgs);
|
||||
if (toolId === "mindmap_update_node_text") return await mindmap_update_node_text(toolArgs);
|
||||
if (toolId === "mindmap_set_hyperlink") return await mindmap_set_hyperlink(toolArgs);
|
||||
if (toolId === "mindmap_append_note") return await mindmap_append_note(toolArgs);
|
||||
if (toolId === "mindmap_set_refs") return await mindmap_set_refs(toolArgs);
|
||||
if (toolId === "mindmap_delete_node") return await mindmap_delete_node(toolArgs);
|
||||
if (toolId === "mindmap_add_attachment_ref") return await mindmap_add_attachment_ref(toolArgs);
|
||||
if (toolId === "mindmap_add_attachment_child") return await mindmap_add_attachment_child(toolArgs);
|
||||
if (toolId === "mindmap_add_image_child") return await mindmap_add_image_child(toolArgs);
|
||||
if (toolId === "mindmap_append_image_note") return await mindmap_append_image_note(toolArgs);
|
||||
if (toolId === "mindmap_expand_node") return await mindmap_expand_node(toolArgs);
|
||||
throw new Error(`不支持的工具:${toolId}`);
|
||||
};
|
||||
|
||||
return { run };
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
export type RagToolContext = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
type RagFetchResult =
|
||||
| { ok: true; status: number; data: unknown }
|
||||
| { ok: false; status: number; error: string; data?: unknown };
|
||||
|
||||
const readEnv = (key: string) => {
|
||||
try {
|
||||
const v = process.env[key];
|
||||
return typeof v === "string" ? v.trim() : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const safeJoinUrl = (base: string, path: string) => {
|
||||
const b = String(base || "").trim().replace(/\/+$/, "");
|
||||
const p = String(path || "").trim().replace(/^\/+/, "");
|
||||
if (!b) return `/${p}`;
|
||||
return `${b}/${p}`;
|
||||
};
|
||||
|
||||
const fetchJson = async (url: string, init: RequestInit, timeoutMs: number): Promise<RagFetchResult> => {
|
||||
const ms = Math.max(500, Math.min(60_000, Math.floor(timeoutMs || 0) || 20_000));
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), ms);
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: controller.signal });
|
||||
const data = (await res.json().catch(() => null)) as unknown;
|
||||
if (!res.ok) {
|
||||
const detail =
|
||||
typeof data === "object" && data && "detail" in (data as any) ? String((data as any).detail ?? "") : "";
|
||||
const msg = detail || `HTTP ${res.status}`;
|
||||
return { ok: false, status: res.status, error: msg, data };
|
||||
}
|
||||
return { ok: true, status: res.status, data };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, status: 0, error: msg };
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
};
|
||||
|
||||
export const createRagServerTools = (args: {
|
||||
ctx: RagToolContext;
|
||||
allowedToolIds: Set<string>;
|
||||
}) => {
|
||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||
if (!args.allowedToolIds.has(toolId)) {
|
||||
throw new Error(`工具未被允许:${toolId}`);
|
||||
}
|
||||
|
||||
if (toolId === "rag_lightrag_query") {
|
||||
const query = String(toolArgs.query ?? "").trim();
|
||||
if (!query) throw new Error("缺少 query");
|
||||
if (query.length < 2) throw new Error("query 太短(至少 2 个字符)");
|
||||
|
||||
const modeRaw = String(toolArgs.mode ?? "mix").trim();
|
||||
const mode =
|
||||
modeRaw === "local" ||
|
||||
modeRaw === "global" ||
|
||||
modeRaw === "hybrid" ||
|
||||
modeRaw === "naive" ||
|
||||
modeRaw === "mix" ||
|
||||
modeRaw === "bypass"
|
||||
? modeRaw
|
||||
: "mix";
|
||||
|
||||
const topKRaw = Number(toolArgs.topK ?? toolArgs.top_k ?? 12);
|
||||
const topK = Math.max(1, Math.min(60, Number.isFinite(topKRaw) ? Math.floor(topKRaw) : 12));
|
||||
|
||||
const chunkTopKRaw = Number(toolArgs.chunkTopK ?? toolArgs.chunk_top_k ?? topK);
|
||||
const chunkTopK = Math.max(1, Math.min(120, Number.isFinite(chunkTopKRaw) ? Math.floor(chunkTopKRaw) : topK));
|
||||
|
||||
const includeReferences = toolArgs.includeReferences !== false;
|
||||
const includeChunkContent = Boolean(toolArgs.includeChunkContent ?? true);
|
||||
const onlyNeedContext = Boolean(toolArgs.onlyNeedContext ?? false);
|
||||
const responseType = String(toolArgs.responseType ?? "").trim() || undefined;
|
||||
|
||||
const baseUrl = readEnv("LIGHTRAG_URL");
|
||||
if (!baseUrl) throw new Error("缺少环境变量:LIGHTRAG_URL");
|
||||
const apiKey = readEnv("LIGHTRAG_API_KEY");
|
||||
|
||||
const url = safeJoinUrl(baseUrl, "/query");
|
||||
const payload = {
|
||||
query,
|
||||
mode,
|
||||
top_k: topK,
|
||||
chunk_top_k: chunkTopK,
|
||||
include_references: includeReferences,
|
||||
include_chunk_content: includeChunkContent,
|
||||
only_need_context: onlyNeedContext,
|
||||
...(responseType ? { response_type: responseType } : {}),
|
||||
};
|
||||
|
||||
const r = await fetchJson(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
...(apiKey ? { "X-API-Key": apiKey } : {}),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
35_000,
|
||||
);
|
||||
|
||||
if (!r.ok) {
|
||||
throw new Error(`LightRAG 查询失败:${r.error}`);
|
||||
}
|
||||
return { ok: true, provider: "lightrag", query, mode, result: r.data };
|
||||
}
|
||||
|
||||
throw new Error(`未知工具:${toolId}`);
|
||||
};
|
||||
|
||||
return { run };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import type { AiAgentTool, AiAgentToolSet } from "../types";
|
||||
|
||||
export const builtinTools: AiAgentTool[] = [
|
||||
{
|
||||
id: "search_web",
|
||||
displayName: "联网检索(SearxNG)",
|
||||
modelDescription: "使用 SearxNG 搜索,返回标题/URL/摘要;用于提供可追溯来源。",
|
||||
inputSchemaText: `{ "query": "string", "count?": "number (1~10, 默认6)" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: false,
|
||||
isWriteTool: false,
|
||||
},
|
||||
{
|
||||
id: "rag_lightrag_query",
|
||||
displayName: "LightRAG 检索(RAG)",
|
||||
modelDescription:
|
||||
"调用本地/远程 LightRAG 服务进行检索与生成,返回 response + references(不写入)。",
|
||||
inputSchemaText:
|
||||
`{ "query": "string", "mode?": "\"mix\"|\"naive\"|\"local\"|\"global\"|\"hybrid\"|\"bypass\"", "topK?": "number (1~60, 默认12)", "chunkTopK?": "number (1~120, 默认与topK一致)", "includeReferences?": "boolean (默认 true)", "includeChunkContent?": "boolean (默认 true)", "onlyNeedContext?": "boolean (默认 false)", "responseType?": "string" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: false,
|
||||
isWriteTool: false,
|
||||
},
|
||||
{
|
||||
id: "docs_search",
|
||||
displayName: "搜索文档(跨页面)",
|
||||
modelDescription: "在工作区内按 title/raw_text 搜索文档,返回匹配列表与摘要片段(不写入)。",
|
||||
inputSchemaText:
|
||||
`{ "query": "string", "limit?": "number (1~30, 默认12)", "workspaceId?": "string", "includeDeleted?": "boolean (默认 false)" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: false,
|
||||
isWriteTool: false,
|
||||
},
|
||||
{
|
||||
id: "docs_read",
|
||||
displayName: "读取文档(跨页面)",
|
||||
modelDescription: "读取指定 documentId 的 title/raw_text(可选含 content),用于引用与对比(不写入)。",
|
||||
inputSchemaText:
|
||||
`{ "documentId": "string", "maxChars?": "number (200~20000, 默认2500)", "includeContent?": "boolean (默认 false)" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: false,
|
||||
isWriteTool: false,
|
||||
},
|
||||
{
|
||||
id: "image_read",
|
||||
displayName: "读取图片(OCR)",
|
||||
modelDescription: "读取图片/附件的 OCR 文本(优先从 media_assets.ocr_text 获取,不写入)。",
|
||||
inputSchemaText:
|
||||
`{ "assetId?": "string", "fileUrl?": "string", "attachmentRef?": "string (可用附件 id/title/url 片段)" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: false,
|
||||
isWriteTool: false,
|
||||
},
|
||||
{
|
||||
id: "slash_run",
|
||||
displayName: "斜杠命令(创建/改名)",
|
||||
modelDescription:
|
||||
"执行预置斜杠命令(例如 /new 创建文档、/rename 重命名)。这是写工具,执行前必须确认。",
|
||||
inputSchemaText:
|
||||
`{ "text?": "string (以 / 开头,例如 /new 标题)", "command?": "\"new_doc\"|\"rename_doc\"", "params?": "object (见命令说明)", "reason?": "string" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: true,
|
||||
isWriteTool: true,
|
||||
},
|
||||
{
|
||||
id: "doc_get",
|
||||
displayName: "读取页面内容(摘要)",
|
||||
modelDescription: "读取当前文档的块摘要(blockId/type/text/depth),用于定位与规划(不写入)。",
|
||||
inputSchemaText: `{ "maxBlocks?": "number (10~240, 默认80)" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: false,
|
||||
isWriteTool: false,
|
||||
},
|
||||
{
|
||||
id: "doc_find",
|
||||
displayName: "在页面中查找(按块)",
|
||||
modelDescription: "按块内容查找匹配的段落/标题,返回 blockId 列表(不写入)。",
|
||||
inputSchemaText: `{ "query": "string", "maxResults?": "number (1~30, 默认8)" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: false,
|
||||
isWriteTool: false,
|
||||
},
|
||||
{
|
||||
id: "doc_insert_blocks",
|
||||
displayName: "插入块(写入页面)",
|
||||
modelDescription:
|
||||
"在指定 block 前/后插入新块(目前支持 paragraph/heading)。写入后返回最新 blocks 快照(data)。",
|
||||
inputSchemaText:
|
||||
`{ "afterBlockId?": "string", "beforeBlockId?": "string", "blocks": "Array<{type:'paragraph'|'heading', text:string, level?:1..5}>", "reason?": "string" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: true,
|
||||
isWriteTool: true,
|
||||
},
|
||||
{
|
||||
id: "doc_replace_range",
|
||||
displayName: "替换块文本(写入页面)",
|
||||
modelDescription:
|
||||
"替换指定 block 的纯文本内容(不改 block 类型/props)。写入后返回最新 blocks 快照(data)。",
|
||||
inputSchemaText: `{ "blockId": "string", "text": "string", "mode?": "'replace'|'append'|'prepend'", "reason?": "string" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: true,
|
||||
isWriteTool: true,
|
||||
},
|
||||
{
|
||||
id: "mindmap_get",
|
||||
displayName: "读取思维导图(摘要)",
|
||||
modelDescription: "读取当前 mindmap 的精简结构(uid/text/父子关系/子数量),用于定位与规划。",
|
||||
inputSchemaText: `{ "maxNodes?": "number (10~300, 默认120)" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: false,
|
||||
isWriteTool: false,
|
||||
},
|
||||
{
|
||||
id: "mindmap_get_subtree",
|
||||
displayName: "读取思维导图子树(摘要)",
|
||||
modelDescription: "读取指定 uid 的子树摘要(用于精确改写/补完)。",
|
||||
inputSchemaText: `{ "uid": "string", "depth?": "number (0~6, 默认2)", "maxNodes?": "number (5~200, 默认60)" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: false,
|
||||
isWriteTool: false,
|
||||
},
|
||||
{
|
||||
id: "mindmap_apply_ops",
|
||||
displayName: "应用思维导图增量操作(写入)",
|
||||
modelDescription: "对 mindmap 应用增量 ops(新增/改名/引用/备注/删除等),并落盘保存。",
|
||||
inputSchemaText: `{ "ops": "MindmapOp[]", "reason?": "string" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: true,
|
||||
isWriteTool: true,
|
||||
},
|
||||
{
|
||||
id: "mindmap_add_child",
|
||||
displayName: "新增子节点",
|
||||
modelDescription: "在 parentUid 下新增一个子节点(可带 hyperlink/refs/note)。",
|
||||
inputSchemaText: `{ "parentUid": "string", "text": "string", "hyperlink?": "string (http/https)", "note?": "string", "refs?": "NodeRef[]", "reason?": "string" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: true,
|
||||
isWriteTool: true,
|
||||
},
|
||||
{
|
||||
id: "mindmap_add_sibling_after",
|
||||
displayName: "在后面新增同级节点",
|
||||
modelDescription: "在 targetUid 后插入一个同级节点(不能用于根节点)。",
|
||||
inputSchemaText: `{ "targetUid": "string", "text": "string", "hyperlink?": "string (http/https)", "note?": "string", "refs?": "NodeRef[]", "reason?": "string" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: true,
|
||||
isWriteTool: true,
|
||||
},
|
||||
{
|
||||
id: "mindmap_update_node_text",
|
||||
displayName: "更新节点文本",
|
||||
modelDescription: "更新指定 uid 的节点 text。",
|
||||
inputSchemaText: `{ "uid": "string", "text": "string", "reason?": "string" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: true,
|
||||
isWriteTool: true,
|
||||
},
|
||||
{
|
||||
id: "mindmap_set_hyperlink",
|
||||
displayName: "设置/清除节点超链接",
|
||||
modelDescription: "为节点设置 hyperlink(http/https),或传 null 清除。",
|
||||
inputSchemaText: `{ "uid": "string", "hyperlink": "string|null", "reason?": "string" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: true,
|
||||
isWriteTool: true,
|
||||
},
|
||||
{
|
||||
id: "mindmap_append_note",
|
||||
displayName: "追加节点备注",
|
||||
modelDescription: "向节点 note 追加一段 markdown 备注(不会覆盖原备注)。",
|
||||
inputSchemaText: `{ "uid": "string", "markdown": "string", "reason?": "string" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: true,
|
||||
isWriteTool: true,
|
||||
},
|
||||
{
|
||||
id: "mindmap_set_refs",
|
||||
displayName: "设置节点引用(refs)",
|
||||
modelDescription: "设置指定 uid 的 refs(覆盖写入)。",
|
||||
inputSchemaText: `{ "uid": "string", "refs": "NodeRef[]", "reason?": "string" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: true,
|
||||
isWriteTool: true,
|
||||
},
|
||||
{
|
||||
id: "mindmap_delete_node",
|
||||
displayName: "删除节点",
|
||||
modelDescription: "删除指定 uid 的节点(不能删除根节点)。",
|
||||
inputSchemaText: `{ "uid": "string", "reason?": "string" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: true,
|
||||
isWriteTool: true,
|
||||
},
|
||||
{
|
||||
id: "mindmap_add_attachment_ref",
|
||||
displayName: "给节点增加附件引用(refs)",
|
||||
modelDescription: "把附件作为 refs 追加/替换到指定节点(支持 pdf/docx/pptx/url + page/slide)。",
|
||||
inputSchemaText:
|
||||
`{ "uid": "string", "attachmentId?": "string", "fileUrl?": "string", "title?": "string", "kind?": "\"pdf\"|\"docx\"|\"pptx\"|\"url\"", "page?": "number", "slide?": "number", "snippet?": "string", "mode?": "\"append\"|\"replace\"", "reason?": "string" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: true,
|
||||
isWriteTool: true,
|
||||
},
|
||||
{
|
||||
id: "mindmap_add_attachment_child",
|
||||
displayName: "把附件插入为子节点",
|
||||
modelDescription: "在 parentUid 下新增一个“附件节点”,并自动写 refs +(可选)hyperlink。",
|
||||
inputSchemaText:
|
||||
`{ "parentUid": "string", "attachmentId?": "string", "fileUrl?": "string", "title?": "string", "text?": "string", "note?": "string", "kind?": "\"pdf\"|\"docx\"|\"pptx\"|\"url\"", "page?": "number", "slide?": "number", "reason?": "string" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: true,
|
||||
isWriteTool: true,
|
||||
},
|
||||
{
|
||||
id: "mindmap_add_image_child",
|
||||
displayName: "插入图片为子节点",
|
||||
modelDescription: "在 parentUid 下新增一个图片子节点(note 中包含 markdown 图片)。",
|
||||
inputSchemaText:
|
||||
`{ "parentUid": "string", "imageRef": "string (attachmentId 或 图片URL)", "alt?": "string", "caption?": "string", "reason?": "string" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: true,
|
||||
isWriteTool: true,
|
||||
},
|
||||
{
|
||||
id: "mindmap_append_image_note",
|
||||
displayName: "在节点备注中插入图片",
|
||||
modelDescription: "向指定节点的 note 追加一张图片(markdown 格式)。",
|
||||
inputSchemaText: `{ "uid": "string", "imageRef": "string (attachmentId 或 图片URL)", "alt?": "string", "reason?": "string" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: true,
|
||||
isWriteTool: true,
|
||||
},
|
||||
{
|
||||
id: "mindmap_expand_node",
|
||||
displayName: "补完思维导图节点(检索→生成→落盘)",
|
||||
modelDescription: "补完指定节点:可选联网检索作为证据,生成 3~6 个子节点并强制 refs,不足则标记“待核验”。",
|
||||
inputSchemaText: `{ "targetUid": "string", "instruction?": "string" }`,
|
||||
source: "builtin",
|
||||
requiresConfirmation: true,
|
||||
isWriteTool: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const builtinToolSets: AiAgentToolSet[] = [
|
||||
{
|
||||
id: "toolset.readonly",
|
||||
displayName: "只读工具(全局)",
|
||||
toolIds: ["search_web"],
|
||||
},
|
||||
{
|
||||
id: "toolset.rag_read",
|
||||
displayName: "RAG 检索(LightRAG)",
|
||||
toolIds: ["rag_lightrag_query"],
|
||||
},
|
||||
{
|
||||
id: "toolset.docs_read",
|
||||
displayName: "文档检索/读取(跨页面)",
|
||||
toolIds: ["docs_search", "docs_read"],
|
||||
},
|
||||
{
|
||||
id: "toolset.media_read",
|
||||
displayName: "媒体读取(OCR/元信息)",
|
||||
toolIds: ["image_read"],
|
||||
},
|
||||
{
|
||||
id: "toolset.slash_write",
|
||||
displayName: "斜杠命令(写入)",
|
||||
toolIds: ["slash_run"],
|
||||
},
|
||||
{
|
||||
id: "toolset.doc_read",
|
||||
displayName: "页面读取(BlockNote)",
|
||||
toolIds: ["doc_get", "doc_find"],
|
||||
},
|
||||
{
|
||||
id: "toolset.doc_write",
|
||||
displayName: "页面写入(BlockNote)",
|
||||
toolIds: ["doc_insert_blocks", "doc_replace_range"],
|
||||
},
|
||||
{
|
||||
id: "toolset.mindmap_read",
|
||||
displayName: "思维导图读取",
|
||||
toolIds: ["mindmap_get", "mindmap_get_subtree"],
|
||||
},
|
||||
{
|
||||
id: "toolset.mindmap_write",
|
||||
displayName: "思维导图写入",
|
||||
toolIds: [
|
||||
"mindmap_apply_ops",
|
||||
"mindmap_add_child",
|
||||
"mindmap_add_sibling_after",
|
||||
"mindmap_update_node_text",
|
||||
"mindmap_set_hyperlink",
|
||||
"mindmap_append_note",
|
||||
"mindmap_set_refs",
|
||||
"mindmap_delete_node",
|
||||
"mindmap_add_attachment_ref",
|
||||
"mindmap_add_attachment_child",
|
||||
"mindmap_add_image_child",
|
||||
"mindmap_append_image_note",
|
||||
"mindmap_expand_node",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,53 @@
|
||||
export type SearxResult = { title: string; url: string; snippet?: string; engine?: string };
|
||||
|
||||
const safeUrlOrNull = (value: unknown) => {
|
||||
const s = typeof value === "string" ? value.trim() : "";
|
||||
if (!s) return null;
|
||||
try {
|
||||
const u = new URL(s);
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||
return u.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const searchSearxng = async (q: string, count = 6): Promise<SearxResult[]> => {
|
||||
const query = String(q || "").trim();
|
||||
if (!query) return [];
|
||||
|
||||
const base = (process.env.SEARXNG_BASE_URL ?? "http://127.0.0.1:8889").replace(/\/+$/, "");
|
||||
const token = (process.env.SEARXNG_API_TOKEN ?? "").trim();
|
||||
const url = `${base}/search?q=${encodeURIComponent(query)}&format=json&language=zhh-CN&categories=general&safesearch=1`;
|
||||
|
||||
const tryFetch = async (headers: Record<string, string>) => {
|
||||
const res = await fetch(url, { headers, method: "GET" });
|
||||
if (!res.ok) return null;
|
||||
return (await res.json().catch(() => null)) as unknown;
|
||||
};
|
||||
|
||||
let json: unknown = null;
|
||||
if (token) {
|
||||
json = (await tryFetch({ Authorization: `Bearer ${token}` })) ?? (await tryFetch({ "X-API-Key": token })) ?? null;
|
||||
}
|
||||
if (!json) json = await tryFetch({});
|
||||
|
||||
const results = (() => {
|
||||
if (typeof json !== "object" || !json) return [];
|
||||
const obj = json as Record<string, unknown>;
|
||||
const value = obj.results;
|
||||
return Array.isArray(value) ? (value as unknown[]) : [];
|
||||
})();
|
||||
return results
|
||||
.map((r: unknown) => {
|
||||
const obj = (typeof r === "object" && r ? (r as Record<string, unknown>) : {}) as Record<string, unknown>;
|
||||
return {
|
||||
title: String(obj.title ?? "").trim(),
|
||||
url: String(obj.url ?? "").trim(),
|
||||
snippet: String(obj.content ?? obj.snippet ?? "").trim(),
|
||||
engine: String(obj.engine ?? "").trim(),
|
||||
};
|
||||
})
|
||||
.filter((r: SearxResult) => r.title && safeUrlOrNull(r.url))
|
||||
.slice(0, Math.max(1, Math.min(10, count)));
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
type SupabaseRouteClient = {
|
||||
from: (table: string) => any;
|
||||
};
|
||||
|
||||
export type SlashSupabaseClient = SupabaseRouteClient;
|
||||
|
||||
export type SlashToolContext = {
|
||||
userId: string;
|
||||
// 可选:在 document scope 下提供,便于默认继承 workspace_id / parent_id
|
||||
currentDocumentId?: string;
|
||||
};
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
const pick = (obj: unknown, key: string) => (isRecord(obj) ? obj[key] : undefined);
|
||||
|
||||
const loadWorkspaceIds = async (supabase: SlashSupabaseClient, userId: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from("workspace_members")
|
||||
.select("workspace_id")
|
||||
.eq("user_id", userId);
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取工作区失败");
|
||||
}
|
||||
const rows = Array.isArray(data) ? data : [];
|
||||
return rows.map((r) => String((r as any)?.workspace_id ?? "")).filter(Boolean);
|
||||
};
|
||||
|
||||
const inferWorkspaceIdFromDoc = async (supabase: SlashSupabaseClient, documentId: string) => {
|
||||
const { data, error } = await supabase.from("documents").select("workspace_id").eq("id", documentId).single();
|
||||
if (error) return null;
|
||||
const wid = String(pick(data, "workspace_id") ?? "").trim();
|
||||
return wid || null;
|
||||
};
|
||||
|
||||
type ParsedSlash =
|
||||
| { ok: true; command: "new_doc"; params: { title: string; parentId?: string | null; workspaceId?: string | null } }
|
||||
| { ok: true; command: "rename_doc"; params: { documentId: string; title: string } }
|
||||
| { ok: false; error: string };
|
||||
|
||||
const parseSlash = (text: string): ParsedSlash => {
|
||||
const raw = String(text || "").trim();
|
||||
if (!raw.startsWith("/")) return { ok: false, error: "不是斜杠命令(必须以 / 开头)" };
|
||||
const parts = raw.split(/\s+/).filter(Boolean);
|
||||
const cmd = parts[0] ?? "";
|
||||
const rest = raw.slice(cmd.length).trim();
|
||||
|
||||
if (cmd === "/new" || cmd === "/new-doc" || cmd === "/newdoc") {
|
||||
const title = rest.trim();
|
||||
if (!title) return { ok: false, error: "用法:/new <标题>" };
|
||||
return { ok: true, command: "new_doc", params: { title } };
|
||||
}
|
||||
|
||||
if (cmd === "/rename" || cmd === "/rename-doc" || cmd === "/renamedoc") {
|
||||
const documentId = String(parts[1] ?? "").trim();
|
||||
const title = raw.split(/\s+/).slice(2).join(" ").trim();
|
||||
if (!documentId || !title) return { ok: false, error: "用法:/rename <documentId> <新标题>" };
|
||||
return { ok: true, command: "rename_doc", params: { documentId, title } };
|
||||
}
|
||||
|
||||
return { ok: false, error: `未知命令:${cmd}` };
|
||||
};
|
||||
|
||||
export const createSlashServerTools = (args: {
|
||||
supabase: SlashSupabaseClient;
|
||||
ctx: SlashToolContext;
|
||||
allowedToolIds: Set<string>;
|
||||
}) => {
|
||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
|
||||
|
||||
if (toolId === "slash_run") {
|
||||
const text = String(toolArgs.text ?? "").trim();
|
||||
const command = String(toolArgs.command ?? "").trim();
|
||||
const params = isRecord(toolArgs.params) ? toolArgs.params : null;
|
||||
|
||||
const parsed: ParsedSlash =
|
||||
text && text.startsWith("/") ? parseSlash(text) : command === "new_doc"
|
||||
? {
|
||||
ok: true,
|
||||
command: "new_doc",
|
||||
params: {
|
||||
title: String(pick(params, "title") ?? "").trim(),
|
||||
parentId: pick(params, "parentId") ? String(pick(params, "parentId")) : null,
|
||||
workspaceId: pick(params, "workspaceId") ? String(pick(params, "workspaceId")) : null,
|
||||
},
|
||||
}
|
||||
: command === "rename_doc"
|
||||
? {
|
||||
ok: true,
|
||||
command: "rename_doc",
|
||||
params: {
|
||||
documentId: String(pick(params, "documentId") ?? "").trim(),
|
||||
title: String(pick(params, "title") ?? "").trim(),
|
||||
},
|
||||
}
|
||||
: { ok: false, error: "缺少 text(以 / 开头)或 command" };
|
||||
|
||||
if (!parsed.ok) throw new Error(parsed.error);
|
||||
|
||||
if (parsed.command === "new_doc") {
|
||||
const title = String(parsed.params.title ?? "").trim();
|
||||
if (!title) throw new Error("缺少标题");
|
||||
const parentId = parsed.params.parentId ? String(parsed.params.parentId) : null;
|
||||
const workspaceIdFromParams = parsed.params.workspaceId ? String(parsed.params.workspaceId) : null;
|
||||
const workspaceId =
|
||||
workspaceIdFromParams ||
|
||||
(args.ctx.currentDocumentId ? await inferWorkspaceIdFromDoc(args.supabase, args.ctx.currentDocumentId) : null) ||
|
||||
(await loadWorkspaceIds(args.supabase, args.ctx.userId))[0] ||
|
||||
null;
|
||||
if (!workspaceId) throw new Error("无法推断 workspaceId(请在 params.workspaceId 指定)");
|
||||
|
||||
const payload = {
|
||||
workspace_id: workspaceId,
|
||||
user_id: args.ctx.userId,
|
||||
parent_id: parentId,
|
||||
title,
|
||||
content: [] as unknown[],
|
||||
raw_text: "",
|
||||
};
|
||||
const { data, error } = await args.supabase.from("documents").insert(payload).select("id,workspace_id,parent_id,title,created_at,updated_at").single();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "创建文档失败");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
command: "new_doc",
|
||||
document: {
|
||||
id: String(pick(data, "id") ?? ""),
|
||||
title: String(pick(data, "title") ?? ""),
|
||||
workspaceId: String(pick(data, "workspace_id") ?? ""),
|
||||
parentId: pick(data, "parent_id") ? String(pick(data, "parent_id")) : null,
|
||||
createdAt: pick(data, "created_at") ?? null,
|
||||
updatedAt: pick(data, "updated_at") ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.command === "rename_doc") {
|
||||
const documentId = String(parsed.params.documentId ?? "").trim();
|
||||
const title = String(parsed.params.title ?? "").trim();
|
||||
if (!documentId || !title) throw new Error("缺少 documentId 或 title");
|
||||
|
||||
const { data, error } = await args.supabase
|
||||
.from("documents")
|
||||
.update({ title })
|
||||
.eq("id", documentId)
|
||||
.select("id,workspace_id,parent_id,title,updated_at")
|
||||
.single();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "重命名失败");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
command: "rename_doc",
|
||||
document: {
|
||||
id: String(pick(data, "id") ?? ""),
|
||||
title: String(pick(data, "title") ?? ""),
|
||||
workspaceId: String(pick(data, "workspace_id") ?? ""),
|
||||
parentId: pick(data, "parent_id") ? String(pick(data, "parent_id")) : null,
|
||||
updatedAt: pick(data, "updated_at") ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`未实现命令:${(parsed as any).command}`);
|
||||
}
|
||||
|
||||
throw new Error(`未知工具:${toolId}`);
|
||||
};
|
||||
|
||||
return { run };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { AiAgentTool, AiAgentToolSet, ToolPermissions } from "./types";
|
||||
|
||||
export type ToolRegistry = {
|
||||
toolsById: Map<string, AiAgentTool>;
|
||||
toolSetsById: Map<string, AiAgentToolSet>;
|
||||
permissions: ToolPermissions;
|
||||
};
|
||||
|
||||
export const createToolRegistry = (args: {
|
||||
tools: AiAgentTool[];
|
||||
toolSets: AiAgentToolSet[];
|
||||
permissions?: Partial<ToolPermissions>;
|
||||
}): ToolRegistry => {
|
||||
const toolsById = new Map<string, AiAgentTool>();
|
||||
for (const t of args.tools) toolsById.set(t.id, t);
|
||||
|
||||
const toolSetsById = new Map<string, AiAgentToolSet>();
|
||||
for (const s of args.toolSets) toolSetsById.set(s.id, s);
|
||||
|
||||
const permissions: ToolPermissions = {
|
||||
read: args.permissions?.read ?? "allow",
|
||||
write: args.permissions?.write ?? "confirm",
|
||||
};
|
||||
|
||||
return { toolsById, toolSetsById, permissions };
|
||||
};
|
||||
|
||||
export const resolveAllowedToolIds = (args: {
|
||||
registry: ToolRegistry;
|
||||
mode: "auto" | "manual";
|
||||
toolSetIds?: string[];
|
||||
toolIds?: string[];
|
||||
}) => {
|
||||
const allowed = new Set<string>();
|
||||
|
||||
if (args.mode === "auto") {
|
||||
// v1:auto 默认启用 readonly;若显式传入 toolSets,则在这些集合内自动选择
|
||||
const toolSetIds = Array.isArray(args.toolSetIds) ? args.toolSetIds : [];
|
||||
if (toolSetIds.length) {
|
||||
for (const sid of toolSetIds) {
|
||||
const s = args.registry.toolSetsById.get(String(sid));
|
||||
s?.toolIds.forEach((id) => allowed.add(id));
|
||||
}
|
||||
if (allowed.size > 0) return allowed;
|
||||
}
|
||||
const readonly = args.registry.toolSetsById.get("toolset.readonly");
|
||||
readonly?.toolIds.forEach((id) => allowed.add(id));
|
||||
return allowed;
|
||||
}
|
||||
|
||||
const toolSetIds = Array.isArray(args.toolSetIds) ? args.toolSetIds : [];
|
||||
for (const sid of toolSetIds) {
|
||||
const s = args.registry.toolSetsById.get(String(sid));
|
||||
s?.toolIds.forEach((id) => allowed.add(id));
|
||||
}
|
||||
|
||||
const toolIds = Array.isArray(args.toolIds) ? args.toolIds : [];
|
||||
for (const tid of toolIds) {
|
||||
if (args.registry.toolsById.has(String(tid))) allowed.add(String(tid));
|
||||
}
|
||||
|
||||
// 手动模式但用户未选:至少保留 readonly,避免完全不可用
|
||||
if (allowed.size === 0) {
|
||||
const readonly = args.registry.toolSetsById.get("toolset.readonly");
|
||||
readonly?.toolIds.forEach((id) => allowed.add(id));
|
||||
}
|
||||
|
||||
return allowed;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
export type AiAgentToolSource = "builtin" | "mcp" | "custom";
|
||||
|
||||
export type AiAgentTool = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
modelDescription: string;
|
||||
// v1 先用“文字 schema”,后续再引入 zod/jsonschema
|
||||
inputSchemaText: string;
|
||||
source: AiAgentToolSource;
|
||||
requiresConfirmation: boolean;
|
||||
isWriteTool: boolean;
|
||||
};
|
||||
|
||||
export type AiAgentToolSet = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
toolIds: string[];
|
||||
};
|
||||
|
||||
export type ToolPermissionMode = "allow" | "confirm" | "deny";
|
||||
|
||||
export type ToolPermissions = {
|
||||
// 默认:读允许、写确认(和 design 文档一致)
|
||||
read: ToolPermissionMode;
|
||||
write: ToolPermissionMode;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user