Files
mnote/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx
T

1838 lines
65 KiB
TypeScript
Raw Normal View History

/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useMemo, useState } from "react";
2026-01-02 07:25:50 +08:00
import Image from "next/image";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Toggle } from "@/components/ui/toggle";
2026-01-02 07:25:50 +08:00
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,
2025-12-31 19:26:38 +08:00
backgroundRepeatList,
backgroundPositionList,
backgroundSizeList,
} from "./mindmapOptions";
2025-12-30 18:39:38 +08:00
import iconConfig from "./mindmapIconConfig";
import imageConfig from "./mindmapImageConfig";
2026-01-10 10:35:21 +08:00
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
2026-01-08 06:28:14 +08:00
import type { MindMapNode } from "./mindmapTypes";
2026-01-10 10:35:21 +08:00
import { MindmapAiAgentPanel } from "./MindmapAiAgentPanel";
2025-12-30 18:39:38 +08:00
// 避免 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 = {
2026-01-10 10:35:21 +08:00
documentId: string;
mindmapId: string;
mindmap: any;
activeNodes: MindMapNode[];
activeTab: SidebarPanel | null;
onClose: () => void;
};
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",
2026-01-02 07:25:50 +08:00
"textDecoration", "borderWidth", "borderColor", "fillColor", "shape",
"lineColor", "lineWidth", "lineDasharray", "borderRadius", "borderDasharray"
].forEach((prop) => {
newStyle[prop] = node.getStyle(prop, false);
});
2026-01-02 07:25:50 +08:00
// 异步更新,避免同步 setState 抛 lint
Promise.resolve().then(() => setStyle(newStyle));
}
}, [activeNodes]);
2026-01-08 06:28:14 +08:00
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",
2025-12-31 19:26:38 +08:00
"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)}
/>
2025-12-31 19:26:38 +08:00
<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[] }) => {
2025-12-30 18:39:38 +08:00
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;
};
2025-12-30 18:39:38 +08:00
}, []);
const [stickerGroups] = useState(imageConfig);
2025-12-30 18:39:38 +08:00
const addIcon = (type: string, name: string) => {
const key = `${type}_${name}`;
activeNodes.forEach((node) => {
2026-01-08 06:28:14 +08:00
const rawIcons = node.getData("icon");
const icons = Array.isArray(rawIcons) ? (rawIcons as string[]) : [];
const newIcons = icons.filter((i) => !i.startsWith(`${type}_`));
2025-12-30 18:39:38 +08:00
newIcons.push(key);
2026-01-08 06:28:14 +08:00
node.setIcon?.(newIcons);
2025-12-30 18:39:38 +08:00
});
};
2025-12-30 18:39:38 +08:00
const removeIcon = (type: string) => {
activeNodes.forEach((node) => {
2026-01-08 06:28:14 +08:00
const rawIcons = node.getData("icon");
const icons = Array.isArray(rawIcons) ? (rawIcons as string[]) : [];
const newIcons = icons.filter((i) => !i.startsWith(`${type}_`));
node.setIcon?.(newIcons);
2025-12-30 18:39:38 +08:00
});
};
2025-12-30 18:39:38 +08:00
const setSticker = (img: { url: string; width?: number; height?: number }) => {
activeNodes.forEach((node) => {
// simple-mind-map 支持 setImage 接收对象,包含 url/width/height
2026-01-08 06:28:14 +08:00
node.setImage?.({
2025-12-30 18:39:38 +08:00
url: img.url,
width: img.width || 100,
height: img.height || 100,
});
});
};
const clearSticker = () => {
activeNodes.forEach((node) => {
// 传入 null 以清除贴纸
2026-01-08 06:28:14 +08:00
node.setImage?.(null);
2025-12-30 18:39:38 +08:00
});
};
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">
2026-01-08 06:28:14 +08:00
{group.list.map((item: any) => (
2025-12-30 18:39:38 +08:00
<button
key={`${group.type}-${item.name}`}
onClick={() => addIcon(group.type, item.name)}
2025-12-31 19:26:38 +08:00
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"
2025-12-30 18:39:38 +08:00
>
{item.icon ? (
typeof item.icon === "string" && item.icon.trim().startsWith("<svg") ? (
<span
2025-12-31 19:26:38 +08:00
className="inline-flex h-6 w-6 items-center justify-center overflow-hidden"
2025-12-30 18:39:38 +08:00
dangerouslySetInnerHTML={{ __html: item.icon }}
/>
) : (
2026-01-02 07:25:50 +08:00
<Image src={item.icon} alt={item.name} width={24} height={24} className="h-6 w-6 object-contain" />
2025-12-30 18:39:38 +08:00
)
) : (
<span className="text-xs text-gray-700">{item.name}</span>
)}
</button>
))}
2025-12-30 18:39:38 +08:00
</div>
</div>
2025-12-30 18:39:38 +08:00
))}
{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"
>
2026-01-02 07:25:50 +08:00
<Image src={item.url} alt={group.name} width={56} height={56} className="h-14 w-14 object-contain" />
2025-12-30 18:39:38 +08:00
</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;
2026-01-02 07:25:50 +08:00
// 仅通过已有方法触发激活,避免直接改 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 }) => {
2026-01-02 07:25:50 +08:00
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,
},
});
2026-01-02 07:25:50 +08:00
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();
}
};
2026-01-02 07:25:50 +08:00
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 (
2026-01-02 07:25:50 +08:00
<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>
2026-01-02 07:25:50 +08:00
<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>
2026-01-02 07:25:50 +08:00
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">AI 功能</Label>
2026-01-02 07:25:50 +08:00
<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>
);
};
2025-12-31 19:26:38 +08:00
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 "";
}
};
2025-12-31 19:26:38 +08:00
// 当选中节点变化时,自动展示首个节点的备注
useEffect(() => {
if (!activeNodes?.length) {
2026-01-02 07:25:50 +08:00
Promise.resolve().then(() => setNote(""));
2025-12-31 19:26:38 +08:00
return;
}
const current = readNote(activeNodes[0]);
2026-01-02 07:25:50 +08:00
Promise.resolve().then(() =>
setNote(typeof current === "string" ? current : ""),
);
2025-12-31 19:26:38 +08:00
}, [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">
2025-12-31 19:26:38 +08:00
<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>
);
};
2026-01-02 07:25:50 +08:00
type AiMode = "chat" | "full" | "partial";
2026-01-10 10:35:21 +08:00
const AiPanel = ({
mindmap,
activeNodes,
documentId,
mindmapId,
}: {
mindmap: any;
activeNodes: MindMapNode[];
documentId: string;
mindmapId: string;
}) => {
const [prompt, setPrompt] = useState("");
2026-01-02 07:25:50 +08:00
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);
2026-01-10 10:35:21 +08:00
// 文档驱动:从 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;
};
2026-01-02 07:25:50 +08:00
const resetStream = () => {
setStreamText("");
};
const stop = () => {
controllerRef.current?.abort();
controllerRef.current = null;
setLoading(false);
};
2026-01-10 10:35:21 +08:00
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);
}
};
2026-01-02 07:25:50 +08:00
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">
2026-01-10 10:35:21 +08:00
<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>
<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>
2026-01-02 07:25:50 +08:00
<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" },
]}
/>
2026-01-02 07:25:50 +08:00
<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>
);
};
2026-01-10 10:35:21 +08:00
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":
2025-12-31 19:26:38 +08:00
return <NotePanel mindmap={mindmap} activeNodes={activeNodes} />;
case "ai":
2026-01-10 10:35:21 +08:00
return (
<MindmapAiAgentPanel
mindmap={mindmap}
activeNodes={activeNodes}
documentId={documentId}
mindmapId={mindmapId}
2026-01-11 12:35:53 +08:00
onClose={onClose}
2026-01-10 10:35:21 +08:00
/>
);
default:
return null;
}
2026-01-11 12:35:53 +08:00
}, [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"
}`}
>
2026-01-11 12:35:53 +08:00
{activeTab === "ai" ? null : (
<div className="flex items-center justify-between border-b px-4 py-3 shrink-0">
2026-01-11 12:35:53 +08:00
<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>
2026-01-11 12:35:53 +08:00
)}
<div className={activeTab === "ai" ? "flex-1 overflow-hidden p-0" : "flex-1 overflow-y-auto px-4 py-4"}>
{content}
</div>
</div>
);
};