0.2.1 onlyoffice修复

This commit is contained in:
liaibo
2026-01-17 10:12:53 +08:00
parent 94957dc361
commit 19907bccdc
102 changed files with 7188 additions and 186 deletions
@@ -162,10 +162,28 @@ const saveDocumentBlocks = async (supabase: DocSupabaseClient, ctx: DocToolConte
};
export const createDocServerTools = (args: {
supabase: DocSupabaseClient;
supabase?: DocSupabaseClient;
ctx: DocToolContext;
allowedToolIds: Set<string>;
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
loadBlocks?: () => Promise<{ blocks: unknown[]; source: string }>;
saveBlocks?: (blocks: unknown[]) => Promise<void>;
}) => {
const loadBlocks = async () => {
if (args.loadBlocks) return await args.loadBlocks();
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadBlocks/saveBlocks");
return await loadDocumentBlocks(args.supabase, args.ctx);
};
const saveBlocks = async (blocks: unknown[]) => {
if (args.saveBlocks) {
await args.saveBlocks(blocks);
return;
}
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadBlocks/saveBlocks");
await saveDocumentBlocks(args.supabase, args.ctx, blocks);
};
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
if (!args.allowedToolIds.has(toolId)) {
throw new Error(`工具未被允许:${toolId}`);
@@ -174,7 +192,7 @@ export const createDocServerTools = (args: {
if (toolId === "doc_get") {
const maxNodesRaw = Number(toolArgs.maxBlocks ?? 80);
const maxBlocks = Math.max(10, Math.min(240, Number.isFinite(maxNodesRaw) ? Math.floor(maxNodesRaw) : 80));
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
const { blocks, source } = await loadBlocks();
const summary = walkSummaries(blocks, maxBlocks);
return { ok: true, source, totalTopLevelBlocks: blocks.length, blocks: summary };
}
@@ -184,7 +202,7 @@ export const createDocServerTools = (args: {
if (!query) throw new Error("缺少 query");
const maxRaw = Number(toolArgs.maxResults ?? 8);
const maxResults = Math.max(1, Math.min(30, Number.isFinite(maxRaw) ? Math.floor(maxRaw) : 8));
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
const { blocks, source } = await loadBlocks();
const summary = walkSummaries(blocks, 400);
const q = query.toLowerCase();
const hits = summary.filter((x) => x.text.toLowerCase().includes(q)).slice(0, maxResults);
@@ -207,7 +225,7 @@ export const createDocServerTools = (args: {
const created = specs.map(buildBlockFromSpec);
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
const { blocks, source } = await loadBlocks();
const targetId = beforeBlockId || afterBlockId;
const found = targetId ? findContainerById(blocks, targetId) : null;
if (targetId && !found) {
@@ -221,7 +239,7 @@ export const createDocServerTools = (args: {
found.container.splice(insertAt, 0, ...created);
}
await saveDocumentBlocks(args.supabase, args.ctx, blocks);
await saveBlocks(blocks);
return {
ok: true,
source,
@@ -238,7 +256,7 @@ export const createDocServerTools = (args: {
const modeRaw = String(toolArgs.mode ?? "replace").trim();
const mode = modeRaw === "append" || modeRaw === "prepend" ? modeRaw : "replace";
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
const { blocks, source } = await loadBlocks();
const found = findContainerById(blocks, blockId);
if (!found) throw new Error(`未找到 blockId${blockId}`);
const block = found.container[found.index];
@@ -247,7 +265,7 @@ export const createDocServerTools = (args: {
const nextText = mode === "append" ? `${prevText}${text}` : mode === "prepend" ? `${text}${prevText}` : text;
found.container[found.index] = { ...block, content: createTextContent(nextText) };
await saveDocumentBlocks(args.supabase, args.ctx, blocks);
await saveBlocks(blocks);
return { ok: true, source, blockId, mode, data: blocks };
}
@@ -45,9 +45,42 @@ const loadWorkspaceIds = async (supabase: DocsSupabaseClient, userId: string) =>
};
export const createDocsServerTools = (args: {
supabase: DocsSupabaseClient;
supabase?: DocsSupabaseClient;
ctx: DocsToolContext;
allowedToolIds: Set<string>;
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
searchDocs?: (args: {
userId: string;
query: string;
limit: number;
workspaceId: string | null;
includeDeleted: boolean;
}) => Promise<
Array<{
id: string;
title: string;
workspaceId: string;
parentId: string | null;
updatedAt: unknown;
snippet: string;
}>
>;
readDoc?: (args: {
userId: string;
documentId: string;
maxChars: number;
includeContent: boolean;
}) => Promise<{
ok: true;
documentId: string;
title: string;
workspaceId: string;
parentId: string | null;
updatedAt: unknown;
rawTextLength: number;
rawText: string;
content?: unknown;
}>;
}) => {
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
if (!args.allowedToolIds.has(toolId)) {
@@ -62,6 +95,21 @@ export const createDocsServerTools = (args: {
const workspaceId = String(toolArgs.workspaceId ?? "").trim() || null;
const includeDeleted = Boolean(toolArgs.includeDeleted ?? false);
if (args.searchDocs) {
const results = await args.searchDocs({
userId: args.ctx.userId,
query,
limit,
workspaceId,
includeDeleted,
});
return { ok: true, query, results };
}
if (!args.supabase) {
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 searchDocs/readDoc");
}
const wsIds = await loadWorkspaceIds(args.supabase, args.ctx.userId);
const wsFilter = workspaceId ? [workspaceId] : wsIds;
if (wsFilter.length === 0) return { ok: true, query, results: [] };
@@ -108,6 +156,19 @@ export const createDocsServerTools = (args: {
const maxChars = Math.max(200, Math.min(20_000, Number.isFinite(maxCharsRaw) ? Math.floor(maxCharsRaw) : 2500));
const includeContent = Boolean(toolArgs.includeContent ?? false);
if (args.readDoc) {
return await args.readDoc({
userId: args.ctx.userId,
documentId,
maxChars,
includeContent,
});
}
if (!args.supabase) {
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 searchDocs/readDoc");
}
const { data, error } = await args.supabase
.from("documents")
.select(includeContent ? "id,title,raw_text,content,workspace_id,parent_id,updated_at" : "id,title,raw_text,workspace_id,parent_id,updated_at")
@@ -30,9 +30,12 @@ const resolveAttachment = (ctx: MediaToolContext, ref: string): ResolvedAttachme
const pick = (obj: unknown, key: string) => (isRecord(obj) ? obj[key] : undefined);
export const createMediaServerTools = (args: {
supabase: MediaSupabaseClient;
supabase?: MediaSupabaseClient;
ctx: MediaToolContext;
allowedToolIds: Set<string>;
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
loadById?: (id: string) => Promise<unknown | null>;
loadByFileUrl?: (fileUrl: string) => Promise<unknown | null>;
}) => {
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
@@ -48,27 +51,37 @@ export const createMediaServerTools = (args: {
let row: unknown = null;
if (targetAssetId) {
const { data, error } = await args.supabase
.from("media_assets")
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
.eq("id", targetAssetId)
.single();
if (error) {
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
if (args.loadById) {
row = await args.loadById(targetAssetId);
} else {
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadById/loadByFileUrl");
const { data, error } = await args.supabase
.from("media_assets")
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
.eq("id", targetAssetId)
.single();
if (error) {
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
}
row = data;
}
row = data;
} else if (targetUrl) {
const { data, error } = await args.supabase
.from("media_assets")
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
.eq("file_url", targetUrl)
.order("updated_at", { ascending: false })
.limit(1)
.maybeSingle();
if (error) {
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
if (args.loadByFileUrl) {
row = await args.loadByFileUrl(targetUrl);
} else {
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadById/loadByFileUrl");
const { data, error } = await args.supabase
.from("media_assets")
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
.eq("file_url", targetUrl)
.order("updated_at", { ascending: false })
.limit(1)
.maybeSingle();
if (error) {
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
}
row = data;
}
row = data;
} else {
throw new Error("缺少 assetId / fileUrl / attachmentRef");
}
@@ -109,4 +122,3 @@ export const createMediaServerTools = (args: {
return { run };
};
@@ -188,13 +188,25 @@ const sanitizeAddChildOps = (args: {
};
export const createMindmapServerTools = (args: {
supabase: SupabaseRouteClient;
supabase?: SupabaseRouteClient;
ctx: MindmapToolContext;
cfg: OpenAiCompatibleChatOptions;
allowedToolIds: Set<string>;
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase/local 文件。
loadMindmap?: () => Promise<{
doc: { id: string; title: string | null; workspace_id: string | null };
base: MindmapTreeNode;
}>;
saveMindmap?: (args: {
doc: { id: string; title: string | null; workspace_id: string | null };
data: MindmapTreeNode;
}) => Promise<void>;
}) => {
const loadDoc = async () => {
const { documentId, userId } = args.ctx;
if (!args.supabase) {
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadMindmap/saveMindmap");
}
const query = args.supabase
.from("documents")
.select("id,title,workspace_id,mindmap_data")
@@ -210,6 +222,11 @@ export const createMindmapServerTools = (args: {
};
const loadMindmap = async () => {
if (args.loadMindmap) {
const loaded = await args.loadMindmap();
ensureMindmapUids(loaded.base);
return loaded;
}
const doc = await loadDoc();
const local = await readMindmapLocal(args.ctx.documentId, args.ctx.mindmapId);
const base = (local.ok ? local.data : (doc.mindmap_data ?? defaultMindmapData)) as MindmapTreeNode;
@@ -217,6 +234,14 @@ export const createMindmapServerTools = (args: {
return { doc, base };
};
const persistMindmap = async (doc: { id: string; title: string | null; workspace_id: string | null }, nextData: MindmapTreeNode) => {
if (args.saveMindmap) {
await args.saveMindmap({ doc, data: nextData });
return;
}
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
};
const mindmap_get = async (toolArgs: Record<string, unknown>) => {
const maxNodes = Number(toolArgs.maxNodes ?? 120);
const { base } = await loadMindmap();
@@ -313,7 +338,7 @@ export const createMindmapServerTools = (args: {
const { doc, base } = await loadMindmap();
const { data: nextData, applied, errors } = applyMindmapOps(base, normalized);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -334,7 +359,7 @@ export const createMindmapServerTools = (args: {
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
};
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -355,7 +380,7 @@ export const createMindmapServerTools = (args: {
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
};
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -368,7 +393,7 @@ export const createMindmapServerTools = (args: {
const { doc, base } = await loadMindmap();
const op: MindmapOp = { op: "updateText", uid, text };
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -384,7 +409,7 @@ export const createMindmapServerTools = (args: {
const { doc, base } = await loadMindmap();
const op: MindmapOp = { op: "setHyperlink", uid, hyperlink: hyperlinkRaw === null ? null : hyperlink };
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -397,7 +422,7 @@ export const createMindmapServerTools = (args: {
const { doc, base } = await loadMindmap();
const op: MindmapOp = { op: "appendNote", uid, markdown };
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -410,7 +435,7 @@ export const createMindmapServerTools = (args: {
const { doc, base } = await loadMindmap();
const op: MindmapOp = { op: "setRefs", uid, refs };
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -421,7 +446,7 @@ export const createMindmapServerTools = (args: {
const { doc, base } = await loadMindmap();
const op: MindmapOp = { op: "deleteNode", uid };
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -493,7 +518,7 @@ export const createMindmapServerTools = (args: {
const nextRefs = mode === "replace" ? [ref] : mergeRefsUnique(prevRefs, [ref]);
const op: MindmapOp = { op: "setRefs", uid, refs: nextRefs };
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -546,7 +571,7 @@ export const createMindmapServerTools = (args: {
node: { text, ...(hyperlink ? { hyperlink } : {}), refs: [ref], ...(note ? { note } : {}) },
};
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -579,7 +604,7 @@ export const createMindmapServerTools = (args: {
const { doc, base } = await loadMindmap();
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -600,7 +625,7 @@ export const createMindmapServerTools = (args: {
const { doc, base } = await loadMindmap();
const op: MindmapOp = { op: "appendNote", uid, markdown };
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return {
ok: true,
applied,
@@ -701,7 +726,7 @@ export const createMindmapServerTools = (args: {
const fixed = sanitizeAddChildOps({ targetUid, currentChildren, ops, searxResults });
const { data: nextData, applied, errors } = applyMindmapOps(base, fixed);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return {
ok: true,
@@ -63,9 +63,27 @@ const parseSlash = (text: string): ParsedSlash => {
};
export const createSlashServerTools = (args: {
supabase: SlashSupabaseClient;
supabase?: SlashSupabaseClient;
ctx: SlashToolContext;
allowedToolIds: Set<string>;
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
loadWorkspaceIds?: (userId: string) => Promise<string[]>;
inferWorkspaceIdFromDoc?: (documentId: string) => Promise<string | null>;
createDoc?: (args: { userId: string; workspaceId: string; parentId: string | null; title: string }) => Promise<{
id: string;
title: string;
workspaceId: string;
parentId: string | null;
createdAt: unknown;
updatedAt: unknown;
}>;
renameDoc?: (args: { userId: string; documentId: string; title: string }) => Promise<{
id: string;
title: string;
workspaceId: string;
parentId: string | null;
updatedAt: unknown;
}>;
}) => {
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
@@ -106,11 +124,35 @@ export const createSlashServerTools = (args: {
const workspaceIdFromParams = parsed.params.workspaceId ? String(parsed.params.workspaceId) : null;
const workspaceId =
workspaceIdFromParams ||
(args.ctx.currentDocumentId ? await inferWorkspaceIdFromDoc(args.supabase, args.ctx.currentDocumentId) : null) ||
(await loadWorkspaceIds(args.supabase, args.ctx.userId))[0] ||
(args.ctx.currentDocumentId
? args.inferWorkspaceIdFromDoc
? await args.inferWorkspaceIdFromDoc(args.ctx.currentDocumentId)
: args.supabase
? await inferWorkspaceIdFromDoc(args.supabase, args.ctx.currentDocumentId)
: null
: null) ||
((args.loadWorkspaceIds
? (await args.loadWorkspaceIds(args.ctx.userId))[0]
: args.supabase
? (await loadWorkspaceIds(args.supabase, args.ctx.userId))[0]
: null) ?? null) ||
null;
if (!workspaceId) throw new Error("无法推断 workspaceId(请在 params.workspaceId 指定)");
if (args.createDoc) {
const doc = await args.createDoc({
userId: args.ctx.userId,
workspaceId,
parentId,
title,
});
return { ok: true, command: "new_doc", document: doc };
}
if (!args.supabase) {
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 createDoc/renameDoc");
}
const payload = {
workspace_id: workspaceId,
user_id: args.ctx.userId,
@@ -142,6 +184,15 @@ export const createSlashServerTools = (args: {
const title = String(parsed.params.title ?? "").trim();
if (!documentId || !title) throw new Error("缺少 documentId 或 title");
if (args.renameDoc) {
const doc = await args.renameDoc({ userId: args.ctx.userId, documentId, title });
return { ok: true, command: "rename_doc", document: doc };
}
if (!args.supabase) {
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 createDoc/renameDoc");
}
const { data, error } = await args.supabase
.from("documents")
.update({ title })
@@ -172,4 +223,3 @@ export const createSlashServerTools = (args: {
return { run };
};
@@ -0,0 +1,27 @@
import type { AuthContext } from "@/lib/auth/types";
import { getDevUser, isDevAuthEnabled } from "@/lib/auth/devUser";
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "HttpError";
this.status = status;
}
}
export function getAuthContext(): AuthContext {
if (isDevAuthEnabled()) return getDevUser();
// 说明:后续接入真实鉴权时,在这里替换为 Supabase/Convex Auth 的校验逻辑。
throw new Error("Auth is not configured");
}
export function requireAuthContext(): AuthContext {
try {
return getAuthContext();
} catch (err) {
const message = err instanceof Error ? err.message : "Unauthorized";
throw new HttpError(401, message);
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { AuthContext } from "@/lib/auth/types";
function _readEnv(key: string): string | undefined {
const value = process.env[key];
if (!value) return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
export function getDevUser(): AuthContext {
// 说明:第三阶段先用固定用户跑通迁移链路,后续接入真实鉴权时再替换这一层。
const userId = _readEnv("DEV_USER_ID") ?? "dev-user";
const email = _readEnv("DEV_USER_EMAIL") ?? "dev@mnote.local";
const name = _readEnv("DEV_USER_NAME") ?? "开发用户";
return { userId, email, name };
}
export function isDevAuthEnabled(): boolean {
// 说明:目前只要启用了 USE_CONVEX,就默认启用固定用户鉴权(便于迁移与测试)。
return process.env.USE_CONVEX === "1";
}
+6
View File
@@ -0,0 +1,6 @@
export type AuthContext = {
userId: string;
email?: string;
name?: string;
};
+114
View File
@@ -0,0 +1,114 @@
import type { Json } from "@/types/supabase";
type BlockLike = {
id: string;
type?: string;
props?: Record<string, unknown>;
content?: unknown;
children?: BlockLike[];
};
const asBlockArray = (value: unknown): BlockLike[] => {
if (!Array.isArray(value)) return [];
return value.filter((b) => b && typeof b === "object" && typeof (b as { id?: unknown }).id === "string") as BlockLike[];
};
export const findBlockInTree = (
blocks: BlockLike[],
blockId: string,
): { block: BlockLike; parent: BlockLike | null; index: number } | null => {
const stack: Array<{ list: BlockLike[]; parent: BlockLike | null }> = [{ list: blocks, parent: null }];
while (stack.length) {
const item = stack.pop()!;
const list = item.list;
for (let i = 0; i < list.length; i += 1) {
const b = list[i]!;
if (b.id === blockId) {
return { block: b, parent: item.parent, index: i };
}
if (Array.isArray(b.children) && b.children.length > 0) {
stack.push({ list: b.children, parent: b });
}
}
}
return null;
};
const cloneBlock = (block: BlockLike): BlockLike => {
return {
...block,
props: block.props ? { ...block.props } : undefined,
content: Array.isArray(block.content) ? [...block.content] : block.content,
children: Array.isArray(block.children) ? block.children.map(cloneBlock) : block.children,
};
};
export const removeBlockSubtree = (
blocks: BlockLike[],
blockId: string,
): { removed: BlockLike | null; nextBlocks: BlockLike[] } => {
// 说明:这里不直接修改入参 blocks,返回新的 nextBlocks。
const nextTop = blocks.map(cloneBlock);
const hit = findBlockInTree(nextTop, blockId);
if (!hit) return { removed: null, nextBlocks: nextTop };
if (hit.parent) {
const parent = hit.parent;
const nextChildren = asBlockArray(parent.children).map(cloneBlock);
const removed = nextChildren.splice(hit.index, 1)[0] ?? null;
parent.children = nextChildren;
return { removed, nextBlocks: nextTop };
}
const removed = nextTop.splice(hit.index, 1)[0] ?? null;
return { removed, nextBlocks: nextTop };
};
export const replaceBlockInTree = (
blocks: BlockLike[],
blockId: string,
nextBlock: BlockLike,
): { ok: boolean; nextBlocks: BlockLike[] } => {
const nextTop = blocks.map(cloneBlock);
const hit = findBlockInTree(nextTop, blockId);
if (!hit) return { ok: false, nextBlocks: nextTop };
const normalized = cloneBlock({ ...nextBlock, id: blockId });
if (hit.parent) {
const parent = hit.parent;
const nextChildren = asBlockArray(parent.children).map(cloneBlock);
nextChildren[hit.index] = normalized;
parent.children = nextChildren;
return { ok: true, nextBlocks: nextTop };
}
nextTop[hit.index] = normalized;
return { ok: true, nextBlocks: nextTop };
};
export const extractBlockText = (block: BlockLike): string => {
const inline = Array.isArray(block.content) ? (block.content as Array<{ text?: unknown }>) : [];
const text = inline.map((n) => (typeof n?.text === "string" ? n.text : "")).join("");
return text.trim();
};
export const getBlocksFromDocumentContent = (content: unknown): BlockLike[] => {
if (Array.isArray(content)) return asBlockArray(content);
if (content && typeof content === "object") {
const blocks = (content as { blocks?: unknown }).blocks;
return asBlockArray(blocks);
}
return [];
};
export const withBlocksWrittenBack = (content: unknown, blocks: BlockLike[]): Json => {
// 复用现有结构:数组或 {blocks: []}
if (Array.isArray(content)) {
return blocks as unknown as Json;
}
if (content && typeof content === "object") {
return { ...(content as Record<string, unknown>), blocks } as Json;
}
return { blocks } as Json;
};
+2
View File
@@ -0,0 +1,2 @@
export { api, internal } from "../../../convex/_generated/api";
+4
View File
@@ -0,0 +1,4 @@
export function isConvexEnabled(): boolean {
return process.env.USE_CONVEX === "1";
}
+11
View File
@@ -0,0 +1,11 @@
import type { ConvexHttpClient } from "convex/browser";
import type { AuthContext } from "@/lib/auth/types";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
export function getAuthedConvexClient(): { auth: AuthContext; client: ConvexHttpClient } {
const auth = requireAuthContext();
const client = getConvexHttpClient();
return { auth, client };
}
+22
View File
@@ -0,0 +1,22 @@
import { ConvexHttpClient } from "convex/browser";
let cached: ConvexHttpClient | null = null;
export function getConvexHttpClient(): ConvexHttpClient {
if (cached) return cached;
const url = process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL;
if (!url) {
throw new Error("缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL 配置");
}
const client = new ConvexHttpClient(url);
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY;
if (adminKey) {
client.setAdminAuth(adminKey);
}
cached = client;
return client;
}
+7 -1
View File
@@ -1,4 +1,9 @@
export type MnoteRuntimeConfig = {
/**
* 是否启用 Convex(自部署)链路。
* 说明:该字段由服务端在运行期注入到 window.__MNOTE_RUNTIME_CONFIG__,用于客户端按需关闭 Supabase 相关能力。
*/
useConvex?: boolean;
supabaseUrl?: string;
/**
* 服务端/本机回源用的 Supabase 地址(通常是 HTTP),用于避免 FRP/自签证书导致 Node 侧 TLS 校验失败。
@@ -39,10 +44,11 @@ declare global {
}
const readFromEnv = (): MnoteRuntimeConfig => ({
useConvex: process.env.USE_CONVEX === "1",
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL,
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL,
onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE,
onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN,