Files
mnote/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx
T
lix-2026 96e03645f7 chore: 收口 review 执行清单与 runtime 验证
- 补齐 design/10-review 执行清单、验收标准与相关设计治理记录

- 迁移已完成的 tree、mindmap、runtime fallback、AI kernel 等设计和缺陷条目

- 推进 Rust Web runtime、tree/sidebar、page aggregate、mindmap 与 OnlyOffice 路由侧验证支撑

- 增加 task177-task180 smoke/audit 脚本及前端相关测试覆盖
2026-05-14 05:52:08 +08:00

1846 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useMemo, useState } from "react";
// 说明:legacy/reference/compat 组件,仅作为 Phase 6 UI shell 迁移素材。
// 3000 文档页默认 mindmap 主链是 leptos-tiptap NodeView + Leptos/Rust shell。
import Image from "next/image";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Toggle } from "@/components/ui/toggle";
import { Bold, Italic, Underline, Type, Strikethrough, Palette, X, Network } from "lucide-react";
import {
fontFamilyList,
fontSizeList,
lineHeightList,
borderDasharrayList,
borderRadiusList,
borderWidthList,
lineWidthList,
shapeList,
lineStyleList,
themeList,
layoutList,
outlineDepthOptions,
backgroundRepeatList,
backgroundPositionList,
backgroundSizeList,
} from "./mindmapOptions";
import iconConfig from "./mindmapIconConfig";
import imageConfig from "./mindmapImageConfig";
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
import type { MindMapNode } from "./mindmapTypes";
import { MindmapAiAgentPanel } from "./MindmapAiAgentPanel";
// 避免 SSR 阶段触发浏览器依赖,改为运行时动态引入
const loadIconModules = async () => {
const { nodeIconList } = await import("simple-mind-map/src/svg/icons.js");
const { mergerIconList } = await import("simple-mind-map/src/utils/index.js");
return { nodeIconList, mergerIconList };
};
type SidebarProps = {
documentId: string;
mindmapId: string;
mindmap: any;
activeNodes: MindMapNode[];
activeTab: SidebarPanel | null;
onClose: () => void;
};
// 旧 Next route 已退场;补完节点能力保留在 AI Agent 的 mindmap_expand_node 工具中。
const MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED = false;
const ColorInput = ({
value,
onChange,
label,
}: {
value: string;
onChange: (val: string) => void;
label?: string;
}) => (
<div className="flex items-center gap-2">
{label && <span className="text-xs text-gray-500">{label}</span>}
<div className="relative h-6 w-full flex-1 overflow-hidden rounded border border-gray-200">
<input
type="color"
value={value || "#000000"}
onChange={(e) => onChange(e.target.value)}
className="absolute inset-[-50%] h-[200%] w-[200%] cursor-pointer border-0 p-0"
/>
</div>
</div>
);
const NativeSelect = ({
value,
onChange,
options,
placeholder,
disabled,
}: {
value: string | number;
onChange: (val: string) => void;
options: { label: string | number; value: string | number }[];
placeholder?: string;
disabled?: boolean;
}) => (
<div className="relative">
<select
disabled={disabled}
value={value}
onChange={(e) => onChange(e.target.value)}
className="flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 appearance-none"
>
{placeholder && <option value="" disabled>{placeholder}</option>}
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
const StylePanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
const [style, setStyle] = useState<Record<string, any>>({});
useEffect(() => {
if (activeNodes.length > 0) {
const node = activeNodes[0];
const newStyle: Record<string, any> = {};
[
"fontFamily", "fontSize", "lineHeight", "color", "fontWeight", "fontStyle",
"textDecoration", "borderWidth", "borderColor", "fillColor", "shape",
"lineColor", "lineWidth", "lineDasharray", "borderRadius", "borderDasharray"
].forEach((prop) => {
newStyle[prop] = node.getStyle(prop, false);
});
// 异步更新,避免同步 setState 抛 lint
Promise.resolve().then(() => setStyle(newStyle));
}
}, [activeNodes]);
const updateStyle = (prop: string, value: any) => {
setStyle((prev) => ({ ...prev, [prop]: value }));
activeNodes.forEach((node) => {
node.setStyle?.(prop, value);
});
};
if (activeNodes.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center p-4 text-center text-gray-400">
<Type className="mb-2 h-10 w-10 opacity-20" />
<p className="text-sm">请选择一个节点以编辑样式</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="space-y-3">
<Label className="text-xs font-semibold text-gray-500">文字</Label>
<NativeSelect
value={style.fontFamily || fontFamilyList[0]?.value}
options={fontFamilyList.map((f) => ({ label: f.name, value: f.value }))}
onChange={(v) => updateStyle("fontFamily", v)}
/>
<div className="grid grid-cols-2 gap-2">
<NativeSelect
value={style.fontSize || 14}
options={fontSizeList.map((s) => ({ label: `${s}px`, value: s }))}
onChange={(v) => updateStyle("fontSize", Number(v))}
/>
<NativeSelect
value={style.lineHeight || 1.5}
options={lineHeightList.map((h) => ({ label: h, value: h }))}
onChange={(v) => updateStyle("lineHeight", Number(v))}
/>
</div>
<div className="flex items-center gap-2">
<Toggle
size="sm"
pressed={style.fontWeight === "bold"}
onPressedChange={(pressed) => updateStyle("fontWeight", pressed ? "bold" : "normal")}
>
<Bold className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={style.fontStyle === "italic"}
onPressedChange={(pressed) => updateStyle("fontStyle", pressed ? "italic" : "normal")}
>
<Italic className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={style.textDecoration === "underline"}
onPressedChange={(pressed) => updateStyle("textDecoration", pressed ? "underline" : "none")}
>
<Underline className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={style.textDecoration === "line-through"}
onPressedChange={(pressed) => updateStyle("textDecoration", pressed ? "line-through" : "none")}
>
<Strikethrough className="h-4 w-4" />
</Toggle>
</div>
<ColorInput
label="颜色"
value={style.color}
onChange={(v) => updateStyle("color", v)}
/>
</div>
<div className="space-y-3">
<Label className="text-xs font-semibold text-gray-500">边框 & 背景</Label>
<div className="grid grid-cols-2 gap-2">
<NativeSelect
value={style.shape || "rectangle"}
options={shapeList.map(s => ({label: s.name, value: s.value}))}
onChange={(v) => updateStyle("shape", v)}
/>
<NativeSelect
value={style.borderDasharray || "none"}
options={borderDasharrayList.map(s => ({label: s.name, value: s.value}))}
onChange={(v) => updateStyle("borderDasharray", v)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<NativeSelect
value={Number.isFinite(style.borderWidth) ? style.borderWidth : (style.borderWidth ?? 0)}
options={borderWidthList.map((n) => ({ label: `${n}px`, value: n }))}
onChange={(v) => updateStyle("borderWidth", Number(v))}
/>
<NativeSelect
value={Number.isFinite(style.borderRadius) ? style.borderRadius : (style.borderRadius ?? 0)}
options={borderRadiusList.map((n) => ({ label: `${n}px`, value: n }))}
onChange={(v) => updateStyle("borderRadius", Number(v))}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<ColorInput
label="边框"
value={style.borderColor}
onChange={(v) => updateStyle("borderColor", v)}
/>
<ColorInput
label="填充"
value={style.fillColor}
onChange={(v) => updateStyle("fillColor", v)}
/>
</div>
</div>
<div className="space-y-3">
<Label className="text-xs font-semibold text-gray-500">连线</Label>
<div className="grid grid-cols-2 gap-2">
<NativeSelect
value={style.lineDasharray || "none"}
options={borderDasharrayList.map(s => ({label: s.name, value: s.value}))}
onChange={(v) => updateStyle("lineDasharray", v)}
/>
<NativeSelect
value={Number.isFinite(style.lineWidth) ? style.lineWidth : (style.lineWidth ?? 0)}
options={lineWidthList.map((n) => ({ label: `${n}px`, value: n }))}
onChange={(v) => updateStyle("lineWidth", Number(v))}
/>
</div>
<ColorInput
label="连线颜色"
value={style.lineColor}
onChange={(v) => updateStyle("lineColor", v)}
/>
</div>
</div>
);
};
const BaseStylePanel = ({ mindmap }: { mindmap: any }) => {
const [style, setStyle] = useState<Record<string, any>>({});
useEffect(() => {
if (!mindmap) return;
const initStyle = () => {
const newStyle: Record<string, any> = {};
[
"backgroundColor", "lineColor", "lineWidth", "lineStyle",
"paddingX", "paddingY",
"backgroundImage", "backgroundRepeat", "backgroundPosition", "backgroundSize",
].forEach(key => {
newStyle[key] = mindmap.getThemeConfig(key);
});
setStyle(newStyle);
};
initStyle();
}, [mindmap]);
const updateThemeConfig = (key: string, value: any) => {
if (!mindmap) return;
setStyle(prev => ({ ...prev, [key]: value }));
mindmap.setThemeConfig({ [key]: value });
};
if (!mindmap) return null;
return (
<div className="space-y-6">
<div className="space-y-3">
<Label className="text-xs font-semibold text-gray-500">背景</Label>
<ColorInput
label="背景颜色"
value={style.backgroundColor}
onChange={(v) => updateThemeConfig("backgroundColor", v)}
/>
<div className="space-y-2">
<Label className="text-[11px] text-gray-500">背景图片</Label>
<Input
value={style.backgroundImage || ""}
onChange={(e) => updateThemeConfig("backgroundImage", e.target.value || "")}
placeholder="可粘贴图片地址"
/>
<div className="grid grid-cols-3 gap-2 text-[12px] text-gray-600">
<NativeSelect
value={style.backgroundRepeat || "no-repeat"}
options={backgroundRepeatList.map((i) => ({ label: i.name, value: i.value }))}
onChange={(v) => updateThemeConfig("backgroundRepeat", v)}
/>
<NativeSelect
value={style.backgroundPosition || "center center"}
options={backgroundPositionList.map((i) => ({ label: i.name, value: i.value }))}
onChange={(v) => updateThemeConfig("backgroundPosition", v)}
/>
<NativeSelect
value={style.backgroundSize || "cover"}
options={backgroundSizeList.map((i) => ({ label: i.name, value: i.value }))}
onChange={(v) => updateThemeConfig("backgroundSize", v)}
/>
</div>
</div>
</div>
<div className="space-y-3">
<Label className="text-xs font-semibold text-gray-500">连线 (默认)</Label>
<div className="grid grid-cols-2 gap-2">
<NativeSelect
value={style.lineStyle || "curve"}
options={lineStyleList.map(s => ({ label: s.name, value: s.value }))}
onChange={(v) => updateThemeConfig("lineStyle", v)}
/>
<NativeSelect
value={Number.isFinite(style.lineWidth) ? style.lineWidth : (style.lineWidth ?? 0)}
options={lineWidthList.map((n) => ({ label: `${n}px`, value: n }))}
onChange={(v) => updateThemeConfig("lineWidth", Number(v))}
/>
</div>
<ColorInput
label="连线颜色"
value={style.lineColor}
onChange={(v) => updateThemeConfig("lineColor", v)}
/>
</div>
<div className="space-y-3">
<Label className="text-xs font-semibold text-gray-500">节点内边距 (默认)</Label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 whitespace-nowrap">水平</span>
<Input
type="number"
className="h-8"
min={0}
value={parseInt(style.paddingX) || 0}
onChange={e => updateThemeConfig("paddingX", Number(e.target.value))}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 whitespace-nowrap">垂直</span>
<Input
type="number"
className="h-8"
min={0}
value={parseInt(style.paddingY) || 0}
onChange={e => updateThemeConfig("paddingY", Number(e.target.value))}
/>
</div>
</div>
</div>
</div>
);
};
const ThemePanel = ({ mindmap }: { mindmap: any }) => {
const changeTheme = (theme: string) => {
if (!mindmap) return;
mindmap.setTheme(theme);
};
if (!mindmap) return null;
return (
<div className="grid grid-cols-2 gap-3">
{themeList.map(t => (
<button
key={t.value}
onClick={() => changeTheme(t.value)}
className="flex flex-col items-center justify-center rounded border border-gray-100 bg-gray-50 p-3 hover:border-blue-500 hover:bg-blue-50"
>
<Palette className="mb-2 h-6 w-6 text-gray-400" />
<span className="text-xs text-gray-600">{t.name}</span>
</button>
))}
</div>
)
}
const StructurePanel = ({ mindmap }: { mindmap: any }) => {
const changeLayout = (layout: string) => {
if (!mindmap) return;
mindmap.setLayout(layout);
};
if (!mindmap) return null;
return (
<div className="grid grid-cols-2 gap-3">
{layoutList.map(l => (
<button
key={l.value}
onClick={() => changeLayout(l.value)}
className="flex flex-col items-center justify-center rounded border border-gray-100 bg-gray-50 p-3 hover:border-blue-500 hover:bg-blue-50"
>
<Network className="mb-2 h-6 w-6 text-gray-400" />
<span className="text-xs text-gray-600">{l.name}</span>
</button>
))}
</div>
)
}
const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
const [activeSubTab, setActiveSubTab] = useState<"icon" | "sticker">("icon");
const [{ iconGroups, loading }, setIconGroups] = useState<{
iconGroups: any[];
loading: boolean;
}>({ iconGroups: [], loading: true });
useEffect(() => {
let mounted = true;
(async () => {
const { nodeIconList, mergerIconList } = await loadIconModules();
if (!mounted) return;
setIconGroups({
iconGroups: mergerIconList([
...nodeIconList,
...(iconConfig as unknown as any[]),
]),
loading: false,
});
})();
return () => {
mounted = false;
};
}, []);
const [stickerGroups] = useState(imageConfig);
const addIcon = (type: string, name: string) => {
const key = `${type}_${name}`;
activeNodes.forEach((node) => {
const rawIcons = node.getData("icon");
const icons = Array.isArray(rawIcons) ? (rawIcons as string[]) : [];
const newIcons = icons.filter((i) => !i.startsWith(`${type}_`));
newIcons.push(key);
node.setIcon?.(newIcons);
});
};
const removeIcon = (type: string) => {
activeNodes.forEach((node) => {
const rawIcons = node.getData("icon");
const icons = Array.isArray(rawIcons) ? (rawIcons as string[]) : [];
const newIcons = icons.filter((i) => !i.startsWith(`${type}_`));
node.setIcon?.(newIcons);
});
};
const setSticker = (img: { url: string; width?: number; height?: number }) => {
activeNodes.forEach((node) => {
// simple-mind-map 支持 setImage 接收对象,包含 url/width/height
node.setImage?.({
url: img.url,
width: img.width || 100,
height: img.height || 100,
});
});
};
const clearSticker = () => {
activeNodes.forEach((node) => {
// 传入 null 以清除贴纸
node.setImage?.(null);
});
};
if (loading) return <div className="p-4 text-center text-gray-400">图标加载中…</div>;
if (iconGroups.length === 0) return <div className="p-4 text-center text-gray-400">暂无图标</div>;
if (activeNodes.length === 0) return <div className="p-4 text-center text-gray-400">请选择节点</div>;
return (
<div className="space-y-4 py-2">
<div className="flex gap-2 px-1">
<button
className={`flex-1 rounded border px-2 py-1 text-xs ${activeSubTab === "icon" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
onClick={() => setActiveSubTab("icon")}
>
图标
</button>
<button
className={`flex-1 rounded border px-2 py-1 text-xs ${activeSubTab === "sticker" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
onClick={() => setActiveSubTab("sticker")}
>
贴纸
</button>
</div>
{activeSubTab === "icon" && iconGroups.map((group) => (
<div key={group.type}>
<div className="mb-2 flex items-center justify-between">
<Label className="text-xs font-bold text-gray-600">{group.name}</Label>
<button onClick={() => removeIcon(group.type)} className="text-[10px] text-blue-500 hover:underline">
清除
</button>
</div>
<div className="flex flex-wrap gap-2">
{group.list.map((item: any) => (
<button
key={`${group.type}-${item.name}`}
onClick={() => addIcon(group.type, item.name)}
className="flex h-9 w-9 items-center justify-center rounded border border-gray-200 bg-white text-xs hover:border-blue-400 hover:shadow-sm overflow-hidden"
>
{item.icon ? (
typeof item.icon === "string" && item.icon.trim().startsWith("<svg") ? (
<span
className="inline-flex h-6 w-6 items-center justify-center overflow-hidden"
dangerouslySetInnerHTML={{ __html: item.icon }}
/>
) : (
<Image src={item.icon} alt={item.name} width={24} height={24} className="h-6 w-6 object-contain" />
)
) : (
<span className="text-xs text-gray-700">{item.name}</span>
)}
</button>
))}
</div>
</div>
))}
{activeSubTab === "sticker" && (
<div className="space-y-4">
<div className="flex items-center justify-between px-1">
<Label className="text-xs font-bold text-gray-600">贴纸</Label>
<button onClick={clearSticker} className="text-[10px] text-blue-500 hover:underline">清除</button>
</div>
{stickerGroups.map((group: any) => (
<div key={group.name}>
<div className="mb-2 text-xs font-semibold text-gray-600">{group.name}</div>
<div className="flex flex-wrap gap-2">
{group.list.map((item: any, idx: number) => (
<button
key={`${group.name}-${idx}`}
onClick={() => setSticker(item)}
className="flex h-16 w-16 items-center justify-center rounded border border-gray-200 bg-white hover:border-blue-400 hover:shadow-sm"
>
<Image src={item.url} alt={group.name} width={56} height={56} className="h-14 w-14 object-contain" />
</button>
))}
</div>
</div>
))}
</div>
)}
</div>
);
};
const OutlinePanel = ({ mindmap }: { mindmap: any }) => {
const [depth, setDepth] = useState<number>(-1);
const nodes = useMemo(() => {
const root = mindmap?.renderer?.renderTree?._node;
const list: { node: any; text: string; depth: number }[] = [];
const walk = (n: any, d: number) => {
if (!n) return;
const raw =
n.nodeData?.data?.text ||
n.nodeData?.text ||
n.data?.text ||
n.data?.data?.text ||
"未命名";
const text =
typeof raw === "string"
? raw.replace(/<[^>]+>/g, "").trim() || "未命名"
: "未命名";
list.push({ node: n, text, depth: d });
(n.children || []).forEach((c: any) => walk(c, d + 1));
};
if (root) walk(root, 0);
return list;
}, [mindmap]);
const visibleNodes = useMemo(
() => nodes.filter((item) => depth < 0 || item.depth < depth),
[nodes, depth],
);
const activate = (node: any) => {
const r = mindmap?.renderer;
if (!node || !r) return;
// 仅通过已有方法触发激活,避免直接改 renderer 属性
if (typeof r.clearActiveNodeList === "function") {
r.clearActiveNodeList();
}
if (typeof r.addNodeToActiveList === "function") {
r.addNodeToActiveList(node, true);
} else {
// 兜底:仍保留最小副作用写入
try {
r?.setActiveNode?.(node);
} catch {
// 最后兜底:不再直接改引用,避免 lint 报错
}
}
mindmap.emit?.("node_active", node, [node]);
r.setRootNodeCenter?.();
};
if (!nodes.length) return <div className="p-4 text-sm text-gray-400">暂无节点</div>;
return (
<div className="space-y-3">
<div>
<Label className="text-xs text-gray-500">显示层级</Label>
<NativeSelect
value={depth}
options={outlineDepthOptions.map((o) => ({ label: o.name, value: o.value }))}
onChange={(v) => setDepth(Number(v))}
/>
</div>
<div className="max-h-[70vh] space-y-1 overflow-auto pr-2">
{visibleNodes.map((item, idx) => (
<button
key={`${item.text}-${idx}`}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1 text-left text-sm hover:bg-gray-50"
onClick={() => activate(item.node)}
>
<span className="text-gray-300" style={{ paddingLeft: item.depth * 12 }}>
{"•".repeat(Math.max(1, item.depth + 1))}
</span>
<span className="text-gray-700">{item.text}</span>
</button>
))}
</div>
</div>
);
};
const SettingsPanel = ({ mindmap }: { mindmap: any }) => {
const [config, setConfig] = useState({
openPerformance: !!mindmap?.opt?.openPerformance,
enableFreeDrag: !!mindmap?.opt?.enableFreeDrag,
mousewheelAction: mindmap?.opt?.mousewheelAction || "zoom",
mousewheelZoomActionReverse: !!mindmap?.opt?.mousewheelZoomActionReverse,
openRealtimeRenderOnNodeTextEdit: !!mindmap?.opt?.openRealtimeRenderOnNodeTextEdit,
alwaysShowExpandBtn: !!mindmap?.opt?.alwaysShowExpandBtn,
enableAutoEnterTextEditWhenKeydown: !!mindmap?.opt?.enableAutoEnterTextEditWhenKeydown,
createNewNodeBehavior: mindmap?.opt?.createNewNodeBehavior || "default",
imgTextMargin: mindmap?.opt?.imgTextMargin ?? 5,
textContentMargin: mindmap?.opt?.textContentMargin ?? 2,
});
const [aiOn, setAiOn] = useState<boolean>(mindmap?.opt?.enableAi ?? true);
const [watermark, setWatermark] = useState({
show: !!mindmap?.opt?.watermarkConfig?.text,
onlyExport: mindmap?.opt?.watermarkConfig?.onlyExport ?? false,
belowNode: mindmap?.opt?.watermarkConfig?.belowNode ?? false,
text: mindmap?.opt?.watermarkConfig?.text ?? "",
lineSpacing: mindmap?.opt?.watermarkConfig?.lineSpacing ?? 100,
textSpacing: mindmap?.opt?.watermarkConfig?.textSpacing ?? 100,
angle: mindmap?.opt?.watermarkConfig?.angle ?? 30,
textStyle: {
color: mindmap?.opt?.watermarkConfig?.textStyle?.color ?? "#999",
opacity: mindmap?.opt?.watermarkConfig?.textStyle?.opacity ?? 0.5,
fontSize: mindmap?.opt?.watermarkConfig?.textStyle?.fontSize ?? 14,
},
});
useEffect(() => {
const opt = mindmap?.opt || {};
Promise.resolve().then(() =>
setConfig({
openPerformance: !!opt.openPerformance,
enableFreeDrag: !!opt.enableFreeDrag,
mousewheelAction: opt.mousewheelAction || "zoom",
mousewheelZoomActionReverse: !!opt.mousewheelZoomActionReverse,
openRealtimeRenderOnNodeTextEdit: !!opt.openRealtimeRenderOnNodeTextEdit,
alwaysShowExpandBtn: !!opt.alwaysShowExpandBtn,
enableAutoEnterTextEditWhenKeydown: !!opt.enableAutoEnterTextEditWhenKeydown,
createNewNodeBehavior: opt.createNewNodeBehavior || "default",
imgTextMargin: opt.imgTextMargin ?? 5,
textContentMargin: opt.textContentMargin ?? 2,
}),
);
const wm = opt.watermarkConfig || {};
Promise.resolve().then(() =>
setWatermark({
show: !!wm.text,
onlyExport: wm.onlyExport ?? false,
belowNode: wm.belowNode ?? false,
text: wm.text ?? "",
lineSpacing: wm.lineSpacing ?? 100,
textSpacing: wm.textSpacing ?? 100,
angle: wm.angle ?? 30,
textStyle: {
color: wm.textStyle?.color ?? "#999",
opacity: wm.textStyle?.opacity ?? 0.5,
fontSize: wm.textStyle?.fontSize ?? 14,
},
}),
);
Promise.resolve().then(() => setAiOn(opt.enableAi ?? true));
}, [mindmap]);
const updateOpt = (key: string, value: any, needRender = false) => {
setConfig((prev) => ({ ...prev, [key]: value }));
mindmap?.updateConfig?.({ [key]: value });
if (needRender && mindmap?.reRender) {
mindmap.reRender();
}
};
const applyWatermark = (next: any) => {
const { show, ...cfg } = next;
const finalCfg = show ? cfg : { ...cfg, text: "" };
mindmap?.watermark?.updateWatermark?.(finalCfg);
mindmap?.updateConfig?.({ watermarkConfig: finalCfg });
};
const updateWatermark = (patch: any) => {
setWatermark((prev) => {
const next = {
...prev,
...patch,
textStyle: { ...prev.textStyle, ...(patch.textStyle || {}) },
};
applyWatermark(next);
return next;
});
};
const updateAi = (val: boolean) => {
setAiOn(val);
mindmap?.updateConfig?.({ enableAi: val });
};
return (
<div className="space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">显示水印</Label>
<Toggle
size="sm"
pressed={watermark.show}
onPressedChange={(v) => updateWatermark({ show: v })}
>
{watermark.show ? "开" : "关"}
</Toggle>
</div>
{watermark.show && (
<div className="space-y-3 rounded-md border border-gray-100 p-3">
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">仅导出时显示</Label>
<Toggle
size="sm"
pressed={watermark.onlyExport}
onPressedChange={(v) => updateWatermark({ onlyExport: v })}
>
{watermark.onlyExport ? "开" : "关"}
</Toggle>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">节点下方</Label>
<Toggle
size="sm"
pressed={watermark.belowNode}
onPressedChange={(v) => updateWatermark({ belowNode: v })}
>
{watermark.belowNode ? "开" : "关"}
</Toggle>
</div>
<div className="space-y-2">
<Label className="text-xs text-gray-500">水印文字</Label>
<Input
value={watermark.text}
placeholder="请输入水印文字"
onChange={(e) => updateWatermark({ text: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<ColorInput
label="颜色"
value={watermark.textStyle.color}
onChange={(val) => updateWatermark({ textStyle: { color: val } })}
/>
<div className="space-y-1">
<Label className="text-xs text-gray-500">透明度 0~1</Label>
<Input
type="number"
min={0}
max={1}
step={0.1}
value={watermark.textStyle.opacity}
onChange={(e) =>
updateWatermark({
textStyle: {
opacity: Math.min(1, Math.max(0, Number(e.target.value) || 0)),
},
})
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">字号</Label>
<Input
type="number"
min={8}
max={50}
step={1}
value={watermark.textStyle.fontSize}
onChange={(e) =>
updateWatermark({
textStyle: { fontSize: Number(e.target.value) || 14 },
})
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">旋转角度</Label>
<Input
type="number"
min={0}
max={90}
step={5}
value={watermark.angle}
onChange={(e) => updateWatermark({ angle: Number(e.target.value) || 0 })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">行间距</Label>
<Input
type="number"
min={10}
step={10}
value={watermark.lineSpacing}
onChange={(e) => updateWatermark({ lineSpacing: Number(e.target.value) || 0 })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">字间距</Label>
<Input
type="number"
min={10}
step={10}
value={watermark.textSpacing}
onChange={(e) => updateWatermark({ textSpacing: Number(e.target.value) || 0 })}
/>
</div>
</div>
</div>
)}
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">性能模式</Label>
<Toggle
size="sm"
pressed={config.openPerformance}
onPressedChange={(v) => updateOpt("openPerformance", v)}
>
{config.openPerformance ? "开" : "关"}
</Toggle>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">自由拖拽</Label>
<Toggle
size="sm"
pressed={config.enableFreeDrag}
onPressedChange={(v) => updateOpt("enableFreeDrag", v)}
>
{config.enableFreeDrag ? "开" : "关"}
</Toggle>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">实时渲染(编辑时)</Label>
<Toggle
size="sm"
pressed={config.openRealtimeRenderOnNodeTextEdit}
onPressedChange={(v) => updateOpt("openRealtimeRenderOnNodeTextEdit", v)}
>
{config.openRealtimeRenderOnNodeTextEdit ? "开" : "关"}
</Toggle>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">展开按钮常显</Label>
<Toggle
size="sm"
pressed={config.alwaysShowExpandBtn}
onPressedChange={(v) => updateOpt("alwaysShowExpandBtn", v, true)}
>
{config.alwaysShowExpandBtn ? "开" : "关"}
</Toggle>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">键入自动进入编辑</Label>
<Toggle
size="sm"
pressed={config.enableAutoEnterTextEditWhenKeydown}
onPressedChange={(v) => updateOpt("enableAutoEnterTextEditWhenKeydown", v)}
>
{config.enableAutoEnterTextEditWhenKeydown ? "开" : "关"}
</Toggle>
</div>
<div className="space-y-2">
<Label className="text-xs text-gray-500">滚轮行为</Label>
<NativeSelect
value={config.mousewheelAction}
onChange={(v) => updateOpt("mousewheelAction", v)}
options={[
{ label: "缩放", value: "zoom" },
{ label: "平移", value: "move" },
]}
/>
</div>
{config.mousewheelAction === "zoom" && (
<div className="space-y-2">
<Label className="text-xs text-gray-500">缩放方向反转</Label>
<NativeSelect
value={String(config.mousewheelZoomActionReverse)}
onChange={(v) => updateOpt("mousewheelZoomActionReverse", v === "true")}
options={[
{ label: "常规", value: "false" },
{ label: "反转", value: "true" },
]}
/>
</div>
)}
<div className="space-y-2">
<Label className="text-xs text-gray-500">创建新节点激活行为</Label>
<NativeSelect
value={config.createNewNodeBehavior}
onChange={(v) => updateOpt("createNewNodeBehavior", v)}
options={[
{ label: "默认", value: "default" },
{ label: "不激活", value: "notActive" },
{ label: "仅激活新节点", value: "activeOnly" },
]}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-gray-500">图片与文本间距</Label>
<Input
type="number"
min={0}
step={1}
value={config.imgTextMargin}
onChange={(e) => updateOpt("imgTextMargin", Number(e.target.value) || 0, true)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">文本内容间距</Label>
<Input
type="number"
min={0}
step={1}
value={config.textContentMargin}
onChange={(e) => updateOpt("textContentMargin", Number(e.target.value) || 0, true)}
/>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">AI 功能</Label>
<Toggle size="sm" pressed={aiOn} onPressedChange={updateAi}>
{aiOn ? "开" : "关"}
</Toggle>
</div>
</div>
);
};
const FormulaPanel = ({ mindmap }: { mindmap: any }) => {
const [formula, setFormula] = useState("a^2+b^2=c^2");
const insert = () => {
const active = mindmap?.renderer?.activeNodeList || [];
mindmap?.execCommand?.("INSERT_FORMULA", formula, active);
};
return (
<div className="space-y-3">
<Label className="text-xs text-gray-500">LaTeX 公式</Label>
<Input value={formula} onChange={(e) => setFormula(e.target.value)} />
<button className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600" onClick={insert}>
插入公式
</button>
</div>
);
};
const NotePanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapNode[] }) => {
const [note, setNote] = useState("");
const readNote = (node: any) => {
try {
return (
node?.getData?.("note") ??
node?.nodeData?.data?.note ??
node?.data?.note ??
""
);
} catch {
return "";
}
};
// 当选中节点变化时,自动展示首个节点的备注
useEffect(() => {
if (!activeNodes?.length) {
Promise.resolve().then(() => setNote(""));
return;
}
const current = readNote(activeNodes[0]);
Promise.resolve().then(() =>
setNote(typeof current === "string" ? current : ""),
);
}, [activeNodes]);
const getActiveList = () =>
mindmap?.renderer?.activeNodeList?.length
? mindmap.renderer.activeNodeList
: activeNodes || [];
const apply = () => {
const list = getActiveList();
list.forEach((node: any) => mindmap?.execCommand?.("SET_NODE_NOTE", node, note));
};
const clearNote = () => {
const list = getActiveList();
list.forEach((node: any) => mindmap?.execCommand?.("SET_NODE_NOTE", node, ""));
setNote("");
};
if (!activeNodes?.length) {
return <div className="p-4 text-sm text-gray-400">请选择节点后编辑备注</div>;
}
return (
<div className="space-y-3">
<Label className="text-xs text-gray-500">节点备注(自动载入首个选中节点)</Label>
<textarea
className="w-full rounded-md border border-gray-200 p-2 text-sm"
rows={38}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="输入备注内容"
/>
<div className="flex gap-2">
<button
className="flex-1 rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600"
onClick={apply}
>
保存 / 更新
</button>
<button
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={clearNote}
>
清除备注
</button>
</div>
</div>
);
};
type AiMode = "chat" | "full" | "partial";
const AiPanel = ({
mindmap,
activeNodes,
documentId,
mindmapId,
}: {
mindmap: any;
activeNodes: MindMapNode[];
documentId: string;
mindmapId: string;
}) => {
const [prompt, setPrompt] = useState("");
const [mode, setMode] = useState<AiMode>("full");
const [model, setModel] = useState("qwen3:30b-a3b-instruct-2507-q4_K_M");
const [baseUrl, setBaseUrl] = useState("http://localhost:11434/api/chat");
const [systemPrompt, setSystemPrompt] = useState("请用 Markdown,仅使用标题和无序列表结构输出思维导图内容。不要添加额外解释。");
const [streamText, setStreamText] = useState("");
const [loading, setLoading] = useState(false);
const controllerRef = React.useRef<AbortController | null>(null);
// 文档驱动:从 PDF 大纲生成导图(M1)
const [docSource, setDocSource] = useState<"test" | "url">("test");
const [testPdfName, setTestPdfName] = useState("卤化反应原理_1-9.pdf");
const [docUrl, setDocUrl] = useState("");
const [docTitle, setDocTitle] = useState("");
const [docPreferProvider, setDocPreferProvider] = useState<"online" | "ollama" | "heuristic">("online");
const [docMaxPages, setDocMaxPages] = useState(9);
const [docLoading, setDocLoading] = useState(false);
const [docDebug, setDocDebug] = useState("");
// AI Agent:补完选中节点(服务端:SearxNG + 在线 AI -> ops -> 落盘)
const [expandInstruction, setExpandInstruction] = useState("");
const [expandLoading, setExpandLoading] = useState(false);
const [expandDebug, setExpandDebug] = useState("");
const [expandUseSearx, setExpandUseSearx] = useState(true);
const persistMindmapData = (data: unknown): boolean => {
try {
const w = window as unknown as {
__mindmapPersistById?: Record<string, (data: unknown) => void>;
};
const fn = w.__mindmapPersistById?.[mindmapId];
if (typeof fn === "function") {
fn(data);
return true;
}
} catch {
// ignore
}
return false;
};
const resetStream = () => {
setStreamText("");
};
const stop = () => {
controllerRef.current?.abort();
controllerRef.current = null;
setLoading(false);
};
const runDocOutlineToMindmap = async () => {
setDocDebug("");
setDocLoading(true);
try {
const body =
docSource === "test"
? {
source: { kind: "test", name: testPdfName },
ollama: { baseUrl, model },
options: { preferProvider: docPreferProvider, maxPages: docMaxPages },
}
: {
source: { kind: "url", fileUrl: docUrl, title: docTitle || undefined },
ollama: { baseUrl, model },
options: { preferProvider: docPreferProvider, maxPages: docMaxPages },
};
const res = await fetch("/api/mindmap-ai/outline-to-mindmap", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = (await res.json().catch(() => null)) as any;
if (!res.ok) {
throw new Error(String(json?.error ?? `请求失败:${res.status}`));
}
if (!json?.mindmapData) {
throw new Error("接口返回缺少 mindmapData");
}
mindmap?.setData?.(json.mindmapData);
mindmap?.command?.clearHistory?.();
// 直接用服务端返回的结构化数据保存(避免 setData 异步导致快照仍为旧数据,从而覆盖成空树)
const ok = persistMindmapData(json.mindmapData);
if (!ok) {
// 兜底:延迟触发一次 data_change,让外层自行抓取快照保存
window.setTimeout(() => {
try {
mindmap?.emit?.("data_change");
} catch {
// ignore
}
}, 250);
}
setDocDebug(
`已生成:${json?.meta?.title ?? "文档"}provider=${json?.meta?.providerUsed ?? "unknown"};候选行 ${json?.candidates?.length ?? 0};节点 ${json?.plan?.chapters ? "plan" : (json?.outline?.length ?? 0)}`,
);
} catch (e) {
setDocDebug(`生成失败:${e instanceof Error ? e.message : String(e)}`);
window.alert(`从 PDF 生成导图失败:${e instanceof Error ? e.message : String(e)}`);
} finally {
setDocLoading(false);
}
};
const runExpandSelectedNode = async () => {
setExpandDebug("");
if (!documentId || !mindmapId) {
window.alert("缺少 documentId/mindmapId,无法补完。");
return;
}
const list = getActiveList();
if (!list?.length) {
window.alert("请先选中一个节点再补完。");
return;
}
const node = list[0] as any;
const uid =
node?.nodeData?.data?.uid ||
node?.nodeData?.uid ||
node?.getData?.("uid") ||
node?.uid ||
"";
if (!uid) {
window.alert("选中节点缺少 uid,无法补完。");
return;
}
const text =
node?.getData?.("text") ||
node?.nodeData?.data?.text ||
node?.data?.text ||
"";
setExpandLoading(true);
try {
const res = await fetch("/api/mindmap-ai/expand-node", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
documentId,
mindmapId,
targetUid: uid,
instruction: expandInstruction || undefined,
sources: { searxng: expandUseSearx },
}),
});
const json = (await res.json().catch(() => null)) as any;
if (!res.ok) {
throw new Error(String(json?.error ?? `请求失败:${res.status}`));
}
if (!json?.data) {
throw new Error("接口返回缺少 data");
}
mindmap?.setData?.(json.data);
mindmap?.command?.clearHistory?.();
// 直接用服务端返回的结构化数据保存(避免 setData 异步导致快照仍为旧数据,从而覆盖成空树)
const ok = persistMindmapData(json.data);
if (!ok) {
// 兜底:延迟触发一次 data_change,让外层自行抓取快照保存
window.setTimeout(() => {
try {
mindmap?.emit?.("data_change");
} catch {
// ignore
}
}, 250);
}
setExpandDebug(
`已补完:${String(text || "目标节点").slice(0, 50)};新增 ${json?.applied ?? 0}searx=${json?.meta?.searched ? "on" : "off"}${json?.meta?.searxCount ?? 0}`,
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setExpandDebug(`补完失败:${msg}`);
window.alert(`补完节点失败:${msg}`);
} finally {
setExpandLoading(false);
}
};
const runChat = async () => {
if (!prompt.trim()) return;
resetStream();
setLoading(true);
const controller = new AbortController();
controllerRef.current = controller;
try {
const res = await fetch(baseUrl, {
method: "POST",
signal: controller.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
stream: true,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: prompt.trim() },
],
}),
});
const reader = res.body?.getReader();
if (!reader) throw new Error("无法读取流");
const decoder = new TextDecoder();
let full = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
// Ollama 返回 json 行
const lines = chunk.split("\n").filter(Boolean);
for (const line of lines) {
try {
const json = JSON.parse(line);
if (json.message?.content) {
full += json.message.content;
setStreamText((prev) => prev + json.message.content);
}
} catch {
// 忽略非 json 行
}
}
}
return full;
} finally {
setLoading(false);
controllerRef.current = null;
}
};
// 将 AI 文本解析为子节点数组,尽量兼容宽松 Markdown 列表
const parseChildrenFromText = async (text: string) => {
const { transformMarkdownTo } = await import("simple-mind-map/src/parse/markdownTo.js");
const { createUid } = await import("simple-mind-map/src/utils/index.js");
const safeText = text || "";
let children = transformMarkdownTo(safeText)?.children || [];
if (!children.length) {
const lines = safeText
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
const listLines = lines.filter((l) => /^[-*+]\s+/.test(l)).map((l) => l.replace(/^[-*+]\s+/, ""));
const headingLines = lines.filter((l) => /^#+\s+/.test(l)).map((l) => l.replace(/^#+\s+/, ""));
const useLines = listLines.length ? listLines : headingLines.length ? headingLines : lines;
children = useLines.map((t) => ({ data: { text: t } }));
}
if (!children.length) {
children = [{ data: { text: safeText.slice(0, 200) || "新节点" } }];
}
const fill = (nodes: any[]) => {
nodes.forEach((node) => {
if (!node.data) node.data = {};
if (!node.data.text) node.data.text = "新节点";
if (!node.data.uid) node.data.uid = createUid();
if (node.children?.length) fill(node.children);
});
};
fill(children);
return JSON.parse(JSON.stringify(children));
};
const getActiveList = () =>
(activeNodes && activeNodes.length ? activeNodes : mindmap?.renderer?.activeNodeList || []) as any[];
const updateDataByUids = async (uids: string[], updater: (node: any) => void) => {
const source = mindmap?.getData?.(true) || mindmap?.getData?.();
const dataClone = source ? JSON.parse(JSON.stringify(source)) : null;
if (!dataClone) {
window.alert("无法获取导图数据,操作失败。");
return false;
}
// 确保整棵树都有 uid,避免官方逻辑依赖 uid 时抛错
const { createUid } = await import("simple-mind-map/src/utils/index.js");
const sanitizeNode = (node: any) => {
if (!node || typeof node !== "object") return null;
if (!node.data) node.data = {};
if (!node.data.uid) node.data.uid = createUid();
if (typeof node.data.text !== "string") {
node.data.text =
node.data.text != null ? String(node.data.text) : "新节点";
}
if (Array.isArray(node.children)) {
node.children = node.children
.map((c: any) => sanitizeNode(c))
.filter(Boolean);
} else {
node.children = [];
}
return node;
};
sanitizeNode(dataClone);
const cleanRenderCallbacks = () => {
try {
const r = mindmap?.renderer;
if (r && Array.isArray((r as any).renderCallbackList)) {
r.renderCallbackList = (r.renderCallbackList as any[]).filter(
(fn) => typeof fn === "function",
);
}
} catch {
// ignore
}
};
cleanRenderCallbacks();
const walk = (node: any) => {
if (uids.includes(node?.data?.uid)) {
updater(node);
}
if (node.children?.length) {
node.children.forEach(walk);
}
};
walk(dataClone);
cleanRenderCallbacks();
mindmap?.updateData?.(dataClone);
cleanRenderCallbacks();
return true;
};
const applyFullMindmap = async (content: string) => {
if (!content.trim()) return;
try {
const { transformMarkdownTo } = await import("simple-mind-map/src/parse/markdownTo.js");
const data = transformMarkdownTo(content);
if (!data?.data) {
data.data = { text: "中心主题" };
}
mindmap?.setData?.(data);
mindmap?.command?.clearHistory?.();
} catch (error) {
console.error(error);
window.alert("解析 AI 结果失败,请检查格式(需 Markdown 标题和无序列表)。");
}
};
const applyPartialMindmap = async (text: string, presetChildren?: any[]) => {
const list = getActiveList();
if (!list?.length) {
window.alert("请选择节点后再续写。");
return;
}
try {
const children = presetChildren || (await parseChildrenFromText(text));
const uids = list
.map(
(node: any) =>
node?.nodeData?.data?.uid ||
node?.nodeData?.uid ||
node?.getData?.("uid") ||
node?.uid,
)
.filter(Boolean);
if (!uids.length) {
window.alert("未获取到选中节点的 UID,续写失败。");
return;
}
const ok = await updateDataByUids(uids as string[], (node) => {
node.children = Array.isArray(node.children) ? node.children : [];
const childrenCopy = JSON.parse(JSON.stringify(children));
node.children.push(...childrenCopy);
});
if (!ok) return;
mindmap?.renderer?.render?.(mindmap?.getData?.());
} catch (e) {
console.error("AI Markdown Parsing Error:", e);
if (e instanceof Error) {
console.error("Error Message:", e.message);
console.error("Error Stack:", e.stack);
}
window.alert("解析 AI 返回的 Markdown 失败,请检查格式。");
}
};
const handleSend = async () => {
const text = (await runChat()) || streamText;
if (mode === "full" && text) {
await applyFullMindmap(text);
}
if (mode === "partial" && text) {
await applyPartialMindmap(text);
}
};
const handleSaveToNote = () => {
const text = streamText.trim();
if (!text) {
window.alert("暂无可保存的内容");
return;
}
const list = getActiveList();
if (!list?.length) {
window.alert("请选择节点后再保存备注");
return;
}
list.forEach((node: any) => mindmap?.execCommand?.("SET_NODE_NOTE", node, text));
};
const handleAppendSingleChild = async () => {
const text = streamText.trim();
if (!text) {
window.alert("暂无可生成的内容");
return;
}
const children = await parseChildrenFromText(text.slice(0, 500));
const first = children.length ? [children[0]] : [{ data: { text } }];
await applyPartialMindmap("", first);
};
const handleAppendMultiChild = async () => {
const text = streamText.trim();
if (!text) {
window.alert("暂无可生成的内容");
return;
}
const children = await parseChildrenFromText(text);
await applyPartialMindmap("", children);
};
const handleReplaceCurrent = async () => {
const text = streamText.trim();
if (!text) {
window.alert("暂无可替换的内容");
return;
}
const list = getActiveList();
if (!list?.length) {
window.alert("请选择节点后再修改");
return;
}
const uids = list
.map(
(node: any) =>
node?.nodeData?.data?.uid ||
node?.nodeData?.uid ||
node?.getData?.("uid") ||
node?.uid,
)
.filter(Boolean) as string[];
const ok = await updateDataByUids(uids, (node) => {
if (!node.data) node.data = {};
node.data.text = text;
});
if (ok) {
mindmap?.renderer?.render?.(mindmap?.getData?.());
}
};
return (
<div className="space-y-3">
<div className="space-y-2 rounded-md border border-gray-200 p-3">
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">文档导图(按大纲生成)</Label>
{docSource === "test" && (
<a
className="text-xs text-blue-600 hover:underline"
href={`/api/mindmap-ai/test-pdf?name=${encodeURIComponent(testPdfName)}`}
target="_blank"
rel="noreferrer"
>
打开 PDF
</a>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
className={`rounded-md border px-2 py-2 text-sm hover:bg-gray-50 ${docSource === "test" ? "border-blue-500 text-blue-600" : "border-gray-300 text-gray-700"}`}
onClick={() => setDocSource("test")}
>
测试 PDF
</button>
<button
type="button"
className={`rounded-md border px-2 py-2 text-sm hover:bg-gray-50 ${docSource === "url" ? "border-blue-500 text-blue-600" : "border-gray-300 text-gray-700"}`}
onClick={() => setDocSource("url")}
>
URL / Signed URL
</button>
</div>
{docSource === "test" ? (
<div className="space-y-1">
<Label className="text-xs text-gray-500">测试文件名(wolai-frontend/test</Label>
<Input
value={testPdfName}
onChange={(e) => setTestPdfName(e.target.value)}
placeholder="例如:卤化反应原理_1-9.pdf"
/>
<p className="text-xs text-gray-400">仅本地开发可用:用于快速验证“生成导图 + 节点跳页链接”。</p>
</div>
) : (
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-xs text-gray-500">PDF 地址</Label>
<Input
value={docUrl}
onChange={(e) => setDocUrl(e.target.value)}
placeholder="http://127.0.0.1:xxx/file.pdf 或 supabase signed url"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">标题(可选)</Label>
<Input value={docTitle} onChange={(e) => setDocTitle(e.target.value)} placeholder="不填则使用“文档”" />
</div>
<p className="text-xs text-gray-400">出于安全考虑,当前仅允许本机或 supabase 域名。</p>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-gray-500">优先模型</Label>
<NativeSelect
value={docPreferProvider}
onChange={(v) => setDocPreferProvider(v as "online" | "ollama" | "heuristic")}
options={[
{ label: "在线 AI(默认)", value: "online" },
{ label: "本地 Ollama", value: "ollama" },
{ label: "规则兜底", value: "heuristic" },
]}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">最大页数</Label>
<Input
type="number"
min={1}
max={200}
step={1}
value={docMaxPages}
onChange={(e) => setDocMaxPages(Number(e.target.value) || 1)}
/>
</div>
</div>
<button
type="button"
disabled={docLoading || (docSource === "url" && !docUrl.trim())}
className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
onClick={runDocOutlineToMindmap}
>
{docLoading ? "生成中..." : "从 PDF 生成导图(替换当前)"}
</button>
{docDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{docDebug}</div>}
</div>
{MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED ? (
<div className="space-y-2 rounded-md border border-gray-200 p-3">
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">AI 补完(选中节点)</Label>
<div className="flex items-center gap-2">
<Toggle
pressed={expandUseSearx}
onPressedChange={(v) => setExpandUseSearx(Boolean(v))}
size="sm"
className="text-xs"
>
<Network className="h-4 w-4 mr-1" />
联网
</Toggle>
</div>
</div>
<textarea
className="w-full rounded-md border border-gray-200 p-2 text-sm"
rows={3}
value={expandInstruction}
onChange={(e) => setExpandInstruction(e.target.value)}
placeholder="例如:补充该节点的关键概念、常见误区与参考链接(每条都要可点击来源)"
/>
<button
type="button"
disabled={expandLoading}
className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
onClick={runExpandSelectedNode}
>
{expandLoading ? "补完中..." : "补完选中节点(写入并保存)"}
</button>
{expandDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{expandDebug}</div>}
<p className="text-xs text-gray-400">
说明:服务端会用 SearxNG 检索证据 + 在线 AI 生成 ops,并自动落盘到当前 mindmap 文件中。
</p>
</div>
) : null}
<Label className="text-xs text-gray-500">模式</Label>
<NativeSelect
value={mode}
onChange={(v) => setMode(v as AiMode)}
options={[
{ label: "整图生成", value: "full" },
{ label: "对话", value: "chat" },
{ label: "选中节点续写", value: "partial" },
]}
/>
<div className="space-y-2">
<Label className="text-xs text-gray-500">模型</Label>
<Input value={model} onChange={(e) => setModel(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-xs text-gray-500">接口地址</Label>
<Input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-xs text-gray-500">系统提示</Label>
<Input value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-xs text-gray-500">{mode === "full" ? "生成主题" : mode === "partial" ? "续写内容" : "对话输入"}</Label>
<textarea
className="w-full rounded-md border border-gray-200 p-2 text-sm"
rows={5}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={
mode === "full"
? "例如:帮我生成一份 OKR 思维导图"
: mode === "partial"
? "描述要续写的方向或要补充的要点"
: "输入你想询问的内容"
}
/>
</div>
<div className="flex gap-2">
<button
disabled={loading}
className="flex-1 rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
onClick={handleSend}
>
{loading ? "生成中..." : "发送"}
</button>
<button
disabled={!loading}
className="w-20 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
onClick={stop}
>
停止
</button>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">流式输出</Label>
<div className="h-40 overflow-auto rounded-md border border-gray-200 p-2 text-sm whitespace-pre-wrap">
{streamText || "等待输出..."}
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">输出处理</Label>
<div className="grid grid-cols-2 gap-2">
<button
className="rounded-md border border-gray-300 px-2 py-2 text-sm hover:bg-gray-50"
onClick={handleSaveToNote}
>
生成到备注
</button>
<button
className="rounded-md border border-gray-300 px-2 py-2 text-sm hover:bg-gray-50"
onClick={handleAppendSingleChild}
>
生成为单一子节点
</button>
<button
className="rounded-md border border-gray-300 px-2 py-2 text-sm hover:bg-gray-50"
onClick={handleAppendMultiChild}
>
生成为多个子节点
</button>
<button
className="rounded-md border border-gray-300 px-2 py-2 text-sm hover:bg-gray-50"
onClick={handleReplaceCurrent}
>
修改当前节点
</button>
</div>
</div>
{mode === "full" && (
<p className="text-xs text-gray-400">
将使用 AI 生成完整思维导图,返回的 Markdown 会自动解析并替换当前导图。请确保本地 Ollama 已启动且模型已下载。
</p>
)}
{mode === "partial" && (
<p className="text-xs text-gray-400">
续写模式:请选择一个或多个节点,AI 返回的 Markdown 子列表会追加为所选节点的子节点。
</p>
)}
</div>
);
};
export const MindmapSidebar = ({
documentId,
mindmapId,
mindmap,
activeNodes,
activeTab,
onClose,
}: SidebarProps) => {
const content = useMemo(() => {
switch (activeTab as SidebarPanel | null) {
case "style":
return <StylePanel activeNodes={activeNodes} />;
case "base":
return <BaseStylePanel mindmap={mindmap} />;
case "theme":
return <ThemePanel mindmap={mindmap} />;
case "structure":
return <StructurePanel mindmap={mindmap} />;
case "icons":
return <IconPanel activeNodes={activeNodes} />;
case "outline":
return <OutlinePanel mindmap={mindmap} />;
case "settings":
return <SettingsPanel mindmap={mindmap} />;
case "formula":
return <FormulaPanel mindmap={mindmap} />;
case "note":
return <NotePanel mindmap={mindmap} activeNodes={activeNodes} />;
case "ai":
return (
<MindmapAiAgentPanel
mindmap={mindmap}
activeNodes={activeNodes}
documentId={documentId}
mindmapId={mindmapId}
onClose={onClose}
/>
);
default:
return null;
}
}, [activeTab, mindmap, activeNodes, documentId, mindmapId, onClose]);
const title = useMemo(() => {
if (!activeTab) return "";
return sidebarTitles[activeTab as SidebarPanel] ?? "";
}, [activeTab]);
return (
<div
className={`absolute top-0 bottom-0 right-0 z-20 w-[300px] flex flex-col border-l border-gray-200 bg-white transition-transform duration-300 ease-in-out shadow-lg ${
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={activeTab === "ai" ? "flex-1 overflow-hidden p-0" : "flex-1 overflow-y-auto px-4 py-4"}>
{content}
</div>
</div>
);
};