0.1.14 上线前更改

This commit is contained in:
liaibo
2026-01-11 12:35:53 +08:00
parent be71849aa5
commit 725a60d3aa
44 changed files with 3427 additions and 310 deletions
@@ -115,7 +115,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
}, [block.props.fileName, fileUrl]);
const isOfficeDoc = useMemo(
() =>
["doc", "docx", "ppt", "pptx", "xls", "xlsx", "odp", "ods", "odt"].includes(
["doc", "docx", "rtf", "ppt", "pptx", "xls", "xlsx", "odp", "ods", "odt", "pdf", "csv"].includes(
extension,
),
[extension],
@@ -1,7 +1,10 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Network, Paperclip, Settings2, Send, X } from "lucide-react";
import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkles, User, Wrench, X, Paperclip } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
type AgentAssetItem = {
kind: "media" | "local-mindmap" | "test-pdf";
@@ -20,6 +23,54 @@ type MindmapInstanceLike = {
command?: { clearHistory?: () => void };
};
const DEFAULT_SESSION_MESSAGES: AgentMessage[] = [
{
role: "assistant",
content:
"你好,我是思维导图 AI Agent。\n- 直接说需求(例如:补完选中节点、总结 @PDF 并写入导图、从 @PDF 生成章/节结构导图)\n- 使用 @ 选择文件或上传文件\n- 你可以让 AI 自动选择工具,或在“工具”里切换到手动并勾选允许使用的工具",
},
];
type ToolLog =
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
| { type: "error"; message: string };
type ChatSession = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messages: AgentMessage[];
toolLogs: ToolLog[];
attachments: AgentAssetItem[];
};
type PanelPage = "chat" | "tools" | "history" | "account" | "settings";
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
const generateId = () => {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `sess_${Math.random().toString(16).slice(2, 10)}`;
};
const normalizeSessions = (sessions: ChatSession[]) => {
const maxSessions = 20;
const maxMessages = 40;
const maxToolLogs = 80;
const maxAttachments = 20;
return sessions
.slice(0, maxSessions)
.map((s) => ({
...s,
messages: Array.isArray(s.messages) ? s.messages.slice(-maxMessages) : [],
toolLogs: Array.isArray(s.toolLogs) ? s.toolLogs.slice(-maxToolLogs) : [],
attachments: Array.isArray(s.attachments) ? s.attachments.slice(-maxAttachments) : [],
}))
.sort((a, b) => b.updatedAt - a.updatedAt);
};
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
@@ -99,45 +150,40 @@ export function MindmapAiAgentPanel({
mindmapId,
mindmap,
activeNodes,
onClose,
}: {
documentId: string;
mindmapId: string;
mindmap: MindmapInstanceLike | null | undefined;
activeNodes: unknown[];
onClose?: () => void;
}) {
const [messages, setMessages] = useState<AgentMessage[]>([
{
role: "assistant",
content:
"你好,我是思维导图 AI Agent。你可以:\n- 直接说需求(例如:补完选中节点、总结 @PDF 并写入导图、从 @PDF 生成章/节结构导图)\n- 使用 @ 选择文件或上传文件\n- 让 AI 自动选择工具,或手动勾选允许使用的工具",
},
]);
const [messages, setMessages] = useState<AgentMessage[]>(() => DEFAULT_SESSION_MESSAGES);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [networkOn, setNetworkOn] = useState(true);
const [toolAuto, setToolAuto] = useState(true);
// 兼容旧 UI(已隐藏),避免影响已有交互与回滚风险
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 [maxSteps, setMaxSteps] = useState<number>(10);
const [page, setPage] = useState<PanelPage>("chat");
const [assets, setAssets] = useState<AgentAssetItem[]>([]);
const [workspaceId, setWorkspaceId] = useState<string>("");
const [attachments, setAttachments] = useState<AgentAssetItem[]>([]);
const [debug, setDebug] = useState<string>("");
const [toolLogs, setToolLogs] = useState<
Array<
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
| { type: "error"; message: string }
>
>([]);
const [toolLogs, setToolLogs] = useState<ToolLog[]>([]);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string>("");
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const abortRef = useRef<AbortController | null>(null);
const syncTimerRef = useRef<number | null>(null);
useEffect(() => {
try {
@@ -165,6 +211,112 @@ export function MindmapAiAgentPanel({
}
}, [aiProvider, aiModel, maxSteps]);
useEffect(() => {
// 切换导图时,默认回到对话页
setPage("chat");
}, [mindmapId]);
// 会话/历史:按 mindmapId 隔离持久化
useEffect(() => {
try {
const key = `mindmap_ai_sessions:${mindmapId}`;
const raw = window.localStorage.getItem(key);
if (!raw) {
const id = generateId();
const now = Date.now();
const session: ChatSession = {
id,
title: "新会话",
createdAt: now,
updatedAt: now,
messages: DEFAULT_SESSION_MESSAGES,
toolLogs: [],
attachments: [],
};
setSessions([session]);
setActiveSessionId(id);
setMessages(session.messages);
setToolLogs([]);
setAttachments([]);
return;
}
const parsed = JSON.parse(raw) as unknown;
const list = typeof parsed === "object" && parsed && "sessions" in parsed ? (parsed as any).sessions : null;
const active =
typeof parsed === "object" && parsed && "activeSessionId" in parsed
? String((parsed as any).activeSessionId ?? "")
: "";
if (!Array.isArray(list) || list.length === 0) return;
const loaded = normalizeSessions(
list
.map((x) => {
const id = String((x as any)?.id ?? "").trim() || generateId();
const createdAt = Number((x as any)?.createdAt ?? Date.now());
const updatedAt = Number((x as any)?.updatedAt ?? createdAt);
const title = String((x as any)?.title ?? "").trim() || "历史会话";
const messages = Array.isArray((x as any)?.messages) ? (x as any).messages : DEFAULT_SESSION_MESSAGES;
const toolLogs = Array.isArray((x as any)?.toolLogs) ? (x as any).toolLogs : [];
const attachments = Array.isArray((x as any)?.attachments) ? (x as any).attachments : [];
return { id, title, createdAt, updatedAt, messages, toolLogs, attachments } as ChatSession;
})
.filter((s) => s.id),
);
setSessions(loaded);
const picked = active && loaded.some((s) => s.id === active) ? active : loaded[0]!.id;
setActiveSessionId(picked);
const cur = loaded.find((s) => s.id === picked) ?? loaded[0]!;
setMessages(cur.messages?.length ? cur.messages : DEFAULT_SESSION_MESSAGES);
setToolLogs(cur.toolLogs ?? []);
setAttachments(cur.attachments ?? []);
} catch {
// ignore
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mindmapId]);
useEffect(() => {
if (!activeSessionId) return;
if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current);
syncTimerRef.current = window.setTimeout(() => {
setSessions((prev) => {
const now = Date.now();
const next = prev.some((s) => s.id === activeSessionId)
? prev.map((s) =>
s.id === activeSessionId ? { ...s, messages, toolLogs, attachments, updatedAt: now } : s,
)
: [
{
id: activeSessionId,
title: "新会话",
createdAt: now,
updatedAt: now,
messages,
toolLogs,
attachments,
},
...prev,
];
return normalizeSessions(next);
});
}, 200);
return () => {
if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current);
};
}, [activeSessionId, attachments, messages, toolLogs]);
useEffect(() => {
if (!mindmapId) return;
try {
const key = `mindmap_ai_sessions:${mindmapId}`;
const payload = JSON.stringify({ activeSessionId, sessions: normalizeSessions(sessions) });
if (payload.length <= 900_000) window.localStorage.setItem(key, payload);
} catch {
// ignore
}
}, [activeSessionId, mindmapId, sessions]);
// @ 选择
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
@@ -435,11 +587,142 @@ export function MindmapAiAgentPanel({
}
};
const currentSession = useMemo(() => sessions.find((s) => s.id === activeSessionId) ?? null, [activeSessionId, sessions]);
const currentSessionTitle = currentSession?.title || "新会话";
const pageTitle = useMemo(() => {
switch (page) {
case "tools":
return "工具(代替 MCP";
case "history":
return "历史会话";
case "account":
return "账户 / 模型";
case "settings":
return "设置";
default:
return "思维导图 AI Agent";
}
}, [page]);
const startNewSession = () => {
if (loading) return;
const id = generateId();
const now = Date.now();
const next: ChatSession = {
id,
title: "新会话",
createdAt: now,
updatedAt: now,
messages: DEFAULT_SESSION_MESSAGES,
toolLogs: [],
attachments: [],
};
setSessions((prev) => normalizeSessions([next, ...prev]));
setActiveSessionId(id);
setMessages(next.messages);
setToolLogs([]);
setAttachments([]);
setInput("");
setDebug("");
};
const switchSession = (id: string) => {
if (loading) return;
const target = sessions.find((s) => s.id === id);
if (!target) return;
setActiveSessionId(target.id);
setMessages(target.messages?.length ? target.messages : DEFAULT_SESSION_MESSAGES);
setToolLogs(target.toolLogs ?? []);
setAttachments(target.attachments ?? []);
setInput("");
setDebug("");
};
const resetCurrentSession = () => {
if (loading) return;
setMessages(DEFAULT_SESSION_MESSAGES);
setToolLogs([]);
setAttachments([]);
setInput("");
setDebug("");
if (activeSessionId) {
setSessions((prev) =>
normalizeSessions(
prev.map((s) =>
s.id === activeSessionId
? { ...s, messages: DEFAULT_SESSION_MESSAGES, toolLogs: [], attachments: [], updatedAt: Date.now(), title: s.title || "当前会话" }
: s,
),
),
);
}
};
const clearHistory = () => {
if (loading) return;
const base: ChatSession =
currentSession ??
({
id: activeSessionId || generateId(),
title: "当前会话",
createdAt: Date.now(),
updatedAt: Date.now(),
messages,
toolLogs,
attachments,
} as ChatSession);
setSessions([
{
...base,
title: base.title || "当前会话",
updatedAt: Date.now(),
messages,
toolLogs,
attachments,
},
]);
setActiveSessionId(base.id);
};
const deleteSession = (id: string) => {
if (loading) return;
setSessions((prev) => normalizeSessions(prev.filter((s) => s.id !== id)));
if (id === activeSessionId) {
const fallback = sessions.filter((s) => s.id !== id)[0];
if (fallback) {
setActiveSessionId(fallback.id);
setMessages(fallback.messages?.length ? fallback.messages : DEFAULT_SESSION_MESSAGES);
setToolLogs(fallback.toolLogs ?? []);
setAttachments(fallback.attachments ?? []);
} else {
startNewSession();
}
}
};
const canSend = useMemo(() => input.trim().length > 0 && !loading, [input, loading]);
const stop = () => {
try {
abortRef.current?.abort();
} catch {
// ignore
} finally {
abortRef.current = null;
setLoading(false);
}
};
const send = async () => {
const content = input.trim();
if (!content) return;
setDebug("");
setToolLogs([]);
if (activeSessionId && currentSessionTitle === "新会话") {
const title = content.length > 18 ? `${content.slice(0, 18)}` : content;
setSessions((prev) => normalizeSessions(prev.map((s) => (s.id === activeSessionId ? { ...s, title, updatedAt: Date.now() } : s))));
}
const nextMessages: AgentMessage[] = [...messages, { role: "user", content }];
setMessages(nextMessages);
@@ -604,6 +887,421 @@ export function MindmapAiAgentPanel({
return (
<div className="flex h-full flex-col">
<div className="border-b p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{page === "chat" ? (
<Sparkles className="h-4 w-4" />
) : (
<Button variant="ghost" size="icon" onClick={() => setPage("chat")} disabled={loading} title="返回对话">
<ChevronLeft className="h-4 w-4" />
</Button>
)}
<span className="text-sm font-medium">{pageTitle}</span>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={startNewSession} disabled={loading} title="新建会话">
<Plus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setPage("tools")} disabled={loading} title="工具(代替 MCP">
<Wrench className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setPage("history")} disabled={loading} title="历史">
<History className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setPage("account")} disabled={loading} title="账户/模型">
<User className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setPage("settings")} disabled={loading} title="设置">
<Settings className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => onClose?.()} disabled={loading} title="关闭">
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{page === "chat" ? (
<>
<div
className="border-b p-3 text-xs text-muted-foreground"
title={
selectedNodes.length
? selectedNodes
.map((n) => (n.text ? `${n.text}${n.uid}` : n.uid))
.slice(0, 3)
.join("")
: ""
}
>
{selectedNodes.length
? `选中节点:${selectedNodes[0]?.text || selectedNodes[0]?.uid}${selectedNodes.length > 1 ? `+${selectedNodes.length - 1}` : ""}`
: "未选中节点(将以整图为上下文)"}
</div>
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 border-b">
<div className="px-3 py-2 text-sm font-medium"></div>
<ScrollArea className="h-[36vh] border-t">
<div className="space-y-3 p-3 text-sm">
{messages.map((m, idx) => (
<div key={idx} className="space-y-1">
<div className="text-xs text-muted-foreground">{m.role === "user" ? "用户" : "AI"}</div>
<div className="whitespace-pre-wrap">{m.content}</div>
</div>
))}
</div>
</ScrollArea>
</div>
<div className="min-h-0">
<div className="px-3 py-2 text-sm font-medium"></div>
<ScrollArea className="h-[24vh] border-t">
<div className="space-y-3 p-3 text-sm">
{toolLogs.length === 0 ? <div className="text-muted-foreground"></div> : null}
{toolLogs.map((l, idx) => {
if (l.type === "error") {
return (
<div key={idx} className="rounded border border-red-200 bg-red-50 p-2 text-red-700">
{l.message}
</div>
);
}
if (l.type === "tool_call") {
return (
<details key={idx} className="rounded border p-2">
<summary className="cursor-pointer select-none text-xs text-muted-foreground">
tool_call · {l.tool} · {l.id}
</summary>
<pre className="mt-2 whitespace-pre-wrap text-xs">{JSON.stringify(l.args, null, 2)}</pre>
</details>
);
}
return (
<details key={idx} className="rounded border p-2">
<summary className="cursor-pointer select-none text-xs text-muted-foreground">
tool_result · {l.tool} · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
</summary>
<pre className="mt-2 whitespace-pre-wrap text-xs">{JSON.stringify(l.result, null, 2)}</pre>
</details>
);
})}
</div>
</ScrollArea>
</div>
</div>
<div className="border-t p-3">
{attachments.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((a) => (
<span
key={a.id}
className="inline-flex items-center gap-1 rounded border bg-muted px-2 py-1 text-xs text-foreground"
title={a.fileUrl}
>
<span className="max-w-[160px] truncate">{a.title}</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() => setAttachments((prev) => prev.filter((x) => x.id !== a.id))}
disabled={loading}
title="移除附件"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
) : null}
<div className="relative">
{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 bg-background 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-muted"
onClick={() => insertMention(a)}
disabled={loading}
>
<span className="truncate">{a.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">{a.kind}</span>
</button>
))}
</div>
) : null}
<Textarea
ref={textareaRef}
data-testid="mindmap-ai-input"
className="min-h-[96px]"
value={input}
placeholder="输入你的需求。使用 @ 选择文件(PDF/附件/本地导图),例如:总结 @xx.pdf 并写入导图(章->节->要点)。"
disabled={loading}
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);
return;
}
if (e.key === "Enter") {
// Enter 发送;Shift+Enter 换行
const isComposing = Boolean((e.nativeEvent as unknown as { isComposing?: boolean })?.isComposing);
if (!e.shiftKey && !isComposing) {
e.preventDefault();
if (canSend) void send();
}
return;
}
}}
onClick={(e) => {
const el = e.currentTarget;
updateMentionState(el.value, el.selectionStart ?? el.value.length);
}}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()} disabled={loading} title="上传文件到当前页面附件">
<Paperclip className="mr-2 h-4 w-4" />
</Button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => {
void uploadFiles(e.target.files);
}}
accept="*/*"
/>
<div className="text-xs text-muted-foreground">Enter · Shift+Enter </div>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" disabled={!loading} onClick={stop} title="停止本次执行">
<X className="mr-2 h-4 w-4" />
</Button>
<Button disabled={!canSend} onClick={() => void send()}>
<Send className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
</>
) : null}
{page === "tools" ? (
<ScrollArea className="h-full">
<div className="space-y-3 p-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className={`inline-flex items-center gap-1 rounded border px-2 py-1 ${toolAuto ? "bg-white" : "bg-muted"}`}
onClick={() => setToolAuto((v) => !v)}
disabled={loading}
title="工具自动/手动"
>
<Settings2 className="h-3.5 w-3.5" />
{toolAuto ? "自动工具" : "手动工具"}
</button>
</div>
{!toolAuto ? (
<div>
<div className="mb-2 text-xs text-muted-foreground">使</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(TOOL_LABEL) as ToolName[]).map((t) => {
const on = selectedTools.includes(t);
return (
<button
key={t}
type="button"
className={`rounded border px-2 py-1 text-xs ${on ? "bg-[#111827] text-white" : "bg-white"}`}
onClick={() =>
setSelectedTools((prev) => (prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t]))
}
disabled={loading}
>
{TOOL_LABEL[t]}
</button>
);
})}
</div>
</div>
) : (
<div className="text-xs text-muted-foreground">AI ToolSet </div>
)}
</div>
</ScrollArea>
) : null}
{page === "history" ? (
<ScrollArea className="h-full">
<div className="p-3 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-muted-foreground"> 20 </div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={resetCurrentSession} disabled={loading}>
</Button>
<Button variant="outline" onClick={startNewSession} disabled={loading}>
<Plus className="mr-2 h-4 w-4" />
</Button>
<Button variant="destructive" onClick={clearHistory} disabled={loading}>
</Button>
</div>
</div>
<div className="mt-3 space-y-2">
{sessions.map((s) => {
const active = s.id === activeSessionId;
const time = new Date(s.updatedAt || s.createdAt).toLocaleString();
return (
<div key={s.id} className={`flex items-center gap-2 rounded border px-3 py-2 ${active ? "border-[#111827]" : "border-border"}`}>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => {
switchSession(s.id);
setPage("chat");
}}
disabled={loading}
title={active ? "当前会话" : "切换到该会话"}
>
<div className="flex items-center justify-between gap-2">
<div className="truncate font-medium">{s.title || "未命名"}</div>
<div className="shrink-0 text-xs text-muted-foreground">{time}</div>
</div>
<div className="mt-1 text-xs text-muted-foreground"> {s.messages.length} · {s.toolLogs.length}</div>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteSession(s.id)}
disabled={loading || active}
title={active ? "不能删除当前会话" : "删除会话"}
>
</Button>
</div>
);
})}
</div>
</div>
</ScrollArea>
) : null}
{page === "account" ? (
<ScrollArea className="h-full">
<div className="space-y-3 p-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<label className="text-xs text-muted-foreground"></label>
<select
className="h-9 rounded border bg-white px-2 text-sm"
value={aiProvider}
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
disabled={loading}
>
<option value="online">线</option>
<option value="local"></option>
</select>
<label className="ml-2 text-xs text-muted-foreground"></label>
{aiProvider === "online" ? (
<select
data-testid="mindmap-ai-model-select"
className="h-9 rounded border bg-white px-2 text-sm"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
disabled={loading}
>
{ONLINE_MODELS.map((m) => (
<option key={m || "__default__"} value={m}>
{m ? m : "默认(ai.md"}
</option>
))}
</select>
) : (
<input
data-testid="mindmap-ai-model-input"
className="h-9 w-[200px] rounded border bg-white px-2 text-sm"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="默认(ai.local.md/环境变量)"
disabled={loading}
/>
)}
</div>
{aiProvider === "local" ? (
<div className="text-xs text-muted-foreground">
AI `LOCAL_AI_BASE_URL`/`LOCAL_AI_MODEL` `ai.local.md` / `ai-local.md`
</div>
) : null}
</div>
</ScrollArea>
) : null}
{page === "settings" ? (
<ScrollArea className="h-full">
<div className="space-y-3 p-3 text-sm">
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
className={`inline-flex items-center gap-1 rounded border px-2 py-1 ${networkOn ? "bg-white" : "bg-muted"}`}
onClick={() => setNetworkOn((v) => !v)}
disabled={loading}
title="联网检索(SearxNG"
>
<Network className="h-3.5 w-3.5" />
{networkOn ? "联网" : "离线"}
</button>
<label className="flex items-center gap-2">
<input
className="w-[92px] rounded border px-2 py-1 text-sm"
type="number"
min={1}
max={24}
step={1}
value={maxSteps}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isFinite(v)) return;
setMaxSteps(clamp(Math.floor(v), 1, 24));
}}
disabled={loading}
/>
</label>
</div>
<div className="text-xs text-muted-foreground"></div>
{debug ? (
<details className="rounded border bg-background p-2 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">SSE </summary>
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap">{debug}</pre>
</details>
) : null}
</div>
</ScrollArea>
) : null}
</div>
{false ? (
<div className="hidden">
<div className="flex items-center justify-between gap-2 pb-3">
<div
className="text-xs text-gray-500"
@@ -924,6 +1622,8 @@ export function MindmapAiAgentPanel({
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap">{debug}</pre>
</details>
) : null}
</div>
) : null}
</div>
);
}
@@ -155,9 +155,18 @@ const resolveAssetUrls = async (data: MindMapData): Promise<{ data: MindMapData;
// 递归收集所有 asset:id
const collectAssetIds = (node: any) => {
if (!node) return;
if (node.image?.url?.startsWith?.("asset:")) {
assetIds.push(node.image.url.replace("asset:", ""));
}
const candidates: unknown[] = [
node?.data?.image,
node?.image,
node?.image?.url,
node?.data?.image?.url,
];
candidates.forEach((value) => {
if (typeof value !== "string") return;
if (!value.startsWith("asset:")) return;
const id = value.replace(/^asset:/, "").trim();
if (id) assetIds.push(id);
});
if (Array.isArray(node.children)) {
node.children.forEach(collectAssetIds);
}
@@ -176,9 +185,7 @@ const resolveAssetUrls = async (data: MindMapData): Promise<{ data: MindMapData;
const result = await response.json();
urlMap.set(id, result.signedUrl);
}
} catch (e) {
console.error(`获取 asset ${id} 签名 URL 失败`, e);
}
} catch {}
}));
// 递归替换 asset:id 为签名 URL,并建立反向映射
@@ -186,15 +193,49 @@ const resolveAssetUrls = async (data: MindMapData): Promise<{ data: MindMapData;
const replaceAssetIds = (node: any): any => {
if (!node) return node;
const newNode = { ...node };
if (newNode.image?.url?.startsWith?.("asset:")) {
const id = newNode.image.url.replace("asset:", "");
const replaceAssetString = (value: unknown): string | null => {
if (typeof value !== "string") return null;
if (!value.startsWith("asset:")) return null;
const id = value.replace(/^asset:/, "").trim();
if (!id) return null;
const signedUrl = urlMap.get(id);
if (signedUrl) {
newNode.image = { ...newNode.image, url: signedUrl };
// 建立反向映射:signedUrl -> asset:id
urlToAssetId.set(signedUrl, id);
return signedUrl;
}
// 即使签名失败,也保留 asset:id -> id 的映射,方便后续删除/撤销逻辑使用
urlToAssetId.set(`asset:${id}`, id);
return `asset:${id}`;
};
// 常见:node.data.image = "asset:xxx"
const nextDataImage = replaceAssetString(newNode?.data?.image);
if (nextDataImage && newNode.data && typeof newNode.data === "object") {
newNode.data = { ...newNode.data, image: nextDataImage };
}
// 兼容:node.image = "asset:xxx"
if (typeof newNode.image === "string") {
const nextImage = replaceAssetString(newNode.image);
if (nextImage) newNode.image = nextImage;
}
// 兼容:node.image.url = "asset:xxx"
const nextImageUrl = replaceAssetString(newNode?.image?.url);
if (nextImageUrl && newNode.image && typeof newNode.image === "object") {
newNode.image = { ...newNode.image, url: nextImageUrl };
}
// 兼容:node.data.image.url = "asset:xxx"
const nextDataImageUrl = replaceAssetString(newNode?.data?.image?.url);
if (nextDataImageUrl && newNode.data && typeof newNode.data === "object") {
const dataImage = (newNode.data as any).image;
if (dataImage && typeof dataImage === "object") {
(newNode.data as any).image = { ...(dataImage as any), url: nextDataImageUrl };
}
}
if (Array.isArray(newNode.children)) {
newNode.children = newNode.children.map(replaceAssetIds);
}
@@ -209,12 +250,41 @@ const revertToAssetIds = (data: MindMapData, urlToAssetId: Map<string, string>):
const revertNode = (node: any): any => {
if (!node) return node;
const newNode = { ...node };
if (newNode.image?.url && typeof newNode.image.url === "string") {
const assetId = urlToAssetId.get(newNode.image.url);
if (assetId) {
newNode.image = { ...newNode.image, url: `asset:${assetId}` };
const revertSigned = (value: unknown): string | null => {
if (typeof value !== "string") return null;
const assetId = urlToAssetId.get(value);
if (!assetId) return null;
return `asset:${assetId}`;
};
// 常见:node.data.image = signedUrl
const nextDataImage = revertSigned(newNode?.data?.image);
if (nextDataImage && newNode.data && typeof newNode.data === "object") {
newNode.data = { ...newNode.data, image: nextDataImage };
}
// 兼容:node.image = signedUrl
if (typeof newNode.image === "string") {
const nextImage = revertSigned(newNode.image);
if (nextImage) newNode.image = nextImage;
}
// 兼容:node.image.url = signedUrl
const nextImageUrl = revertSigned(newNode?.image?.url);
if (nextImageUrl && newNode.image && typeof newNode.image === "object") {
newNode.image = { ...newNode.image, url: nextImageUrl };
}
// 兼容:node.data.image.url = signedUrl
const nextDataImageUrl = revertSigned(newNode?.data?.image?.url);
if (nextDataImageUrl && newNode.data && typeof newNode.data === "object") {
const dataImage = (newNode.data as any).image;
if (dataImage && typeof dataImage === "object") {
(newNode.data as any).image = { ...(dataImage as any), url: nextDataImageUrl };
}
}
if (Array.isArray(newNode.children)) {
newNode.children = newNode.children.map(revertNode);
}
@@ -297,10 +367,7 @@ const patchSvgRbox = async () => {
cx: Math.max(0, viewportWidth / 2),
cy: Math.max(0, viewportHeight / 2),
};
if (!warned) {
console.warn("rbox 失败,使用 DOM 边界框降级避免崩溃", error, fallback);
warned = true;
}
if (!warned) warned = true;
return fallback;
}
};
@@ -510,9 +577,7 @@ const MindmapBlockView = ({
}, 0);
}
}
} catch (error) {
console.warn("加载本地/远端思维导图失败", error);
}
} catch {}
})();
return () => {
cancelled = true;
@@ -1232,7 +1297,7 @@ const MindmapBlockView = ({
});
}
})
.catch((err) => console.warn("思维导图同步失败", err));
.catch(() => {});
}
},
[autosaveKey, block, docId, editor, mindmapId, effectiveFullscreen],
@@ -1292,15 +1357,8 @@ const MindmapBlockView = ({
body: JSON.stringify({ data, createOnly: true }),
});
if (!resp.ok) {
console.warn(
"初次创建思维导图文件失败",
resp.status,
await resp.text().catch(() => ""),
);
}
} catch (err) {
console.warn("初次创建思维导图文件失败", err);
} finally {
} catch {} finally {
// 即便远端失败,也通知侧边栏刷新,保证本地文件树及时更新
const fileName = `mindmap-${mindmapId}.json`;
emitAssetsChanged(docId, {
@@ -1479,7 +1537,6 @@ const MindmapBlockView = ({
plugins.forEach(({ name, plugin }) => {
if (!plugin) {
console.warn(`思维导图插件加载失败:${name}`);
return;
}
const MindMapCtor = MindMap as unknown as {
@@ -1491,7 +1548,6 @@ const MindmapBlockView = ({
typeof hasPlugin === "function" ? hasPlugin(plugin) === -1 : true;
const registerPlugin = MindMapCtor.usePlugin;
if (notRegistered && typeof registerPlugin === "function") {
console.log(`注册插件: ${name}`);
registerPlugin(plugin);
}
});
@@ -1673,11 +1729,9 @@ const MindmapBlockView = ({
renderer.setRootNodeCenter();
}
instance.view?.fit?.();
} catch (error) {
} catch {
if (retry < 3) {
window.setTimeout(() => centerAndFit(retry + 1), 50);
} else {
console.warn("思维导图初始居中失败,已跳过", error);
}
}
};
@@ -2089,9 +2143,7 @@ const MindmapBlockView = ({
const data = await response.json();
return data.signedUrl;
}
} catch (e) {
console.error("获取签名 URL 失败", e);
}
} catch {}
return urlOrAssetId;
}
// 如果是完整的 URL,直接返回
@@ -2104,9 +2156,17 @@ const MindmapBlockView = ({
const urls: string[] = [];
const traverse = (node: any) => {
if (!node) return;
if (node.image && typeof node.image === "string" && node.image) {
urls.push(node.image);
}
const candidates: unknown[] = [
node?.data?.image,
node?.image,
node?.image?.url,
node?.data?.image?.url,
];
candidates.forEach((value) => {
if (typeof value === "string" && value) {
urls.push(value);
}
});
if (Array.isArray(node.children)) {
node.children.forEach(traverse);
}
@@ -2119,7 +2179,6 @@ const MindmapBlockView = ({
const deleteImageAssets = async (assetIds: string[]) => {
if (!assetIds.length || !docId) return;
try {
console.log("[MindmapBlock] Deleting image assets:", assetIds);
const response = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -2128,12 +2187,11 @@ const MindmapBlockView = ({
if (response.ok) {
// 记录到已删除列表
assetIds.forEach(id => deletedAssetIdsRef.current.add(id));
console.log("[MindmapBlock] Image assets deleted successfully, emitting ASSETS_CHANGED_EVENT");
// 通知文件树刷新,传递被删除的 assetIds
emitAssetsChanged(docId, undefined, assetIds);
} else {
const payload = await response.json().catch(() => null);
console.error("[MindmapBlock] Failed to delete image assets:", payload?.error);
console.error("删除图片资源失败", payload?.error);
}
} catch (e) {
console.error("删除图片资源失败", e);
@@ -2280,20 +2338,15 @@ const MindmapBlockView = ({
useEffect(() => {
if (!mindmap || !docId) return;
console.log("[MindmapBlock] Setting up image deletion monitoring");
// 初始化:收集当前所有图片 URL
const initialData = mindmap.getData?.();
if (initialData) {
const urls = collectImageUrls(initialData);
currentImageUrlsRef.current = new Set(urls);
console.log("[MindmapBlock] Initial image URLs:", urls.length, "Map size:", signedUrlToAssetIdRef.current.size);
console.log("[MindmapBlock] Initial URLs:", urls);
}
// 处理数据变化
const handleDataChange = () => {
console.log("[MindmapBlock] data_change event fired!");
const newData = mindmap.getData?.();
if (!newData) return;
@@ -2301,10 +2354,6 @@ const MindmapBlockView = ({
const newUrlsSet = new Set(newUrls);
const oldUrlsSet = currentImageUrlsRef.current;
console.log("[MindmapBlock] Old URLs:", Array.from(oldUrlsSet));
console.log("[MindmapBlock] New URLs:", newUrls);
console.log("[MindmapBlock] Map entries:", Array.from(signedUrlToAssetIdRef.current.entries()));
// 检测被删除的图片(在旧集合中但不在新集合中)
const deletedUrls: string[] = [];
oldUrlsSet.forEach(url => {
@@ -2323,30 +2372,22 @@ const MindmapBlockView = ({
// 处理删除的图片
if (deletedUrls.length > 0) {
console.log("[MindmapBlock] Detected deleted URLs:", deletedUrls);
const assetIdsToDelete: string[] = [];
deletedUrls.forEach(url => {
const assetId = signedUrlToAssetIdRef.current.get(url);
console.log("[MindmapBlock] URL:", url.substring(0, 100), "-> Asset ID:", assetId);
if (assetId) {
assetIdsToDelete.push(assetId);
} else {
console.warn("[MindmapBlock] Asset ID not found in map for URL:", url.substring(0, 100));
}
});
console.log("[MindmapBlock] Asset IDs to delete:", assetIdsToDelete);
if (assetIdsToDelete.length > 0) {
deleteImageAssets(assetIdsToDelete);
} else {
console.warn("[MindmapBlock] No asset IDs found for deleted URLs!");
}
}
// 处理恢复的图片(可能是撤销操作)
if (addedUrls.length > 0) {
console.log("[MindmapBlock] Detected added URLs:", addedUrls);
const assetIdsToRestore: string[] = [];
addedUrls.forEach(url => {
const assetId = signedUrlToAssetIdRef.current.get(url);
@@ -2354,7 +2395,6 @@ const MindmapBlockView = ({
assetIdsToRestore.push(assetId);
}
});
console.log("[MindmapBlock] Asset IDs to restore:", assetIdsToRestore);
if (assetIdsToRestore.length > 0) {
restoreImageAssets(assetIdsToRestore);
}
@@ -2369,10 +2409,7 @@ const MindmapBlockView = ({
mindmap.on?.("back_forward", handleDataChange);
mindmap.on?.("node_data_change", handleDataChange);
console.log("[MindmapBlock] Event listeners registered");
return () => {
console.log("[MindmapBlock] Cleaning up event listeners");
mindmap.off?.("data_change", handleDataChange);
mindmap.off?.("back_forward", handleDataChange);
mindmap.off?.("node_data_change", handleDataChange);
@@ -2482,11 +2519,11 @@ const MindmapBlockView = ({
let displayUrl = url;
if (url.startsWith("asset:")) {
displayUrl = await resolveImageUrl(url);
// 记录映射关系供保存时使用
if (displayUrl !== url) {
const assetId = url.replace("asset:", "");
// 记录映射关系供保存/删除/撤销使用(无论是否签名成功)
const assetId = url.replace(/^asset:/, "").trim();
if (assetId) {
signedUrlToAssetIdRef.current.set(displayUrl, assetId);
console.log("[MindmapBlock] New image mapped:", displayUrl.substring(0, 80), "-> Asset ID:", assetId);
signedUrlToAssetIdRef.current.set(`asset:${assetId}`, assetId);
}
}
@@ -2511,7 +2548,6 @@ const MindmapBlockView = ({
if (newData) {
const newUrls = collectImageUrls(newData);
currentImageUrlsRef.current = new Set(newUrls);
console.log("[MindmapBlock] Updated currentImageUrlsRef after insert:", newUrls);
}
}, 100);
};
@@ -2589,8 +2625,7 @@ const MindmapBlockView = ({
}
window.alert("暂不支持该文件类型,请选择 .json / .smm / .xmind / .mmap / .md");
} catch (error) {
console.error(error);
} catch {
window.alert("导入失败:文件格式或内容错误");
} finally {
reset();
@@ -2627,8 +2662,7 @@ const MindmapBlockView = ({
const handleExport = async (type: string, name = "mindmap") => {
try {
await mindmap?.doExport?.export(type, true, name);
} catch (error) {
console.error(error);
} catch {
window.alert(`导出 ${type.toUpperCase()} 失败,请稍后再试`);
}
};
@@ -2861,6 +2895,9 @@ const MindmapBlockView = ({
form.append("file", file);
form.append("workspaceId", workspaceId);
form.append("documentId", docId);
if (mindmapId) {
form.append("mindmapId", mindmapId);
}
const response = await fetch("/api/media/upload", {
method: "POST",
body: form,
@@ -2999,26 +3036,12 @@ const MindmapBlockView = ({
ref={wrapperRef}
data-testid="mindmap-fullscreen"
tabIndex={0}
className="fixed inset-0 z-[9999] overflow-hidden bg-white"
className="fixed inset-0 z-[9999] overflow-hidden bg-white"
>
<div className="fixed left-0 right-0 top-0 z-30 flex h-12 items-center justify-between border-b border-gray-200 bg-white/90 px-4 backdrop-blur">
<div className="text-sm font-medium text-gray-700">
</div>
<button
type="button"
title="退出全屏"
className="rounded p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
onClick={exitLocalFullscreen}
>
<span className="text-lg leading-none">×</span>
</button>
</div>
<div className="fixed left-1/2 top-14 z-20 flex w-fit -translate-x-1/2 transform items-center justify-center gap-4">
<div className="fixed left-1/2 top-2 z-20 flex w-fit -translate-x-1/2 transform items-center justify-center gap-4">
<MindmapToolbar {...toolbarProps} />
</div>
<div className="relative h-full w-full pt-12">
<div className="relative h-full w-full pt-0">
<div
ref={containerRef}
className="h-full w-full"
@@ -1802,12 +1802,13 @@ export const MindmapSidebar = ({
activeNodes={activeNodes}
documentId={documentId}
mindmapId={mindmapId}
onClose={onClose}
/>
);
default:
return null;
}
}, [activeTab, mindmap, activeNodes, documentId, mindmapId]);
}, [activeTab, mindmap, activeNodes, documentId, mindmapId, onClose]);
const title = useMemo(() => {
if (!activeTab) return "";
@@ -1820,15 +1821,17 @@ export const MindmapSidebar = ({
activeTab ? "translate-x-0" : "translate-x-full"
}`}
>
{activeTab === "ai" ? null : (
<div className="flex items-center justify-between border-b px-4 py-3 shrink-0">
<span className="font-medium text-gray-700">{title}</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
{content}
<span className="font-medium text-gray-700">{title}</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="h-4 w-4" />
</button>
</div>
)}
<div className={activeTab === "ai" ? "flex-1 overflow-hidden p-0" : "flex-1 overflow-y-auto px-4 py-4"}>
{content}
</div>
</div>
);
};