571 lines
20 KiB
TypeScript
571 lines
20 KiB
TypeScript
"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>
|
||
);
|
||
}
|