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
@@ -209,7 +209,7 @@ export function BlockNoteEditor({
}
: undefined,
},
[documentId],
[documentId, normalizedInitialContent],
);
useEffect(
@@ -627,6 +627,9 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
openTableFullScreen: (tableId: string) => {
setFullScreenTableId(tableId);
},
insertMediaAsset: (asset: MediaAsset) => {
insertMediaAssetBlock(asset);
},
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
editor.focus();
const cursor = editor.getTextCursorPosition();
@@ -0,0 +1,570 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Network, Paperclip, Settings2, Send, X } from "lucide-react";
type AgentAssetItem = {
kind: "media" | "local-mindmap" | "test-pdf";
id: string;
title: string;
fileUrl: string;
mimeType?: string | null;
assetType?: string | null;
fileName?: string | null;
};
type AgentMessage = { role: "user" | "assistant"; content: string };
type ToolName =
| "mindmap_get"
| "mindmap_get_subtree"
| "search_web"
| "mindmap_apply_ops"
| "pdf_replace_mindmap";
const TOOL_LABEL: Record<ToolName, string> = {
mindmap_get: "读导图(摘要)",
mindmap_get_subtree: "读子树(按 uid",
search_web: "联网检索(SearxNG",
mindmap_apply_ops: "写入导图(ops",
pdf_replace_mindmap: "PDF→导图(替换当前)",
};
const DEFAULT_TOOLS: ToolName[] = [
"mindmap_get",
"mindmap_get_subtree",
"search_web",
"mindmap_apply_ops",
"pdf_replace_mindmap",
];
const ONLINE_MODELS = [
"",
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-3-pro-preview",
"gemini-3-flash-preview",
] as const;
export function MindmapAiAgentPanel({
documentId,
mindmapId,
mindmap,
activeNodes,
}: {
documentId: string;
mindmapId: string;
mindmap: any;
activeNodes: any[];
}) {
const [messages, setMessages] = useState<AgentMessage[]>([
{
role: "assistant",
content:
"你好,我是思维导图 AI Agent。你可以:\n- 直接说需求(例如:补完选中节点、总结 @PDF 并写入导图、从 @PDF 生成章/节结构导图)\n- 使用 @ 选择文件或上传文件\n- 让 AI 自动选择工具,或手动勾选允许使用的工具",
},
]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [networkOn, setNetworkOn] = useState(true);
const [toolAuto, setToolAuto] = useState(true);
const [toolPickerOpen, setToolPickerOpen] = useState(false);
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
const [aiModel, setAiModel] = useState<string>("");
const [assets, setAssets] = useState<AgentAssetItem[]>([]);
const [workspaceId, setWorkspaceId] = useState<string>("");
const [attachments, setAttachments] = useState<AgentAssetItem[]>([]);
const [debug, setDebug] = useState<string>("");
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
try {
const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim();
const m = window.localStorage.getItem("mindmap_ai_model") || "";
if (p === "local" || p === "online") setAiProvider(p);
if (typeof m === "string") setAiModel(m);
} catch {
// ignore
}
}, []);
useEffect(() => {
try {
window.localStorage.setItem("mindmap_ai_provider", aiProvider);
window.localStorage.setItem("mindmap_ai_model", aiModel);
} catch {
// ignore
}
}, [aiProvider, aiModel]);
// @ 选择
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const [mentionRange, setMentionRange] = useState<{ start: number; end: number } | null>(null);
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 selectedUids = useMemo(() => {
const list = Array.isArray(activeNodes) ? activeNodes : [];
return list
.slice(0, 3)
.map((n) => String(n?.nodeData?.data?.uid ?? n?.nodeData?.uid ?? n?.getData?.("uid") ?? n?.uid ?? ""))
.filter(Boolean);
}, [activeNodes]);
useEffect(() => {
let cancelled = false;
if (!documentId) return;
void (async () => {
try {
const res = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(documentId)}`);
const json = (await res.json().catch(() => null)) as any;
if (!res.ok) return;
if (cancelled) return;
setWorkspaceId(String(json?.workspaceId ?? ""));
setAssets(Array.isArray(json?.items) ? (json.items as AgentAssetItem[]) : []);
} catch {
// ignore
}
})();
return () => {
cancelled = true;
};
}, [documentId]);
const filteredAssets = useMemo(() => {
const q = mentionQuery.trim().toLowerCase();
const base = assets.slice(0, 200);
if (!q) return base.slice(0, 12);
return base
.filter((a) => {
const hay = `${a.title} ${a.fileName ?? ""}`.toLowerCase();
return hay.includes(q);
})
.slice(0, 12);
}, [assets, mentionQuery]);
const updateMentionState = (value: string, cursor: number) => {
const before = value.slice(0, Math.max(0, cursor));
const at = before.lastIndexOf("@");
if (at === -1) {
setMentionOpen(false);
setMentionQuery("");
setMentionRange(null);
return;
}
// 若 @ 前是非空白字符,视为 email/路径等,避免误触发
if (at > 0 && /\S/.test(before[at - 1] || "")) {
setMentionOpen(false);
setMentionQuery("");
setMentionRange(null);
return;
}
const token = before.slice(at + 1);
// 遇到换行/空格则不触发
if (/\s/.test(token)) {
setMentionOpen(false);
setMentionQuery("");
setMentionRange(null);
return;
}
setMentionOpen(true);
setMentionQuery(token);
setMentionRange({ start: at, end: cursor });
};
const insertMention = (item: AgentAssetItem) => {
const el = textareaRef.current;
if (!el || !mentionRange) return;
const next = `${input.slice(0, mentionRange.start)}@${item.title}${input.slice(mentionRange.end)}`;
setInput(next);
setMentionOpen(false);
setMentionQuery("");
setMentionRange(null);
// 去重加入附件
setAttachments((prev) => (prev.some((x) => x.id === item.id) ? prev : [...prev, item]));
// 光标移动到插入后
window.setTimeout(() => {
try {
const pos = mentionRange.start + 1 + item.title.length;
el.focus();
el.setSelectionRange(pos, pos);
} catch {
// ignore
}
}, 0);
};
const toggleTool = (tool: ToolName) => {
setSelectedTools((prev) => {
if (prev.includes(tool)) return prev.filter((t) => t !== tool);
return [...prev, tool];
});
};
const uploadFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return;
if (!workspaceId || !documentId) {
window.alert("缺少 workspaceId/documentId,无法上传。请刷新后重试。");
return;
}
const file = files[0];
const form = new FormData();
form.append("file", file);
form.append("workspaceId", workspaceId);
form.append("documentId", documentId);
setLoading(true);
try {
const res = await fetch("/api/media/upload", { method: "POST", body: form });
const json = (await res.json().catch(() => null)) as any;
if (!res.ok) throw new Error(String(json?.error ?? `上传失败:${res.status}`));
const asset = json?.asset;
const item: AgentAssetItem = {
kind: "media",
id: String(asset?.id ?? `media:${Date.now()}`),
title: String(asset?.file_name ?? file.name),
fileUrl: String(asset?.file_url ?? ""),
mimeType: String(asset?.mime_type ?? file.type ?? ""),
assetType: String(asset?.asset_type ?? "file"),
fileName: String(asset?.file_name ?? file.name),
};
setAttachments((prev) => (prev.some((x) => x.id === item.id) ? prev : [...prev, item]));
// 刷新资产列表
try {
const listRes = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(documentId)}`);
const listJson = (await listRes.json().catch(() => null)) as any;
if (listRes.ok && Array.isArray(listJson?.items)) {
setAssets(listJson.items as AgentAssetItem[]);
setWorkspaceId(String(listJson?.workspaceId ?? workspaceId));
}
} catch {
// ignore
}
} catch (e) {
window.alert(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const send = async () => {
const content = input.trim();
if (!content) return;
setDebug("");
const nextMessages: AgentMessage[] = [...messages, { role: "user", content }];
setMessages(nextMessages);
setInput("");
setLoading(true);
try {
const res = await fetch("/api/mindmap-ai/agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
documentId,
mindmapId,
selectedUids,
messages: nextMessages,
attachments: attachments.map((a) => ({
id: a.id,
title: a.title,
fileUrl: a.fileUrl,
mimeType: a.mimeType ?? null,
})),
toolChoice: toolAuto ? { mode: "auto" } : { mode: "manual", tools: selectedTools },
options: { searxng: networkOn, ai: { provider: aiProvider, model: aiModel.trim() || undefined } },
}),
});
const json = (await res.json().catch(() => null)) as any;
if (!res.ok) throw new Error(String(json?.error ?? `请求失败:${res.status}`));
const assistantText = String(json?.message ?? "");
setMessages((prev) => [...prev, { role: "assistant", content: assistantText || "(无输出)" }]);
if (json?.data) {
mindmap?.setData?.(json.data);
mindmap?.command?.clearHistory?.();
persistMindmapData(json.data);
}
if (json?.trace) {
setDebug(JSON.stringify(json.trace, null, 2));
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setMessages((prev) => [...prev, { role: "assistant", content: `执行失败:${msg}` }]);
} finally {
setLoading(false);
}
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2 pb-3">
<div className="text-xs text-gray-500">
{selectedUids.length ? `选中节点:${selectedUids[0]}` : "未选中节点(将以整图为上下文)"}
</div>
<div className="flex items-center gap-1">
<button
type="button"
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs ${networkOn ? "border-blue-200 bg-blue-50 text-blue-700" : "border-gray-200 text-gray-500"}`}
title="联网检索(SearxNG"
onClick={() => setNetworkOn((v) => !v)}
>
<Network className="h-3 w-3" />
</button>
<button
type="button"
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs ${toolPickerOpen ? "border-gray-300 bg-gray-50 text-gray-700" : "border-gray-200 text-gray-600"}`}
title="选择允许使用的工具"
onClick={() => setToolPickerOpen((v) => !v)}
>
<Settings2 className="h-3 w-3" />
</button>
</div>
</div>
{toolPickerOpen && (
<div className="mb-3 rounded-md border border-gray-200 bg-white p-2">
<div className="mb-2 flex items-center justify-between">
<div className="text-xs font-medium text-gray-700"></div>
<button
type="button"
className={`rounded px-2 py-1 text-xs ${toolAuto ? "bg-blue-500 text-white" : "border border-gray-200 text-gray-600"}`}
onClick={() => setToolAuto((v) => !v)}
title="自动:AI 自行选择;手动:仅允许勾选工具"
>
{toolAuto ? "自动" : "手动"}
</button>
</div>
<div className="space-y-1">
{DEFAULT_TOOLS.map((t) => (
<label key={t} className={`flex cursor-pointer items-center gap-2 text-xs ${toolAuto ? "opacity-50" : ""}`}>
<input
type="checkbox"
disabled={toolAuto}
checked={selectedTools.includes(t)}
onChange={() => toggleTool(t)}
/>
<span className="text-gray-700">{TOOL_LABEL[t]}</span>
</label>
))}
</div>
<div className="mt-2 text-[11px] text-gray-400">
AI
</div>
</div>
)}
<div className="flex-1 overflow-y-auto rounded-md border border-gray-200 bg-white p-2">
<div className="space-y-2">
{messages.map((m, idx) => (
<div
key={idx}
className={`whitespace-pre-wrap rounded-md px-2 py-2 text-sm ${m.role === "user" ? "bg-gray-50 text-gray-900" : "bg-white text-gray-800"}`}
>
<div className="mb-1 text-[11px] text-gray-400">{m.role === "user" ? "你" : "AI"}</div>
<div>{m.content}</div>
</div>
))}
</div>
</div>
{attachments.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{attachments.map((a) => (
<span
key={a.id}
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2 py-1 text-[11px] text-gray-700"
title={a.fileUrl}
>
@{a.title}
<button
type="button"
className="text-gray-400 hover:text-gray-700"
onClick={() => setAttachments((prev) => prev.filter((x) => x.id !== a.id))}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<div className="mt-2 rounded-md border border-gray-200 bg-white px-2 py-2 text-xs text-gray-700">
<div className="flex flex-wrap items-center gap-3">
<div className="text-gray-500">AI</div>
<label className="inline-flex cursor-pointer items-center gap-1">
<input
type="radio"
name="mindmap-ai-provider"
data-testid="mindmap-ai-provider-online"
checked={aiProvider === "online"}
onChange={() => setAiProvider("online")}
/>
线
</label>
<label className="inline-flex cursor-pointer items-center gap-1">
<input
type="radio"
name="mindmap-ai-provider"
data-testid="mindmap-ai-provider-local"
checked={aiProvider === "local"}
onChange={() => setAiProvider("local")}
/>
</label>
<div className="flex items-center gap-2">
<div className="text-gray-500"></div>
{aiProvider === "online" ? (
<select
data-testid="mindmap-ai-model-select"
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
>
{ONLINE_MODELS.map((m) => (
<option key={m || "__default__"} value={m}>
{m ? m : "默认(ai.md"}
</option>
))}
</select>
) : (
<input
data-testid="mindmap-ai-model-input"
className="w-[220px] rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="默认(ai.local.md/环境变量)"
/>
)}
</div>
</div>
{aiProvider === "local" ? (
<div className="mt-1 text-[11px] text-gray-400">
AI `LOCAL_AI_BASE_URL`/`LOCAL_AI_MODEL` `ai.local.md` / `ai-local.md`
</div>
) : null}
</div>
<div className="relative mt-2">
{mentionOpen && filteredAssets.length > 0 && (
<div className="absolute bottom-[calc(100%+8px)] left-0 right-0 z-30 max-h-56 overflow-auto rounded-md border border-gray-200 bg-white shadow">
{filteredAssets.map((a) => (
<button
key={a.id}
type="button"
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50"
onClick={() => insertMention(a)}
>
<span className="truncate text-gray-800">{a.title}</span>
<span className="shrink-0 text-[11px] text-gray-400">{a.kind}</span>
</button>
))}
</div>
)}
<textarea
ref={textareaRef}
data-testid="mindmap-ai-input"
className="w-full resize-none rounded-md border border-gray-200 bg-white p-2 text-sm outline-none focus:border-blue-300"
rows={4}
value={input}
placeholder="输入你的需求。使用 @ 选择文件(PDF/附件/本地导图),例如:总结 @卤化反应原理_1-9.pdf 并写入导图(章->节->要点)。"
onChange={(e) => {
const v = e.target.value;
setInput(v);
updateMentionState(v, e.target.selectionStart ?? v.length);
}}
onKeyDown={(e) => {
// 焦点在 AI 输入框时,不应触发导图 Enter/Tab 快捷键
e.stopPropagation();
if (e.key === "Escape") {
setMentionOpen(false);
setToolPickerOpen(false);
return;
}
if (e.key === "Enter") {
// Enter 发送;Shift+Enter 换行
if (!e.shiftKey && !e.isComposing) {
e.preventDefault();
if (!loading) void send();
}
return;
}
}}
onClick={(e) => {
const el = e.currentTarget;
updateMentionState(el.value, el.selectionStart ?? el.value.length);
}}
/>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
onClick={() => fileInputRef.current?.click()}
title="上传文件到当前页面附件"
disabled={loading}
>
<Paperclip className="h-3 w-3" />
</button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => {
void uploadFiles(e.target.files);
}}
accept="*/*"
/>
<div className="text-[11px] text-gray-400">Enter · Shift+Enter </div>
</div>
<button
type="button"
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
disabled={loading || !input.trim()}
onClick={() => void send()}
>
<Send className="h-4 w-4" />
{loading ? "执行中..." : "发送"}
</button>
</div>
</div>
{debug ? (
<details className="mt-2 rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-600">
<summary className="cursor-pointer select-none">tool trace</summary>
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap">{debug}</pre>
</details>
) : null}
</div>
);
}
@@ -296,6 +296,7 @@ const MindmapBlockView = ({
block,
editor,
fullscreen = false,
onExitFullscreen,
}: {
block: SpecificBlock<
CustomBlockSchema,
@@ -305,6 +306,7 @@ const MindmapBlockView = ({
>;
editor: BlockNoteEditor<CustomBlockSchema>;
fullscreen?: boolean;
onExitFullscreen?: () => void;
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
@@ -327,9 +329,8 @@ const MindmapBlockView = ({
const hotkeyScopeRef = useRef(false);
const lastInteractionAtRef = useRef(0);
const skipNextPasteRef = useRef(false);
const persistDataRef = useRef<((data: unknown) => void) | null>(null);
const persistDataRef = useRef<((data: unknown) => void) | null>(null);
const recentNodeDblclickRef = useRef(false);
const [fullscreenApiActive, setFullscreenApiActive] = useState(false);
const deletingRef = useRef(false);
const getRootNode = useCallback((mm?: MindMapInstance | null) => {
@@ -441,6 +442,7 @@ const MindmapBlockView = ({
// 这里用 setTimeout(0) 而不是 microtaskBlockNote/ProseMirror 可能会在同一轮事件里重新抢回焦点,
// 导致“选中节点后 Ctrl+V 把思维导图替换成纯文本”。延后一拍把焦点拉回 wrapper,保证快捷键/粘贴作用域稳定。
if (effectiveFullscreen) return;
window.setTimeout(() => {
try {
wrapperRef.current?.focus?.({ preventScroll: true });
@@ -455,13 +457,14 @@ const MindmapBlockView = ({
document.removeEventListener("pointerdown", onPointerDownCapture, true);
document.removeEventListener("mousedown", onPointerDownCapture, true);
};
}, []);
}, [effectiveFullscreen]);
// 关键:阻止鼠标事件冒泡到 BlockNote/ProseMirror(它们会在 contenteditable 上处理 mousedown,从而产生 NodeSelection)。
// 不能用 React 的 onMouseDown(事件委托在 document,太晚了),必须用原生监听挂在 wrapper 上,确保在 bubble 链路中先于 editor DOM。
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
if (effectiveFullscreen) return;
const stopBubble = (e: Event) => {
const target = e.target as HTMLElement | null;
@@ -525,7 +528,10 @@ const MindmapBlockView = ({
const exitLocalFullscreen = useCallback(() => {
setActiveSidebar(null);
if (fullscreen) return;
if (fullscreen) {
onExitFullscreen?.();
return;
}
if (typeof document === "undefined") {
setLocalFullscreen(false);
return;
@@ -540,29 +546,12 @@ const MindmapBlockView = ({
return;
}
setLocalFullscreen(false);
}, [fullscreen]);
}, [fullscreen, onExitFullscreen]);
const enterLocalFullscreen = useCallback(() => {
if (fullscreen) return;
setLocalFullscreen(true);
setActiveSidebar(null);
if (typeof document === "undefined") return;
if (!document.fullscreenEnabled) return;
if (document.fullscreenElement) return;
// 必须在“用户手势回调”中同步调用,避免 Fullscreen API 被浏览器拒绝
try {
const target = document.documentElement as unknown as {
requestFullscreen?: () => Promise<void>;
};
const p = target.requestFullscreen?.();
if (p && typeof p.catch === "function") {
p.catch(() => {
// ignore:失败则保持 Portal 伪全屏
});
}
} catch {
// ignore:失败则保持 Portal 伪全屏
}
}, [fullscreen]);
// 沉浸式全屏:锁定页面滚动、支持 ESC 退出(仅对内嵌全屏生效)
@@ -596,24 +585,6 @@ const MindmapBlockView = ({
};
}, [localFullscreen, fullscreen, exitLocalFullscreen]);
// “真全屏”:使用 Fullscreen API 隐藏浏览器/桌面窗口的外层 UIElectron/Web 都可用)
useEffect(() => {
if (typeof document === "undefined") return;
const onFsChange = () => {
const active = Boolean(document.fullscreenElement);
setFullscreenApiActive(active);
// 注意:浏览器在打开原生对话框(例如 file picker / alert / confirm)时可能会自动退出
// Fullscreen API。此时不应退出“沉浸式全屏”UI,否则会导致用户在全屏编辑中执行导入/新建
// 等操作时被强制退出全屏。
};
document.addEventListener("fullscreenchange", onFsChange);
return () => {
document.removeEventListener("fullscreenchange", onFsChange);
};
}, [localFullscreen]);
// 让 Enter/Tab/复制/粘贴在“思维导图块”内生效(避免 BlockNote 抢走快捷键)
useLayoutEffect(() => {
const onKeyDownCapture = (e: KeyboardEvent) => {
@@ -706,6 +677,21 @@ const MindmapBlockView = ({
//(例如 Ctrl+C/Ctrl+V 作为文本复制粘贴)。
if (isNodeTextEditing) return;
if ((key === "Delete" || key === "Backspace") && !e.shiftKey && !e.altKey && !isMod) {
stop();
const inst = ensureActiveBefore(mm);
if (!inst) return;
inst.execCommand?.("REMOVE_NODE");
hasLocalEditsRef.current = true;
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
} catch {
// ignore
}
return;
}
if (isMod && lower === "c") {
stop();
const inst = ensureActiveBefore(mm);
@@ -1043,10 +1029,10 @@ const MindmapBlockView = ({
);
(async () => {
try {
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, {
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data }),
body: JSON.stringify({ data, createOnly: true }),
});
if (!resp.ok) {
console.warn(
@@ -1426,9 +1412,14 @@ const MindmapBlockView = ({
window.__mindmapInstance = instance;
const w = window as unknown as {
__mindmapInstancesById?: Record<string, MindMapInstance>;
__mindmapPersistById?: Record<string, (data: unknown) => void>;
};
if (!w.__mindmapInstancesById) w.__mindmapInstancesById = {};
w.__mindmapInstancesById[mindmapId] = instance;
if (!w.__mindmapPersistById) w.__mindmapPersistById = {};
w.__mindmapPersistById[mindmapId] = (data: unknown) => {
persistDataRef.current?.(data);
};
}
setMindmap(instance);
@@ -1471,13 +1462,17 @@ const MindmapBlockView = ({
// 这里用内部事件标记“当前在思维导图作用域内”,确保 Ctrl+C/Ctrl+V 不会被 BlockNote 抢走。
hotkeyScopeRef.current = true;
lastInteractionAtRef.current = Date.now();
window.setTimeout(() => {
try {
wrapperRef.current?.focus?.({ preventScroll: true });
} catch {
// ignore
}
}, 0);
// 仅内嵌视图需要把焦点从编辑器拉回本块;全屏(Portal)下强制 focus
// 可能会抢走节点文本编辑的输入焦点,导致双击编辑不出光标。
if (!effectiveFullscreen) {
window.setTimeout(() => {
try {
wrapperRef.current?.focus?.({ preventScroll: true });
} catch {
// ignore
}
}, 0);
}
});
instance.on?.("node_click", (node: unknown) => {
// 确保点击即可激活(调用节点自身的 active 逻辑,保持和官方一致)
@@ -1499,13 +1494,17 @@ const MindmapBlockView = ({
);
hotkeyScopeRef.current = true;
lastInteractionAtRef.current = Date.now();
window.setTimeout(() => {
try {
wrapperRef.current?.focus?.({ preventScroll: true });
} catch {
// ignore
}
}, 0);
// 仅内嵌视图需要把焦点从编辑器拉回本块;全屏(Portal)下强制 focus
// 可能会抢走节点文本编辑的输入焦点,导致双击编辑不出光标。
if (!effectiveFullscreen) {
window.setTimeout(() => {
try {
wrapperRef.current?.focus?.({ preventScroll: true });
} catch {
// ignore
}
}, 0);
}
});
instance.on?.("painter_start", () => setPainterMode(true));
instance.on?.("painter_end", () => setPainterMode(false));
@@ -1601,10 +1600,16 @@ const MindmapBlockView = ({
window.__mindmapInstance = null;
}
try {
const w = window as unknown as { __mindmapInstancesById?: Record<string, MindMapInstance> };
const w = window as unknown as {
__mindmapInstancesById?: Record<string, MindMapInstance>;
__mindmapPersistById?: Record<string, (data: unknown) => void>;
};
if (w.__mindmapInstancesById && w.__mindmapInstancesById[mindmapId] === createdInstance) {
delete w.__mindmapInstancesById[mindmapId];
}
if (w.__mindmapPersistById && w.__mindmapPersistById[mindmapId]) {
delete w.__mindmapPersistById[mindmapId];
}
} catch {
// ignore
}
@@ -2392,7 +2397,7 @@ const MindmapBlockView = ({
>
<div className="fixed left-0 right-0 top-0 z-30 flex h-12 items-center justify-between border-b border-gray-200 bg-white/90 px-4 backdrop-blur">
<div className="text-sm font-medium text-gray-700">
{fullscreenApiActive ? "·真" : ""}
</div>
<button
type="button"
@@ -2421,6 +2426,8 @@ const MindmapBlockView = ({
onSelect={setActiveSidebar}
/>
<MindmapSidebar
documentId={docId}
mindmapId={mindmapId}
mindmap={mindmap}
activeNodes={activeNodes}
activeTab={activeSidebar}
@@ -2510,6 +2517,8 @@ const MindmapBlockView = ({
onSelect={setActiveSidebar}
/>
<MindmapSidebar
documentId={docId}
mindmapId={mindmapId}
mindmap={mindmap}
activeNodes={activeNodes}
activeTab={activeSidebar}
@@ -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 "";
@@ -1,7 +1,7 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
import { PageBacklinksPanel } from "@/components/editor/page-backlinks-panel";
@@ -12,6 +12,7 @@ import { DocumentHistoryDrawer } from "@/components/editor/document-history-draw
import type { DocumentSnapshot } from "@/types/document";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { ImagePickerProvider } from "@/components/media/image-picker-context";
import { useRouter } from "next/navigation";
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
@@ -31,6 +32,7 @@ export interface DocumentContentProps {
initialContent: unknown;
initialOptions: PageOptionsState;
initialStats: DocumentStats | null;
openTableId?: string | null;
}
const defaultOptions: PageOptionsState = {
@@ -52,14 +54,45 @@ export function DocumentContent({
initialContent,
initialOptions,
initialStats,
openTableId,
}: DocumentContentProps) {
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
const [history, setHistory] = useState<DocumentSnapshot[]>([]);
const [historyOpen, setHistoryOpen] = useState(false);
const editorBridge = useEditorBridgeStore((state) => state.bridge);
const router = useRouter();
const showInspector = usePageLayoutStore((state) => state.showInspector);
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
const [content, setContent] = useState<unknown>(initialContent);
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
const [contentError, setContentError] = useState<string | null>(null);
const [contentReloadKey, setContentReloadKey] = useState(0);
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
const contentLoadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingOpenTableRef = useRef<string | null>(null);
useEffect(() => {
const tableId = (openTableId ?? "").trim();
if (!tableId) return;
if (!editorBridge?.openTableFullScreen) return;
if (pendingOpenTableRef.current === tableId) return;
pendingOpenTableRef.current = tableId;
editorBridge.openTableFullScreen(tableId);
// 清理 URL 参数,避免刷新/回退时重复触发
if (typeof window !== "undefined") {
try {
const url = new URL(window.location.href);
url.searchParams.delete("openTableId");
window.history.replaceState({}, "", url.toString());
} catch {
// fallback:不影响主流程
router.replace(`/documents/${documentId}`);
}
}
}, [documentId, editorBridge, openTableId, router]);
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
@@ -75,6 +108,72 @@ export function DocumentContent({
}, [initialStats]);
/* eslint-enable react-hooks/set-state-in-effect */
useEffect(() => {
let canceled = false;
const controller = new AbortController();
const load = async () => {
setContentError(null);
setContentLoading(initialContent == null);
setContent(initialContent);
setShowContentLoadingIndicator(false);
if (initialContent != null) {
return;
}
if (contentLoadingTimerRef.current) {
clearTimeout(contentLoadingTimerRef.current);
contentLoadingTimerRef.current = null;
}
// 避免“秒闪”的加载提示:只有当加载超过短阈值时才显示提示
contentLoadingTimerRef.current = setTimeout(() => {
if (!canceled) {
setShowContentLoadingIndicator(true);
}
}, 200);
try {
const response = await fetch(`/api/documents/content?documentId=${encodeURIComponent(documentId)}`, {
method: "GET",
credentials: "include",
signal: controller.signal,
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload?.error ?? "加载页面内容失败");
}
const payload = (await response.json()) as { content?: unknown };
if (canceled) return;
setContent(payload.content ?? null);
} catch (error) {
if (canceled) return;
if ((error as { name?: string })?.name === "AbortError") return;
setContentError(error instanceof Error ? error.message : "加载页面内容失败");
} finally {
if (!canceled) {
setContentLoading(false);
setShowContentLoadingIndicator(false);
}
if (contentLoadingTimerRef.current) {
clearTimeout(contentLoadingTimerRef.current);
contentLoadingTimerRef.current = null;
}
}
};
void load();
return () => {
canceled = true;
controller.abort();
if (contentLoadingTimerRef.current) {
clearTimeout(contentLoadingTimerRef.current);
contentLoadingTimerRef.current = null;
}
};
}, [documentId, initialContent, contentReloadKey]);
const persistTitle = useCallback(
async (nextTitle: string) => {
const payload = nextTitle.trim() || "无标题";
@@ -226,14 +325,39 @@ export function DocumentContent({
<p className="text-sm text-gray-400">{formattedUpdatedAt}</p>
</div>
<div className="flex-1 overflow-y-auto px-12 py-6">
<BlockNoteEditor
documentId={documentId}
workspaceId={workspaceId}
initialContent={initialContent}
pageOptions={options}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
/>
{contentLoading ? (
showContentLoadingIndicator ? (
<div className="flex h-64 items-center justify-center text-sm text-gray-400">
...
</div>
) : (
<div className="h-64" />
)
) : contentError ? (
<div className="flex h-64 flex-col items-center justify-center gap-2 text-sm text-red-600">
<div>{contentError}</div>
<button
type="button"
className="rounded-md border border-red-200 bg-red-50 px-3 py-1 text-sm text-red-700 hover:bg-red-100"
onClick={() => {
setContentError(null);
setContentLoading(true);
setContentReloadKey((prev) => prev + 1);
}}
>
</button>
</div>
) : (
<BlockNoteEditor
documentId={documentId}
workspaceId={workspaceId}
initialContent={content}
pageOptions={options}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
/>
)}
<PageBacklinksPanel className="mt-10" workspaceId={workspaceId} documentId={documentId} />
</div>
</div>
@@ -213,6 +213,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
props: { tableId: newTable.id, title: newTable.title },
content: [],
});
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId: newTable.id } }));
} catch (error) {
console.error("Failed to create table:", error);
// TODO: 插入错误提示块
@@ -44,6 +44,12 @@ export function PageBacklinksPanel({ workspaceId, documentId, className }: PageB
});
const records = useMemo(() => data ?? [], [data]);
// 避免页面切换时“先出现加载态、随后又消失”的闪烁:
// 当尚未拿到任何记录且正在加载时,直接不渲染面板。
if (!error && records.length === 0 && (isLoading || isFetching)) {
return null;
}
if (!isLoading && !error && records.length === 0) {
return null;
}