{
- imgToolbarHover.current = true;
- setImgToolbarState((s) => ({ ...s, show: true }));
- }}
- onMouseLeave={() => {
- imgToolbarHover.current = false;
- setImgToolbarState((s) => ({ ...s, show: false }));
- }}
+ className="pointer-events-auto fixed z-[9999] flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 shadow-md"
+ style={{ left: x, top: y }}
+ onClick={(e) => e.stopPropagation()}
>
- {btn("top", ArrowUp, "顶部")}
- {btn("bottom", ArrowDown, "底部")}
- {btn("left", ArrowLeft, "靠左")}
- {btn("right", ArrowRight, "靠右")}
+ {PosBtn("top", ArrowUp, "顶部")}
+ {PosBtn("bottom", ArrowDown, "底部")}
+ {PosBtn("left", ArrowLeft, "靠左")}
+ {PosBtn("right", ArrowRight, "靠右")}
);
};
@@ -2122,22 +2462,41 @@ const MindmapBlockView = ({
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_NOTE", node, note));
};
- const handleImageConfirm = () => {
+ const handleImageConfirm = async () => {
const url = imageUrl.trim();
- const width = Number(imageWidth) || 260;
- const height = Number(imageHeight) || 200;
+ // 获取原始图片尺寸,如果没有则使用默认值
+ let width = Number(imageWidth) || 0;
+ let height = Number(imageHeight) || 0;
+
if (!url) {
window.alert("请输入图片链接");
return;
}
setShowImageModal(false);
+
+ // 保存旧的 signed URL(如果存在),用于后续删除检测
+ const oldData = mindmap?.getData?.();
+ const oldUrls = oldData ? collectImageUrls(oldData) : [];
+
+ // 如果是 asset:id 格式,需要先转换为 signed URL 供显示
+ let displayUrl = url;
+ if (url.startsWith("asset:")) {
+ displayUrl = await resolveImageUrl(url);
+ // 记录映射关系供保存时使用
+ if (displayUrl !== url) {
+ const assetId = url.replace("asset:", "");
+ signedUrlToAssetIdRef.current.set(displayUrl, assetId);
+ console.log("[MindmapBlock] New image mapped:", displayUrl.substring(0, 80), "-> Asset ID:", assetId);
+ }
+ }
+
applyToActiveNodes(mindmap, (node) =>
mindmap?.execCommand("SET_NODE_IMAGE", node, {
- url,
+ url: displayUrl,
width,
height,
title: imageTitle || "",
- position: imagePosition || "top",
+ custom: false, // 让 simple-mind-map 自动缩放到合适尺寸
}),
);
applyToActiveNodes(mindmap, (node) =>
@@ -2145,6 +2504,16 @@ const MindmapBlockView = ({
imgPlacement: imagePosition || "top",
}),
);
+
+ // 等待命令执行后更新 currentImageUrlsRef
+ setTimeout(() => {
+ const newData = mindmap?.getData?.();
+ if (newData) {
+ const newUrls = collectImageUrls(newData);
+ currentImageUrlsRef.current = new Set(newUrls);
+ console.log("[MindmapBlock] Updated currentImageUrlsRef after insert:", newUrls);
+ }
+ }, 100);
};
const handleAttachment = () => {
@@ -2376,7 +2745,16 @@ const MindmapBlockView = ({
const imageViewer = showImageViewer ? (
setShowImageViewer(false)}>
-
+
e.stopPropagation()}
+ onWheel={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const delta = e.deltaY > 0 ? -0.1 : 0.1;
+ setViewerZoom((prev) => Math.max(0.1, Math.min(5, prev + delta)));
+ }}
+ >
-
{viewerMeta.title || "图片预览"}
-
{viewerMeta.width && viewerMeta.height ? `${viewerMeta.width} × ${viewerMeta.height}px` : ""}
+
{viewerMeta.title || "图片预览"}
+
+ {viewerMeta.width && viewerMeta.height ? `${viewerMeta.width} × ${viewerMeta.height}px` : ""}
+ {Math.round(viewerZoom * 100)}%
+
+
+
+
{/* eslint-disable-next-line @next/next/no-img-element */}

e.stopPropagation()}
/>
) : null;
- const imgToolbar = renderImgToolbar();
+ // 使用 createPortal 将工具栏挂载到 body,确保 position: fixed 相对于视口
+ const imgToolbar = typeof document !== "undefined" && imgToolbarState.show
+ ? createPortal(renderImgToolbar(), document.body)
+ : null;
const imageModal = showImageModal ? (
@@ -2423,7 +2826,15 @@ const MindmapBlockView = ({
选择文件
- {imageUrl.startsWith("data:") ? "已选择本地图片" : "未选择文件"}
+ {uploadingImage
+ ? "上传中..."
+ : imageUrl.startsWith("asset:")
+ ? "已选择图片"
+ : imageUrl.startsWith("data:")
+ ? "已选择本地图片"
+ : imageUrl
+ ? "已输入图片地址"
+ : "未选择文件"}
{
const file = e.target.files?.[0];
if (!file) return;
- const reader = new FileReader();
- reader.onload = async () => {
- const dataUrl = String(reader.result);
- setImageUrl(dataUrl);
- const size = await getImageSizeSafe(dataUrl);
- if (size) {
- setImageWidth(size.width);
- setImageHeight(size.height);
+ if (!workspaceId || !docId) {
+ window.alert("缺少空间信息,无法上传文件");
+ return;
+ }
+ setUploadingImage(true);
+ try {
+ // 先获取原始图片尺寸
+ const localSize = await getImageSizeSafe(URL.createObjectURL(file));
+ if (localSize) {
+ setImageWidth(localSize.width);
+ setImageHeight(localSize.height);
}
- };
- reader.readAsDataURL(file);
+
+ const form = new FormData();
+ form.append("file", file);
+ form.append("workspaceId", workspaceId);
+ form.append("documentId", docId);
+ const response = await fetch("/api/media/upload", {
+ method: "POST",
+ body: form,
+ });
+ if (!response.ok) {
+ const payload = await response.json().catch(() => null);
+ throw new Error(payload?.error ?? "上传失败");
+ }
+ const payload = (await response.json()) as { mindmapUrl?: string };
+ if (!payload.mindmapUrl) {
+ throw new Error("返回数据缺少图片地址");
+ }
+ setImageUrl(payload.mindmapUrl);
+ } catch (e) {
+ window.alert(e instanceof Error ? e.message : String(e));
+ } finally {
+ setUploadingImage(false);
+ if (fileInputForImage.current) fileInputForImage.current.value = "";
+ }
}}
/>
@@ -2619,6 +3055,7 @@ const MindmapBlockView = ({
/>
+
{imgToolbar}
{noteModal}
{imageModal}
@@ -2710,6 +3147,7 @@ const MindmapBlockView = ({
/>
+
{imgToolbar}
{noteModal}
{imageModal}
diff --git a/wolai-frontend/src/components/editor/blocks/MindmapContextMenu.tsx b/wolai-frontend/src/components/editor/blocks/MindmapContextMenu.tsx
new file mode 100644
index 00000000..518c96c1
--- /dev/null
+++ b/wolai-frontend/src/components/editor/blocks/MindmapContextMenu.tsx
@@ -0,0 +1,520 @@
+"use client";
+
+import React, { useEffect, useRef, useState, useCallback } from "react";
+import { createPortal } from "react-dom";
+import type { MindMapNode } from "./mindmapTypes";
+
+// 菜单项配置
+interface ContextMenuItem {
+ key?: string;
+ label?: string;
+ shortcut?: string;
+ danger?: boolean;
+ disabled?: boolean;
+ divider?: boolean;
+ show?: (node: MindMapNode | null) => boolean;
+}
+
+// 节点右键菜单配置
+const NODE_MENU_ITEMS: ContextMenuItem[] = [
+ {
+ key: "INSERT_NODE",
+ label: "插入同级节点",
+ shortcut: "Enter",
+ },
+ {
+ key: "INSERT_CHILD_NODE",
+ label: "插入子级节点",
+ shortcut: "Tab",
+ },
+ {
+ key: "INSERT_PARENT_NODE",
+ label: "插入父节点",
+ shortcut: "Shift + Tab",
+ },
+ {
+ key: "ADD_GENERALIZATION",
+ label: "插入概要",
+ shortcut: "Ctrl + G",
+ },
+ { divider: true },
+ {
+ key: "UP_NODE",
+ label: "上移节点",
+ shortcut: "Ctrl + ↑",
+ },
+ {
+ key: "DOWN_NODE",
+ label: "下移节点",
+ shortcut: "Ctrl + ↓",
+ },
+ {
+ key: "UNEXPAND_ALL",
+ label: "收起所有下级节点",
+ },
+ {
+ key: "EXPAND_ALL",
+ label: "展开所有下级节点",
+ },
+ { divider: true },
+ {
+ key: "REMOVE_NODE",
+ label: "删除节点",
+ shortcut: "Delete",
+ danger: true,
+ },
+ {
+ key: "REMOVE_CURRENT_NODE",
+ label: "仅删除当前节点",
+ shortcut: "Shift + Backspace",
+ danger: true,
+ },
+ { divider: true },
+ {
+ key: "COPY_NODE",
+ label: "复制节点",
+ shortcut: "Ctrl + C",
+ },
+ {
+ key: "CUT_NODE",
+ label: "剪切节点",
+ shortcut: "Ctrl + X",
+ },
+ {
+ key: "PASTE_NODE",
+ label: "粘贴节点",
+ shortcut: "Ctrl + V",
+ },
+ { divider: true },
+ {
+ key: "REMOVE_HYPERLINK",
+ label: "移除超链接",
+ show: (node) => !!node?.getData?.("hyperlink"),
+ },
+ {
+ key: "REMOVE_NOTE",
+ label: "移除备注",
+ show: (node) => !!node?.getData?.("note"),
+ },
+ {
+ key: "REMOVE_CUSTOM_STYLES",
+ label: "一键去除自定义样式",
+ },
+ {
+ key: "EXPORT_CUR_NODE_TO_PNG",
+ label: "导出该节点为图片",
+ },
+ { divider: true },
+ {
+ key: "AI_CONTINUE",
+ label: "AI续写",
+ },
+];
+
+interface MindmapContextMenuProps {
+ mindmap: any | null;
+}
+
+export function MindmapContextMenu({ mindmap }: MindmapContextMenuProps) {
+ const [visible, setVisible] = useState(false);
+ const [position, setPosition] = useState({ x: 0, y: 0 });
+ const [targetNode, setTargetNode] = useState
(null);
+ const menuRef = useRef(null);
+ const requestShowRef = useRef<{ x: number; y: number; node: MindMapNode } | null>(null);
+
+ // 判断是否禁用某个菜单项
+ const isItemDisabled = useCallback(
+ (item: ContextMenuItem): boolean => {
+ if (!targetNode) return false;
+
+ const isRoot = (targetNode as any).isRoot === true;
+ const isGeneralization = (targetNode as any).isGeneralization === true;
+
+ switch (item.key) {
+ case "INSERT_NODE":
+ case "INSERT_PARENT_NODE":
+ case "ADD_GENERALIZATION":
+ return isRoot || isGeneralization;
+
+ case "INSERT_CHILD_NODE":
+ return isGeneralization;
+
+ case "COPY_NODE":
+ case "CUT_NODE":
+ return isGeneralization;
+
+ case "UP_NODE": {
+ if (isRoot || isGeneralization) return true;
+ const parent = (targetNode as any).parent;
+ if (!parent || !Array.isArray(parent.children)) return true;
+ return parent.children.findIndex((item: unknown) => item === targetNode) === 0;
+ }
+
+ case "DOWN_NODE": {
+ if (isRoot || isGeneralization) return true;
+ const parent = (targetNode as any).parent;
+ if (!parent || !Array.isArray(parent.children)) return true;
+ const children = parent.children;
+ return children.findIndex((item: unknown) => item === targetNode) === children.length - 1;
+ }
+
+ default:
+ return false;
+ }
+ },
+ [targetNode]
+ );
+
+ // 过滤显示的菜单项
+ const getVisibleItems = useCallback((): ContextMenuItem[] => {
+ return NODE_MENU_ITEMS.filter((item) => {
+ if (item.divider) return true;
+ if (item.show && !item.show(targetNode)) return false;
+ return true;
+ });
+ }, [targetNode]);
+
+ // 执行命令
+ const executeCommand = useCallback(
+ (key: string) => {
+ if (!mindmap || !targetNode) return;
+
+ switch (key) {
+ case "COPY_NODE":
+ mindmap.renderer?.copy?.();
+ break;
+ case "CUT_NODE":
+ mindmap.renderer?.cut?.();
+ break;
+ case "PASTE_NODE":
+ mindmap.renderer?.paste?.();
+ break;
+ case "REMOVE_HYPERLINK":
+ if (typeof (targetNode as any).setHyperlink === "function") {
+ (targetNode as any).setHyperlink("", "");
+ }
+ break;
+ case "REMOVE_NOTE":
+ if (typeof (targetNode as any).setNote === "function") {
+ (targetNode as any).setNote("");
+ }
+ break;
+ case "EXPORT_CUR_NODE_TO_PNG": {
+ const getTextFromHtml = (html: string) => {
+ const div = document.createElement("div");
+ div.innerHTML = html;
+ return div.textContent || div.innerText || "";
+ };
+ const nodeText = targetNode.getData?.("text") || "";
+ mindmap.doExport?.export("png", true, getTextFromHtml(String(nodeText)), false, targetNode);
+ break;
+ }
+ case "UNEXPAND_ALL":
+ mindmap.execCommand?.(key, false, targetNode);
+ break;
+ case "EXPAND_ALL":
+ mindmap.execCommand?.(key, (targetNode as any).uid || "");
+ break;
+ case "AI_CONTINUE":
+ // 触发 AI 续写
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(
+ new CustomEvent("mindmap-ai-continue", {
+ detail: { node: targetNode },
+ })
+ );
+ }
+ break;
+ default:
+ mindmap.execCommand?.(key);
+ break;
+ }
+
+ hide();
+ },
+ [mindmap, targetNode]
+ );
+
+ // 隐藏菜单
+ const hide = useCallback(() => {
+ setVisible(false);
+ setTargetNode(null);
+ setPosition({ x: -9999, y: -9999 });
+ requestShowRef.current = null;
+ }, []);
+
+ // 显示菜单 - 使用 requestAnimationFrame 确保 DOM 更新后再显示
+ const show = useCallback((x: number, y: number, node: MindMapNode) => {
+ setTargetNode(node);
+
+ // 计算可见菜单项数量
+ const visibleItems = NODE_MENU_ITEMS.filter((item) => {
+ if (item.divider) return true;
+ if (item.show && !item.show(node)) return false;
+ return true;
+ });
+
+ // 计算实际菜单高度(每个菜单项约40px,分隔符约10px)
+ const itemHeight = 40;
+ const dividerHeight = 10;
+ const estimatedHeight = visibleItems.reduce((acc, item) => {
+ return acc + (item.divider ? dividerHeight : itemHeight);
+ }, 0) + 16; // +16 是上下 padding
+
+ const menuWidth = 250;
+ const menuHeight = estimatedHeight + 20; // 额外的安全边距
+
+ // 初始位置:鼠标右侧下方
+ let posX = x + 10;
+ let posY = y + 10;
+
+ // 如果右侧空间不足,显示在左侧
+ if (posX + menuWidth > window.innerWidth) {
+ posX = x - menuWidth - 20;
+ }
+
+ // 如果下方空间不足,固定到窗口底部(与参考实现一致)
+ if (posY + menuHeight > window.innerHeight) {
+ posY = window.innerHeight - menuHeight - 10;
+ }
+
+ // 确保不会超出左边界
+ if (posX < 10) {
+ posX = 10;
+ }
+
+ // 确保菜单顶部不会超出窗口
+ if (posY < 10) {
+ posY = 10;
+ }
+
+ setPosition({ x: posX, y: posY });
+ setVisible(true);
+ }, []);
+
+ // 监听右键事件
+ useEffect(() => {
+ if (!mindmap) return;
+
+ const handleContextMenu = (e: Event) => {
+ const mouseEvent = e as MouseEvent;
+
+ // 检查是否点击在节点上
+ const target = mouseEvent.target as HTMLElement | SVGElement;
+
+ // simple-mind-map 的节点结构
+ // 尝试多种选择器
+ const nodeSelectors = [
+ ".smm-node", // 主节点容器
+ ".smm-node-light", // 亮色主题节点
+ "g[role='node']", // 带 role 属性的 g 元素
+ "g.smooth-smooth", // 特定样式的 g 元素
+ ];
+
+ let clickedNodeEl: Element | null = null;
+ for (const selector of nodeSelectors) {
+ clickedNodeEl = target.closest?.(selector) || null;
+ if (clickedNodeEl) break;
+ }
+
+ // 如果没找到节点选择器,尝试查找包含 text 的元素
+ if (!clickedNodeEl) {
+ const parent = target.parentElement;
+ if (parent) {
+ // 检查父元素是否包含文本内容
+ const textContainer = parent.querySelector("text");
+ if (textContainer) {
+ clickedNodeEl = parent;
+ }
+ }
+ }
+
+ if (!clickedNodeEl) return;
+
+ // 阻止默认右键菜单
+ mouseEvent.preventDefault();
+ mouseEvent.stopPropagation();
+
+ // 获取当前激活的节点作为右键点击的节点
+ const renderer = mindmap.renderer;
+ if (!renderer) return;
+
+ // 使用 activeNodeList 或 lastActiveNodeList
+ const activeList = renderer.activeNodeList ?? [];
+ const lastActiveList = renderer.lastActiveNodeList ?? [];
+
+ const node = (activeList[0] ?? lastActiveList[0] ?? null) as MindMapNode | null;
+
+ if (node) {
+ show(mouseEvent.clientX, mouseEvent.clientY, node);
+ }
+ };
+
+ // 延迟查找容器,确保 DOM 已经渲染
+ const timer = setTimeout(() => {
+ const container = document.querySelector("[data-testid='mindmap-canvas']");
+ if (container) {
+ container.addEventListener("contextmenu", handleContextMenu, true);
+ }
+ }, 100);
+
+ return () => {
+ clearTimeout(timer);
+ const container = document.querySelector("[data-testid='mindmap-canvas']");
+ if (container) {
+ container.removeEventListener("contextmenu", handleContextMenu, true);
+ }
+ };
+ }, [mindmap, show]);
+
+ // 监听画布点击事件隐藏菜单
+ useEffect(() => {
+ if (!mindmap) return;
+
+ const hideMenu = () => {
+ hide();
+ };
+
+ mindmap.on?.("draw_click", hideMenu);
+ mindmap.on?.("node_click", hideMenu);
+ mindmap.on?.("expand_btn_click", hideMenu);
+
+ return () => {
+ mindmap.off?.("draw_click", hideMenu);
+ mindmap.off?.("node_click", hideMenu);
+ mindmap.off?.("expand_btn_click", hideMenu);
+ };
+ }, [mindmap, hide]);
+
+ // 点击外部隐藏菜单
+ useEffect(() => {
+ if (!visible) return;
+
+ const handleClickOutside = (e: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+ hide();
+ }
+ };
+
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ hide();
+ }
+ };
+
+ const handleScroll = () => {
+ hide();
+ };
+
+ const handleResize = () => {
+ hide();
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ document.addEventListener("keydown", handleEscape);
+ document.addEventListener("scroll", handleScroll, true);
+ window.addEventListener("resize", handleResize);
+
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ document.removeEventListener("keydown", handleEscape);
+ document.removeEventListener("scroll", handleScroll, true);
+ window.removeEventListener("resize", handleResize);
+ };
+ }, [visible, hide]);
+
+ // 渲染菜单
+ const renderMenu = () => {
+ const visibleItems = getVisibleItems();
+
+ return (
+ {
+ e.preventDefault();
+ }}
+ >
+ {visibleItems.map((item, index) => {
+ if (item.divider) {
+ return (
+
+ );
+ }
+
+ const disabled = isItemDisabled(item);
+
+ return (
+
{
+ if (disabled) return;
+ if (!item.key) return;
+ executeCommand(item.key);
+ }}
+ style={{
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ padding: "8px 16px",
+ cursor: disabled ? "not-allowed" : "pointer",
+ color: item.danger ? "#ef4444" : disabled ? "#9ca3af" : "#1f2937",
+ background: "transparent",
+ transition: "background 0.1s",
+ }}
+ onMouseEnter={(e) => {
+ if (!disabled) {
+ e.currentTarget.style.background = "#f3f4f6";
+ }
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = "transparent";
+ }}
+ >
+ {item.label || ""}
+ {item.shortcut && (
+
+ {item.shortcut}
+
+ )}
+
+ );
+ })}
+
+ );
+ };
+
+ if (typeof document === "undefined" || !visible) {
+ return null;
+ }
+
+ return createPortal(renderMenu(), document.body);
+}
diff --git a/wolai-frontend/src/components/editor/blocks/mindmapTypes.ts b/wolai-frontend/src/components/editor/blocks/mindmapTypes.ts
index 9346c814..f30b69ac 100644
--- a/wolai-frontend/src/components/editor/blocks/mindmapTypes.ts
+++ b/wolai-frontend/src/components/editor/blocks/mindmapTypes.ts
@@ -2,7 +2,7 @@ export type MindMapNode = {
getStyle: (key: string, checkRoot?: boolean) => unknown;
setStyle?: (key: string, value: unknown) => void;
setIcon?: (icons: string[]) => void;
- setImage?: (img: { url: string; width?: number; height?: number } | null) => void;
+ setImage?: (img: { url: string; title?: string; width?: number; height?: number } | null) => void;
getData: (key: string) => unknown;
nodeData?: { data?: Record };
data?: Record;
@@ -10,5 +10,25 @@ export type MindMapNode = {
parent?: { children?: unknown[] } | null;
children?: unknown[];
uid?: string;
+ isRoot?: boolean;
+ isGeneralization?: boolean;
+ setHyperlink?: (url: string, title: string) => void;
+ setNote?: (note: string) => void;
};
+// 思维导图实例:这里仅声明本项目用到的最小形状,避免到处用 any
+export type MindMapInstance = {
+ renderer?: {
+ activeNodeList?: MindMapNode[];
+ lastActiveNodeList?: MindMapNode[];
+ copy?: () => void;
+ cut?: () => void;
+ paste?: () => void;
+ };
+ execCommand?: (command: string, ...args: unknown[]) => void;
+ doExport?: { export: (type: string, ...args: unknown[]) => void };
+ on?: (event: string, fn: (...args: unknown[]) => void) => void;
+ off?: (event: string, fn: (...args: unknown[]) => void) => void;
+ setData?: (data: unknown) => void;
+ command?: { clearHistory?: () => void };
+};
diff --git a/wolai-frontend/src/components/editor/document-content.tsx b/wolai-frontend/src/components/editor/document-content.tsx
index 4774b8c7..aa490b0f 100644
--- a/wolai-frontend/src/components/editor/document-content.tsx
+++ b/wolai-frontend/src/components/editor/document-content.tsx
@@ -13,6 +13,7 @@ import type { DocumentSnapshot } from "@/types/document";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { ImagePickerProvider } from "@/components/media/image-picker-context";
import { useRouter } from "next/navigation";
+import { DocumentAiAgentPanel } from "./DocumentAiAgentPanel";
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
@@ -71,6 +72,7 @@ export function DocumentContent({
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
const contentLoadingTimerRef = useRef | null>(null);
const pendingOpenTableRef = useRef(null);
+ const latestBlocksRef = useRef(null);
useEffect(() => {
const tableId = (openTableId ?? "").trim();
@@ -257,6 +259,7 @@ export function DocumentContent({
}, [history, title]);
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
+ latestBlocksRef.current = payload.blocks;
setHistory((prev) => {
const now = Date.now();
if (prev.length > 0 && now - prev[0].timestamp < 4000) {
@@ -303,7 +306,7 @@ export function DocumentContent({
);
return (
-
+
@@ -378,6 +381,7 @@ export function DocumentContent({
history={history}
onRestore={handleRestoreSnapshot}
/>
+
latestBlocksRef.current} />
);
}
diff --git a/wolai-frontend/src/hooks/use-backend-health.ts b/wolai-frontend/src/hooks/use-backend-health.ts
index 6e1d3ac8..923d2b01 100644
--- a/wolai-frontend/src/hooks/use-backend-health.ts
+++ b/wolai-frontend/src/hooks/use-backend-health.ts
@@ -1,27 +1,21 @@
import { useEffect, useState } from "react";
-type Status = "idle" | "ok" | "error";
+type Status = "idle" | "ok" | "error" | "disabled";
export function useBackendHealth() {
const [status, setStatus] = useState(() => {
- if (!process.env.NEXT_PUBLIC_BACKEND_URL) {
- return "error";
- }
return "idle";
});
useEffect(() => {
let destroyed = false;
- const url = process.env.NEXT_PUBLIC_BACKEND_URL;
- if (!url) {
- return;
- }
const controller = new AbortController();
const check = async () => {
try {
- const response = await fetch(`${url}/health`, { signal: controller.signal });
+ const response = await fetch("/api/backend/health", { signal: controller.signal });
+ const payload = (await response.json().catch(() => null)) as { status?: Status } | null;
if (!destroyed) {
- setStatus(response.ok ? "ok" : "error");
+ setStatus(payload?.status ?? "error");
}
} catch {
if (!destroyed) {
diff --git a/wolai-frontend/src/lib/ai-agent/protocol/toolTagProtocol.ts b/wolai-frontend/src/lib/ai-agent/protocol/toolTagProtocol.ts
new file mode 100644
index 00000000..63a3a451
--- /dev/null
+++ b/wolai-frontend/src/lib/ai-agent/protocol/toolTagProtocol.ts
@@ -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 标签”协议:
+// - 工具调用:{...json...}
+// - 工具结果(回喂模型):{...json...}
+//
+// 解析策略:
+// - 只解析白名单工具名(避免误把普通 HTML 当工具)
+// - 不做嵌套/属性解析(足够支撑 v1)
+export const parseToolTagCalls = (input: string, allowedToolNames: Set): 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 `${body}`;
+};
+
diff --git a/wolai-frontend/src/lib/ai-agent/runtime/events.ts b/wolai-frontend/src/lib/ai-agent/runtime/events.ts
new file mode 100644
index 00000000..c476d84f
--- /dev/null
+++ b/wolai-frontend/src/lib/ai-agent/runtime/events.ts
@@ -0,0 +1,7 @@
+export type AiAgentSseEvent =
+ | { event: "assistant_message"; data: { text: string } }
+ | { event: "tool_call"; data: { id: string; tool: string; args: Record } }
+ | { 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 } };
+
diff --git a/wolai-frontend/src/lib/ai-agent/runtime/runAgent.ts b/wolai-frontend/src/lib/ai-agent/runtime/runAgent.ts
new file mode 100644
index 00000000..c4017328
--- /dev/null
+++ b/wolai-frontend/src/lib/ai-agent/runtime/runAgent.ts
@@ -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;
+ runTool: (toolId: string, toolArgs: Record) => Promise;
+ maxSteps?: number;
+ onEvent?: (event: { type: string; data: unknown }) => void;
+ systemContextText?: string;
+ defaultMindmapTargetUid?: string;
+};
+
+const nowMs = () => Date.now();
+const DEFAULT_MAX_STEPS = 10;
+
+const safeParseJsonObject = (raw: string): Record | null => {
+ const s = String(raw ?? "").trim();
+ if (!s) return null;
+ try {
+ const parsed = JSON.parse(s);
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record;
+ return null;
+ } catch {
+ return null;
+ }
+};
+
+const buildSystemPrompt = (allowedTools: Set, systemContextText?: string) => {
+ const toolLines: string[] = [];
+ if (allowedTools.has("search_web")) {
+ toolLines.push("- search_web:{\"query\":\"...\",\"count\":6}");
+ }
+ if (allowedTools.has("rag_lightrag_query")) {
+ toolLines.push(
+ "- rag_lightrag_query:{\"query\":\"...\",\"mode\":\"mix\",\"topK\":12,\"chunkTopK\":12}",
+ );
+ }
+ if (allowedTools.has("docs_search")) {
+ toolLines.push("- docs_search:{\"query\":\"...\",\"limit\":12}");
+ }
+ if (allowedTools.has("docs_read")) {
+ toolLines.push("- docs_read:{\"documentId\":\"...\",\"maxChars\":2500}");
+ }
+ if (allowedTools.has("image_read")) {
+ toolLines.push("- image_read:{\"attachmentRef\":\"...\"}");
+ }
+ if (allowedTools.has("slash_run")) {
+ toolLines.push("- slash_run:{\"text\":\"/new 新页面标题\"}");
+ }
+ if (allowedTools.has("doc_get")) {
+ toolLines.push("- doc_get:{\"maxBlocks\":80}");
+ }
+ if (allowedTools.has("doc_find")) {
+ toolLines.push("- doc_find:{\"query\":\"...\",\"maxResults\":8}");
+ }
+ if (allowedTools.has("doc_insert_blocks")) {
+ toolLines.push(
+ "- doc_insert_blocks:{\"afterBlockId\":\"...\",\"blocks\":[{\"type\":\"heading\",\"level\":2,\"text\":\"...\"},{\"type\":\"paragraph\",\"text\":\"...\"}]}",
+ );
+ }
+ if (allowedTools.has("doc_replace_range")) {
+ toolLines.push(
+ "- doc_replace_range:{\"blockId\":\"...\",\"text\":\"...\",\"mode\":\"replace\"}",
+ );
+ }
+ if (allowedTools.has("mindmap_get")) {
+ toolLines.push("- mindmap_get:{\"maxNodes\":120}");
+ }
+ if (allowedTools.has("mindmap_get_subtree")) {
+ toolLines.push("- mindmap_get_subtree:{\"uid\":\"...\",\"depth\":2,\"maxNodes\":60}");
+ }
+ if (allowedTools.has("mindmap_apply_ops")) {
+ toolLines.push("- mindmap_apply_ops:{\"ops\":[{\"op\":\"updateText\",\"uid\":\"...\",\"text\":\"...\"}],\"reason\":\"...\"}");
+ }
+ if (allowedTools.has("mindmap_add_child")) {
+ toolLines.push(
+ "- mindmap_add_child:{\"parentUid\":\"...\",\"text\":\"...\",\"hyperlink\":\"https://...\",\"note\":\"...\",\"refs\":[{\"kind\":\"url\",\"fileUrl\":\"https://...\"}]}",
+ );
+ }
+ if (allowedTools.has("mindmap_add_sibling_after")) {
+ toolLines.push("- mindmap_add_sibling_after:{\"targetUid\":\"...\",\"text\":\"...\",\"hyperlink\":\"https://...\"}");
+ }
+ if (allowedTools.has("mindmap_update_node_text")) {
+ toolLines.push("- mindmap_update_node_text:{\"uid\":\"...\",\"text\":\"...\"}");
+ }
+ if (allowedTools.has("mindmap_set_hyperlink")) {
+ toolLines.push("- mindmap_set_hyperlink:{\"uid\":\"...\",\"hyperlink\":\"https://...\"}(清除传 null)");
+ }
+ if (allowedTools.has("mindmap_append_note")) {
+ toolLines.push("- mindmap_append_note:{\"uid\":\"...\",\"markdown\":\"...\"}");
+ }
+ if (allowedTools.has("mindmap_set_refs")) {
+ toolLines.push("- mindmap_set_refs:{\"uid\":\"...\",\"refs\":[{\"kind\":\"url\",\"fileUrl\":\"https://...\"}]}");
+ }
+ if (allowedTools.has("mindmap_delete_node")) {
+ toolLines.push("- mindmap_delete_node:{\"uid\":\"...\"}");
+ }
+ if (allowedTools.has("mindmap_add_attachment_ref")) {
+ toolLines.push("- mindmap_add_attachment_ref:{\"uid\":\"...\",\"attachmentId\":\"...\",\"page\":12,\"mode\":\"append\"}");
+ }
+ if (allowedTools.has("mindmap_add_attachment_child")) {
+ toolLines.push("- mindmap_add_attachment_child:{\"parentUid\":\"...\",\"attachmentId\":\"...\",\"page\":12}");
+ }
+ if (allowedTools.has("mindmap_add_image_child")) {
+ toolLines.push("- mindmap_add_image_child:{\"parentUid\":\"...\",\"imageRef\":\"...\",\"caption\":\"...\"}");
+ }
+ if (allowedTools.has("mindmap_append_image_note")) {
+ toolLines.push("- mindmap_append_image_note:{\"uid\":\"...\",\"imageRef\":\"...\"}");
+ }
+ if (allowedTools.has("mindmap_expand_node")) {
+ toolLines.push("- mindmap_expand_node:{\"targetUid\":\"...\",\"instruction\":\"...\"}");
+ }
+
+ 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) 标签格式:{...JSON...},JSON 必须是严格 JSON(双引号)。",
+ "3) 你将收到工具结果:{...JSON...},再继续。",
+ "",
+ "工具列表(允许集):",
+ ...toolLines,
+ "",
+ "如何选择工具(建议):",
+ ...guideLines,
+ "",
+ "行为要求(硬性):",
+ ...hardRules,
+ ].join("\n");
+};
+
+export const runAiAgent = async (args: RunAiAgentArgs): Promise<{ ok: true; text: string; steps: number } | { ok: false; error: string }> => {
+ const maxSteps = Math.max(1, Math.min(24, Math.floor(args.maxSteps ?? DEFAULT_MAX_STEPS)));
+ const allowedToolIds = args.allowedToolIds;
+ const allowedToolNames = new Set(Array.from(allowedToolIds));
+
+ const 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})` };
+};
diff --git a/wolai-frontend/src/lib/ai-agent/tools/builtins/doc/docServerTools.ts b/wolai-frontend/src/lib/ai-agent/tools/builtins/doc/docServerTools.ts
new file mode 100644
index 00000000..aaa9dc1a
--- /dev/null
+++ b/wolai-frontend/src/lib/ai-agent/tools/builtins/doc/docServerTools.ts
@@ -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) => 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 =>
+ 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 => {
+ const type = spec.type;
+ const text = String(spec.text ?? "").trim();
+ const base: Record = {
+ 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;
+}) => {
+ const run = async (toolId: string, toolArgs: Record) => {
+ 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 };
+};
diff --git a/wolai-frontend/src/lib/ai-agent/tools/builtins/docs/docsServerTools.ts b/wolai-frontend/src/lib/ai-agent/tools/builtins/docs/docsServerTools.ts
new file mode 100644
index 00000000..fc81d0c8
--- /dev/null
+++ b/wolai-frontend/src/lib/ai-agent/tools/builtins/docs/docsServerTools.ts
@@ -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 =>
+ 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;
+}) => {
+ const run = async (toolId: string, toolArgs: Record) => {
+ 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 };
+};
diff --git a/wolai-frontend/src/lib/ai-agent/tools/builtins/media/mediaServerTools.ts b/wolai-frontend/src/lib/ai-agent/tools/builtins/media/mediaServerTools.ts
new file mode 100644
index 00000000..bfb9f2af
--- /dev/null
+++ b/wolai-frontend/src/lib/ai-agent/tools/builtins/media/mediaServerTools.ts
@@ -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 =>
+ 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;
+}) => {
+ const run = async (toolId: string, toolArgs: Record) => {
+ 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 };
+};
+
diff --git a/wolai-frontend/src/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools.ts b/wolai-frontend/src/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools.ts
new file mode 100644
index 00000000..836111e5
--- /dev/null
+++ b/wolai-frontend/src/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools.ts
@@ -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();
+ 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 }).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;
+}) => {
+ 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)) {
+ throw new Error(String((error as Record).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) => {
+ 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) => {
+ 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) => {
+ 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;
+ 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) : {}) as Record;
+ 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) => {
+ 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) => {
+ 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) => {
+ 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) => {
+ 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) => {
+ 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) => {
+ 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) => {
+ 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) => {
+ 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;
+ 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) => {
+ 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) => {
+ 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) => {
+ 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) => {
+ 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;
+ const choices = Array.isArray(r.choices) ? (r.choices as unknown[]) : [];
+ const first = (choices[0] && typeof choices[0] === "object" ? (choices[0] as Record) : null) ?? null;
+ finishReason = first ? String(first.finish_reason ?? "") : "";
+ }
+ const json = tryExtractJsonObject(text);
+ if (json && Array.isArray((json as Record).ops)) ops = (json as Record).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) => {
+ 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 };
+};
diff --git a/wolai-frontend/src/lib/ai-agent/tools/builtins/rag/lightragServerTools.ts b/wolai-frontend/src/lib/ai-agent/tools/builtins/rag/lightragServerTools.ts
new file mode 100644
index 00000000..c0b9f56c
--- /dev/null
+++ b/wolai-frontend/src/lib/ai-agent/tools/builtins/rag/lightragServerTools.ts
@@ -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 => {
+ 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;
+}) => {
+ const run = async (toolId: string, toolArgs: Record) => {
+ 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 };
+};
+
diff --git a/wolai-frontend/src/lib/ai-agent/tools/builtins/registryBuiltins.ts b/wolai-frontend/src/lib/ai-agent/tools/builtins/registryBuiltins.ts
new file mode 100644
index 00000000..c9f4b84c
--- /dev/null
+++ b/wolai-frontend/src/lib/ai-agent/tools/builtins/registryBuiltins.ts
@@ -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",
+ ],
+ },
+];
diff --git a/wolai-frontend/src/lib/ai-agent/tools/builtins/searchWeb.ts b/wolai-frontend/src/lib/ai-agent/tools/builtins/searchWeb.ts
new file mode 100644
index 00000000..fa8e2d2a
--- /dev/null
+++ b/wolai-frontend/src/lib/ai-agent/tools/builtins/searchWeb.ts
@@ -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 => {
+ 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) => {
+ 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;
+ 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) : {}) as Record;
+ 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)));
+};
diff --git a/wolai-frontend/src/lib/ai-agent/tools/builtins/slash/slashServerTools.ts b/wolai-frontend/src/lib/ai-agent/tools/builtins/slash/slashServerTools.ts
new file mode 100644
index 00000000..492d4542
--- /dev/null
+++ b/wolai-frontend/src/lib/ai-agent/tools/builtins/slash/slashServerTools.ts
@@ -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 =>
+ 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 <新标题>" };
+ return { ok: true, command: "rename_doc", params: { documentId, title } };
+ }
+
+ return { ok: false, error: `未知命令:${cmd}` };
+};
+
+export const createSlashServerTools = (args: {
+ supabase: SlashSupabaseClient;
+ ctx: SlashToolContext;
+ allowedToolIds: Set;
+}) => {
+ const run = async (toolId: string, toolArgs: Record) => {
+ 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 };
+};
+
diff --git a/wolai-frontend/src/lib/ai-agent/tools/registry.ts b/wolai-frontend/src/lib/ai-agent/tools/registry.ts
new file mode 100644
index 00000000..aa7f2b69
--- /dev/null
+++ b/wolai-frontend/src/lib/ai-agent/tools/registry.ts
@@ -0,0 +1,69 @@
+import type { AiAgentTool, AiAgentToolSet, ToolPermissions } from "./types";
+
+export type ToolRegistry = {
+ toolsById: Map;
+ toolSetsById: Map;
+ permissions: ToolPermissions;
+};
+
+export const createToolRegistry = (args: {
+ tools: AiAgentTool[];
+ toolSets: AiAgentToolSet[];
+ permissions?: Partial;
+}): ToolRegistry => {
+ const toolsById = new Map();
+ for (const t of args.tools) toolsById.set(t.id, t);
+
+ const toolSetsById = new Map();
+ 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();
+
+ 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;
+};
diff --git a/wolai-frontend/src/lib/ai-agent/tools/types.ts b/wolai-frontend/src/lib/ai-agent/tools/types.ts
new file mode 100644
index 00000000..01b55720
--- /dev/null
+++ b/wolai-frontend/src/lib/ai-agent/tools/types.ts
@@ -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;
+};
+
diff --git a/wolai-frontend/src/lib/supabase/server.ts b/wolai-frontend/src/lib/supabase/server.ts
index 30a8ae0f..72349778 100644
--- a/wolai-frontend/src/lib/supabase/server.ts
+++ b/wolai-frontend/src/lib/supabase/server.ts
@@ -3,14 +3,34 @@ import { getDecodedCookies } from "@/lib/server-cookies";
export const createSupabaseServerClient = async () => {
const cookieStore = await getDecodedCookies();
+ const supabaseUrl = process.env.SUPABASE_INTERNAL_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL;
+ const supabaseAnonKey = process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
+
+ if (!supabaseUrl || !supabaseAnonKey) {
+ throw new Error(
+ "缺少 Supabase 环境变量,请配置 NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY(可选:SUPABASE_INTERNAL_URL / SUPABASE_ANON_KEY 作为服务端内网地址)",
+ );
+ }
return createServerComponentClient({
cookies: () => cookieStore as any,
+ supabaseUrl,
+ supabaseKey: supabaseAnonKey,
} as any);
};
export const createSupabaseRouteClient = async () => {
const cookieStore = await getDecodedCookies();
+ const supabaseUrl = process.env.SUPABASE_INTERNAL_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL;
+ const supabaseAnonKey = process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
+
+ if (!supabaseUrl || !supabaseAnonKey) {
+ throw new Error(
+ "缺少 Supabase 环境变量,请配置 NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY(可选:SUPABASE_INTERNAL_URL / SUPABASE_ANON_KEY 作为服务端内网地址)",
+ );
+ }
return createRouteHandlerClient({
cookies: () => cookieStore as any,
+ supabaseUrl,
+ supabaseKey: supabaseAnonKey,
} as any);
};
diff --git a/wolai-frontend/src/store/ai-agent-ui.ts b/wolai-frontend/src/store/ai-agent-ui.ts
new file mode 100644
index 00000000..a35e15ab
--- /dev/null
+++ b/wolai-frontend/src/store/ai-agent-ui.ts
@@ -0,0 +1,24 @@
+"use client";
+
+import { create } from "zustand";
+
+type AiAgentUiState = {
+ documentAgentAvailable: boolean;
+ documentAgentOpen: boolean;
+ setDocumentAgentAvailable: (available: boolean) => void;
+ setDocumentAgentOpen: (open: boolean) => void;
+ toggleDocumentAgentOpen: () => void;
+};
+
+export const useAiAgentUiStore = create((set, get) => ({
+ documentAgentAvailable: false,
+ documentAgentOpen: false,
+ setDocumentAgentAvailable: (available) => set({ documentAgentAvailable: available }),
+ setDocumentAgentOpen: (open) => set({ documentAgentOpen: open }),
+ toggleDocumentAgentOpen: () => {
+ const s = get();
+ if (!s.documentAgentAvailable) return;
+ set({ documentAgentOpen: !s.documentAgentOpen });
+ },
+}));
+