0.1.14 上线前更改
This commit is contained in:
@@ -579,7 +579,7 @@ export function DocumentAiAgentPanel({
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent side="right" className="p-0">
|
||||
<SheetContent side="right" showCloseButton={false} className="p-0">
|
||||
<SheetHeader className="border-b">
|
||||
<SheetTitle className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bot, Settings, Wrench, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
|
||||
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 PanelPage = "chat" | "tools" | "settings";
|
||||
|
||||
type AgentAttachment = { id: string; title: string; fileUrl: string; mimeType?: string | null };
|
||||
|
||||
const CHANNEL = "mnote_onlyoffice_agent_tools_v1";
|
||||
|
||||
const DEFAULT_MESSAGES: AgentMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"你好,我是 OnlyOffice AI Agent。\n- 先选中一段文字,再说“改写/补全/翻译/润色/删除/插入”\n- 我会通过 oo_* 工具读取/替换选区\n- 需要引用资料时可联网检索或用 LightRAG/文档检索",
|
||||
},
|
||||
];
|
||||
|
||||
const ONLINE_MODELS = [
|
||||
"",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
] as const;
|
||||
|
||||
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
const parseSseChunks = async (res: Response, onEvent: (event: string, dataText: string) => void) => {
|
||||
if (!res.body) throw new Error("响应不支持流式读取");
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (true) {
|
||||
const sep = buffer.indexOf("\n\n");
|
||||
if (sep === -1) break;
|
||||
const raw = buffer.slice(0, sep);
|
||||
buffer = buffer.slice(sep + 2);
|
||||
|
||||
// 注释/心跳:以 ":" 开头
|
||||
if (raw.trimStart().startsWith(":")) continue;
|
||||
|
||||
const lines = raw.split(/\r?\n/);
|
||||
let event = "message";
|
||||
const dataLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event:")) event = line.slice("event:".length).trim() || "message";
|
||||
if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).trimStart());
|
||||
}
|
||||
onEvent(event, dataLines.join("\n"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const blobToDataUrl = (blob: Blob) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result ?? ""));
|
||||
reader.onerror = () => reject(new Error("读取图片失败(FileReader)"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
export function OnlyOfficeAiAgentPanel({
|
||||
openFile,
|
||||
}: {
|
||||
openFile: { id: string; title: string; fileUrl: string; mimeType?: string | null };
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [page, setPage] = useState<PanelPage>("chat");
|
||||
|
||||
const [messages, setMessages] = useState<AgentMessage[]>(() => DEFAULT_MESSAGES);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toolLogs, setToolLogs] = useState<ToolLog[]>([]);
|
||||
|
||||
const [networkOn, setNetworkOn] = useState(true);
|
||||
const [toolAuto, setToolAuto] = useState(true);
|
||||
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
|
||||
const [aiModel, setAiModel] = useState<string>("");
|
||||
const [maxSteps, setMaxSteps] = useState<number>(10);
|
||||
|
||||
const [pluginReady, setPluginReady] = useState(false);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const pluginTargetRef = useRef<{ win: Window | null; origin: string }>({ win: null, origin: "*" });
|
||||
const pendingPluginCallsRef = useRef<
|
||||
Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timeoutId: number }>
|
||||
>(new Map());
|
||||
|
||||
const attachments = useMemo<AgentAttachment[]>(
|
||||
() => [
|
||||
{
|
||||
id: openFile.id,
|
||||
title: openFile.title,
|
||||
fileUrl: openFile.fileUrl,
|
||||
mimeType: openFile.mimeType ?? null,
|
||||
},
|
||||
],
|
||||
[openFile.fileUrl, openFile.id, openFile.mimeType, openFile.title],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stepsRaw = window.localStorage.getItem("onlyoffice_ai_max_steps") || "";
|
||||
const providerRaw = (window.localStorage.getItem("onlyoffice_ai_provider") || "").trim();
|
||||
const modelRaw = window.localStorage.getItem("onlyoffice_ai_model") || "";
|
||||
const parsed = Number(stepsRaw);
|
||||
if (providerRaw === "online" || providerRaw === "local") setAiProvider(providerRaw);
|
||||
if (typeof modelRaw === "string") setAiModel(modelRaw);
|
||||
if (Number.isFinite(parsed) && parsed >= 1) setMaxSteps(clamp(Math.floor(parsed), 1, 24));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem("onlyoffice_ai_provider", aiProvider);
|
||||
window.localStorage.setItem("onlyoffice_ai_model", aiModel);
|
||||
window.localStorage.setItem("onlyoffice_ai_max_steps", String(maxSteps));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [aiProvider, aiModel, maxSteps]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (ev: MessageEvent) => {
|
||||
const data = ev.data as unknown;
|
||||
if (!isRecord(data)) return;
|
||||
if (data.channel !== CHANNEL) return;
|
||||
|
||||
const type = String(data.type ?? "").trim();
|
||||
if (type === "ready") {
|
||||
// 记录插件窗口与来源,后续回发消息更稳
|
||||
pluginTargetRef.current = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
win: (ev.source as any) && typeof (ev.source as any).postMessage === "function" ? ((ev.source as any) as Window) : null,
|
||||
origin: String(ev.origin || "*"),
|
||||
};
|
||||
setPluginReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const callId = String(data.callId ?? "").trim();
|
||||
if (!callId) return;
|
||||
const pending = pendingPluginCallsRef.current.get(callId);
|
||||
if (!pending) return;
|
||||
pendingPluginCallsRef.current.delete(callId);
|
||||
window.clearTimeout(pending.timeoutId);
|
||||
|
||||
const ok = Boolean(data.ok);
|
||||
if (ok) {
|
||||
pending.resolve("result" in data ? (data as Record<string, unknown>).result : null);
|
||||
} else {
|
||||
pending.reject(new Error(String((data as Record<string, unknown>).error ?? "插件执行失败")));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, []);
|
||||
|
||||
const callPlugin = async (callId: string, tool: string, args: Record<string, unknown>) => {
|
||||
const target = pluginTargetRef.current;
|
||||
if (!target.win) throw new Error("插件未就绪(未收到 ready),请稍等或刷新文档");
|
||||
|
||||
const payload = { channel: CHANNEL, type: "call", callId, tool, args };
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
pendingPluginCallsRef.current.delete(callId);
|
||||
reject(new Error("插件调用超时"));
|
||||
}, 60_000);
|
||||
|
||||
pendingPluginCallsRef.current.set(callId, { resolve, reject, timeoutId });
|
||||
try {
|
||||
target.win!.postMessage(payload, target.origin || "*");
|
||||
} catch (e) {
|
||||
window.clearTimeout(timeoutId);
|
||||
pendingPluginCallsRef.current.delete(callId);
|
||||
reject(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const postClientToolResult = async ({
|
||||
requestId,
|
||||
callId,
|
||||
ok,
|
||||
result,
|
||||
error,
|
||||
}: {
|
||||
requestId: string;
|
||||
callId: string;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}) => {
|
||||
await fetch("/api/ai-agent/client-tool-result", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requestId, callId, ok, result, error }),
|
||||
});
|
||||
};
|
||||
|
||||
const resolveImageRefToDataUrl = async (imageRef: string) => {
|
||||
const s = String(imageRef ?? "").trim();
|
||||
if (!s) throw new Error("缺少 imageRef");
|
||||
|
||||
// 1) 优先当作附件 id
|
||||
const match = attachments.find((a) => a.id === s) ?? null;
|
||||
const url = match ? match.fileUrl : s;
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`图片下载失败:HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
return await blobToDataUrl(blob);
|
||||
};
|
||||
|
||||
const handleClientToolCall = async (payloadText: string) => {
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = JSON.parse(payloadText || "null");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const obj = isRecord(data) ? data : ({} as Record<string, unknown>);
|
||||
const requestId = String(obj.requestId ?? "").trim();
|
||||
const callId = String(obj.callId ?? "").trim();
|
||||
const tool = String(obj.tool ?? "").trim();
|
||||
const args = isRecord(obj.args) ? (obj.args as Record<string, unknown>) : {};
|
||||
if (!requestId || !callId || !tool) return;
|
||||
|
||||
try {
|
||||
let result: unknown = null;
|
||||
if (tool === "oo_insert_image") {
|
||||
const imageRef = String(args.imageRef ?? "").trim();
|
||||
const src = await resolveImageRefToDataUrl(imageRef);
|
||||
const width = Number(args.width ?? 0);
|
||||
const height = Number(args.height ?? 0);
|
||||
result = await callPlugin(callId, tool, { ...args, src, width, height });
|
||||
} else {
|
||||
result = await callPlugin(callId, tool, args);
|
||||
}
|
||||
await postClientToolResult({ requestId, callId, ok: true, result });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
await postClientToolResult({ requestId, callId, ok: false, error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
if (loading) return;
|
||||
|
||||
const nextMessages: AgentMessage[] = [...messages, { role: "user", content: text }];
|
||||
setMessages(nextMessages);
|
||||
setInput("");
|
||||
setLoading(true);
|
||||
setToolLogs([]);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/ai-agent/run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
maxSteps,
|
||||
scope: "onlyoffice",
|
||||
messages: nextMessages.slice(-20),
|
||||
attachments,
|
||||
toolChoice: {
|
||||
mode: toolAuto ? "auto" : "manual",
|
||||
toolSets: [
|
||||
"toolset.readonly",
|
||||
"toolset.rag_read",
|
||||
"toolset.media_read",
|
||||
"toolset.docs_read",
|
||||
"toolset.onlyoffice_editor",
|
||||
],
|
||||
},
|
||||
options: { searxng: networkOn, ai: { provider: aiProvider, model: aiModel } },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = (await res.json().catch(() => null)) as unknown;
|
||||
const err =
|
||||
typeof j === "object" && j && "error" in j ? String((j as Record<string, unknown>).error ?? "") : "";
|
||||
throw new Error(err || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
await parseSseChunks(res, (event, dataText) => {
|
||||
if (event === "assistant_message") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const t = isRecord(d) && "text" in d ? String(d.text ?? "") : "";
|
||||
if (t) setMessages((prev) => [...prev, { role: "assistant", content: t }]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "client_tool_call") {
|
||||
void handleClientToolCall(dataText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "tool_call") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
|
||||
setToolLogs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "tool_call",
|
||||
id: String(obj.id ?? ""),
|
||||
tool: String(obj.tool ?? ""),
|
||||
args: (isRecord(obj.args) ? obj.args : {}) as Record<string, unknown>,
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "tool_result") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
|
||||
setToolLogs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "tool_result",
|
||||
id: String(obj.id ?? ""),
|
||||
tool: String(obj.tool ?? ""),
|
||||
ok: Boolean(obj.ok),
|
||||
ms: Number(obj.ms ?? 0),
|
||||
result: "result" in obj ? obj.result : null,
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "error") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const msg = isRecord(d) && "message" in d ? String(d.message ?? "") : "";
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: msg || "未知错误" }]);
|
||||
} catch {
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: "未知错误" }]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: msg }]);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canSend = useMemo(() => input.trim().length > 0 && !loading, [input, loading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-4 right-4 z-[60]">
|
||||
<Button
|
||||
className="shadow"
|
||||
onClick={() => {
|
||||
setOpen((v) => !v);
|
||||
if (!open) setPage("chat");
|
||||
}}
|
||||
>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
AI
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className="fixed right-0 top-0 z-[70] h-screen w-[420px] border-l bg-background shadow-xl">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<div className="text-sm font-semibold">OnlyOffice AI</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => setOpen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b px-2 py-2">
|
||||
<Button
|
||||
variant={page === "chat" ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setPage("chat")}
|
||||
>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
对话
|
||||
</Button>
|
||||
<Button
|
||||
variant={page === "tools" ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setPage("tools")}
|
||||
>
|
||||
<Wrench className="mr-2 h-4 w-4" />
|
||||
工具
|
||||
</Button>
|
||||
<Button
|
||||
variant={page === "settings" ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setPage("settings")}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
设置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{page === "tools" ? (
|
||||
<div className="space-y-3 p-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">联网检索</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={networkOn}
|
||||
onChange={(e) => setNetworkOn(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">自动工具</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toolAuto}
|
||||
onChange={(e) => setToolAuto(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded border p-2 text-xs text-muted-foreground">
|
||||
<div>插件状态:{pluginReady ? "已连接" : "未连接(等待 ready)"}</div>
|
||||
<div>说明:oo_* 工具依赖该插件执行“选区读写”。</div>
|
||||
</div>
|
||||
|
||||
<details open className="rounded border p-2">
|
||||
<summary className="cursor-pointer select-none text-sm font-medium">工具日志(可折叠)</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{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 (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
<div className="text-xs text-muted-foreground">tool_call · {l.id}</div>
|
||||
<div className="font-medium">{l.tool}</div>
|
||||
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.args, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
tool_result · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
|
||||
</div>
|
||||
<div className="font-medium">{l.tool}</div>
|
||||
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.result, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{page === "settings" ? (
|
||||
<div className="space-y-3 p-3 text-sm">
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">最大步数</span>
|
||||
<input
|
||||
className="w-[96px] rounded border px-2 py-1 text-xs"
|
||||
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>
|
||||
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">AI 提供方</span>
|
||||
<select
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
value={aiProvider}
|
||||
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="online">在线</option>
|
||||
<option value="local">本地</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">模型</span>
|
||||
<select
|
||||
className="w-[220px] rounded border px-2 py-1 text-xs"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading || aiProvider !== "online"}
|
||||
>
|
||||
{ONLINE_MODELS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m || "默认"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{page === "chat" ? (
|
||||
<div className="flex h-[calc(100vh-96px)] flex-col">
|
||||
<ScrollArea className="flex-1">
|
||||
<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 className="border-t p-3">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="输入你的需求(Enter 发送,Shift+Enter 换行)"
|
||||
className="min-h-[72px] flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canSend) void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button disabled={!canSend} onClick={() => void send()}>
|
||||
发送
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={!loading} onClick={stop}>
|
||||
停止
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,10 @@ interface FileTreeProps {
|
||||
onRowContextMenu: (row: FileTreeRow, event: React.MouseEvent) => void;
|
||||
onRowDragStart?: (row: FileTreeRow, event: React.DragEvent) => void;
|
||||
onToggleExpand: (docId: string) => void;
|
||||
onToggleAssetFolderExpand?: (assetId: string) => void;
|
||||
onCreateChild: (parentId: string | null) => void;
|
||||
onBlankMouseDown?: (event: React.MouseEvent) => void;
|
||||
onDropFiles?: (docId: string, files: FileList) => void;
|
||||
onDropFiles?: (docId: string, files: FileList, targetRow?: FileTreeRow) => void;
|
||||
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
|
||||
}
|
||||
|
||||
@@ -34,6 +35,7 @@ export function FileTree({
|
||||
onRowContextMenu,
|
||||
onRowDragStart,
|
||||
onToggleExpand,
|
||||
onToggleAssetFolderExpand,
|
||||
onCreateChild,
|
||||
onBlankMouseDown,
|
||||
onDropFiles,
|
||||
@@ -53,8 +55,8 @@ export function FileTree({
|
||||
// 标记为 drop feedback(包含它的所有可见子节点)。我们用“扁平化 rows +
|
||||
// depth”来近似计算该范围。
|
||||
let endIndex = startIndex + 1;
|
||||
if (target.kind === "doc" && target.isExpanded && target.hasChildren) {
|
||||
while (endIndex < rows.length && rows[endIndex].depth > targetDepth) {
|
||||
if ((target.kind === "doc" || target.kind === "asset-folder") && target.isExpanded && target.hasChildren) {
|
||||
while (endIndex < rows.length && rows[endIndex].depth > targetDepth) {
|
||||
endIndex += 1;
|
||||
}
|
||||
}
|
||||
@@ -91,7 +93,7 @@ export function FileTree({
|
||||
(row) => row.kind === "doc" && row.docId === activeId,
|
||||
);
|
||||
const firstDocRow = rows.find((row) => row.kind === "doc");
|
||||
const targetDocId = activeDocRow?.docId ?? firstDocRow?.docId ?? "";
|
||||
const targetDocId = activeDocRow?.docId ?? firstDocRow?.docId ?? "";
|
||||
onDropFiles(targetDocId, files);
|
||||
}
|
||||
}}
|
||||
@@ -210,7 +212,7 @@ export function FileTree({
|
||||
if (onDropFiles && event.dataTransfer.files?.length) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onDropFiles(row.docId, event.dataTransfer.files);
|
||||
onDropFiles(row.docId, event.dataTransfer.files, row);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
@@ -258,6 +260,31 @@ export function FileTree({
|
||||
<FileText className="h-4 w-4 text-gray-500" />
|
||||
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
|
||||
</>
|
||||
) : row.kind === "asset-folder" ? (
|
||||
<>
|
||||
{row.hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="展开或折叠"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleAssetFolderExpand?.(row.asset.id);
|
||||
}}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform",
|
||||
row.isExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="h-5 w-5" />
|
||||
)}
|
||||
<Folder className="h-4 w-4 text-[#2563eb]" />
|
||||
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="h-4 w-4" />
|
||||
|
||||
@@ -6,10 +6,8 @@ import { useRouter, useSelectedLayoutSegments } from "next/navigation";
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
ArrowUpRight,
|
||||
Bell,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Dice5,
|
||||
Edit3,
|
||||
GitMerge,
|
||||
Globe,
|
||||
@@ -19,7 +17,6 @@ import {
|
||||
Link as LinkIcon,
|
||||
MoreHorizontal,
|
||||
PanelRightOpen,
|
||||
PenSquare,
|
||||
Plus,
|
||||
Search as SearchIcon,
|
||||
Share2,
|
||||
@@ -66,10 +63,10 @@ const TOP_BUTTONS = [
|
||||
{ id: "graph", icon: Share2, label: "关系图" },
|
||||
{ id: "import", icon: Upload, label: "导入" },
|
||||
{ id: "members", icon: Users, label: "成员" },
|
||||
{ id: "inbox", icon: Bell, label: "消息箱" },
|
||||
{ id: "quick-note", icon: PenSquare, label: "今日速记" },
|
||||
{ id: "lucky", icon: Dice5, label: "手气不错" },
|
||||
{ id: "more", icon: MoreHorizontal, label: "更多" },
|
||||
{ id: "starred", icon: Star, label: "星标置顶" },
|
||||
{ id: "public", icon: Globe, label: "公共页面" },
|
||||
{ id: "shared", icon: Shield, label: "共享页面" },
|
||||
{ id: "templates", icon: LayoutGrid, label: "模板中心" },
|
||||
] as const;
|
||||
|
||||
const SECTION_ICONS: Record<Exclude<SidebarSectionId, "private">, React.ReactNode> = {
|
||||
@@ -79,6 +76,29 @@ const SECTION_ICONS: Record<Exclude<SidebarSectionId, "private">, React.ReactNod
|
||||
templates: <LayoutGrid className="h-4 w-4 text-[#34d399]" />,
|
||||
};
|
||||
|
||||
const normalizeStoragePath = (storagePath: string): string => storagePath.replaceAll("\\", "/");
|
||||
|
||||
const extractMindmapIdFromStoragePath = (
|
||||
storagePath: string | null | undefined,
|
||||
): string | null => {
|
||||
if (!storagePath) return null;
|
||||
const normalized = normalizeStoragePath(storagePath);
|
||||
|
||||
const prefix = "mindmaps/";
|
||||
if (normalized.startsWith(prefix)) {
|
||||
const rest = normalized.slice(prefix.length);
|
||||
const id = rest.split("/")[0];
|
||||
return id ? id : null;
|
||||
}
|
||||
|
||||
const marker = "/mindmaps/";
|
||||
const idx = normalized.indexOf(marker);
|
||||
if (idx === -1) return null;
|
||||
const rest = normalized.slice(idx + marker.length);
|
||||
const id = rest.split("/")[0];
|
||||
return id ? id : null;
|
||||
};
|
||||
|
||||
interface SidebarProps {
|
||||
initialData: SidebarInitialData;
|
||||
}
|
||||
@@ -90,8 +110,11 @@ interface ContextMenuState {
|
||||
}
|
||||
|
||||
export function Sidebar({ initialData }: SidebarProps) {
|
||||
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } =
|
||||
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, setSectionCollapsed, trashConfirm, setTrashConfirm } =
|
||||
useSidebarStore();
|
||||
const sectionsTrayOpen = useSidebarStore((state) => state.sectionsTrayOpen);
|
||||
const toggleSectionsTray = useSidebarStore((state) => state.toggleSectionsTray);
|
||||
const setSectionsTrayOpen = useSidebarStore((state) => state.setSectionsTrayOpen);
|
||||
const viewMode = useSidebarStore((state) => state.viewMode);
|
||||
const setViewMode = useSidebarStore((state) => state.setViewMode);
|
||||
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
|
||||
@@ -274,9 +297,67 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword));
|
||||
}, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, trashSearch]);
|
||||
|
||||
const mindmapChildrenSnapshot = useMemo(() => {
|
||||
const mapping = sidebarData.mindmapAssetChildren ?? {};
|
||||
const mindmapDocById = new Map<string, string>(
|
||||
(mindmapAssets ?? [])
|
||||
.filter((asset) => asset.asset_type === "mindmap")
|
||||
.map((asset) => [asset.id, asset.document_id]),
|
||||
);
|
||||
const mindmapIds = new Set(mindmapDocById.keys());
|
||||
|
||||
const mediaById = new Map<string, MediaAsset>(
|
||||
(mediaAssets ?? []).map((asset) => [asset.id, asset]),
|
||||
);
|
||||
|
||||
const childAssetsByMindmapId: Record<string, MediaAsset[]> = {};
|
||||
const childIds = new Set<string>();
|
||||
const assigned = new Set<string>();
|
||||
|
||||
// 物理目录:storage_path 归属到 mindmaps/<mindmapId>/ 的附件,作为导图文件夹内容
|
||||
(mediaAssets ?? []).forEach((asset) => {
|
||||
const sp = asset.storage_path;
|
||||
if (!sp || typeof sp !== "string") return;
|
||||
const mindmapId = extractMindmapIdFromStoragePath(sp);
|
||||
if (!mindmapId) return;
|
||||
if (!mindmapIds.has(mindmapId)) return;
|
||||
const docId = mindmapDocById.get(mindmapId);
|
||||
if (docId && asset.document_id !== docId) return;
|
||||
if (assigned.has(asset.id)) return;
|
||||
assigned.add(asset.id);
|
||||
childIds.add(asset.id);
|
||||
if (!childAssetsByMindmapId[mindmapId]) childAssetsByMindmapId[mindmapId] = [];
|
||||
childAssetsByMindmapId[mindmapId].push(asset);
|
||||
});
|
||||
|
||||
// 引用图片:从 mindmap JSON 解析出的 assetIds,也放到导图文件夹下(去重)
|
||||
(mindmapAssets ?? []).forEach((mindmapAsset) => {
|
||||
const ids = mapping[mindmapAsset.id] ?? [];
|
||||
if (!Array.isArray(ids) || ids.length === 0) return;
|
||||
ids.forEach((id) => {
|
||||
const asset = mediaById.get(id);
|
||||
if (!asset) return;
|
||||
if (asset.document_id !== mindmapAsset.document_id) return;
|
||||
if (assigned.has(asset.id)) return;
|
||||
assigned.add(asset.id);
|
||||
childIds.add(asset.id);
|
||||
if (!childAssetsByMindmapId[mindmapAsset.id]) childAssetsByMindmapId[mindmapAsset.id] = [];
|
||||
childAssetsByMindmapId[mindmapAsset.id].push(asset);
|
||||
});
|
||||
});
|
||||
|
||||
return { childAssetsByMindmapId, childIds };
|
||||
}, [mediaAssets, mindmapAssets, sidebarData.mindmapAssetChildren]);
|
||||
|
||||
const assetsByDoc = useMemo(() => {
|
||||
const map: Record<string, MediaAsset[]> = {};
|
||||
const assets = [...(mediaAssets ?? []), ...(mindmapAssets ?? []), ...(tableAssets ?? [])];
|
||||
const assets = [
|
||||
...((mediaAssets ?? []).filter(
|
||||
(asset) => !mindmapChildrenSnapshot.childIds.has(asset.id),
|
||||
)),
|
||||
...(mindmapAssets ?? []),
|
||||
...(tableAssets ?? []),
|
||||
];
|
||||
|
||||
assets.forEach((asset) => {
|
||||
if (!map[asset.document_id]) {
|
||||
@@ -288,7 +369,9 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [mediaAssets, mindmapAssets, tableAssets]);
|
||||
}, [mediaAssets, mindmapAssets, tableAssets, mindmapChildrenSnapshot.childIds]);
|
||||
|
||||
const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const fileTreeRows = useMemo(
|
||||
() =>
|
||||
@@ -296,8 +379,10 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
nodes: filteredPrivateTree,
|
||||
expanded,
|
||||
assetsByDoc,
|
||||
assetChildrenByAssetId: mindmapChildrenSnapshot.childAssetsByMindmapId,
|
||||
expandedAssetFolderIds: expandedAssetFolders,
|
||||
}),
|
||||
[assetsByDoc, expanded, filteredPrivateTree],
|
||||
[assetsByDoc, expanded, expandedAssetFolders, filteredPrivateTree, mindmapChildrenSnapshot.childAssetsByMindmapId],
|
||||
);
|
||||
|
||||
const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]);
|
||||
@@ -406,6 +491,29 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const knownMindmapFolderIdsRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
setExpandedAssetFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
(mindmapAssets ?? []).forEach((asset) => {
|
||||
if (asset.asset_type !== "mindmap") return;
|
||||
if (knownMindmapFolderIdsRef.current.has(asset.id)) return;
|
||||
knownMindmapFolderIdsRef.current.add(asset.id);
|
||||
next.add(asset.id);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, [mindmapAssets]);
|
||||
|
||||
const toggleAssetFolderExpand = useCallback((assetId: string) => {
|
||||
setExpandedAssetFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(assetId)) next.delete(assetId);
|
||||
else next.add(assetId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOpenAsset = useCallback((asset: MediaAsset) => {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
router.push(`/mindmap/${asset.document_id}/${asset.id}`);
|
||||
@@ -480,7 +588,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
|
||||
const handleFileTreeRowDoubleClick = useCallback(
|
||||
(row: FileTreeRow, _event?: React.MouseEvent) => {
|
||||
if (row.kind === "asset") {
|
||||
if (row.kind === "asset" || row.kind === "asset-folder") {
|
||||
handleOpenAsset(row.asset);
|
||||
return;
|
||||
}
|
||||
@@ -497,8 +605,8 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
reduceFileTreeSelection(prev, { type: "contextmenu", rowId: row.rowId }),
|
||||
);
|
||||
|
||||
if (row.kind === "asset") {
|
||||
setAssetMenu({ asset: row.asset, x: event.clientX, y: event.clientY });
|
||||
if (row.kind === "asset" || row.kind === "asset-folder") {
|
||||
setAssetMenu({ asset: row.asset, x: event.clientX, y: event.clientY });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1052,11 +1160,26 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
);
|
||||
|
||||
const handleFileTreeDropFiles = useCallback(
|
||||
(docId: string, files: FileList) => {
|
||||
(docId: string, files: FileList, targetRow?: FileTreeRow) => {
|
||||
void (async () => {
|
||||
const droppedFiles = Array.from(files ?? []);
|
||||
if (droppedFiles.length === 0) return;
|
||||
|
||||
const targetMindmapId = (() => {
|
||||
if (!targetRow) return null;
|
||||
if (targetRow.kind === "asset-folder" && targetRow.asset.asset_type === "mindmap") {
|
||||
return targetRow.asset.id;
|
||||
}
|
||||
if (targetRow.kind === "asset") {
|
||||
return extractMindmapIdFromStoragePath(targetRow.asset.storage_path);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (targetMindmapId) {
|
||||
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
|
||||
}
|
||||
|
||||
const inferredTargetDocId =
|
||||
docId ||
|
||||
inferPasteTargetDocId({
|
||||
@@ -1085,6 +1208,9 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
form.append("file", file);
|
||||
form.append("workspaceId", workspaceId);
|
||||
form.append("documentId", inferredTargetDocId);
|
||||
if (targetMindmapId) {
|
||||
form.append("mindmapId", targetMindmapId);
|
||||
}
|
||||
const resp = await fetch("/api/media/upload", { method: "POST", body: form });
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
@@ -1095,7 +1221,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
if (payload.asset?.id) {
|
||||
emitAssetsChanged(inferredTargetDocId, payload.asset);
|
||||
// 拖拽文件进文件树:如果落点就是当前打开的页面,则把文件也插入到主编辑区
|
||||
if (inferredTargetDocId === activeId) {
|
||||
if (inferredTargetDocId === activeId && !targetMindmapId) {
|
||||
editorBridge?.insertMediaAsset?.(payload.asset);
|
||||
}
|
||||
} else {
|
||||
@@ -1140,6 +1266,22 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetMindmapId = (() => {
|
||||
if (args.targetRow.kind === "asset-folder" && args.targetRow.asset.asset_type === "mindmap") {
|
||||
return args.targetRow.asset.id;
|
||||
}
|
||||
if (args.targetRow.kind === "asset") {
|
||||
return extractMindmapIdFromStoragePath(args.targetRow.asset.storage_path);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const targetSubPath = targetMindmapId ? `mindmaps/${targetMindmapId}` : undefined;
|
||||
|
||||
if (targetMindmapId) {
|
||||
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
|
||||
}
|
||||
|
||||
const uniqueRowIds: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
args.rowIds.forEach((id) => {
|
||||
@@ -1188,6 +1330,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
action: "copy",
|
||||
assetIds: copyableAssetIds,
|
||||
targetDocumentId: targetDocId,
|
||||
targetSubPath,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
@@ -1248,6 +1391,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
action: "move",
|
||||
assetIds: copyableAssetIds,
|
||||
targetDocumentId: targetDocId,
|
||||
targetSubPath,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
@@ -1533,9 +1677,20 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
openSearchPalette();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
buttonId === "starred" ||
|
||||
buttonId === "public" ||
|
||||
buttonId === "shared" ||
|
||||
buttonId === "templates"
|
||||
) {
|
||||
setViewMode("section");
|
||||
setSectionsTrayOpen(true);
|
||||
setSectionCollapsed(buttonId, false);
|
||||
return;
|
||||
}
|
||||
window.alert("该功能即将上线,敬请期待");
|
||||
},
|
||||
[openSearchPalette],
|
||||
[openSearchPalette, setSectionCollapsed, setSectionsTrayOpen, setViewMode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1666,34 +1821,55 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
|
||||
{viewMode === "section" ? (
|
||||
<>
|
||||
<SectionList
|
||||
label="星标置顶"
|
||||
icon={SECTION_ICONS.starred}
|
||||
nodes={starredNodes}
|
||||
collapsed={collapsedSections.starred}
|
||||
onToggle={() => toggleSection("starred")}
|
||||
/>
|
||||
<SectionList
|
||||
label="公共页面"
|
||||
icon={SECTION_ICONS.public}
|
||||
nodes={publicNodes}
|
||||
collapsed={collapsedSections.public}
|
||||
onToggle={() => toggleSection("public")}
|
||||
/>
|
||||
<SectionList
|
||||
label="共享页面"
|
||||
icon={SECTION_ICONS.shared}
|
||||
nodes={sharedNodes}
|
||||
collapsed={collapsedSections.shared}
|
||||
onToggle={() => toggleSection("shared")}
|
||||
/>
|
||||
<SectionList
|
||||
label="模板中心"
|
||||
icon={SECTION_ICONS.templates}
|
||||
nodes={templateNodes}
|
||||
collapsed={collapsedSections.templates}
|
||||
onToggle={() => toggleSection("templates")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between border-b border-[#f1f1f1] px-4 py-3 text-sm font-medium text-gray-600 hover:bg-gray-50"
|
||||
onClick={toggleSectionsTray}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4 text-[#f5a623]" />
|
||||
星标 / 公共 / 共享 / 模板
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 text-gray-400 transition-transform",
|
||||
sectionsTrayOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{sectionsTrayOpen ? (
|
||||
<>
|
||||
<SectionList
|
||||
label="星标置顶"
|
||||
icon={SECTION_ICONS.starred}
|
||||
nodes={starredNodes}
|
||||
collapsed={collapsedSections.starred}
|
||||
onToggle={() => toggleSection("starred")}
|
||||
/>
|
||||
<SectionList
|
||||
label="公共页面"
|
||||
icon={SECTION_ICONS.public}
|
||||
nodes={publicNodes}
|
||||
collapsed={collapsedSections.public}
|
||||
onToggle={() => toggleSection("public")}
|
||||
/>
|
||||
<SectionList
|
||||
label="共享页面"
|
||||
icon={SECTION_ICONS.shared}
|
||||
nodes={sharedNodes}
|
||||
collapsed={collapsedSections.shared}
|
||||
onToggle={() => toggleSection("shared")}
|
||||
/>
|
||||
<SectionList
|
||||
label="模板中心"
|
||||
icon={SECTION_ICONS.templates}
|
||||
nodes={templateNodes}
|
||||
collapsed={collapsedSections.templates}
|
||||
onToggle={() => toggleSection("templates")}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
|
||||
<button
|
||||
@@ -1740,6 +1916,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
onRowContextMenu={handleFileTreeRowContextMenu}
|
||||
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
|
||||
onToggleExpand={toggleExpand}
|
||||
onToggleAssetFolderExpand={toggleAssetFolderExpand}
|
||||
onCreateChild={handleCreate}
|
||||
onBlankMouseDown={handleFileTreeBlankMouseDown}
|
||||
onDropFiles={handleFileTreeDropFiles}
|
||||
|
||||
@@ -32,6 +32,11 @@ export interface SidebarInitialData {
|
||||
* 思维导图文件列表(用于文件树显示,多导图时一页可有多个)
|
||||
*/
|
||||
mindmapAssets?: MediaAsset[];
|
||||
/**
|
||||
* 思维导图(mindmapAssets)引用的图片附件:mindmapId -> media_asset ids
|
||||
* 用于在文件树中把“导图图片”显示在对应 mindmap 文件下一级。
|
||||
*/
|
||||
mindmapAssetChildren?: Record<string, string[]>;
|
||||
/**
|
||||
* 可选的媒体资源列表(旧字段向后兼容)
|
||||
*/
|
||||
|
||||
@@ -48,9 +48,11 @@ function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
@@ -72,10 +74,12 @@ function SheetContent({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{showCloseButton ? (
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
) : null}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user