0.1.11 ai修复与全屏

This commit is contained in:
liaibo
2026-01-10 10:35:21 +08:00
parent e74219c802
commit 0bcdc3e730
55 changed files with 6597 additions and 174 deletions
@@ -24,8 +24,9 @@ import {
} from "./mindmapOptions";
import iconConfig from "./mindmapIconConfig";
import imageConfig from "./mindmapImageConfig";
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
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");
@@ -34,6 +35,8 @@ const loadIconModules = async () => {
};
type SidebarProps = {
documentId: string;
mindmapId: string;
mindmap: any;
activeNodes: MindMapNode[];
activeTab: SidebarPanel | null;
@@ -1067,7 +1070,17 @@ const NotePanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMa
type AiMode = "chat" | "full" | "partial";
const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapNode[] }) => {
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");
@@ -1077,6 +1090,38 @@ const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapN
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("");
};
@@ -1087,6 +1132,133 @@ const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapN
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();
@@ -1359,6 +1531,139 @@ const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapN
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>
<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>
<Label className="text-xs text-gray-500"></Label>
<NativeSelect
value={mode}
@@ -1462,7 +1767,14 @@ const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapN
);
};
export const MindmapSidebar = ({ mindmap, activeNodes, activeTab, onClose }: SidebarProps) => {
export const MindmapSidebar = ({
documentId,
mindmapId,
mindmap,
activeNodes,
activeTab,
onClose,
}: SidebarProps) => {
const content = useMemo(() => {
switch (activeTab as SidebarPanel | null) {
case "style":
@@ -1484,11 +1796,18 @@ export const MindmapSidebar = ({ mindmap, activeNodes, activeTab, onClose }: Sid
case "note":
return <NotePanel mindmap={mindmap} activeNodes={activeNodes} />;
case "ai":
return <AiPanel mindmap={mindmap} activeNodes={activeNodes} />;
return (
<MindmapAiAgentPanel
mindmap={mindmap}
activeNodes={activeNodes}
documentId={documentId}
mindmapId={mindmapId}
/>
);
default:
return null;
}
}, [activeTab, mindmap, activeNodes]);
}, [activeTab, mindmap, activeNodes, documentId, mindmapId]);
const title = useMemo(() => {
if (!activeTab) return "";