"use client"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { Network, Paperclip, Settings2, Send, X } from "lucide-react"; type AgentAssetItem = { kind: "media" | "local-mindmap" | "test-pdf"; id: string; title: string; fileUrl: string; mimeType?: string | null; assetType?: string | null; fileName?: string | null; }; type AgentMessage = { role: "user" | "assistant"; content: string }; type ToolName = | "mindmap_get" | "mindmap_get_subtree" | "search_web" | "mindmap_apply_ops" | "pdf_replace_mindmap"; const TOOL_LABEL: Record = { mindmap_get: "读导图(摘要)", mindmap_get_subtree: "读子树(按 uid)", search_web: "联网检索(SearxNG)", mindmap_apply_ops: "写入导图(ops)", pdf_replace_mindmap: "PDF→导图(替换当前)", }; const DEFAULT_TOOLS: ToolName[] = [ "mindmap_get", "mindmap_get_subtree", "search_web", "mindmap_apply_ops", "pdf_replace_mindmap", ]; const ONLINE_MODELS = [ "", "gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-pro-preview", "gemini-3-flash-preview", ] as const; export function MindmapAiAgentPanel({ documentId, mindmapId, mindmap, activeNodes, }: { documentId: string; mindmapId: string; mindmap: any; activeNodes: any[]; }) { const [messages, setMessages] = useState([ { role: "assistant", content: "你好,我是思维导图 AI Agent。你可以:\n- 直接说需求(例如:补完选中节点、总结 @PDF 并写入导图、从 @PDF 生成章/节结构导图)\n- 使用 @ 选择文件或上传文件\n- 让 AI 自动选择工具,或手动勾选允许使用的工具", }, ]); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [networkOn, setNetworkOn] = useState(true); const [toolAuto, setToolAuto] = useState(true); const [toolPickerOpen, setToolPickerOpen] = useState(false); const [selectedTools, setSelectedTools] = useState(DEFAULT_TOOLS); const [aiProvider, setAiProvider] = useState<"online" | "local">("online"); const [aiModel, setAiModel] = useState(""); const [assets, setAssets] = useState([]); const [workspaceId, setWorkspaceId] = useState(""); const [attachments, setAttachments] = useState([]); const [debug, setDebug] = useState(""); const textareaRef = useRef(null); const fileInputRef = useRef(null); useEffect(() => { try { const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim(); const m = window.localStorage.getItem("mindmap_ai_model") || ""; if (p === "local" || p === "online") setAiProvider(p); if (typeof m === "string") setAiModel(m); } catch { // ignore } }, []); useEffect(() => { try { window.localStorage.setItem("mindmap_ai_provider", aiProvider); window.localStorage.setItem("mindmap_ai_model", aiModel); } catch { // ignore } }, [aiProvider, aiModel]); // @ 选择 const [mentionOpen, setMentionOpen] = useState(false); const [mentionQuery, setMentionQuery] = useState(""); const [mentionRange, setMentionRange] = useState<{ start: number; end: number } | null>(null); const persistMindmapData = (data: unknown): boolean => { try { const w = window as unknown as { __mindmapPersistById?: Record void>; }; const fn = w.__mindmapPersistById?.[mindmapId]; if (typeof fn === "function") { fn(data); return true; } } catch { // ignore } return false; }; const selectedUids = useMemo(() => { const list = Array.isArray(activeNodes) ? activeNodes : []; return list .slice(0, 3) .map((n) => String(n?.nodeData?.data?.uid ?? n?.nodeData?.uid ?? n?.getData?.("uid") ?? n?.uid ?? "")) .filter(Boolean); }, [activeNodes]); useEffect(() => { let cancelled = false; if (!documentId) return; void (async () => { try { const res = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(documentId)}`); const json = (await res.json().catch(() => null)) as any; if (!res.ok) return; if (cancelled) return; setWorkspaceId(String(json?.workspaceId ?? "")); setAssets(Array.isArray(json?.items) ? (json.items as AgentAssetItem[]) : []); } catch { // ignore } })(); return () => { cancelled = true; }; }, [documentId]); const filteredAssets = useMemo(() => { const q = mentionQuery.trim().toLowerCase(); const base = assets.slice(0, 200); if (!q) return base.slice(0, 12); return base .filter((a) => { const hay = `${a.title} ${a.fileName ?? ""}`.toLowerCase(); return hay.includes(q); }) .slice(0, 12); }, [assets, mentionQuery]); const updateMentionState = (value: string, cursor: number) => { const before = value.slice(0, Math.max(0, cursor)); const at = before.lastIndexOf("@"); if (at === -1) { setMentionOpen(false); setMentionQuery(""); setMentionRange(null); return; } // 若 @ 前是非空白字符,视为 email/路径等,避免误触发 if (at > 0 && /\S/.test(before[at - 1] || "")) { setMentionOpen(false); setMentionQuery(""); setMentionRange(null); return; } const token = before.slice(at + 1); // 遇到换行/空格则不触发 if (/\s/.test(token)) { setMentionOpen(false); setMentionQuery(""); setMentionRange(null); return; } setMentionOpen(true); setMentionQuery(token); setMentionRange({ start: at, end: cursor }); }; const insertMention = (item: AgentAssetItem) => { const el = textareaRef.current; if (!el || !mentionRange) return; const next = `${input.slice(0, mentionRange.start)}@${item.title}${input.slice(mentionRange.end)}`; setInput(next); setMentionOpen(false); setMentionQuery(""); setMentionRange(null); // 去重加入附件 setAttachments((prev) => (prev.some((x) => x.id === item.id) ? prev : [...prev, item])); // 光标移动到插入后 window.setTimeout(() => { try { const pos = mentionRange.start + 1 + item.title.length; el.focus(); el.setSelectionRange(pos, pos); } catch { // ignore } }, 0); }; const toggleTool = (tool: ToolName) => { setSelectedTools((prev) => { if (prev.includes(tool)) return prev.filter((t) => t !== tool); return [...prev, tool]; }); }; const uploadFiles = async (files: FileList | null) => { if (!files || files.length === 0) return; if (!workspaceId || !documentId) { window.alert("缺少 workspaceId/documentId,无法上传。请刷新后重试。"); return; } const file = files[0]; const form = new FormData(); form.append("file", file); form.append("workspaceId", workspaceId); form.append("documentId", documentId); setLoading(true); try { const res = await fetch("/api/media/upload", { method: "POST", body: form }); const json = (await res.json().catch(() => null)) as any; if (!res.ok) throw new Error(String(json?.error ?? `上传失败:${res.status}`)); const asset = json?.asset; const item: AgentAssetItem = { kind: "media", id: String(asset?.id ?? `media:${Date.now()}`), title: String(asset?.file_name ?? file.name), fileUrl: String(asset?.file_url ?? ""), mimeType: String(asset?.mime_type ?? file.type ?? ""), assetType: String(asset?.asset_type ?? "file"), fileName: String(asset?.file_name ?? file.name), }; setAttachments((prev) => (prev.some((x) => x.id === item.id) ? prev : [...prev, item])); // 刷新资产列表 try { const listRes = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(documentId)}`); const listJson = (await listRes.json().catch(() => null)) as any; if (listRes.ok && Array.isArray(listJson?.items)) { setAssets(listJson.items as AgentAssetItem[]); setWorkspaceId(String(listJson?.workspaceId ?? workspaceId)); } } catch { // ignore } } catch (e) { window.alert(e instanceof Error ? e.message : String(e)); } finally { setLoading(false); if (fileInputRef.current) fileInputRef.current.value = ""; } }; const send = async () => { const content = input.trim(); if (!content) return; setDebug(""); const nextMessages: AgentMessage[] = [...messages, { role: "user", content }]; setMessages(nextMessages); setInput(""); setLoading(true); try { const res = await fetch("/api/mindmap-ai/agent", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ documentId, mindmapId, selectedUids, messages: nextMessages, attachments: attachments.map((a) => ({ id: a.id, title: a.title, fileUrl: a.fileUrl, mimeType: a.mimeType ?? null, })), toolChoice: toolAuto ? { mode: "auto" } : { mode: "manual", tools: selectedTools }, options: { searxng: networkOn, ai: { provider: aiProvider, model: aiModel.trim() || undefined } }, }), }); const json = (await res.json().catch(() => null)) as any; if (!res.ok) throw new Error(String(json?.error ?? `请求失败:${res.status}`)); const assistantText = String(json?.message ?? ""); setMessages((prev) => [...prev, { role: "assistant", content: assistantText || "(无输出)" }]); if (json?.data) { mindmap?.setData?.(json.data); mindmap?.command?.clearHistory?.(); persistMindmapData(json.data); } if (json?.trace) { setDebug(JSON.stringify(json.trace, null, 2)); } } catch (e) { const msg = e instanceof Error ? e.message : String(e); setMessages((prev) => [...prev, { role: "assistant", content: `执行失败:${msg}` }]); } finally { setLoading(false); } }; return (
{selectedUids.length ? `选中节点:${selectedUids[0]}` : "未选中节点(将以整图为上下文)"}
{toolPickerOpen && (
工具选择
{DEFAULT_TOOLS.map((t) => ( ))}
提示:如果你希望 AI 一定要“写入导图”,请在需求中明确说“请写入并保存”。
)}
{messages.map((m, idx) => (
{m.role === "user" ? "你" : "AI"}
{m.content}
))}
{attachments.length > 0 && (
{attachments.map((a) => ( @{a.title} ))}
)}
AI:
模型:
{aiProvider === "online" ? ( ) : ( setAiModel(e.target.value)} placeholder="默认(ai.local.md/环境变量)" /> )}
{aiProvider === "local" ? (
本地 AI 需配置 `LOCAL_AI_BASE_URL`/`LOCAL_AI_MODEL` 或 `ai.local.md` / `ai-local.md`
) : null}
{mentionOpen && filteredAssets.length > 0 && (
{filteredAssets.map((a) => ( ))}
)}