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
@@ -0,0 +1,71 @@
import "server-only";
import path from "path";
import { promises as fs } from "fs";
import { preferredBaseDir, legacyBaseDir } from "@/lib/mindmap-files";
export function resolveMindmapFileName(mindmapId: string) {
if (mindmapId === "legacy" || mindmapId.startsWith("legacy-")) {
return "mindmap.json";
}
return `mindmap-${mindmapId}.json`;
}
async function ensureDir(dir: string) {
await fs.mkdir(dir, { recursive: true });
}
async function ensureIndexFile(folder: string, title = "无标题") {
const indexFile = path.join(folder, "index.md");
try {
await fs.access(indexFile);
} catch {
await fs.writeFile(indexFile, `# ${title}\n`, "utf8");
}
}
async function tryReadJson(file: string) {
try {
const content = await fs.readFile(file, "utf8");
return JSON.parse(content);
} catch {
return null;
}
}
export type ReadMindmapResult =
| { ok: true; data: any; source: "preferred" | "legacy" }
| { ok: false; data: null; source: null };
export async function readMindmapLocal(docId: string, mindmapId: string): Promise<ReadMindmapResult> {
const preferredFolder = path.join(preferredBaseDir, docId);
const preferredFile = path.join(preferredFolder, resolveMindmapFileName(mindmapId));
const preferredLegacy = path.join(preferredFolder, "mindmap.json");
const legacyDirFile =
path.basename(preferredFile) === "mindmap.json"
? path.join(legacyBaseDir, docId, "mindmap.json")
: null;
const data =
(await tryReadJson(preferredFile)) ??
(await tryReadJson(preferredLegacy)) ??
(legacyDirFile ? await tryReadJson(legacyDirFile) : null);
if (data) return { ok: true, data, source: "preferred" };
return { ok: false, data: null, source: null };
}
export async function writeMindmapLocal(
docId: string,
mindmapId: string,
data: unknown,
docTitle: string,
) {
const folder = path.join(preferredBaseDir, docId);
const file = path.join(folder, resolveMindmapFileName(mindmapId));
await ensureDir(folder);
await ensureIndexFile(folder, docTitle || "无标题");
await fs.writeFile(file, JSON.stringify(data ?? { data: { text: "中心主题" }, children: [] }, null, 2), "utf8");
return { folder, file };
}
@@ -0,0 +1,241 @@
import "server-only";
export type NodeRef = {
kind: "pdf" | "docx" | "pptx" | "url";
assetId?: string;
fileUrl?: string;
page?: number; // 1-based
slide?: number; // 1-based
title?: string;
snippet?: string;
};
export type MindmapNodeData = {
uid?: string;
text?: string;
hyperlink?: string;
note?: string;
refs?: NodeRef[];
[k: string]: unknown;
};
export type MindmapTreeNode = {
data: MindmapNodeData;
children?: MindmapTreeNode[];
[k: string]: unknown;
};
export type MindmapOp =
| {
op: "addChild";
parentUid: string;
node: { uid?: string; text: string; hyperlink?: string; refs?: NodeRef[]; note?: string };
}
| {
op: "addSiblingAfter";
targetUid: string;
node: { uid?: string; text: string; hyperlink?: string; refs?: NodeRef[]; note?: string };
}
| { op: "updateText"; uid: string; text: string }
| { op: "setHyperlink"; uid: string; hyperlink: string | null }
| { op: "setRefs"; uid: string; refs: NodeRef[] }
| { op: "appendNote"; uid: string; markdown: string }
| { op: "deleteNode"; uid: string };
const createUid = () => {
const anyCrypto = globalThis.crypto as unknown as { randomUUID?: () => string } | undefined;
if (anyCrypto?.randomUUID) return anyCrypto.randomUUID();
return Math.random().toString(36).slice(2);
};
export const ensureMindmapUids = (root: MindmapTreeNode) => {
const walk = (node: MindmapTreeNode) => {
if (!node.data) node.data = {};
if (!node.data.uid) node.data.uid = createUid();
if (typeof node.data.text !== "string") node.data.text = String(node.data.text ?? "新节点");
if (!Array.isArray(node.children)) node.children = [];
node.children.forEach(walk);
};
walk(root);
return root;
};
type Indexed = {
node: MindmapTreeNode;
parent: MindmapTreeNode | null;
index: number;
};
const buildIndex = (root: MindmapTreeNode) => {
const map = new Map<string, Indexed>();
const walk = (node: MindmapTreeNode, parent: MindmapTreeNode | null) => {
const uid = String(node?.data?.uid || "");
if (uid) map.set(uid, { node, parent, index: -1 });
const children = Array.isArray(node.children) ? node.children : [];
children.forEach((child, idx) => {
const cuid = String(child?.data?.uid || "");
if (cuid) map.set(cuid, { node: child, parent: node, index: idx });
walk(child, node);
});
};
walk(root, null);
return map;
};
const safeUrlOrNull = (value: unknown) => {
const s = typeof value === "string" ? value.trim() : "";
if (!s) return null;
try {
const u = new URL(s);
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
return u.toString();
} catch {
return null;
}
};
export const applyMindmapOps = (
raw: unknown,
ops: MindmapOp[],
): { data: MindmapTreeNode; applied: number; errors: string[] } => {
const root = (raw && typeof raw === "object" ? (raw as MindmapTreeNode) : null) ?? {
data: { text: "中心主题" },
children: [],
};
ensureMindmapUids(root);
const errors: string[] = [];
let applied = 0;
for (const op of ops) {
ensureMindmapUids(root);
const index = buildIndex(root);
if (!op || typeof op !== "object" || !("op" in op)) {
errors.push("无效 op");
continue;
}
if (op.op === "addChild") {
const parent = index.get(op.parentUid)?.node ?? null;
if (!parent) {
errors.push(`addChild: 找不到 parentUid=${op.parentUid}`);
continue;
}
if (!Array.isArray(parent.children)) parent.children = [];
const uid = op.node.uid || createUid();
parent.children.push({
data: {
uid,
text: String(op.node.text ?? "新节点"),
...(op.node.note ? { note: String(op.node.note) } : {}),
...(op.node.refs ? { refs: op.node.refs } : {}),
...(op.node.hyperlink ? { hyperlink: safeUrlOrNull(op.node.hyperlink) ?? undefined } : {}),
},
children: [],
});
applied += 1;
continue;
}
if (op.op === "addSiblingAfter") {
const hit = index.get(op.targetUid);
if (!hit?.parent) {
errors.push(`addSiblingAfter: 找不到 targetUid=${op.targetUid} 或无父节点(不能对根节点加同级)`);
continue;
}
if (!Array.isArray(hit.parent.children)) hit.parent.children = [];
const uid = op.node.uid || createUid();
const insertAt = Math.max(0, Math.min(hit.parent.children.length, hit.index + 1));
hit.parent.children.splice(insertAt, 0, {
data: {
uid,
text: String(op.node.text ?? "新节点"),
...(op.node.note ? { note: String(op.node.note) } : {}),
...(op.node.refs ? { refs: op.node.refs } : {}),
...(op.node.hyperlink ? { hyperlink: safeUrlOrNull(op.node.hyperlink) ?? undefined } : {}),
},
children: [],
});
applied += 1;
continue;
}
if (op.op === "updateText") {
const hit = index.get(op.uid)?.node ?? null;
if (!hit) {
errors.push(`updateText: 找不到 uid=${op.uid}`);
continue;
}
hit.data = hit.data || {};
hit.data.text = String(op.text ?? "");
applied += 1;
continue;
}
if (op.op === "setHyperlink") {
const hit = index.get(op.uid)?.node ?? null;
if (!hit) {
errors.push(`setHyperlink: 找不到 uid=${op.uid}`);
continue;
}
hit.data = hit.data || {};
const url = safeUrlOrNull(op.hyperlink);
if (!url) {
delete (hit.data as any).hyperlink;
} else {
hit.data.hyperlink = url;
}
applied += 1;
continue;
}
if (op.op === "setRefs") {
const hit = index.get(op.uid)?.node ?? null;
if (!hit) {
errors.push(`setRefs: 找不到 uid=${op.uid}`);
continue;
}
hit.data = hit.data || {};
hit.data.refs = Array.isArray(op.refs) ? op.refs : [];
applied += 1;
continue;
}
if (op.op === "appendNote") {
const hit = index.get(op.uid)?.node ?? null;
if (!hit) {
errors.push(`appendNote: 找不到 uid=${op.uid}`);
continue;
}
hit.data = hit.data || {};
const prev = typeof hit.data.note === "string" ? hit.data.note : "";
const next = String(op.markdown ?? "");
hit.data.note = prev ? `${prev}\n\n${next}` : next;
applied += 1;
continue;
}
if (op.op === "deleteNode") {
const hit = index.get(op.uid);
if (!hit) {
errors.push(`deleteNode: 找不到 uid=${op.uid}`);
continue;
}
if (!hit.parent) {
errors.push("deleteNode: 不能删除根节点");
continue;
}
if (!Array.isArray(hit.parent.children)) hit.parent.children = [];
hit.parent.children = hit.parent.children.filter((c) => String(c?.data?.uid || "") !== op.uid);
applied += 1;
continue;
}
errors.push(`不支持的 op: ${(op as any).op}`);
}
ensureMindmapUids(root);
return { data: root, applied, errors };
};