Files
mnote/wolai-frontend/src/components/editor/blocks/MindmapAiAgentPanel.tsx
T
2026-01-10 10:35:21 +08:00

571 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}