0.1.11 ai修复与全屏
This commit is contained in:
@@ -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 "";
|
||||
|
||||
Reference in New Issue
Block a user