Files
mnote/wolai-frontend/src/components/editor/blocks/MindmapAiAgentPanel.tsx
T

571 lines
20 KiB
TypeScript
Raw Normal View History

2026-01-10 10:35:21 +08:00
"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<ToolName, string> = {
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<AgentMessage[]>([
{
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<ToolName[]>(DEFAULT_TOOLS);
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
const [aiModel, setAiModel] = useState<string>("");
const [assets, setAssets] = useState<AgentAssetItem[]>([]);
const [workspaceId, setWorkspaceId] = useState<string>("");
const [attachments, setAttachments] = useState<AgentAssetItem[]>([]);
const [debug, setDebug] = useState<string>("");
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(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<string, (data: unknown) => 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 (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2 pb-3">
<div className="text-xs text-gray-500">
{selectedUids.length ? `选中节点:${selectedUids[0]}` : "未选中节点(将以整图为上下文)"}
</div>
<div className="flex items-center gap-1">
<button
type="button"
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs ${networkOn ? "border-blue-200 bg-blue-50 text-blue-700" : "border-gray-200 text-gray-500"}`}
title="联网检索(SearxNG"
onClick={() => setNetworkOn((v) => !v)}
>
<Network className="h-3 w-3" />
联网
</button>
<button
type="button"
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs ${toolPickerOpen ? "border-gray-300 bg-gray-50 text-gray-700" : "border-gray-200 text-gray-600"}`}
title="选择允许使用的工具"
onClick={() => setToolPickerOpen((v) => !v)}
>
<Settings2 className="h-3 w-3" />
工具
</button>
</div>
</div>
{toolPickerOpen && (
<div className="mb-3 rounded-md border border-gray-200 bg-white p-2">
<div className="mb-2 flex items-center justify-between">
<div className="text-xs font-medium text-gray-700">工具选择</div>
<button
type="button"
className={`rounded px-2 py-1 text-xs ${toolAuto ? "bg-blue-500 text-white" : "border border-gray-200 text-gray-600"}`}
onClick={() => setToolAuto((v) => !v)}
title="自动:AI 自行选择;手动:仅允许勾选工具"
>
{toolAuto ? "自动" : "手动"}
</button>
</div>
<div className="space-y-1">
{DEFAULT_TOOLS.map((t) => (
<label key={t} className={`flex cursor-pointer items-center gap-2 text-xs ${toolAuto ? "opacity-50" : ""}`}>
<input
type="checkbox"
disabled={toolAuto}
checked={selectedTools.includes(t)}
onChange={() => toggleTool(t)}
/>
<span className="text-gray-700">{TOOL_LABEL[t]}</span>
</label>
))}
</div>
<div className="mt-2 text-[11px] text-gray-400">
提示:如果你希望 AI 一定要“写入导图”,请在需求中明确说“请写入并保存”。
</div>
</div>
)}
<div className="flex-1 overflow-y-auto rounded-md border border-gray-200 bg-white p-2">
<div className="space-y-2">
{messages.map((m, idx) => (
<div
key={idx}
className={`whitespace-pre-wrap rounded-md px-2 py-2 text-sm ${m.role === "user" ? "bg-gray-50 text-gray-900" : "bg-white text-gray-800"}`}
>
<div className="mb-1 text-[11px] text-gray-400">{m.role === "user" ? "你" : "AI"}</div>
<div>{m.content}</div>
</div>
))}
</div>
</div>
{attachments.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{attachments.map((a) => (
<span
key={a.id}
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2 py-1 text-[11px] text-gray-700"
title={a.fileUrl}
>
@{a.title}
<button
type="button"
className="text-gray-400 hover:text-gray-700"
onClick={() => setAttachments((prev) => prev.filter((x) => x.id !== a.id))}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<div className="mt-2 rounded-md border border-gray-200 bg-white px-2 py-2 text-xs text-gray-700">
<div className="flex flex-wrap items-center gap-3">
<div className="text-gray-500">AI</div>
<label className="inline-flex cursor-pointer items-center gap-1">
<input
type="radio"
name="mindmap-ai-provider"
data-testid="mindmap-ai-provider-online"
checked={aiProvider === "online"}
onChange={() => setAiProvider("online")}
/>
在线
</label>
<label className="inline-flex cursor-pointer items-center gap-1">
<input
type="radio"
name="mindmap-ai-provider"
data-testid="mindmap-ai-provider-local"
checked={aiProvider === "local"}
onChange={() => setAiProvider("local")}
/>
本地
</label>
<div className="flex items-center gap-2">
<div className="text-gray-500">模型:</div>
{aiProvider === "online" ? (
<select
data-testid="mindmap-ai-model-select"
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
>
{ONLINE_MODELS.map((m) => (
<option key={m || "__default__"} value={m}>
{m ? m : "默认(ai.md"}
</option>
))}
</select>
) : (
<input
data-testid="mindmap-ai-model-input"
className="w-[220px] rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="默认(ai.local.md/环境变量)"
/>
)}
</div>
</div>
{aiProvider === "local" ? (
<div className="mt-1 text-[11px] text-gray-400">
本地 AI 需配置 `LOCAL_AI_BASE_URL`/`LOCAL_AI_MODEL` `ai.local.md` / `ai-local.md`
</div>
) : null}
</div>
<div className="relative mt-2">
{mentionOpen && filteredAssets.length > 0 && (
<div className="absolute bottom-[calc(100%+8px)] left-0 right-0 z-30 max-h-56 overflow-auto rounded-md border border-gray-200 bg-white shadow">
{filteredAssets.map((a) => (
<button
key={a.id}
type="button"
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50"
onClick={() => insertMention(a)}
>
<span className="truncate text-gray-800">{a.title}</span>
<span className="shrink-0 text-[11px] text-gray-400">{a.kind}</span>
</button>
))}
</div>
)}
<textarea
ref={textareaRef}
data-testid="mindmap-ai-input"
className="w-full resize-none rounded-md border border-gray-200 bg-white p-2 text-sm outline-none focus:border-blue-300"
rows={4}
value={input}
placeholder="输入你的需求。使用 @ 选择文件(PDF/附件/本地导图),例如:总结 @卤化反应原理_1-9.pdf 并写入导图(章->节->要点)。"
onChange={(e) => {
const v = e.target.value;
setInput(v);
updateMentionState(v, e.target.selectionStart ?? v.length);
}}
onKeyDown={(e) => {
// 焦点在 AI 输入框时,不应触发导图 Enter/Tab 快捷键
e.stopPropagation();
if (e.key === "Escape") {
setMentionOpen(false);
setToolPickerOpen(false);
return;
}
if (e.key === "Enter") {
// Enter 发送;Shift+Enter 换行
if (!e.shiftKey && !e.isComposing) {
e.preventDefault();
if (!loading) void send();
}
return;
}
}}
onClick={(e) => {
const el = e.currentTarget;
updateMentionState(el.value, el.selectionStart ?? el.value.length);
}}
/>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
onClick={() => fileInputRef.current?.click()}
title="上传文件到当前页面附件"
disabled={loading}
>
<Paperclip className="h-3 w-3" />
上传
</button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => {
void uploadFiles(e.target.files);
}}
accept="*/*"
/>
<div className="text-[11px] text-gray-400">Enter 发送 · Shift+Enter 换行</div>
</div>
<button
type="button"
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
disabled={loading || !input.trim()}
onClick={() => void send()}
>
<Send className="h-4 w-4" />
{loading ? "执行中..." : "发送"}
</button>
</div>
</div>
{debug ? (
<details className="mt-2 rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-600">
<summary className="cursor-pointer select-none">调试信息(tool trace</summary>
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap">{debug}</pre>
</details>
) : null}
</div>
);
}