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>
|
|||
|
|
);
|
|||
|
|
}
|