0.1.11 ai修复与全屏
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
export type LocalAiConfig = {
|
||||
baseUrl: string; // 形如 http(s)://host/v1
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
const tryReadText = async (file: string) => {
|
||||
try {
|
||||
return await fs.readFile(file, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const findConfigText = async () => {
|
||||
const cwd = process.cwd();
|
||||
const candidates = [
|
||||
path.join(cwd, "ai.local.md"),
|
||||
path.join(cwd, "ai-local.md"),
|
||||
path.join(cwd, "..", "ai.local.md"),
|
||||
path.join(cwd, "..", "ai-local.md"),
|
||||
path.join(cwd, "..", "..", "ai.local.md"),
|
||||
path.join(cwd, "..", "..", "ai-local.md"),
|
||||
];
|
||||
for (const f of candidates) {
|
||||
const text = await tryReadText(f);
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseAiMd = (raw: string): LocalAiConfig | null => {
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// 兼容格式:
|
||||
// apikey:xxxx(可选)
|
||||
// http://.../v1
|
||||
// model-name
|
||||
const apiLine = lines.find((l) => /^apikey[::]/i.test(l));
|
||||
const apiKey = apiLine ? apiLine.replace(/^apikey[::]\s*/i, "").trim() : "";
|
||||
const baseUrl = lines.find((l) => /^https?:\/\//i.test(l)) ?? "";
|
||||
const model =
|
||||
(lines.findLast?.((l) => !/^apikey[::]/i.test(l) && !/^https?:\/\//i.test(l)) ??
|
||||
lines.find((l) => !/^apikey[::]/i.test(l) && !/^https?:\/\//i.test(l)) ??
|
||||
"").trim();
|
||||
|
||||
if (!baseUrl || !model) return null;
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl: baseUrl.replace(/\/+$/, ""),
|
||||
model,
|
||||
};
|
||||
};
|
||||
|
||||
export const loadLocalAiConfig = async (): Promise<LocalAiConfig | null> => {
|
||||
// 环境变量优先(方便部署),其次读取 ai.local.md / ai-local.md
|
||||
const envBase = (process.env.LOCAL_AI_BASE_URL ?? "").trim();
|
||||
const envModel = (process.env.LOCAL_AI_MODEL ?? "").trim();
|
||||
const envKey = (process.env.LOCAL_AI_API_KEY ?? "").trim();
|
||||
if (envBase && envModel) {
|
||||
return {
|
||||
baseUrl: envBase.replace(/\/+$/, ""),
|
||||
apiKey: envKey,
|
||||
model: envModel,
|
||||
};
|
||||
}
|
||||
|
||||
const text = await findConfigText();
|
||||
if (!text) return null;
|
||||
return parseAiMd(text);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
export type OnlineAiConfig = {
|
||||
baseUrl: string; // 形如 http(s)://host/v1
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
const tryReadText = async (file: string) => {
|
||||
try {
|
||||
return await fs.readFile(file, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const findConfigText = async () => {
|
||||
const cwd = process.cwd();
|
||||
const candidates = [
|
||||
path.join(cwd, "ai.md"),
|
||||
path.join(cwd, "..", "ai.md"),
|
||||
path.join(cwd, "..", "..", "ai.md"),
|
||||
];
|
||||
for (const f of candidates) {
|
||||
const text = await tryReadText(f);
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseAiMd = (raw: string): OnlineAiConfig | null => {
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// 兼容格式:
|
||||
// apikey:xxxx
|
||||
// http://.../v1
|
||||
// model-name
|
||||
const apiLine = lines.find((l) => /^apikey[::]/i.test(l));
|
||||
const apiKey = apiLine ? apiLine.replace(/^apikey[::]\s*/i, "").trim() : "";
|
||||
const baseUrl = lines.find((l) => /^https?:\/\//i.test(l)) ?? "";
|
||||
// model 行通常是最后一行
|
||||
const model = (lines.findLast?.((l) => !/^apikey[::]/i.test(l) && !/^https?:\/\//i.test(l)) ??
|
||||
lines.find((l) => !/^apikey[::]/i.test(l) && !/^https?:\/\//i.test(l)) ??
|
||||
"").trim();
|
||||
|
||||
if (!apiKey || !baseUrl || !model) return null;
|
||||
|
||||
// Cloudflare 场景下 http 可能只允许 GET(/models),但 POST(/chat/completions)会被拦截;
|
||||
// 对非本机地址默认升级到 https,确保在线推理可用。
|
||||
let normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
|
||||
try {
|
||||
const u = new URL(normalizedBaseUrl);
|
||||
if (u.protocol === "http:" && u.hostname !== "127.0.0.1" && u.hostname !== "localhost") {
|
||||
u.protocol = "https:";
|
||||
normalizedBaseUrl = u.toString().replace(/\/+$/, "");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
model,
|
||||
};
|
||||
};
|
||||
|
||||
export const loadOnlineAiConfig = async (): Promise<OnlineAiConfig | null> => {
|
||||
// 环境变量优先(方便生产/部署),其次读取 ai.md(本地开发快捷配置)
|
||||
const envKey = (process.env.ONLINE_AI_API_KEY ?? "").trim();
|
||||
const envBase = (process.env.ONLINE_AI_BASE_URL ?? "").trim();
|
||||
const envModel = (process.env.ONLINE_AI_MODEL ?? "").trim();
|
||||
if (envKey && envBase && envModel) {
|
||||
// 同 parseAiMd:默认把非本机 http 升级为 https
|
||||
let normalizedBaseUrl = envBase.replace(/\/+$/, "");
|
||||
try {
|
||||
const u = new URL(normalizedBaseUrl);
|
||||
if (u.protocol === "http:" && u.hostname !== "127.0.0.1" && u.hostname !== "localhost") {
|
||||
u.protocol = "https:";
|
||||
normalizedBaseUrl = u.toString().replace(/\/+$/, "");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { apiKey: envKey, baseUrl: normalizedBaseUrl, model: envModel };
|
||||
}
|
||||
|
||||
const text = await findConfigText();
|
||||
if (!text) return null;
|
||||
return parseAiMd(text);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
export type OpenAiCompatibleChatMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type OpenAiCompatibleChatOptions = {
|
||||
baseUrl: string; // 形如 https://host/v1
|
||||
apiKey: string;
|
||||
model: string;
|
||||
timeoutMs?: number;
|
||||
maxTokens?: number;
|
||||
// 某些 OpenAI 兼容网关使用 max_completion_tokens 字段;如需可传入该值
|
||||
maxCompletionTokens?: number;
|
||||
responseFormat?: "json_object";
|
||||
};
|
||||
|
||||
export const tryExtractJsonObject = (value: string): Record<string, unknown> | null => {
|
||||
const s = value ?? "";
|
||||
const start = s.indexOf("{");
|
||||
const end = s.lastIndexOf("}");
|
||||
if (start === -1 || end === -1 || end <= start) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(s.slice(start, end + 1));
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const openAiCompatibleChat = async (
|
||||
messages: OpenAiCompatibleChatMessage[],
|
||||
opts: OpenAiCompatibleChatOptions,
|
||||
): Promise<{ text: string; raw: unknown }> => {
|
||||
const timeoutMs = Math.max(500, Math.min(120_000, opts.timeoutMs ?? 20_000));
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const url = `${opts.baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
||||
const maxCompletionTokens =
|
||||
typeof opts.maxCompletionTokens === "number" && Number.isFinite(opts.maxCompletionTokens)
|
||||
? Math.max(64, Math.min(16_000, Math.floor(opts.maxCompletionTokens)))
|
||||
: undefined;
|
||||
const maxTokens =
|
||||
typeof opts.maxTokens === "number" && Number.isFinite(opts.maxTokens)
|
||||
? Math.max(64, Math.min(16_000, Math.floor(opts.maxTokens)))
|
||||
: undefined;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${opts.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: opts.model,
|
||||
stream: false,
|
||||
temperature: 0.2,
|
||||
...(maxTokens ? { max_tokens: maxTokens } : {}),
|
||||
...(maxCompletionTokens ? { max_completion_tokens: maxCompletionTokens } : {}),
|
||||
...(opts.responseFormat === "json_object" ? { response_format: { type: "json_object" } } : {}),
|
||||
messages,
|
||||
}),
|
||||
});
|
||||
|
||||
const raw = (await res.json().catch(() => null)) as unknown;
|
||||
if (!res.ok) {
|
||||
const errText =
|
||||
typeof raw === "object" && raw && "error" in (raw as any)
|
||||
? String((raw as any).error?.message ?? (raw as any).error)
|
||||
: `HTTP ${res.status}`;
|
||||
throw new Error(`在线 AI 调用失败:${errText}`);
|
||||
}
|
||||
|
||||
const choiceText =
|
||||
(raw as any)?.choices?.[0]?.message?.content ??
|
||||
(raw as any)?.choices?.[0]?.text ??
|
||||
"";
|
||||
const text = String(choiceText ?? "");
|
||||
return { text, raw };
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
};
|
||||
|
||||
export const openAiCompatibleChatJson = async (
|
||||
messages: OpenAiCompatibleChatMessage[],
|
||||
opts: OpenAiCompatibleChatOptions,
|
||||
): Promise<{ json: Record<string, unknown>; text: string; raw: unknown }> => {
|
||||
const { text, raw } = await openAiCompatibleChat(messages, opts);
|
||||
const json = tryExtractJsonObject(text);
|
||||
if (!json) {
|
||||
const preview = String(text || "").slice(0, 220).replace(/\s+/g, " ").trim();
|
||||
throw new Error(`在线 AI 未返回可解析的 JSON 对象:${preview || "(empty)"}`);
|
||||
}
|
||||
return { json, text, raw };
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import "server-only";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
const preferredBaseDir = path.join(process.cwd(), "public", "documents");
|
||||
const legacyBaseDir = path.join(process.cwd(), "public", "mindmaps");
|
||||
@@ -85,3 +87,94 @@ export async function detectLocalMindmapFiles(docIds: string[]): Promise<LocalMi
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
type TrashedMindmapMeta = {
|
||||
docId: string;
|
||||
mindmapId: string;
|
||||
originalFileName: string;
|
||||
originalPath: string;
|
||||
trashedFileName: string;
|
||||
deleted_at: string;
|
||||
};
|
||||
|
||||
async function tryReadJsonFile<T>(file: string): Promise<T | null> {
|
||||
try {
|
||||
const content = await fs.readFile(file, "utf8");
|
||||
return JSON.parse(content) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function listTrashMetasForFolder(folder: string): Promise<TrashedMindmapMeta[]> {
|
||||
const trashDir = path.join(folder, ".trash");
|
||||
try {
|
||||
const entries = await fs.readdir(trashDir);
|
||||
const metas: TrashedMindmapMeta[] = [];
|
||||
for (const name of entries) {
|
||||
if (!name.endsWith(".deleted.json")) continue;
|
||||
const metaPath = path.join(trashDir, name);
|
||||
const meta = await tryReadJsonFile<TrashedMindmapMeta>(metaPath);
|
||||
if (meta?.docId && meta?.mindmapId && meta?.trashedFileName && meta?.deleted_at) {
|
||||
// 若原路径已存在(用户撤销/恢复),则不再展示为垃圾桶记录,避免堆积。
|
||||
try {
|
||||
await fs.access(meta.originalPath);
|
||||
await fs.rm(path.join(trashDir, meta.trashedFileName), { force: true });
|
||||
await fs.rm(metaPath, { force: true });
|
||||
continue;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
metas.push(meta);
|
||||
}
|
||||
}
|
||||
return metas;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectLocalTrashedMindmapAssets(
|
||||
workspaceId: string,
|
||||
docIds: string[],
|
||||
): Promise<MediaAsset[]> {
|
||||
const results: MediaAsset[] = [];
|
||||
|
||||
for (const docId of docIds) {
|
||||
const preferredFolder = path.join(preferredBaseDir, docId);
|
||||
const legacyFolder = path.join(legacyBaseDir, docId);
|
||||
const metas = [
|
||||
...(await listTrashMetasForFolder(preferredFolder)),
|
||||
...(await listTrashMetasForFolder(legacyFolder)),
|
||||
];
|
||||
|
||||
metas.forEach((meta) => {
|
||||
results.push({
|
||||
id: meta.mindmapId,
|
||||
workspace_id: workspaceId,
|
||||
document_id: docId,
|
||||
asset_type: "mindmap",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: meta.originalFileName,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
deleted_at: meta.deleted_at,
|
||||
deleted_by: null,
|
||||
purged_at: null,
|
||||
signed_url: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
results.sort((a, b) => (b.deleted_at ?? "").localeCompare(a.deleted_at ?? ""));
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -7,14 +7,25 @@ import { buildDocumentTree } from "@/lib/documents";
|
||||
|
||||
type TypedClient = SupabaseClient<Database>;
|
||||
|
||||
export type SidebarTableRow = {
|
||||
id: string;
|
||||
workspace_id: string | null;
|
||||
document_id: string;
|
||||
title: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export interface SidebarDataset {
|
||||
documents: DocumentRecord[];
|
||||
trashedDocuments: TrashRecord[];
|
||||
trashedMediaAssets: MediaAsset[];
|
||||
/**
|
||||
* 仅依据远端 mindmap_data 判定的页面 id 列表;本地文件检测由服务器侧辅助完成
|
||||
*/
|
||||
mindmapDocs: string[];
|
||||
mediaAssets?: MediaAsset[];
|
||||
tables?: SidebarTableRow[];
|
||||
}
|
||||
|
||||
export async function fetchSidebarDataset(
|
||||
@@ -76,6 +87,7 @@ export async function fetchSidebarDataset(
|
||||
.from("media_assets")
|
||||
.select("*")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.is("deleted_at", null)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (assetError) {
|
||||
@@ -84,11 +96,43 @@ export async function fetchSidebarDataset(
|
||||
|
||||
const mediaAssets: MediaAsset[] = (assetRows ?? []) as MediaAsset[];
|
||||
|
||||
const { data: trashedAssetRows, error: trashedAssetError } = await client
|
||||
.from("media_assets")
|
||||
.select("*")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.not("deleted_at", "is", null)
|
||||
.is("purged_at", null)
|
||||
.order("deleted_at", { ascending: false })
|
||||
.limit(200);
|
||||
|
||||
if (trashedAssetError) {
|
||||
throw new Error(`获取附件垃圾桶失败:${trashedAssetError.message}`);
|
||||
}
|
||||
|
||||
const trashedMediaAssets: MediaAsset[] = (trashedAssetRows ?? []) as MediaAsset[];
|
||||
|
||||
// 说明:当前 supabase types 可能未包含 document_tables;这里用 any 兜底,
|
||||
// 避免类型缺失阻塞侧边栏功能。
|
||||
const { data: tableRows, error: tableError } = await (client as any)
|
||||
.from("document_tables")
|
||||
.select("id,workspace_id,document_id,title,created_at,updated_at,is_archived")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("is_archived", false)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
if (tableError) {
|
||||
throw new Error(`获取在线表格列表失败:${tableError.message}`);
|
||||
}
|
||||
|
||||
const tables: SidebarTableRow[] = (tableRows ?? []) as unknown as SidebarTableRow[];
|
||||
|
||||
return {
|
||||
documents,
|
||||
trashedDocuments,
|
||||
trashedMediaAssets,
|
||||
mindmapDocs,
|
||||
mediaAssets,
|
||||
tables,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user