feat: 完成 rust cutover phase 8 收口
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createBlockSnapshotOpsAdapter as createDocBlockOpsAdapter,
|
||||
type BlockSnapshot as DocBlockSnapshot,
|
||||
} from "@/lib/blocks/block-ops-adapter";
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createDocServerTools } from "./docServerTools";
|
||||
|
||||
describe("createDocServerTools", () => {
|
||||
it("doc_insert_blocks 和 doc_replace_range 只操作快照,不直连 loadBlocks", async () => {
|
||||
const baseBlocks = [
|
||||
{
|
||||
id: "block_1",
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
||||
const loadBlocks = vi.fn(async () => ({ blocks: baseBlocks, source: "client" }));
|
||||
const tools = createDocServerTools({
|
||||
ctx: { documentId: "doc_1", userId: "user_1", baseBlocks },
|
||||
allowedToolIds: new Set(["doc_insert_blocks", "doc_replace_range", "doc_get"]),
|
||||
loadBlocks,
|
||||
});
|
||||
|
||||
const insertResult = await tools.run("doc_insert_blocks", {
|
||||
afterBlockId: "block_1",
|
||||
blocks: [{ type: "heading", text: "新标题", level: 2 }],
|
||||
});
|
||||
expect(insertResult.ok).toBe(true);
|
||||
expect(loadBlocks).not.toHaveBeenCalled();
|
||||
expect(Array.isArray((insertResult as any).data)).toBe(true);
|
||||
expect((insertResult as any).data).toHaveLength(2);
|
||||
|
||||
const replaceResult = await tools.run("doc_replace_range", {
|
||||
blockId: "block_1",
|
||||
text: " world",
|
||||
mode: "append",
|
||||
});
|
||||
expect(replaceResult.ok).toBe(true);
|
||||
expect((replaceResult as any).data).toHaveLength(1);
|
||||
expect(((replaceResult as any).data[0] as any).content?.[0]?.text).toBe("helloworld");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,14 @@
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { createDocBlockOpsAdapter } from "./blockOpsAdapter";
|
||||
|
||||
export const DOC_TOOL_IDS = ["doc_get", "doc_find", "doc_insert_blocks", "doc_replace_range"] as const;
|
||||
|
||||
export type DocToolId = (typeof DOC_TOOL_IDS)[number];
|
||||
|
||||
export const isDocToolId = (toolId: string): toolId is DocToolId =>
|
||||
(DOC_TOOL_IDS as readonly string[]).includes(toolId);
|
||||
|
||||
export const getDocToolInvocationKind = (toolId: DocToolId) =>
|
||||
toolId === "doc_get" || toolId === "doc_find" ? "query" : "command";
|
||||
|
||||
export type DocToolContext = {
|
||||
documentId: string;
|
||||
@@ -36,12 +46,6 @@ type DocBlockSummary = {
|
||||
childCount: number;
|
||||
};
|
||||
|
||||
type DocBlockSpec = {
|
||||
type: "paragraph" | "heading";
|
||||
text: string;
|
||||
level?: number;
|
||||
};
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
@@ -85,50 +89,6 @@ const walkSummaries = (rootBlocks: unknown[], maxNodes: number): DocBlockSummary
|
||||
return list;
|
||||
};
|
||||
|
||||
const findContainerById = (
|
||||
blocks: unknown[],
|
||||
targetId: string,
|
||||
): { container: unknown[]; index: number } | null => {
|
||||
const id = String(targetId || "").trim();
|
||||
if (!id) return null;
|
||||
for (let i = 0; i < blocks.length; i += 1) {
|
||||
const b = blocks[i];
|
||||
const bid = String(getValue(b, "id") ?? "").trim();
|
||||
if (bid === id) return { container: blocks, index: i };
|
||||
const childrenRaw = getValue(b, "children");
|
||||
const children = Array.isArray(childrenRaw) ? childrenRaw : [];
|
||||
const found = findContainerById(children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const createTextContent = (text: string): unknown[] => [{ type: "text", text }];
|
||||
|
||||
const generateId = () => {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `bn_${Math.random().toString(36).slice(2, 10)}`;
|
||||
};
|
||||
|
||||
const buildBlockFromSpec = (spec: DocBlockSpec): Record<string, unknown> => {
|
||||
const type = spec.type;
|
||||
const text = String(spec.text ?? "").trim();
|
||||
const base: Record<string, unknown> = {
|
||||
id: generateId(),
|
||||
type,
|
||||
props: {},
|
||||
content: createTextContent(text),
|
||||
children: [],
|
||||
};
|
||||
if (type === "heading") {
|
||||
const level = Number(spec.level ?? 2);
|
||||
base.props = { level: Math.max(1, Math.min(5, Number.isFinite(level) ? Math.floor(level) : 2)) };
|
||||
}
|
||||
return base;
|
||||
};
|
||||
|
||||
const loadDocumentBlocks = async (supabase: DocSupabaseClient, ctx: DocToolContext) => {
|
||||
const base = normalizeBlocks(ctx.baseBlocks);
|
||||
if (base.length > 0) {
|
||||
@@ -149,41 +109,24 @@ const loadDocumentBlocks = async (supabase: DocSupabaseClient, ctx: DocToolConte
|
||||
return { blocks, source: "db" as const };
|
||||
};
|
||||
|
||||
const saveDocumentBlocks = async (supabase: DocSupabaseClient, ctx: DocToolContext, blocks: unknown[]) => {
|
||||
const resp = (await (supabase
|
||||
.from("documents")
|
||||
.update({ content: blocks as unknown as Json })
|
||||
.eq("id", ctx.documentId)
|
||||
.eq("user_id", ctx.userId) as unknown as Promise<{ error: unknown }>)) ?? { error: null };
|
||||
const error = resp.error;
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "保存文档失败");
|
||||
}
|
||||
};
|
||||
|
||||
export const createDocServerTools = (args: {
|
||||
supabase?: DocSupabaseClient;
|
||||
ctx: DocToolContext;
|
||||
allowedToolIds: Set<string>;
|
||||
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
|
||||
loadBlocks?: () => Promise<{ blocks: unknown[]; source: string }>;
|
||||
saveBlocks?: (blocks: unknown[]) => Promise<void>;
|
||||
}) => {
|
||||
const blockOps = createDocBlockOpsAdapter({
|
||||
baseBlocks: args.ctx.baseBlocks,
|
||||
loadBlocks: args.loadBlocks,
|
||||
});
|
||||
|
||||
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}`);
|
||||
@@ -216,35 +159,27 @@ export const createDocServerTools = (args: {
|
||||
if (!Array.isArray(specsRaw) || specsRaw.length === 0) throw new Error("缺少 blocks");
|
||||
if (specsRaw.length > 20) throw new Error("blocks 过多(最多 20)");
|
||||
|
||||
const specs: DocBlockSpec[] = specsRaw.map((x) => {
|
||||
const specs = specsRaw.map((x) => {
|
||||
const t = isRecord(x) ? String(x.type ?? "paragraph") : "paragraph";
|
||||
const text = isRecord(x) ? String(x.text ?? "") : "";
|
||||
const level = isRecord(x) ? Number(x.level ?? 2) : 2;
|
||||
return { type: t === "heading" ? "heading" : "paragraph", text, level };
|
||||
return {
|
||||
type: (t === "heading" ? "heading" : "paragraph") as "heading" | "paragraph",
|
||||
text,
|
||||
level,
|
||||
};
|
||||
});
|
||||
|
||||
const created = specs.map(buildBlockFromSpec);
|
||||
|
||||
const { blocks, source } = await loadBlocks();
|
||||
const targetId = beforeBlockId || afterBlockId;
|
||||
const found = targetId ? findContainerById(blocks, targetId) : null;
|
||||
if (targetId && !found) {
|
||||
throw new Error(`未找到 blockId:${targetId}`);
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
blocks.push(...created);
|
||||
} else {
|
||||
const insertAt = beforeBlockId ? found.index : found.index + 1;
|
||||
found.container.splice(insertAt, 0, ...created);
|
||||
}
|
||||
|
||||
await saveBlocks(blocks);
|
||||
const { data, inserted, source } = await blockOps.insertBlocks({
|
||||
afterBlockId: afterBlockId || undefined,
|
||||
beforeBlockId: beforeBlockId || undefined,
|
||||
blocks: specs,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
source,
|
||||
inserted: created.map((b) => String(b.id ?? "")),
|
||||
data: blocks,
|
||||
inserted,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,17 +191,12 @@ export const createDocServerTools = (args: {
|
||||
const modeRaw = String(toolArgs.mode ?? "replace").trim();
|
||||
const mode = modeRaw === "append" || modeRaw === "prepend" ? modeRaw : "replace";
|
||||
|
||||
const { blocks, source } = await loadBlocks();
|
||||
const found = findContainerById(blocks, blockId);
|
||||
if (!found) throw new Error(`未找到 blockId:${blockId}`);
|
||||
const block = found.container[found.index];
|
||||
if (!isRecord(block)) throw new Error(`block 数据异常:${blockId}`);
|
||||
const prevText = extractInlineText(block);
|
||||
const nextText = mode === "append" ? `${prevText}${text}` : mode === "prepend" ? `${text}${prevText}` : text;
|
||||
found.container[found.index] = { ...block, content: createTextContent(nextText) };
|
||||
|
||||
await saveBlocks(blocks);
|
||||
return { ok: true, source, blockId, mode, data: blocks };
|
||||
const { data, source } = await blockOps.replaceRange({
|
||||
blockId,
|
||||
text,
|
||||
mode,
|
||||
});
|
||||
return { ok: true, source, blockId, mode, data };
|
||||
}
|
||||
|
||||
throw new Error(`未知工具:${toolId}`);
|
||||
|
||||
@@ -37,88 +37,83 @@ export const createMediaServerTools = (args: {
|
||||
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}`);
|
||||
const resolveImageReadTransport = async (toolArgs: Record<string, unknown>) => {
|
||||
if (!args.allowedToolIds.has("image_read")) throw new Error("工具未被允许:image_read");
|
||||
|
||||
if (toolId === "image_read") {
|
||||
const assetId = String(toolArgs.assetId ?? "").trim();
|
||||
const fileUrl = String(toolArgs.fileUrl ?? "").trim();
|
||||
const attachmentRef = String(toolArgs.attachmentRef ?? "").trim();
|
||||
const assetId = String(toolArgs.assetId ?? "").trim();
|
||||
const fileUrl = String(toolArgs.fileUrl ?? "").trim();
|
||||
const attachmentRef = String(toolArgs.attachmentRef ?? "").trim();
|
||||
|
||||
const resolved = attachmentRef ? resolveAttachment(args.ctx, attachmentRef) : null;
|
||||
const targetAssetId = assetId || resolved?.id || "";
|
||||
const targetUrl = fileUrl || resolved?.fileUrl || "";
|
||||
const resolved = attachmentRef ? resolveAttachment(args.ctx, attachmentRef) : null;
|
||||
const targetAssetId = assetId || resolved?.id || "";
|
||||
const targetUrl = fileUrl || resolved?.fileUrl || "";
|
||||
|
||||
let row: unknown = null;
|
||||
if (targetAssetId) {
|
||||
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;
|
||||
}
|
||||
} else if (targetUrl) {
|
||||
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;
|
||||
}
|
||||
let row: unknown = null;
|
||||
if (targetAssetId) {
|
||||
if (args.loadById) {
|
||||
row = await args.loadById(targetAssetId);
|
||||
} else {
|
||||
throw new Error("缺少 assetId / fileUrl / attachmentRef");
|
||||
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;
|
||||
}
|
||||
|
||||
if (!row) return { ok: true, found: false };
|
||||
|
||||
const ocrText = String(pick(row, "ocr_text") ?? "");
|
||||
const ocrStatus = String(pick(row, "ocr_status") ?? "");
|
||||
const mimeType = String(pick(row, "mime_type") ?? "");
|
||||
const result = {
|
||||
ok: true,
|
||||
found: true,
|
||||
asset: {
|
||||
id: String(pick(row, "id") ?? ""),
|
||||
fileName: String(pick(row, "file_name") ?? ""),
|
||||
fileUrl: String(pick(row, "file_url") ?? ""),
|
||||
mimeType,
|
||||
storagePath: pick(row, "storage_path") ? String(pick(row, "storage_path")) : null,
|
||||
bucket: pick(row, "bucket") ? String(pick(row, "bucket")) : null,
|
||||
documentId: pick(row, "document_id") ? String(pick(row, "document_id")) : null,
|
||||
workspaceId: pick(row, "workspace_id") ? String(pick(row, "workspace_id")) : null,
|
||||
deletedAt: pick(row, "deleted_at") ?? null,
|
||||
purgedAt: pick(row, "purged_at") ?? null,
|
||||
updatedAt: pick(row, "updated_at") ?? null,
|
||||
},
|
||||
ocrStatus,
|
||||
ocrText,
|
||||
hasOcrText: Boolean(ocrText.trim()),
|
||||
note: ocrText.trim()
|
||||
? "已返回 ocr_text。"
|
||||
: "该图片暂未生成 ocr_text(可等待后台 OCR,或后续再补一个 image_ocr 写工具)。",
|
||||
};
|
||||
return result;
|
||||
} else if (targetUrl) {
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
throw new Error("缺少 assetId / fileUrl / attachmentRef");
|
||||
}
|
||||
|
||||
throw new Error(`未知工具:${toolId}`);
|
||||
if (!row) return { ok: true, found: false };
|
||||
|
||||
const ocrText = String(pick(row, "ocr_text") ?? "");
|
||||
const ocrStatus = String(pick(row, "ocr_status") ?? "");
|
||||
const mimeType = String(pick(row, "mime_type") ?? "");
|
||||
return {
|
||||
ok: true,
|
||||
found: true,
|
||||
asset: {
|
||||
id: String(pick(row, "id") ?? ""),
|
||||
fileName: String(pick(row, "file_name") ?? ""),
|
||||
fileUrl: String(pick(row, "file_url") ?? ""),
|
||||
mimeType,
|
||||
storagePath: pick(row, "storage_path") ? String(pick(row, "storage_path")) : null,
|
||||
bucket: pick(row, "bucket") ? String(pick(row, "bucket")) : null,
|
||||
documentId: pick(row, "document_id") ? String(pick(row, "document_id")) : null,
|
||||
workspaceId: pick(row, "workspace_id") ? String(pick(row, "workspace_id")) : null,
|
||||
deletedAt: pick(row, "deleted_at") ?? null,
|
||||
purgedAt: pick(row, "purged_at") ?? null,
|
||||
updatedAt: pick(row, "updated_at") ?? null,
|
||||
},
|
||||
ocrStatus,
|
||||
ocrText,
|
||||
hasOcrText: Boolean(ocrText.trim()),
|
||||
note: ocrText.trim()
|
||||
? "已返回 ocr_text。"
|
||||
: "该图片暂未生成 ocr_text(可等待后台 OCR,或后续再补一个 image_ocr 写工具)。",
|
||||
};
|
||||
};
|
||||
|
||||
return { run };
|
||||
return { resolveImageReadTransport };
|
||||
};
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { OpenAiCompatibleChatOptions } from "@/lib/ai/openaiCompatibleChat";
|
||||
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
||||
import { applyMindmapOps, ensureMindmapUids, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
|
||||
import { DocumentBridgeError } from "@/lib/documents/bridge";
|
||||
import {
|
||||
applyMindmapOps,
|
||||
classifyMindmapOpErrors,
|
||||
ensureMindmapUids,
|
||||
type MindmapOp,
|
||||
type MindmapTreeNode,
|
||||
type NodeRef,
|
||||
} from "@/lib/mindmap/mindmapOps";
|
||||
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
||||
import { searchSearxng, type SearxResult } from "../searchWeb";
|
||||
|
||||
@@ -28,6 +36,16 @@ type SupabaseQuery = {
|
||||
|
||||
export type MindmapSupabaseClient = SupabaseRouteClient;
|
||||
|
||||
type MindmapRustToolRunner = (input: {
|
||||
toolId: string;
|
||||
invocationKind: "query" | "command" | "job";
|
||||
toolArgs: Record<string, unknown>;
|
||||
data: MindmapTreeNode;
|
||||
target?: { workspaceId?: string | null; pageId?: string | null; blockId?: string | null } | null;
|
||||
reason?: string | null;
|
||||
refs?: string[];
|
||||
}) => Promise<unknown>;
|
||||
|
||||
const defaultMindmapData: MindmapTreeNode = { data: { text: "中心主题" }, children: [] };
|
||||
|
||||
const safeUrlOrNull = (value: unknown) => {
|
||||
@@ -123,6 +141,23 @@ const mergeRefsUnique = (a: NodeRef[], b: NodeRef[]) => {
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
const throwMindmapOperationError = (
|
||||
message: string,
|
||||
kind: "rejected" | "failed",
|
||||
details?: Record<string, unknown>,
|
||||
) => {
|
||||
if (kind === "rejected") {
|
||||
throw new DocumentBridgeError(message, 409, "REJECTED", {
|
||||
reason: "mindmap_rejected",
|
||||
...details,
|
||||
});
|
||||
}
|
||||
throw new DocumentBridgeError(message, 500, "TRANSPORT_ERROR", {
|
||||
reason: "mindmap_failed",
|
||||
...details,
|
||||
});
|
||||
};
|
||||
|
||||
const sanitizeAddChildOps = (args: {
|
||||
targetUid: string;
|
||||
currentChildren: string[];
|
||||
@@ -192,6 +227,7 @@ export const createMindmapServerTools = (args: {
|
||||
ctx: MindmapToolContext;
|
||||
cfg: OpenAiCompatibleChatOptions;
|
||||
allowedToolIds: Set<string>;
|
||||
runRustTool?: MindmapRustToolRunner;
|
||||
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase/local 文件。
|
||||
loadMindmap?: () => Promise<{
|
||||
doc: { id: string; title: string | null; workspace_id: string | null };
|
||||
@@ -242,9 +278,38 @@ export const createMindmapServerTools = (args: {
|
||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||
};
|
||||
|
||||
const mindmapTarget = (doc: { id: string; title: string | null; workspace_id: string | null }) => ({
|
||||
pageId: doc.id,
|
||||
workspaceId: doc.workspace_id ?? null,
|
||||
blockId: args.ctx.mindmapId,
|
||||
});
|
||||
|
||||
const withMindmapIds = (toolArgs: Record<string, unknown>) => ({
|
||||
...toolArgs,
|
||||
documentId: args.ctx.documentId,
|
||||
mindmapId: args.ctx.mindmapId,
|
||||
});
|
||||
|
||||
const persistResultData = async (doc: { id: string; title: string | null; workspace_id: string | null }, result: unknown) => {
|
||||
if (!result || typeof result !== "object" || Array.isArray(result)) return;
|
||||
const nextData = (result as Record<string, unknown>).data;
|
||||
if (!nextData || typeof nextData !== "object" || Array.isArray(nextData)) return;
|
||||
await persistMindmap(doc, nextData as MindmapTreeNode);
|
||||
};
|
||||
|
||||
const mindmap_get = async (toolArgs: Record<string, unknown>) => {
|
||||
const maxNodes = Number(toolArgs.maxNodes ?? 120);
|
||||
const { base } = await loadMindmap();
|
||||
const loaded = await loadMindmap();
|
||||
const { base } = loaded;
|
||||
if (args.runRustTool) {
|
||||
return await args.runRustTool({
|
||||
toolId: "mindmap_get",
|
||||
invocationKind: "query",
|
||||
toolArgs: withMindmapIds({ maxNodes }),
|
||||
data: base,
|
||||
target: mindmapTarget(loaded.doc),
|
||||
});
|
||||
}
|
||||
const list = walkSummaries(base, Number.isFinite(maxNodes) ? Math.max(10, Math.min(300, Math.floor(maxNodes))) : 120);
|
||||
return { ok: true, documentId: args.ctx.documentId, mindmapId: args.ctx.mindmapId, nodes: list };
|
||||
};
|
||||
@@ -254,7 +319,17 @@ export const createMindmapServerTools = (args: {
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
const depth = Number(toolArgs.depth ?? 2);
|
||||
const maxNodes = Number(toolArgs.maxNodes ?? 60);
|
||||
const { base } = await loadMindmap();
|
||||
const loaded = await loadMindmap();
|
||||
const { base } = loaded;
|
||||
if (args.runRustTool) {
|
||||
return await args.runRustTool({
|
||||
toolId: "mindmap_get_subtree",
|
||||
invocationKind: "query",
|
||||
toolArgs: withMindmapIds({ uid, depth, maxNodes }),
|
||||
data: base,
|
||||
target: mindmapTarget(loaded.doc),
|
||||
});
|
||||
}
|
||||
const hit = findNodeByUid(base, uid);
|
||||
if (!hit) throw new Error(`未找到 uid=${uid}`);
|
||||
const list = summarizeSubtree(
|
||||
@@ -265,6 +340,33 @@ export const createMindmapServerTools = (args: {
|
||||
return { ok: true, uid, nodes: list };
|
||||
};
|
||||
|
||||
const resolveMindmapPutTree = (toolArgs: Record<string, unknown>) => {
|
||||
const raw = toolArgs.data ?? toolArgs.tree ?? toolArgs.mindmap ?? null;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw new Error("缺少 data");
|
||||
}
|
||||
return raw as MindmapTreeNode;
|
||||
};
|
||||
|
||||
const mindmap_put = async (toolArgs: Record<string, unknown>) => {
|
||||
const loaded = await loadMindmap();
|
||||
const data = resolveMindmapPutTree(toolArgs);
|
||||
if (args.runRustTool) {
|
||||
const result = await args.runRustTool({
|
||||
toolId: "mindmap_put",
|
||||
invocationKind: "command",
|
||||
toolArgs: withMindmapIds({ ...toolArgs, data }),
|
||||
data: loaded.base,
|
||||
target: mindmapTarget(loaded.doc),
|
||||
});
|
||||
await persistResultData(loaded.doc, result);
|
||||
return result;
|
||||
}
|
||||
ensureMindmapUids(data);
|
||||
await persistMindmap(loaded.doc, data);
|
||||
return { ok: true, data };
|
||||
};
|
||||
|
||||
const mindmap_apply_ops = async (toolArgs: Record<string, unknown>) => {
|
||||
const ops = (toolArgs.ops ?? []) as unknown;
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
@@ -336,8 +438,28 @@ export const createMindmapServerTools = (args: {
|
||||
const normalized = ops.map((x) => normalizeOp(x)).filter(Boolean) as MindmapOp[];
|
||||
if (normalized.length === 0) throw new Error("ops 无有效操作(请使用 MindmapOp 协议或已支持的别名)");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const loaded = await loadMindmap();
|
||||
const { base } = loaded;
|
||||
if (args.runRustTool) {
|
||||
const result = await args.runRustTool({
|
||||
toolId: "mindmap_apply_ops",
|
||||
invocationKind: "command",
|
||||
toolArgs: withMindmapIds({ ops: normalized, reason }),
|
||||
data: base,
|
||||
target: mindmapTarget(doc),
|
||||
reason,
|
||||
});
|
||||
await persistResultData(doc, result);
|
||||
return result;
|
||||
}
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, normalized);
|
||||
if (errors.length > 0) {
|
||||
throwMindmapOperationError("mindmap_apply_ops 存在冲突或非法操作", classifyMindmapOpErrors(errors) ?? "failed", {
|
||||
errors,
|
||||
applied,
|
||||
opCount: normalized.length,
|
||||
});
|
||||
}
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
};
|
||||
@@ -352,15 +474,12 @@ export const createMindmapServerTools = (args: {
|
||||
if (!parentUid) throw new Error("缺少 parentUid");
|
||||
if (!text) throw new Error("缺少 text");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = {
|
||||
op: "addChild",
|
||||
parentUid,
|
||||
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
|
||||
};
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_add_sibling_after = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -373,15 +492,12 @@ export const createMindmapServerTools = (args: {
|
||||
if (!targetUid) throw new Error("缺少 targetUid");
|
||||
if (!text) throw new Error("缺少 text");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = {
|
||||
op: "addSiblingAfter",
|
||||
targetUid,
|
||||
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
|
||||
};
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_update_node_text = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -390,11 +506,8 @@ export const createMindmapServerTools = (args: {
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
if (!text) throw new Error("缺少 text");
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = { op: "updateText", uid, text };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_set_hyperlink = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -406,11 +519,8 @@ export const createMindmapServerTools = (args: {
|
||||
if (hyperlinkRaw !== null && hyperlinkRaw !== undefined && !hyperlink) {
|
||||
throw new Error("hyperlink 必须是 http(s) URL 或 null");
|
||||
}
|
||||
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 persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_append_note = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -419,11 +529,8 @@ export const createMindmapServerTools = (args: {
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
if (!markdown) throw new Error("缺少 markdown");
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = { op: "appendNote", uid, markdown };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_set_refs = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -432,22 +539,16 @@ export const createMindmapServerTools = (args: {
|
||||
const refs = Array.isArray(toolArgs.refs) ? (toolArgs.refs as NodeRef[]) : [];
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
if (!refs.length) throw new Error("缺少 refs");
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = { op: "setRefs", uid, refs };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_delete_node = async (toolArgs: Record<string, unknown>) => {
|
||||
const uid = String(toolArgs.uid ?? "").trim();
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = { op: "deleteNode", uid };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_add_attachment_ref = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -462,7 +563,8 @@ export const createMindmapServerTools = (args: {
|
||||
if (!uid) throw new Error("缺少 uid");
|
||||
if (!attachmentId && !fileUrlDirect) throw new Error("缺少 attachmentId 或 fileUrl");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const loaded = await loadMindmap();
|
||||
const { base } = loaded;
|
||||
const node = findNodeByUid(base, uid);
|
||||
if (!node) throw new Error(`未找到 uid=${uid}`);
|
||||
|
||||
@@ -499,9 +601,7 @@ export const createMindmapServerTools = (args: {
|
||||
const prevRefs = Array.isArray(node.data?.refs) ? (node.data.refs as NodeRef[]) : [];
|
||||
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 persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_add_attachment_child = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -517,7 +617,6 @@ export const createMindmapServerTools = (args: {
|
||||
if (!parentUid) throw new Error("缺少 parentUid");
|
||||
if (!attachmentId && !fileUrlDirect) throw new Error("缺少 attachmentId 或 fileUrl");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const resolved = attachmentId ? resolveAttachmentFromContext(args.ctx, attachmentId) : null;
|
||||
const finalUrl = String(resolved?.fileUrl || fileUrlDirect || "").trim();
|
||||
const finalTitle = String(titleOverride || resolved?.title || attachmentId || "附件").trim();
|
||||
@@ -552,9 +651,7 @@ export const createMindmapServerTools = (args: {
|
||||
parentUid,
|
||||
node: { text, ...(hyperlink ? { hyperlink } : {}), refs: [ref], ...(note ? { note } : {}) },
|
||||
};
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_add_image_child = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -583,11 +680,7 @@ export const createMindmapServerTools = (args: {
|
||||
refs: [{ kind: "url", ...(resolved?.id ? { assetId: resolved.id } : {}), fileUrl: url, title }],
|
||||
},
|
||||
};
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
return await mindmap_apply_ops({ ops: [op], reason });
|
||||
};
|
||||
|
||||
const mindmap_append_image_note = async (toolArgs: Record<string, unknown>) => {
|
||||
@@ -604,16 +697,18 @@ export const createMindmapServerTools = (args: {
|
||||
if (!url) throw new Error("图片缺少 URL");
|
||||
|
||||
const markdown = ``;
|
||||
const { doc, base } = await loadMindmap();
|
||||
const op: MindmapOp = { op: "appendNote", uid, markdown };
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||
await persistMindmap(doc, nextData);
|
||||
const result = await mindmap_apply_ops({ ops: [op], reason });
|
||||
if (result && typeof result === "object") {
|
||||
return {
|
||||
...(result as Record<string, unknown>),
|
||||
meta: { reason, ref: { kind: "url", ...(resolved?.id ? { assetId: resolved.id } : {}), fileUrl: url, title } },
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
applied,
|
||||
errors,
|
||||
data: nextData,
|
||||
meta: { reason, ref: { kind: "url", ...(resolved?.id ? { assetId: resolved.id } : {}), fileUrl: url, title } },
|
||||
result,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -623,7 +718,8 @@ export const createMindmapServerTools = (args: {
|
||||
const instruction = String(toolArgs.instruction ?? "").trim();
|
||||
const useSearx = args.allowedToolIds.has("search_web");
|
||||
|
||||
const { doc, base } = await loadMindmap();
|
||||
const loaded = await loadMindmap();
|
||||
const { base } = loaded;
|
||||
const target = findNodeByUid(base, targetUid);
|
||||
if (!target) throw new Error("未找到目标节点(uid 不存在)");
|
||||
|
||||
@@ -707,22 +803,26 @@ export const createMindmapServerTools = (args: {
|
||||
}
|
||||
|
||||
const fixed = sanitizeAddChildOps({ targetUid, currentChildren, ops, searxResults });
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, fixed);
|
||||
await persistMindmap(doc, nextData);
|
||||
|
||||
const result = await mindmap_apply_ops({ ops: fixed, reason: instruction || null });
|
||||
if (result && typeof result === "object") {
|
||||
return {
|
||||
...(result as Record<string, unknown>),
|
||||
ops: fixed,
|
||||
meta: { finishReason, searched: useSearx, searxCount: searxResults.length },
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
applied,
|
||||
errors,
|
||||
ops: fixed,
|
||||
data: nextData,
|
||||
meta: { finishReason, searched: useSearx, searxCount: searxResults.length },
|
||||
result,
|
||||
};
|
||||
};
|
||||
|
||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||
if (toolId === "mindmap_get") return await mindmap_get(toolArgs);
|
||||
if (toolId === "mindmap_get_subtree") return await mindmap_get_subtree(toolArgs);
|
||||
if (toolId === "mindmap_put") return await mindmap_put(toolArgs);
|
||||
if (toolId === "mindmap_apply_ops") return await mindmap_apply_ops(toolArgs);
|
||||
if (toolId === "mindmap_add_child") return await mindmap_add_child(toolArgs);
|
||||
if (toolId === "mindmap_add_sibling_after") return await mindmap_add_sibling_after(toolArgs);
|
||||
|
||||
@@ -394,3 +394,43 @@ export const builtinToolSets: AiAgentToolSet[] = [
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export type BuiltinRustCutoverStatus = "rust" | "mixed" | "ts" | "transport";
|
||||
|
||||
export type BuiltinRustCutoverBinding = {
|
||||
rustToolsetId: string;
|
||||
rustToolName: string | null;
|
||||
status: BuiltinRustCutoverStatus;
|
||||
note: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 当前只把已经进入 Rust runtime 的 builtin tool 做成代码侧绑定。
|
||||
* 完整的一一对应矩阵见 `design/ai-tool-cutover-matrix.md`。
|
||||
*/
|
||||
export const builtinRustCutoverBindings: Record<string, BuiltinRustCutoverBinding> = {
|
||||
search_web: {
|
||||
rustToolsetId: "toolset.readonly",
|
||||
rustToolName: "search_web",
|
||||
status: "rust",
|
||||
note: "联网检索已切到 Rust runtime,TS 仅保留 route transport 壳。",
|
||||
},
|
||||
image_read: {
|
||||
rustToolsetId: "toolset.media_read",
|
||||
rustToolName: "image_read",
|
||||
status: "rust",
|
||||
note: "图片/附件 OCR 结果归一化已切到 Rust runtime,TS 只负责最小 transport 取数。",
|
||||
},
|
||||
slash_run: {
|
||||
rustToolsetId: "toolset.slash_write",
|
||||
rustToolName: "slash_run",
|
||||
status: "rust",
|
||||
note: "斜杠命令解析已切到 Rust runtime,TS 仅保留创建/重命名 transport 写壳。",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const builtinRustRuntimeToolIds = new Set<string>(
|
||||
Object.entries(builtinRustCutoverBindings)
|
||||
.filter(([, binding]) => binding.status === "rust" || binding.status === "mixed")
|
||||
.map(([toolId]) => toolId),
|
||||
);
|
||||
|
||||
@@ -38,7 +38,7 @@ export const searchSearxng = async (q: string, count = 6): Promise<SearxResult[]
|
||||
const value = obj.results;
|
||||
return Array.isArray(value) ? (value as unknown[]) : [];
|
||||
})();
|
||||
return results
|
||||
const normalized = results
|
||||
.map((r: unknown) => {
|
||||
const obj = (typeof r === "object" && r ? (r as Record<string, unknown>) : {}) as Record<string, unknown>;
|
||||
return {
|
||||
@@ -48,6 +48,7 @@ export const searchSearxng = async (q: string, count = 6): Promise<SearxResult[]
|
||||
engine: String(obj.engine ?? "").trim(),
|
||||
};
|
||||
})
|
||||
.filter((r: SearxResult) => r.title && safeUrlOrNull(r.url))
|
||||
.slice(0, Math.max(1, Math.min(10, count)));
|
||||
.filter((r: SearxResult) => r.title && safeUrlOrNull(r.url))
|
||||
.slice(0, Math.max(1, Math.min(10, count)));
|
||||
return normalized;
|
||||
};
|
||||
|
||||
@@ -85,141 +85,112 @@ export const createSlashServerTools = (args: {
|
||||
updatedAt: unknown;
|
||||
}>;
|
||||
}) => {
|
||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
|
||||
|
||||
if (toolId === "slash_run") {
|
||||
const text = String(toolArgs.text ?? "").trim();
|
||||
const command = String(toolArgs.command ?? "").trim();
|
||||
const params = isRecord(toolArgs.params) ? toolArgs.params : null;
|
||||
|
||||
const parsed: ParsedSlash =
|
||||
text && text.startsWith("/") ? parseSlash(text) : command === "new_doc"
|
||||
? {
|
||||
ok: true,
|
||||
command: "new_doc",
|
||||
params: {
|
||||
title: String(pick(params, "title") ?? "").trim(),
|
||||
parentId: pick(params, "parentId") ? String(pick(params, "parentId")) : null,
|
||||
workspaceId: pick(params, "workspaceId") ? String(pick(params, "workspaceId")) : null,
|
||||
},
|
||||
}
|
||||
: command === "rename_doc"
|
||||
? {
|
||||
ok: true,
|
||||
command: "rename_doc",
|
||||
params: {
|
||||
documentId: String(pick(params, "documentId") ?? "").trim(),
|
||||
title: String(pick(params, "title") ?? "").trim(),
|
||||
},
|
||||
}
|
||||
: { ok: false, error: "缺少 text(以 / 开头)或 command" };
|
||||
|
||||
if (!parsed.ok) throw new Error(parsed.error);
|
||||
|
||||
if (parsed.command === "new_doc") {
|
||||
const title = String(parsed.params.title ?? "").trim();
|
||||
if (!title) throw new Error("缺少标题");
|
||||
const parentId = parsed.params.parentId ? String(parsed.params.parentId) : null;
|
||||
const workspaceIdFromParams = parsed.params.workspaceId ? String(parsed.params.workspaceId) : null;
|
||||
const workspaceId =
|
||||
workspaceIdFromParams ||
|
||||
(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,
|
||||
parent_id: parentId,
|
||||
title,
|
||||
content: [] as unknown[],
|
||||
raw_text: "",
|
||||
};
|
||||
const { data, error } = await args.supabase.from("documents").insert(payload).select("id,workspace_id,parent_id,title,created_at,updated_at").single();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "创建文档失败");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
command: "new_doc",
|
||||
document: {
|
||||
id: String(pick(data, "id") ?? ""),
|
||||
title: String(pick(data, "title") ?? ""),
|
||||
workspaceId: String(pick(data, "workspace_id") ?? ""),
|
||||
parentId: pick(data, "parent_id") ? String(pick(data, "parent_id")) : null,
|
||||
createdAt: pick(data, "created_at") ?? null,
|
||||
updatedAt: pick(data, "updated_at") ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.command === "rename_doc") {
|
||||
const documentId = String(parsed.params.documentId ?? "").trim();
|
||||
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 })
|
||||
.eq("id", documentId)
|
||||
.select("id,workspace_id,parent_id,title,updated_at")
|
||||
.single();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "重命名失败");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
command: "rename_doc",
|
||||
document: {
|
||||
id: String(pick(data, "id") ?? ""),
|
||||
title: String(pick(data, "title") ?? ""),
|
||||
workspaceId: String(pick(data, "workspace_id") ?? ""),
|
||||
parentId: pick(data, "parent_id") ? String(pick(data, "parent_id")) : null,
|
||||
updatedAt: pick(data, "updated_at") ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`未实现命令:${(parsed as any).command}`);
|
||||
const executeSlashTransport = async (parsed: ParsedSlash) => {
|
||||
if (!args.allowedToolIds.has("slash_run")) throw new Error("工具未被允许:slash_run");
|
||||
if (!parsed.ok) {
|
||||
throw new Error("error" in parsed ? parsed.error : "缺少 text(以 / 开头)或 command");
|
||||
}
|
||||
|
||||
throw new Error(`未知工具:${toolId}`);
|
||||
if (parsed.command === "new_doc") {
|
||||
const title = String(parsed.params.title ?? "").trim();
|
||||
if (!title) throw new Error("缺少标题");
|
||||
const parentId = parsed.params.parentId ? String(parsed.params.parentId) : null;
|
||||
const workspaceIdFromParams = parsed.params.workspaceId ? String(parsed.params.workspaceId) : null;
|
||||
const workspaceId =
|
||||
workspaceIdFromParams ||
|
||||
(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,
|
||||
parent_id: parentId,
|
||||
title,
|
||||
content: [] as unknown[],
|
||||
raw_text: "",
|
||||
};
|
||||
const { data, error } = await args.supabase.from("documents").insert(payload).select("id,workspace_id,parent_id,title,created_at,updated_at").single();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "创建文档失败");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
command: "new_doc",
|
||||
document: {
|
||||
id: String(pick(data, "id") ?? ""),
|
||||
title: String(pick(data, "title") ?? ""),
|
||||
workspaceId: String(pick(data, "workspace_id") ?? ""),
|
||||
parentId: pick(data, "parent_id") ? String(pick(data, "parent_id")) : null,
|
||||
createdAt: pick(data, "created_at") ?? null,
|
||||
updatedAt: pick(data, "updated_at") ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.command === "rename_doc") {
|
||||
const documentId = String(parsed.params.documentId ?? "").trim();
|
||||
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 })
|
||||
.eq("id", documentId)
|
||||
.select("id,workspace_id,parent_id,title,updated_at")
|
||||
.single();
|
||||
if (error) {
|
||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "重命名失败");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
command: "rename_doc",
|
||||
document: {
|
||||
id: String(pick(data, "id") ?? ""),
|
||||
title: String(pick(data, "title") ?? ""),
|
||||
workspaceId: String(pick(data, "workspace_id") ?? ""),
|
||||
parentId: pick(data, "parent_id") ? String(pick(data, "parent_id")) : null,
|
||||
updatedAt: pick(data, "updated_at") ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`未实现命令:${(parsed as any).command}`);
|
||||
};
|
||||
|
||||
return { run };
|
||||
return { parseSlash, executeSlashTransport };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
import "server-only";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
findBlockInTree,
|
||||
getBlocksFromDocumentContent,
|
||||
removeBlockSubtree,
|
||||
replaceBlockInTree,
|
||||
withBlocksWrittenBack,
|
||||
} from "@/lib/blocks";
|
||||
import { createBlockSnapshotOpsAdapter, type BlockInsertSpec } from "@/lib/blocks/block-ops-adapter";
|
||||
import {
|
||||
assertBlockId,
|
||||
assertDocumentId,
|
||||
assertNextBlock,
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
buildDocumentQueryEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
DocumentBridgeError,
|
||||
type BridgeContext,
|
||||
type CommandEnvelope,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
executeRustBridgeQueryTransport,
|
||||
resolveRustBridgeCommandPlan,
|
||||
resolveRustBridgeQueryPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
|
||||
type DocumentContentQueryResult = {
|
||||
content?: unknown;
|
||||
revision?: number | null;
|
||||
conflict_detection_key?: string | null;
|
||||
} | null;
|
||||
|
||||
type DocumentMetaResult = {
|
||||
id?: string;
|
||||
workspace_id?: string | null;
|
||||
embed_default_block_id?: string | null;
|
||||
} | null;
|
||||
|
||||
type DocumentState = {
|
||||
documentId: string;
|
||||
workspaceId: string | null;
|
||||
content: unknown;
|
||||
blocks: ReturnType<typeof getBlocksFromDocumentContent>;
|
||||
revision: number | null;
|
||||
conflictDetectionKey: string | null;
|
||||
meta: DocumentMetaResult;
|
||||
};
|
||||
|
||||
type BlockCommandMeta = {
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
commandId: string;
|
||||
commandName: string;
|
||||
};
|
||||
|
||||
type BlockCommandResult<TResult> = BlockCommandMeta & {
|
||||
result: TResult;
|
||||
};
|
||||
|
||||
type GetBlockResult = {
|
||||
block: Record<string, unknown>;
|
||||
meta: {
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
queryName: string;
|
||||
};
|
||||
};
|
||||
|
||||
function normalizeRevision(value: number | null | undefined): number | null {
|
||||
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
function normalizeConflictDetectionKey(
|
||||
documentId: string,
|
||||
revision: number | null,
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
return revision === null ? null : `${documentId}:${revision}`;
|
||||
}
|
||||
|
||||
async function loadDocumentState(input: {
|
||||
request: Request;
|
||||
documentId: string;
|
||||
workspaceId?: string | null;
|
||||
}) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request: input.request,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
});
|
||||
const envelope = buildDocumentQueryEnvelope({
|
||||
name: "documents.content.get",
|
||||
payload: {
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
},
|
||||
});
|
||||
const plan = await resolveRustBridgeQueryPlan({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
const result = await executeRustBridgeQueryTransport<DocumentContentQueryResult>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
if (!result) {
|
||||
throw new DocumentBridgeError("页面不存在或无权限", 404, "NOT_FOUND");
|
||||
}
|
||||
const meta = await client.query(api.documents.getMeta, { id: input.documentId });
|
||||
const revision = normalizeRevision(result.revision);
|
||||
const workspaceId = (meta?.workspace_id ?? input.workspaceId ?? null) as string | null;
|
||||
return {
|
||||
client,
|
||||
context,
|
||||
queryName: envelope.name,
|
||||
state: {
|
||||
documentId: input.documentId,
|
||||
workspaceId,
|
||||
content: result.content ?? null,
|
||||
blocks: getBlocksFromDocumentContent(result.content ?? null),
|
||||
revision,
|
||||
conflictDetectionKey: normalizeConflictDetectionKey(
|
||||
input.documentId,
|
||||
revision,
|
||||
result.conflict_detection_key,
|
||||
),
|
||||
meta: meta as DocumentMetaResult,
|
||||
} satisfies DocumentState,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveBlockCommandEnvelope<TPayload>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<TPayload>;
|
||||
}) {
|
||||
await resolveRustBridgeCommandPlan({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
});
|
||||
return {
|
||||
requestId: input.context.requestId,
|
||||
traceId: input.context.traceId,
|
||||
commandId: input.envelope.commandId,
|
||||
commandName: input.envelope.name,
|
||||
} satisfies BlockCommandMeta;
|
||||
}
|
||||
|
||||
async function executeDocumentSaveTransport(input: {
|
||||
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
|
||||
request: Request;
|
||||
documentId: string;
|
||||
workspaceId: string | null;
|
||||
content: unknown;
|
||||
revision: number | null;
|
||||
conflictDetectionKey: string | null;
|
||||
recordArtifacts?: boolean;
|
||||
}) {
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request: input.request,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.save",
|
||||
payload: {
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId,
|
||||
revision: input.revision,
|
||||
content: input.content,
|
||||
conflictDetectionKey: input.conflictDetectionKey,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId: input.workspaceId,
|
||||
pageId: input.documentId,
|
||||
},
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
const result = await executeRustBridgeMutationTransport<{
|
||||
revision?: number | null;
|
||||
conflict_detection_key?: string | null;
|
||||
}>({
|
||||
client: input.client,
|
||||
plan,
|
||||
});
|
||||
if (input.recordArtifacts !== false) {
|
||||
await recordBridgeCommandArtifacts({
|
||||
client: input.client,
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
}
|
||||
return {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
result,
|
||||
} satisfies BlockCommandResult<{
|
||||
revision?: number | null;
|
||||
conflict_detection_key?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function executeBlockGetQuery(input: {
|
||||
request: Request;
|
||||
sourceDocumentId: string;
|
||||
blockId: string;
|
||||
}): Promise<GetBlockResult> {
|
||||
const documentId = assertDocumentId(input.sourceDocumentId);
|
||||
const blockId = assertBlockId(input.blockId);
|
||||
const { context, state } = await loadDocumentState({
|
||||
request: input.request,
|
||||
documentId,
|
||||
});
|
||||
const envelope = buildDocumentQueryEnvelope({
|
||||
name: "blocks.get",
|
||||
payload: {
|
||||
blockId,
|
||||
workspaceId: state.workspaceId,
|
||||
},
|
||||
});
|
||||
await resolveRustBridgeQueryPlan({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
const hit = findBlockInTree(state.blocks as never[], blockId);
|
||||
if (!hit || !hit.block || typeof hit.block !== "object") {
|
||||
throw new DocumentBridgeError("块不存在或无权限", 404, "NOT_FOUND");
|
||||
}
|
||||
return {
|
||||
block: hit.block as Record<string, unknown>,
|
||||
meta: {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
queryName: envelope.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeBlockPatchCommand(input: {
|
||||
request: Request;
|
||||
sourceDocumentId: string;
|
||||
workspaceId?: string | null;
|
||||
blockId: string;
|
||||
nextBlock: unknown;
|
||||
}) {
|
||||
const documentId = assertDocumentId(input.sourceDocumentId);
|
||||
const blockId = assertBlockId(input.blockId);
|
||||
assertNextBlock(input.nextBlock);
|
||||
|
||||
const { client, state } = await loadDocumentState({
|
||||
request: input.request,
|
||||
documentId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
});
|
||||
const replaced = replaceBlockInTree(state.blocks as never[], blockId, input.nextBlock as never);
|
||||
if (!replaced.ok) {
|
||||
throw new DocumentBridgeError("块不存在或无权限", 404, "NOT_FOUND");
|
||||
}
|
||||
const nextContent = withBlocksWrittenBack(state.content, replaced.nextBlocks as never[]);
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request: input.request,
|
||||
workspaceId: state.workspaceId,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "blocks.patch",
|
||||
payload: {
|
||||
documentId,
|
||||
workspaceId: state.workspaceId,
|
||||
blockId,
|
||||
nextBlock: input.nextBlock,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId: state.workspaceId,
|
||||
pageId: documentId,
|
||||
blockId,
|
||||
},
|
||||
});
|
||||
const meta = await resolveBlockCommandEnvelope({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
const save = await executeDocumentSaveTransport({
|
||||
client,
|
||||
request: input.request,
|
||||
documentId,
|
||||
workspaceId: state.workspaceId,
|
||||
content: nextContent,
|
||||
revision: state.revision,
|
||||
conflictDetectionKey: state.conflictDetectionKey,
|
||||
recordArtifacts: false,
|
||||
});
|
||||
await recordBridgeCommandArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
return {
|
||||
...meta,
|
||||
result: save.result,
|
||||
} satisfies BlockCommandResult<{ revision?: number | null; conflict_detection_key?: string | null }>;
|
||||
}
|
||||
|
||||
export async function executeBlockMoveCommand(input: {
|
||||
request: Request;
|
||||
sourceDocumentId: string;
|
||||
blockId: string;
|
||||
targetDocumentId: string;
|
||||
}) {
|
||||
const sourceDocumentId = assertDocumentId(input.sourceDocumentId);
|
||||
const targetDocumentId = assertDocumentId(input.targetDocumentId);
|
||||
const blockId = assertBlockId(input.blockId);
|
||||
|
||||
if (sourceDocumentId === targetDocumentId) {
|
||||
const context = await buildDocumentBridgeContext({ request: input.request, workspaceId: null });
|
||||
return {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
commandId: "noop",
|
||||
commandName: "blocks.move",
|
||||
result: { ok: true, noop: true },
|
||||
};
|
||||
}
|
||||
|
||||
const { client, state: sourceState } = await loadDocumentState({
|
||||
request: input.request,
|
||||
documentId: sourceDocumentId,
|
||||
});
|
||||
const removed = removeBlockSubtree(sourceState.blocks as never[], blockId);
|
||||
if (!removed.removed) {
|
||||
throw new DocumentBridgeError("源块不存在或无权限", 404, "NOT_FOUND");
|
||||
}
|
||||
const { state: targetState } = await loadDocumentState({
|
||||
request: input.request,
|
||||
documentId: targetDocumentId,
|
||||
});
|
||||
|
||||
const nextSourceContent = withBlocksWrittenBack(sourceState.content, removed.nextBlocks as never[]);
|
||||
const nextTargetContent = withBlocksWrittenBack(targetState.content, [
|
||||
...(targetState.blocks as never[]),
|
||||
removed.removed as never,
|
||||
]);
|
||||
const workspaceId = sourceState.workspaceId ?? targetState.workspaceId ?? null;
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request: input.request,
|
||||
workspaceId,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "blocks.move",
|
||||
payload: {
|
||||
sourceDocumentId,
|
||||
targetDocumentId,
|
||||
blockId,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
pageId: targetDocumentId,
|
||||
blockId,
|
||||
},
|
||||
});
|
||||
const meta = await resolveBlockCommandEnvelope({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
const sourceSave = await executeDocumentSaveTransport({
|
||||
client,
|
||||
request: input.request,
|
||||
documentId: sourceDocumentId,
|
||||
workspaceId: sourceState.workspaceId,
|
||||
content: nextSourceContent,
|
||||
revision: sourceState.revision,
|
||||
conflictDetectionKey: sourceState.conflictDetectionKey,
|
||||
recordArtifacts: false,
|
||||
});
|
||||
const targetSave = await executeDocumentSaveTransport({
|
||||
client,
|
||||
request: input.request,
|
||||
documentId: targetDocumentId,
|
||||
workspaceId: targetState.workspaceId,
|
||||
content: nextTargetContent,
|
||||
revision: targetState.revision,
|
||||
conflictDetectionKey: targetState.conflictDetectionKey,
|
||||
recordArtifacts: false,
|
||||
});
|
||||
await recordBridgeCommandArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
return {
|
||||
...meta,
|
||||
result: {
|
||||
ok: true,
|
||||
sourceRevision: sourceSave.result.revision ?? null,
|
||||
targetRevision: targetSave.result.revision ?? null,
|
||||
},
|
||||
} satisfies BlockCommandResult<{
|
||||
ok: boolean;
|
||||
sourceRevision: number | null;
|
||||
targetRevision: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function executeBlockEmbedCommand(input: {
|
||||
request: Request;
|
||||
sourceDocumentId: string;
|
||||
blockId: string;
|
||||
targetDocumentId: string;
|
||||
}) {
|
||||
const sourceDocumentId = assertDocumentId(input.sourceDocumentId);
|
||||
const targetDocumentId = assertDocumentId(input.targetDocumentId);
|
||||
const blockId = assertBlockId(input.blockId);
|
||||
|
||||
if (sourceDocumentId === targetDocumentId) {
|
||||
throw new DocumentBridgeError("禁止嵌入到当前页面", 400, "VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
const { client, state: sourceState } = await loadDocumentState({
|
||||
request: input.request,
|
||||
documentId: sourceDocumentId,
|
||||
});
|
||||
const hit = findBlockInTree(sourceState.blocks as never[], blockId);
|
||||
if (!hit) {
|
||||
throw new DocumentBridgeError("源块不存在或无权限", 404, "NOT_FOUND");
|
||||
}
|
||||
const { state: targetState } = await loadDocumentState({
|
||||
request: input.request,
|
||||
documentId: targetDocumentId,
|
||||
});
|
||||
const targetBlocks = [...(targetState.blocks as Array<Record<string, unknown>>)];
|
||||
const anchorId =
|
||||
typeof targetState.meta?.embed_default_block_id === "string" && targetState.meta.embed_default_block_id.trim()
|
||||
? targetState.meta.embed_default_block_id.trim()
|
||||
: null;
|
||||
const anchorIndex = anchorId
|
||||
? targetBlocks.findIndex((block) => String(block?.id ?? "") === anchorId)
|
||||
: -1;
|
||||
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : targetBlocks.length;
|
||||
const referenceBlock = {
|
||||
id: randomUUID(),
|
||||
type: "blockReference",
|
||||
props: {
|
||||
sourceDocumentId,
|
||||
targetBlockId: blockId,
|
||||
display: "embed",
|
||||
},
|
||||
content: [],
|
||||
children: [],
|
||||
};
|
||||
const nextBlocks = [
|
||||
...targetBlocks.slice(0, insertIndex),
|
||||
referenceBlock,
|
||||
...targetBlocks.slice(insertIndex),
|
||||
];
|
||||
const nextContent = withBlocksWrittenBack(targetState.content, nextBlocks as never[]);
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request: input.request,
|
||||
workspaceId: targetState.workspaceId,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "blocks.embed",
|
||||
payload: {
|
||||
sourceDocumentId,
|
||||
targetDocumentId,
|
||||
blockId,
|
||||
targetBlockId: anchorId,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId: targetState.workspaceId,
|
||||
pageId: targetDocumentId,
|
||||
blockId,
|
||||
},
|
||||
});
|
||||
const meta = await resolveBlockCommandEnvelope({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
const save = await executeDocumentSaveTransport({
|
||||
client,
|
||||
request: input.request,
|
||||
documentId: targetDocumentId,
|
||||
workspaceId: targetState.workspaceId,
|
||||
content: nextContent,
|
||||
revision: targetState.revision,
|
||||
conflictDetectionKey: targetState.conflictDetectionKey,
|
||||
recordArtifacts: false,
|
||||
});
|
||||
await recordBridgeCommandArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
return {
|
||||
...meta,
|
||||
result: {
|
||||
ok: true,
|
||||
revision: save.result.revision ?? null,
|
||||
referenceBlockId: String(referenceBlock.id),
|
||||
},
|
||||
} satisfies BlockCommandResult<{
|
||||
ok: boolean;
|
||||
revision: number | null;
|
||||
referenceBlockId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function executeDocumentSnapshotSaveCommand(input: {
|
||||
request: Request;
|
||||
documentId: string;
|
||||
workspaceId?: string | null;
|
||||
content: unknown;
|
||||
recordArtifacts?: boolean;
|
||||
}) {
|
||||
const documentId = assertDocumentId(input.documentId);
|
||||
const { client, state } = await loadDocumentState({
|
||||
request: input.request,
|
||||
documentId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
});
|
||||
return executeDocumentSaveTransport({
|
||||
client,
|
||||
request: input.request,
|
||||
documentId,
|
||||
workspaceId: state.workspaceId,
|
||||
content: input.content,
|
||||
revision: state.revision,
|
||||
conflictDetectionKey: state.conflictDetectionKey,
|
||||
recordArtifacts: input.recordArtifacts,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeDocumentBlockInsertCommand(input: {
|
||||
request: Request;
|
||||
documentId: string;
|
||||
afterBlockId?: string | null;
|
||||
beforeBlockId?: string | null;
|
||||
blocks: BlockInsertSpec[];
|
||||
baseBlocks?: unknown;
|
||||
}) {
|
||||
const adapter = createBlockSnapshotOpsAdapter({
|
||||
baseBlocks: input.baseBlocks,
|
||||
loadBlocks: async () => {
|
||||
const { state } = await loadDocumentState({
|
||||
request: input.request,
|
||||
documentId: input.documentId,
|
||||
});
|
||||
return {
|
||||
blocks: state.blocks as unknown[],
|
||||
source: "server",
|
||||
};
|
||||
},
|
||||
});
|
||||
const snapshot = await adapter.insertBlocks({
|
||||
afterBlockId: input.afterBlockId ?? undefined,
|
||||
beforeBlockId: input.beforeBlockId ?? undefined,
|
||||
blocks: input.blocks,
|
||||
});
|
||||
const save = await executeDocumentSnapshotSaveCommand({
|
||||
request: input.request,
|
||||
documentId: input.documentId,
|
||||
content: snapshot.data,
|
||||
});
|
||||
return {
|
||||
...save,
|
||||
inserted: snapshot.inserted,
|
||||
data: snapshot.data,
|
||||
source: snapshot.source,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeDocumentBlockReplaceRangeCommand(input: {
|
||||
request: Request;
|
||||
documentId: string;
|
||||
blockId: string;
|
||||
text: string;
|
||||
mode: "replace" | "append" | "prepend";
|
||||
baseBlocks?: unknown;
|
||||
}) {
|
||||
const adapter = createBlockSnapshotOpsAdapter({
|
||||
baseBlocks: input.baseBlocks,
|
||||
loadBlocks: async () => {
|
||||
const { state } = await loadDocumentState({
|
||||
request: input.request,
|
||||
documentId: input.documentId,
|
||||
});
|
||||
return {
|
||||
blocks: state.blocks as unknown[],
|
||||
source: "server",
|
||||
};
|
||||
},
|
||||
});
|
||||
const snapshot = await adapter.replaceRange({
|
||||
blockId: assertBlockId(input.blockId),
|
||||
text: input.text,
|
||||
mode: input.mode,
|
||||
});
|
||||
const save = await executeDocumentSnapshotSaveCommand({
|
||||
request: input.request,
|
||||
documentId: input.documentId,
|
||||
content: snapshot.data,
|
||||
});
|
||||
return {
|
||||
...save,
|
||||
blockId: input.blockId,
|
||||
mode: input.mode,
|
||||
data: snapshot.data,
|
||||
source: snapshot.source,
|
||||
};
|
||||
}
|
||||
|
||||
export { documentBridgeErrorResponse };
|
||||
@@ -0,0 +1,183 @@
|
||||
import { getBlocksFromDocumentContent, replaceBlockInTree } from "@/lib/blocks";
|
||||
import type { Json } from "@/types/supabase";
|
||||
|
||||
type TreeBlockLike = {
|
||||
id: string;
|
||||
type?: string;
|
||||
props?: Record<string, unknown>;
|
||||
content?: unknown;
|
||||
children?: TreeBlockLike[];
|
||||
};
|
||||
|
||||
export type BlockLike = {
|
||||
id: string;
|
||||
type?: string;
|
||||
props?: Record<string, unknown>;
|
||||
content?: unknown;
|
||||
children?: unknown;
|
||||
};
|
||||
|
||||
export type BlockInsertSpec = {
|
||||
type: "paragraph" | "heading";
|
||||
text: string;
|
||||
level?: number;
|
||||
};
|
||||
|
||||
export type BlockSnapshot = Json;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const normalizeBlocks = (content: unknown): BlockLike[] => {
|
||||
return getBlocksFromDocumentContent(content) as BlockLike[];
|
||||
};
|
||||
|
||||
const createTextContent = (text: string): unknown[] => [{ type: "text", text }];
|
||||
|
||||
const generateId = () => {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `bn_${Math.random().toString(36).slice(2, 10)}`;
|
||||
};
|
||||
|
||||
const buildBlockFromSpec = (spec: BlockInsertSpec): BlockLike => {
|
||||
const base: BlockLike = {
|
||||
id: generateId(),
|
||||
type: spec.type,
|
||||
props: {},
|
||||
content: createTextContent(String(spec.text ?? "").trim()),
|
||||
children: [],
|
||||
};
|
||||
if (spec.type === "heading") {
|
||||
const level = Number(spec.level ?? 2);
|
||||
base.props = { level: Math.max(1, Math.min(5, Number.isFinite(level) ? Math.floor(level) : 2)) };
|
||||
}
|
||||
return base;
|
||||
};
|
||||
|
||||
const cloneBlock = (block: BlockLike): BlockLike => ({
|
||||
...block,
|
||||
props: isRecord(block.props) ? { ...block.props } : block.props,
|
||||
content: Array.isArray(block.content) ? [...block.content] : block.content,
|
||||
children: Array.isArray(block.children)
|
||||
? (block.children as BlockLike[]).map((child) => cloneBlock(child))
|
||||
: block.children,
|
||||
});
|
||||
|
||||
export const findBlockContainerById = (
|
||||
blocks: BlockLike[],
|
||||
targetId: string,
|
||||
): { container: BlockLike[]; index: number } | null => {
|
||||
const id = String(targetId || "").trim();
|
||||
if (!id) return null;
|
||||
for (let index = 0; index < blocks.length; index += 1) {
|
||||
const block = blocks[index];
|
||||
if (String(block?.id ?? "").trim() === id) {
|
||||
return { container: blocks, index };
|
||||
}
|
||||
const children = Array.isArray(block?.children)
|
||||
? (block.children as BlockLike[]).filter(
|
||||
(item): item is BlockLike => Boolean(item && typeof item.id === "string"),
|
||||
)
|
||||
: [];
|
||||
const found = findBlockContainerById(children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const readInlineBlockText = (block: BlockLike): string => {
|
||||
const inline = Array.isArray(block.content) ? (block.content as Array<{ text?: unknown }>) : [];
|
||||
return inline.map((node) => (typeof node?.text === "string" ? node.text : "")).join("").trim();
|
||||
};
|
||||
|
||||
export function createBlockSnapshotOpsAdapter(input: {
|
||||
baseBlocks?: unknown;
|
||||
loadBlocks?: () => Promise<{ blocks: unknown[]; source: string }>;
|
||||
}) {
|
||||
const loadSnapshot = async () => {
|
||||
if (input.baseBlocks) {
|
||||
return {
|
||||
blocks: normalizeBlocks(input.baseBlocks),
|
||||
source: "client" as const,
|
||||
};
|
||||
}
|
||||
if (input.loadBlocks) {
|
||||
const loaded = await input.loadBlocks();
|
||||
return {
|
||||
blocks: normalizeBlocks(loaded.blocks),
|
||||
source: "route" as const,
|
||||
};
|
||||
}
|
||||
return {
|
||||
blocks: [] as BlockLike[],
|
||||
source: "empty" as const,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
getSnapshot: loadSnapshot,
|
||||
async insertBlocks(input: {
|
||||
afterBlockId?: string;
|
||||
beforeBlockId?: string;
|
||||
blocks: BlockInsertSpec[];
|
||||
}) {
|
||||
const snapshot = await loadSnapshot();
|
||||
const blocks = snapshot.blocks.map((block) => cloneBlock(block));
|
||||
const created = input.blocks.map(buildBlockFromSpec);
|
||||
const targetId = input.beforeBlockId || input.afterBlockId;
|
||||
const found = targetId ? findBlockContainerById(blocks, targetId) : null;
|
||||
if (targetId && !found) {
|
||||
throw new Error(`未找到 blockId:${targetId}`);
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
blocks.push(...created);
|
||||
} else {
|
||||
const insertAt = input.beforeBlockId ? found.index : found.index + 1;
|
||||
found.container.splice(insertAt, 0, ...created);
|
||||
}
|
||||
|
||||
return {
|
||||
data: blocks as BlockSnapshot,
|
||||
inserted: created.map((block) => String(block.id ?? "")),
|
||||
source: snapshot.source,
|
||||
};
|
||||
},
|
||||
async replaceRange(input: {
|
||||
blockId: string;
|
||||
text: string;
|
||||
mode: "replace" | "append" | "prepend";
|
||||
}) {
|
||||
const snapshot = await loadSnapshot();
|
||||
const blocks = snapshot.blocks.map((block) => cloneBlock(block));
|
||||
const found = findBlockContainerById(blocks, input.blockId);
|
||||
if (!found) {
|
||||
throw new Error(`未找到 blockId:${input.blockId}`);
|
||||
}
|
||||
|
||||
const block = found.container[found.index];
|
||||
const prevText = readInlineBlockText(block);
|
||||
const nextText =
|
||||
input.mode === "append"
|
||||
? `${prevText}${input.text}`
|
||||
: input.mode === "prepend"
|
||||
? `${input.text}${prevText}`
|
||||
: input.text;
|
||||
|
||||
const replaced = replaceBlockInTree(blocks, input.blockId, {
|
||||
...(block as TreeBlockLike),
|
||||
content: createTextContent(nextText),
|
||||
} as TreeBlockLike);
|
||||
if (!replaced.ok) {
|
||||
throw new Error(`未找到 blockId:${input.blockId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
data: replaced.nextBlocks as BlockSnapshot,
|
||||
source: snapshot.source,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
buildDocumentQueryEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
type BridgeContext,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
recordBridgeCommandArtifacts,
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
executeRustBridgeQueryTransport,
|
||||
resolveRustBridgeCommandPlan,
|
||||
resolveRustBridgeQueryPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||
|
||||
type BlockLike = {
|
||||
id: string;
|
||||
type?: string;
|
||||
props?: Record<string, unknown>;
|
||||
content?: unknown;
|
||||
children?: BlockLike[];
|
||||
};
|
||||
|
||||
function 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((item) => cloneBlock(item)) : block.children,
|
||||
};
|
||||
}
|
||||
|
||||
function 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 > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current) continue;
|
||||
for (let i = 0; i < current.list.length; i += 1) {
|
||||
const block = current.list[i]!;
|
||||
if (block.id === blockId) {
|
||||
return { block, parent: current.parent, index: i };
|
||||
}
|
||||
if (Array.isArray(block.children) && block.children.length > 0) {
|
||||
stack.push({ list: block.children, parent: block });
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function removeBlockSubtree(blocks: BlockLike[], blockId: string) {
|
||||
const nextTop = blocks.map((item) => cloneBlock(item));
|
||||
const hit = findBlockInTree(nextTop, blockId);
|
||||
if (!hit) {
|
||||
return { removed: null as BlockLike | null, nextBlocks: nextTop };
|
||||
}
|
||||
if (hit.parent) {
|
||||
const nextChildren = Array.isArray(hit.parent.children) ? hit.parent.children.map((item) => cloneBlock(item)) : [];
|
||||
const removed = nextChildren.splice(hit.index, 1)[0] ?? null;
|
||||
hit.parent.children = nextChildren;
|
||||
return { removed, nextBlocks: nextTop };
|
||||
}
|
||||
const removed = nextTop.splice(hit.index, 1)[0] ?? null;
|
||||
return { removed, nextBlocks: nextTop };
|
||||
}
|
||||
|
||||
function replaceBlockInTree(blocks: BlockLike[], blockId: string, nextBlock: unknown) {
|
||||
const nextTop = blocks.map((item) => cloneBlock(item));
|
||||
const hit = findBlockInTree(nextTop, blockId);
|
||||
if (!hit || !nextBlock || typeof nextBlock !== "object" || Array.isArray(nextBlock)) {
|
||||
return { ok: false, nextBlocks: nextTop };
|
||||
}
|
||||
const normalized = cloneBlock({ ...(nextBlock as BlockLike), id: blockId });
|
||||
if (hit.parent) {
|
||||
const nextChildren = Array.isArray(hit.parent.children) ? hit.parent.children.map((item) => cloneBlock(item)) : [];
|
||||
nextChildren[hit.index] = normalized;
|
||||
hit.parent.children = nextChildren;
|
||||
return { ok: true, nextBlocks: nextTop };
|
||||
}
|
||||
nextTop[hit.index] = normalized;
|
||||
return { ok: true, nextBlocks: nextTop };
|
||||
}
|
||||
|
||||
function buildReferenceBlock(sourceDocumentId: string, blockId: string): BlockLike {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
type: "blockReference",
|
||||
props: {
|
||||
sourceDocumentId,
|
||||
targetBlockId: blockId,
|
||||
display: "embed",
|
||||
},
|
||||
content: [],
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function buildBridgeContext(request: Request, workspaceId: string | null): Promise<BridgeContext> {
|
||||
return await buildDocumentBridgeContext({ request, workspaceId });
|
||||
}
|
||||
|
||||
export async function executeBlockGetBridgeQuery(input: {
|
||||
request: Request;
|
||||
sourceDocumentId: string;
|
||||
blockId: string;
|
||||
}) {
|
||||
const context = await buildBridgeContext(input.request, null);
|
||||
const envelope = buildDocumentQueryEnvelope({
|
||||
name: "blocks.get",
|
||||
payload: {
|
||||
sourceDocumentId: input.sourceDocumentId,
|
||||
blockId: input.blockId,
|
||||
},
|
||||
});
|
||||
const plan = await resolveRustBridgeQueryPlan({ context, envelope });
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const doc = await executeRustBridgeQueryTransport<{ content?: unknown } | null>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
if (!doc) {
|
||||
throw new Error("页面不存在或无权限");
|
||||
}
|
||||
const blocks = extractBlocksFromContent(doc.content) as BlockLike[];
|
||||
const hit = findBlockInTree(blocks, input.blockId);
|
||||
if (!hit) {
|
||||
throw new Error("块不存在或无权限");
|
||||
}
|
||||
return {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
queryName: envelope.name,
|
||||
result: { block: hit.block },
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeBlockPatchBridgeCommand(input: {
|
||||
request: Request;
|
||||
sourceDocumentId: string;
|
||||
workspaceId: string | null;
|
||||
blockId: string;
|
||||
nextBlock: unknown;
|
||||
}) {
|
||||
const context = await buildBridgeContext(input.request, input.workspaceId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "blocks.patch",
|
||||
payload: {
|
||||
documentId: input.sourceDocumentId,
|
||||
workspaceId: input.workspaceId,
|
||||
blockId: input.blockId,
|
||||
nextBlock: input.nextBlock,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId: input.workspaceId,
|
||||
pageId: input.sourceDocumentId,
|
||||
blockId: input.blockId,
|
||||
},
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const doc = await client.query(api.documents.getContent, { id: input.sourceDocumentId });
|
||||
if (!doc) {
|
||||
throw new Error("页面不存在或无权限");
|
||||
}
|
||||
const blocks = extractBlocksFromContent(doc.content) as BlockLike[];
|
||||
const replaced = replaceBlockInTree(blocks, input.blockId, input.nextBlock);
|
||||
if (!replaced.ok) {
|
||||
throw new Error("块不存在或无权限");
|
||||
}
|
||||
try {
|
||||
await executeRustBridgeMutationTransport({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
id: input.sourceDocumentId,
|
||||
content: composeContentWithBlocks(doc.content, replaced.nextBlocks as never),
|
||||
});
|
||||
await recordBridgeCommandArtifacts({ context, envelope });
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
result: { ok: true as const },
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeBlockMoveBridgeCommand(input: {
|
||||
request: Request;
|
||||
sourceDocumentId: string;
|
||||
targetDocumentId: string;
|
||||
blockId: string;
|
||||
}) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const source = await client.query(api.documents.getContent, { id: input.sourceDocumentId });
|
||||
if (!source) throw new Error("源页面不存在或无权限");
|
||||
const target = await client.query(api.documents.getContent, { id: input.targetDocumentId });
|
||||
if (!target) throw new Error("目标页面不存在或无权限");
|
||||
const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
|
||||
const removedRes = removeBlockSubtree(sourceBlocks, input.blockId);
|
||||
if (!removedRes.removed) throw new Error("源块不存在或无权限");
|
||||
const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
|
||||
const nextSourceContent = composeContentWithBlocks(source.content, removedRes.nextBlocks as never);
|
||||
const nextTargetContent = composeContentWithBlocks(target.content, [...targetBlocks, removedRes.removed] as never);
|
||||
const context = await buildBridgeContext(input.request, null);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "blocks.move",
|
||||
payload: {
|
||||
sourceDocumentId: input.sourceDocumentId,
|
||||
targetDocumentId: input.targetDocumentId,
|
||||
blockId: input.blockId,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
pageId: input.targetDocumentId,
|
||||
blockId: input.blockId,
|
||||
},
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||
try {
|
||||
await executeRustBridgeMutationTransport({ client, plan });
|
||||
await client.mutation(api.documents.updateContent, { id: input.sourceDocumentId, content: nextSourceContent });
|
||||
await client.mutation(api.documents.updateContent, { id: input.targetDocumentId, content: nextTargetContent });
|
||||
await recordBridgeCommandArtifacts({ context, envelope });
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
result: { ok: true as const },
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeBlockEmbedBridgeCommand(input: {
|
||||
request: Request;
|
||||
sourceDocumentId: string;
|
||||
targetDocumentId: string;
|
||||
blockId: string;
|
||||
}) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const source = await client.query(api.documents.getContent, { id: input.sourceDocumentId });
|
||||
if (!source) throw new Error("源页面不存在或无权限");
|
||||
const target = await client.query(api.documents.getContent, { id: input.targetDocumentId });
|
||||
if (!target) throw new Error("目标页面不存在或无权限");
|
||||
const targetMeta = await client.query(api.documents.getMeta, { id: input.targetDocumentId });
|
||||
const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
|
||||
const hit = findBlockInTree(sourceBlocks, input.blockId);
|
||||
if (!hit) throw new Error("源块不存在或无权限");
|
||||
const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
|
||||
const anchorId = (targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id ?? null;
|
||||
const anchorIndex =
|
||||
typeof anchorId === "string" && anchorId.trim()
|
||||
? targetBlocks.findIndex((block) => String(block.id ?? "") === anchorId)
|
||||
: -1;
|
||||
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : targetBlocks.length;
|
||||
const nextTargetBlocks = [
|
||||
...targetBlocks.slice(0, insertIndex),
|
||||
buildReferenceBlock(input.sourceDocumentId, input.blockId),
|
||||
...targetBlocks.slice(insertIndex),
|
||||
];
|
||||
const context = await buildBridgeContext(input.request, null);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "blocks.embed",
|
||||
payload: {
|
||||
sourceDocumentId: input.sourceDocumentId,
|
||||
targetDocumentId: input.targetDocumentId,
|
||||
blockId: input.blockId,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
pageId: input.targetDocumentId,
|
||||
blockId: input.blockId,
|
||||
},
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||
try {
|
||||
await executeRustBridgeMutationTransport({ client, plan });
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
id: input.targetDocumentId,
|
||||
content: composeContentWithBlocks(target.content, nextTargetBlocks as never),
|
||||
});
|
||||
await recordBridgeCommandArtifacts({ context, envelope });
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
result: { ok: true as const },
|
||||
};
|
||||
}
|
||||
|
||||
export function handleBlockBridgeError(error: unknown) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import { api } from "@/lib/convex/api";
|
||||
import type { BridgeContext, BridgeTarget, CommandEnvelope } from "@/lib/documents/bridge";
|
||||
import {
|
||||
DocumentBridgeError,
|
||||
type BridgeContext,
|
||||
type BridgeTarget,
|
||||
type CommandEnvelope,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
|
||||
export type BridgeCommandLogStatus = "pending" | "succeeded" | "failed" | "rolled_back";
|
||||
export type BridgeDomainEventStatus = "pending" | "committed" | "rejected" | "failed";
|
||||
|
||||
function buildPayloadSummary(commandName: string, context: BridgeContext): string {
|
||||
return `command=${commandName};request_id=${context.requestId};trace_id=${context.traceId}`;
|
||||
}
|
||||
@@ -15,6 +23,10 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<T>;
|
||||
client?: ConvexHttpClient;
|
||||
status?: BridgeCommandLogStatus;
|
||||
eventStatus?: BridgeDomainEventStatus;
|
||||
error?: string | null;
|
||||
now?: string;
|
||||
}): Promise<void> {
|
||||
const workspaceId = normalizeWorkspaceId(input.context, input.envelope.target);
|
||||
if (!workspaceId) return;
|
||||
@@ -22,8 +34,15 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
||||
const client = input.client ?? (await getAuthedConvexClient()).client;
|
||||
const commandLogId = `clog_${input.envelope.commandId}`;
|
||||
const eventId = `evt_${input.envelope.commandId}`;
|
||||
const now = new Date().toISOString();
|
||||
const now = input.now ?? new Date().toISOString();
|
||||
const payload = input.envelope.payload as Record<string, unknown>;
|
||||
const status = input.status ?? "succeeded";
|
||||
const eventStatus =
|
||||
input.eventStatus ??
|
||||
(status === "pending" ? "pending" : status === "failed" || status === "rolled_back" ? "failed" : "committed");
|
||||
const aggregateType = input.envelope.target?.blockId ? "block" : input.envelope.target?.pageId ? "page" : "workspace";
|
||||
const aggregateId =
|
||||
input.envelope.target?.blockId ?? input.envelope.target?.pageId ?? workspaceId;
|
||||
|
||||
await client.mutation(api.bridgeLogs.recordCommandLog, {
|
||||
workspaceId,
|
||||
@@ -36,16 +55,16 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
||||
actorType: input.context.actor.actorType,
|
||||
sourceChannel: input.context.source.channel,
|
||||
sourceClient: input.context.source.client,
|
||||
status: "succeeded",
|
||||
status,
|
||||
targetPageId: input.envelope.target?.pageId ?? null,
|
||||
targetBlockId: input.envelope.target?.blockId ?? null,
|
||||
payload,
|
||||
payloadSummary: buildPayloadSummary(input.envelope.name, input.context),
|
||||
refs: input.envelope.refs,
|
||||
idempotencyKey: input.envelope.idempotencyKey,
|
||||
error: null,
|
||||
error: input.error ?? null,
|
||||
createdAt: now,
|
||||
finishedAt: now,
|
||||
finishedAt: status === "pending" ? null : now,
|
||||
});
|
||||
|
||||
await client.mutation(api.bridgeLogs.recordDomainEvent, {
|
||||
@@ -56,18 +75,77 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
||||
commandId: input.envelope.commandId,
|
||||
commandLogId,
|
||||
eventType: `${input.envelope.name}.requested`,
|
||||
aggregateType: input.envelope.target?.blockId ? "block" : "page",
|
||||
aggregateId:
|
||||
input.envelope.target?.blockId ?? input.envelope.target?.pageId ?? workspaceId,
|
||||
aggregateType,
|
||||
aggregateId,
|
||||
eventVersion: 1,
|
||||
status: "committed",
|
||||
status: eventStatus,
|
||||
actorType: input.context.actor.actorType,
|
||||
payload: {
|
||||
request_id: input.context.requestId,
|
||||
trace_id: input.context.traceId,
|
||||
command_id: input.envelope.commandId,
|
||||
command_name: input.envelope.name,
|
||||
idempotency_key: input.envelope.idempotencyKey,
|
||||
error: input.error ?? null,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeBridgeErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string" && error.trim()) {
|
||||
return error.trim();
|
||||
}
|
||||
return "未知 bridge 错误";
|
||||
}
|
||||
|
||||
function resolveFailureStatuses(error: unknown): {
|
||||
status: BridgeCommandLogStatus;
|
||||
eventStatus: BridgeDomainEventStatus;
|
||||
} {
|
||||
const details =
|
||||
error instanceof DocumentBridgeError && error.details && typeof error.details === "object"
|
||||
? (error.details as Record<string, unknown>)
|
||||
: null;
|
||||
const reason = typeof details?.reason === "string" ? details.reason.trim() : "";
|
||||
if (reason === "rolled_back" || reason === "compensation_applied") {
|
||||
return {
|
||||
status: "rolled_back",
|
||||
eventStatus: "failed",
|
||||
};
|
||||
}
|
||||
if (error instanceof DocumentBridgeError && error.code === "REJECTED") {
|
||||
return {
|
||||
status: "failed",
|
||||
eventStatus: "rejected",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "failed",
|
||||
eventStatus: "failed",
|
||||
};
|
||||
}
|
||||
|
||||
export async function recordBridgeCommandFailureArtifacts<T>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<T>;
|
||||
client?: ConvexHttpClient;
|
||||
error: unknown;
|
||||
}): Promise<void> {
|
||||
const { status, eventStatus } = resolveFailureStatuses(input.error);
|
||||
try {
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
client: input.client,
|
||||
status,
|
||||
eventStatus,
|
||||
error: normalizeBridgeErrorMessage(input.error),
|
||||
});
|
||||
} catch (loggingError) {
|
||||
console.warn("[bridge-log] failure artifacts skipped:", loggingError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
|
||||
vi.mock("@/lib/auth/authContext", () => ({
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "@/lib/documents/bridge";
|
||||
import { executeMediaAssetWritebackBridgeCommand } from "@/lib/documents/media-asset-command-adapter";
|
||||
import { executeMetadataBridgeCommand } from "@/lib/documents/metadata-command-adapter";
|
||||
import { executePageLifecycleBridgeCommand } from "@/lib/documents/page-command-adapter";
|
||||
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
|
||||
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
|
||||
@@ -46,6 +47,14 @@ vi.mock("@/lib/convex/route", () => ({
|
||||
|
||||
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||
recordBridgeCommandArtifacts: vi.fn(),
|
||||
recordBridgeCommandFailureArtifacts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
resolveRustBridgeCommandPlan: vi.fn(),
|
||||
executeRustBridgeMutationTransport: vi.fn(),
|
||||
resolveRustBridgeQueryPlan: vi.fn(),
|
||||
executeRustBridgeQueryTransport: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockContext: BridgeContext = {
|
||||
@@ -70,6 +79,10 @@ const mockContext: BridgeContext = {
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("documents bridge helpers", () => {
|
||||
it("assertDocumentId returns trimmed id", () => {
|
||||
expect(assertDocumentId(" doc_1 ")).toBe("doc_1");
|
||||
@@ -270,6 +283,45 @@ describe("documents bridge helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("buildDocumentBridgeMutationRequest builds page lifecycle runtime request", () => {
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.create",
|
||||
payload: {
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: "parent_1",
|
||||
title: "无标题",
|
||||
accessScope: "private",
|
||||
content: [],
|
||||
},
|
||||
context: mockContext,
|
||||
target: { workspaceId: "ws_1", pageId: "doc_1" },
|
||||
});
|
||||
|
||||
const request = buildDocumentBridgeMutationRequest({
|
||||
context: mockContext,
|
||||
envelope,
|
||||
mapConvexArgs: (payload) => ({
|
||||
id: payload.documentId,
|
||||
workspaceId: payload.workspaceId,
|
||||
parentId: payload.parentId,
|
||||
title: payload.title,
|
||||
accessScope: payload.accessScope,
|
||||
content: payload.content,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(request.functionName).toBe("documents:createWithParentReference");
|
||||
expect(request.args).toEqual({
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: "parent_1",
|
||||
title: "无标题",
|
||||
accessScope: "private",
|
||||
content: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("buildDocumentBridgeContextWithActor keeps explicit actor and source", () => {
|
||||
const request = new Request("http://127.0.0.1:3001/api/onlyoffice/callback", {
|
||||
headers: {
|
||||
@@ -308,14 +360,34 @@ describe("documents bridge helpers", () => {
|
||||
});
|
||||
|
||||
it("executeMetadataBridgeCommand routes title update through adapter", async () => {
|
||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
const {
|
||||
resolveRustBridgeCommandPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
} = await import("@/lib/documents/rust-runtime");
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: { userId: "user_1" },
|
||||
client: {
|
||||
mutation,
|
||||
mutation: vi.fn(),
|
||||
} as unknown as ConvexHttpClient,
|
||||
});
|
||||
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "documents.title.update",
|
||||
commandId: "cmd_title_1",
|
||||
functionName: "documents:updateTitle",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{\"kind\":\"command\"}",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
title: "新标题",
|
||||
},
|
||||
});
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({ ok: true });
|
||||
|
||||
const result = await executeMetadataBridgeCommand({
|
||||
context: mockContext,
|
||||
@@ -331,10 +403,21 @@ describe("documents bridge helpers", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(mutation.mock.calls[0]?.[1]).toEqual({
|
||||
id: "doc_1",
|
||||
title: "新标题",
|
||||
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "documents.title.update",
|
||||
}),
|
||||
});
|
||||
expect(executeRustBridgeMutationTransport).toHaveBeenCalledWith({
|
||||
client: expect.any(Object),
|
||||
plan: expect.objectContaining({
|
||||
functionName: "documents:updateTitle",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
title: "新标题",
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(result.commandName).toBe("documents.title.update");
|
||||
});
|
||||
@@ -390,16 +473,41 @@ describe("documents bridge helpers", () => {
|
||||
});
|
||||
|
||||
it("executeSaveBridgeCommand routes documents.save through adapter", async () => {
|
||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
const {
|
||||
resolveRustBridgeCommandPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
} = await import("@/lib/documents/rust-runtime");
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: { userId: "user_1" },
|
||||
client: {
|
||||
mutation,
|
||||
mutation: vi.fn(),
|
||||
} as unknown as ConvexHttpClient,
|
||||
});
|
||||
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
|
||||
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "documents.save",
|
||||
commandId: "cmd_save_1",
|
||||
functionName: "documents:updateContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{\"kind\":\"command\"}",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
content: [{ id: "block_1" }],
|
||||
expectedRevision: 7,
|
||||
conflictDetectionKey: "conflict_1",
|
||||
},
|
||||
});
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||
revision: 7,
|
||||
conflict_detection_key: "conflict_1",
|
||||
});
|
||||
const previousBridgeArtifactCalls = vi.mocked(recordBridgeCommandArtifacts).mock.calls.length;
|
||||
|
||||
const payload = buildDocumentSavePayload({
|
||||
@@ -422,12 +530,23 @@ describe("documents bridge helpers", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(mutation.mock.calls[0]?.[1]).toEqual({
|
||||
id: "doc_1",
|
||||
content: [{ id: "block_1" }],
|
||||
expectedRevision: 7,
|
||||
conflictDetectionKey: "conflict_1",
|
||||
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "documents.save",
|
||||
}),
|
||||
});
|
||||
expect(executeRustBridgeMutationTransport).toHaveBeenCalledWith({
|
||||
client: expect.any(Object),
|
||||
plan: expect.objectContaining({
|
||||
functionName: "documents:updateContent",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
content: [{ id: "block_1" }],
|
||||
expectedRevision: 7,
|
||||
conflictDetectionKey: "conflict_1",
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(vi.mocked(recordBridgeCommandArtifacts).mock.calls.length).toBe(
|
||||
previousBridgeArtifactCalls + 1,
|
||||
@@ -446,14 +565,38 @@ describe("documents bridge helpers", () => {
|
||||
});
|
||||
|
||||
it("executeSaveBridgeCommand 将冲突错误归一为 bridge rejected", async () => {
|
||||
const mutation = vi.fn().mockRejectedValue(new Error("正文内容已变更,请刷新后重试"));
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
const {
|
||||
resolveRustBridgeCommandPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
} = await import("@/lib/documents/rust-runtime");
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: { userId: "user_1" },
|
||||
client: {
|
||||
mutation,
|
||||
mutation: vi.fn(),
|
||||
} as unknown as ConvexHttpClient,
|
||||
});
|
||||
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "documents.save",
|
||||
commandId: "cmd_save_1",
|
||||
functionName: "documents:updateContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{\"kind\":\"command\"}",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
content: [{ id: "block_1" }],
|
||||
expectedRevision: 7,
|
||||
conflictDetectionKey: "conflict_1",
|
||||
},
|
||||
});
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockRejectedValue(
|
||||
new Error("正文内容已变更,请刷新后重试"),
|
||||
);
|
||||
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: "doc_1",
|
||||
@@ -480,6 +623,87 @@ describe("documents bridge helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("executePageLifecycleBridgeCommand routes page mutation through rust runtime", async () => {
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
const {
|
||||
resolveRustBridgeCommandPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
} = await import("@/lib/documents/rust-runtime");
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: { userId: "user_1" },
|
||||
client: {
|
||||
mutation: vi.fn(),
|
||||
} as unknown as ConvexHttpClient,
|
||||
});
|
||||
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
|
||||
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "documents.create",
|
||||
commandId: "cmd_create_1",
|
||||
functionName: "documents:createWithParentReference",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{\"kind\":\"command\"}",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: "parent_1",
|
||||
title: "无标题",
|
||||
accessScope: "private",
|
||||
content: [],
|
||||
},
|
||||
});
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||
id: "doc_1",
|
||||
title: "无标题",
|
||||
});
|
||||
|
||||
const result = await executePageLifecycleBridgeCommand({
|
||||
context: mockContext,
|
||||
envelope: buildDocumentCommandEnvelope({
|
||||
name: "documents.create",
|
||||
payload: {
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: "parent_1",
|
||||
title: "无标题",
|
||||
accessScope: "private",
|
||||
content: [],
|
||||
},
|
||||
context: mockContext,
|
||||
target: { workspaceId: "ws_1", pageId: "doc_1" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "documents.create",
|
||||
}),
|
||||
});
|
||||
expect(executeRustBridgeMutationTransport).toHaveBeenCalledWith({
|
||||
client: expect.any(Object),
|
||||
plan: expect.objectContaining({
|
||||
functionName: "documents:createWithParentReference",
|
||||
}),
|
||||
});
|
||||
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||
client: expect.any(Object),
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "documents.create",
|
||||
}),
|
||||
});
|
||||
expect(result.result).toEqual({
|
||||
id: "doc_1",
|
||||
title: "无标题",
|
||||
});
|
||||
});
|
||||
|
||||
it("executeMediaAssetWritebackBridgeCommand routes callback writeback through adapter", async () => {
|
||||
const mutation = vi.fn().mockResolvedValue({ ok: true, fileUrl: "https://example.com/file.docx" });
|
||||
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
|
||||
@@ -96,7 +96,36 @@ export type DocumentBridgeMutationRequest<
|
||||
args: TArgs;
|
||||
};
|
||||
|
||||
export type DocumentBridgeQueryRequest<
|
||||
TArgs extends Record<string, unknown> = Record<string, unknown>,
|
||||
> = {
|
||||
functionName: string;
|
||||
deploymentId: string | null;
|
||||
projectId: string | null;
|
||||
workspaceId: string | null;
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
actorId: string;
|
||||
payloadJson: string;
|
||||
args: TArgs;
|
||||
};
|
||||
|
||||
const DOCUMENT_BRIDGE_QUERY_FUNCTIONS = {
|
||||
"documents.content.get": "documents:getContent",
|
||||
"documents.meta.get": "documents:getMeta",
|
||||
"blocks.get": "documents:getContent",
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
|
||||
"documents.create": "documents:createWithParentReference",
|
||||
"documents.move": "documents:move",
|
||||
"documents.delete": "documents:softDelete",
|
||||
"documents.restore": "documents:restore",
|
||||
"documents.duplicate": "documents:duplicateWithMindmaps",
|
||||
"documents.copy_tree": "documents:copyTree",
|
||||
"blocks.patch": "documents:updateContent",
|
||||
"blocks.move": "documents:updateContent",
|
||||
"blocks.embed": "documents:updateContent",
|
||||
"documents.title.update": "documents:updateTitle",
|
||||
"documents.stats.update": "documents:updateStats",
|
||||
"documents.options.update": "documents:updateOptions",
|
||||
@@ -303,6 +332,28 @@ function buildDocumentCommandPayloadJson(input: {
|
||||
});
|
||||
}
|
||||
|
||||
function buildDocumentQueryPayloadJson(input: {
|
||||
context: BridgeContext;
|
||||
queryName: string;
|
||||
workspaceId: string | null;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
kind: "query",
|
||||
name: input.queryName,
|
||||
request_id: input.context.requestId,
|
||||
trace_id: input.context.traceId,
|
||||
deployment_id: input.context.deploymentId,
|
||||
project_id: input.context.projectId,
|
||||
workspace_id: input.workspaceId,
|
||||
tenant_id: input.context.tenantId,
|
||||
actor_id: input.context.actor.actorId,
|
||||
source: {
|
||||
channel: input.context.source.channel,
|
||||
client: input.context.source.client,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDocumentBridgeMutationRequest<
|
||||
TPayload,
|
||||
TArgs extends Record<string, unknown>,
|
||||
@@ -333,6 +384,47 @@ export function buildDocumentBridgeMutationRequest<
|
||||
};
|
||||
}
|
||||
|
||||
function getDocumentBridgeQueryFunctionName(queryName: string): string {
|
||||
const functionName =
|
||||
DOCUMENT_BRIDGE_QUERY_FUNCTIONS[
|
||||
queryName as keyof typeof DOCUMENT_BRIDGE_QUERY_FUNCTIONS
|
||||
];
|
||||
if (!functionName) {
|
||||
throw new DocumentBridgeError(
|
||||
`未注册文档 bridge query: ${queryName}`,
|
||||
500,
|
||||
"TRANSPORT_ERROR",
|
||||
);
|
||||
}
|
||||
return functionName;
|
||||
}
|
||||
|
||||
export function buildDocumentBridgeQueryRequest<
|
||||
TPayload,
|
||||
TArgs extends Record<string, unknown>,
|
||||
>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: QueryEnvelope<TPayload>;
|
||||
mapConvexArgs: (payload: TPayload) => TArgs;
|
||||
}): DocumentBridgeQueryRequest<TArgs> {
|
||||
const workspaceId = input.context.workspaceId ?? null;
|
||||
return {
|
||||
functionName: getDocumentBridgeQueryFunctionName(input.envelope.name),
|
||||
deploymentId: input.context.deploymentId,
|
||||
projectId: input.context.projectId,
|
||||
workspaceId,
|
||||
requestId: input.context.requestId,
|
||||
traceId: input.context.traceId,
|
||||
actorId: input.context.actor.actorId,
|
||||
payloadJson: buildDocumentQueryPayloadJson({
|
||||
context: input.context,
|
||||
queryName: input.envelope.name,
|
||||
workspaceId,
|
||||
}),
|
||||
args: input.mapConvexArgs(input.envelope.payload),
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeDocumentBridgeMutationRequest<
|
||||
TArgs extends Record<string, unknown>,
|
||||
TResult,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import type { Id } from "../../../convex/_generated/dataModel";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import {
|
||||
buildDocumentBridgeMutationRequest,
|
||||
@@ -6,7 +7,10 @@ import {
|
||||
type CommandEnvelope,
|
||||
type BridgeContext,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
||||
import {
|
||||
recordBridgeCommandArtifacts,
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
|
||||
export type MediaAssetReplaceStoragePayload = {
|
||||
assetId: string;
|
||||
@@ -34,15 +38,25 @@ export async function executeMediaAssetWritebackBridgeCommand(input: {
|
||||
mapConvexArgs: (payload) => ({
|
||||
userId: payload.userId,
|
||||
id: payload.assetId,
|
||||
storageId: payload.storageId as any,
|
||||
storageId: payload.storageId as Id<"_storage">,
|
||||
}),
|
||||
});
|
||||
|
||||
await executeDocumentBridgeMutationRequest({
|
||||
client: input.client,
|
||||
mutation: api.mediaAssets.replaceStorageFromUpload,
|
||||
request: mutationRequest,
|
||||
});
|
||||
try {
|
||||
await executeDocumentBridgeMutationRequest({
|
||||
client: input.client,
|
||||
mutation: api.mediaAssets.replaceStorageFromUpload,
|
||||
request: mutationRequest,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
client: input.client,
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await recordBridgeCommandArtifacts({
|
||||
|
||||
@@ -6,7 +6,14 @@ import {
|
||||
type CommandEnvelope,
|
||||
type BridgeContext,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
||||
import {
|
||||
recordBridgeCommandArtifacts,
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
resolveRustBridgeCommandPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
import type { PageOptionsState } from "@/types/page-options";
|
||||
|
||||
export type DocumentTitleUpdatePayload = {
|
||||
@@ -106,19 +113,40 @@ export async function executeMetadataBridgeCommand<TPayload>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<TPayload>;
|
||||
}): Promise<MetadataCommandExecutionResult> {
|
||||
const adapter = getMetadataWriteAdapter<TPayload>(input.envelope.name);
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const mutationRequest = buildDocumentBridgeMutationRequest({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
mapConvexArgs: adapter.mapConvexArgs,
|
||||
});
|
||||
try {
|
||||
if (input.envelope.name === "documents.title.update") {
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
});
|
||||
await executeRustBridgeMutationTransport({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
} else {
|
||||
const adapter = getMetadataWriteAdapter<TPayload>(input.envelope.name);
|
||||
const mutationRequest = buildDocumentBridgeMutationRequest({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
mapConvexArgs: adapter.mapConvexArgs,
|
||||
});
|
||||
|
||||
await executeDocumentBridgeMutationRequest({
|
||||
client,
|
||||
mutation: adapter.convexMutation,
|
||||
request: mutationRequest,
|
||||
});
|
||||
await executeDocumentBridgeMutationRequest({
|
||||
client,
|
||||
mutation: adapter.convexMutation,
|
||||
request: mutationRequest,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
client,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
assertDocumentId,
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
type BridgeContext,
|
||||
type CommandEnvelope,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
recordBridgeCommandArtifacts,
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
resolveRustBridgeCommandPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
import type { Json } from "@/types/supabase";
|
||||
|
||||
export type DocumentCreatePayload = {
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
parentId: string | null;
|
||||
title: string;
|
||||
accessScope: "private" | "shared" | "public";
|
||||
content: unknown;
|
||||
};
|
||||
|
||||
export type DocumentMovePayload = {
|
||||
documentId: string;
|
||||
workspaceId: string | null;
|
||||
parentId: string | null;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type DocumentDeletePayload = {
|
||||
documentId: string;
|
||||
workspaceId: string | null;
|
||||
};
|
||||
|
||||
export type DocumentRestorePayload = {
|
||||
documentId: string;
|
||||
workspaceId: string | null;
|
||||
};
|
||||
|
||||
export type DocumentDuplicatePayload = {
|
||||
sourceDocumentId: string;
|
||||
newDocumentId: string;
|
||||
workspaceId: string | null;
|
||||
title: string | null;
|
||||
};
|
||||
|
||||
export type DocumentCopyTreePayload = {
|
||||
workspaceId: string | null;
|
||||
targetParentId: string | null;
|
||||
items: Array<{
|
||||
documentId: string;
|
||||
recursive: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type PageCommandExecutionResult<TResult> = {
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
commandId: string;
|
||||
commandName: string;
|
||||
result: TResult;
|
||||
};
|
||||
|
||||
function trimOrNull(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeTitle(title: string | null | undefined): string {
|
||||
const safe = typeof title === "string" ? title.trim() : "";
|
||||
return safe.length > 0 ? safe : "无标题";
|
||||
}
|
||||
|
||||
function safeRandomId(): string {
|
||||
return typeof crypto.randomUUID === "function" ? crypto.randomUUID() : randomUUID();
|
||||
}
|
||||
|
||||
function normalizeWorkspaceId(value: string | null | undefined): string | null {
|
||||
return trimOrNull(value);
|
||||
}
|
||||
|
||||
async function withAuthedClient() {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
return { auth, client };
|
||||
}
|
||||
|
||||
async function buildRuntimeContext(request: Request, workspaceId: string | null) {
|
||||
return buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async function recordLifecycleArtifacts<T>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<T>;
|
||||
client: ConvexHttpClient;
|
||||
}) {
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
client: input.client,
|
||||
});
|
||||
}
|
||||
|
||||
async function recordLifecycleFailureArtifacts<T>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<T>;
|
||||
client: ConvexHttpClient;
|
||||
error: unknown;
|
||||
}) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
client: input.client,
|
||||
error: input.error,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executePageLifecycleBridgeCommand<TPayload, TResult>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<TPayload>;
|
||||
client?: ConvexHttpClient;
|
||||
}): Promise<PageCommandExecutionResult<TResult>> {
|
||||
const client = input.client ?? (await getAuthedConvexClient()).client;
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
});
|
||||
const result = await executeRustBridgeMutationTransport<TResult>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
client,
|
||||
});
|
||||
|
||||
return {
|
||||
requestId: input.context.requestId,
|
||||
traceId: input.context.traceId,
|
||||
commandId: input.envelope.commandId,
|
||||
commandName: input.envelope.name,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeDocumentCreateChildBridgeCommand(request: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const payload = (await request.json()) as {
|
||||
parentId?: string | null;
|
||||
title?: string;
|
||||
blocks?: unknown;
|
||||
};
|
||||
if (typeof payload.parentId === "undefined") {
|
||||
return NextResponse.json({ error: "缺少 parentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { auth, client } = await withAuthedClient();
|
||||
const parentId = trimOrNull(payload.parentId);
|
||||
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
||||
workspaceIdIfCreate: safeRandomId(),
|
||||
});
|
||||
|
||||
let workspaceId: string | null = null;
|
||||
let accessScope: "private" | "shared" | "public" = "private";
|
||||
|
||||
if (parentId) {
|
||||
const parentDoc = await client.query(api.documents.getMeta, { id: parentId });
|
||||
if (!parentDoc) {
|
||||
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
workspaceId = normalizeWorkspaceId(parentDoc.workspace_id);
|
||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||
} else {
|
||||
workspaceId = normalizeWorkspaceId(workspaceBootstrap.activeWorkspaceId);
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
|
||||
const resolvedTitle = normalizeTitle(payload.title);
|
||||
const contentPayload = Array.isArray(payload.blocks) ? payload.blocks : [];
|
||||
const pageId = safeRandomId();
|
||||
const context = await buildRuntimeContext(request, workspaceId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.createChild",
|
||||
payload: {
|
||||
documentId: pageId,
|
||||
workspaceId,
|
||||
parentId,
|
||||
title: resolvedTitle,
|
||||
accessScope,
|
||||
content: contentPayload,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
pageId,
|
||||
},
|
||||
});
|
||||
|
||||
let created;
|
||||
try {
|
||||
created = await client.mutation(api.documents.create, {
|
||||
id: pageId,
|
||||
workspaceId,
|
||||
parentId,
|
||||
title: resolvedTitle,
|
||||
accessScope,
|
||||
content: contentPayload,
|
||||
});
|
||||
|
||||
await recordLifecycleArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordLifecycleFailureArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
pageId: created.id,
|
||||
title: created.title ?? resolvedTitle,
|
||||
meta: {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeDocumentEmbedBridgeCommand(request: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const { sourceId, targetId } = (await request.json()) as {
|
||||
sourceId?: string;
|
||||
targetId?: string;
|
||||
};
|
||||
const normalizedSourceId = assertDocumentId(sourceId);
|
||||
const normalizedTargetId = assertDocumentId(targetId);
|
||||
const { client } = await withAuthedClient();
|
||||
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: normalizedSourceId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const targetContent = await client.query(api.documents.getContent, { id: normalizedTargetId });
|
||||
if (!targetContent) {
|
||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
const targetMeta = await client.query(api.documents.getMeta, { id: normalizedTargetId });
|
||||
|
||||
const currentBlocks = extractBlocksFromContent(targetContent.content);
|
||||
const anchorId = trimOrNull((targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id);
|
||||
const anchorIndex =
|
||||
anchorId
|
||||
? currentBlocks.findIndex(
|
||||
(block) => typeof block === "object" && block !== null && String((block as { id?: string }).id ?? "") === anchorId,
|
||||
)
|
||||
: -1;
|
||||
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
|
||||
|
||||
const nextBlocks: Json[] = [
|
||||
...currentBlocks.slice(0, insertIndex),
|
||||
{
|
||||
id: safeRandomId(),
|
||||
type: "pageReference",
|
||||
props: {
|
||||
pageId: normalizedSourceId,
|
||||
title: sourceDoc.title ?? "无标题",
|
||||
},
|
||||
},
|
||||
...currentBlocks.slice(insertIndex),
|
||||
];
|
||||
|
||||
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
|
||||
const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id) ?? normalizeWorkspaceId((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
|
||||
const context = await buildRuntimeContext(request, workspaceId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.embed",
|
||||
payload: {
|
||||
sourceId: normalizedSourceId,
|
||||
targetId: normalizedTargetId,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
pageId: normalizedTargetId,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
id: normalizedTargetId,
|
||||
content: payload,
|
||||
});
|
||||
|
||||
await recordLifecycleArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordLifecycleFailureArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
meta: {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeDocumentTemplateBridgeCommand(request: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const payload = (await request.json()) as {
|
||||
documentId?: string;
|
||||
isTemplate?: boolean;
|
||||
};
|
||||
const normalizedDocumentId = assertDocumentId(payload.documentId);
|
||||
if (typeof payload.isTemplate !== "boolean") {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { client } = await withAuthedClient();
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: normalizedDocumentId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id);
|
||||
const context = await buildRuntimeContext(request, workspaceId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.template",
|
||||
payload: {
|
||||
documentId: normalizedDocumentId,
|
||||
isTemplate: payload.isTemplate,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
pageId: normalizedDocumentId,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await client.mutation(api.documents.setTemplate, {
|
||||
id: normalizedDocumentId,
|
||||
isTemplate: payload.isTemplate,
|
||||
});
|
||||
|
||||
await recordLifecycleArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordLifecycleFailureArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
meta: {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeDocumentEmptyTrashBridgeCommand(request: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const payload = (await request.json()) as {
|
||||
workspaceId?: string;
|
||||
};
|
||||
const workspaceId = normalizeWorkspaceId(payload.workspaceId);
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { client } = await withAuthedClient();
|
||||
const context = await buildRuntimeContext(request, workspaceId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.emptyTrashByWorkspace",
|
||||
payload: {
|
||||
workspaceId,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await client.mutation(api.documents.emptyTrashByWorkspace, { workspaceId });
|
||||
|
||||
await recordLifecycleArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordLifecycleFailureArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
meta: {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeDocumentPurgeBridgeCommand(request: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const payload = (await request.json()) as {
|
||||
documentId?: string;
|
||||
};
|
||||
const normalizedDocumentId = assertDocumentId(payload.documentId);
|
||||
const { client } = await withAuthedClient();
|
||||
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: normalizedDocumentId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id);
|
||||
const context = await buildRuntimeContext(request, workspaceId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.purge",
|
||||
payload: {
|
||||
documentId: normalizedDocumentId,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
pageId: normalizedDocumentId,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await client.mutation(api.documents.purge, { id: normalizedDocumentId });
|
||||
|
||||
await recordLifecycleArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordLifecycleFailureArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
meta: {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import "server-only";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { NextResponse } from "next/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
buildDocumentBridgeContextWithActor,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
type BridgeContext,
|
||||
type CommandEnvelope,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
recordBridgeCommandArtifacts,
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import { executeRustBridgeMutationTransport, resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
|
||||
type CreatePayload = {
|
||||
parentId?: string | null;
|
||||
};
|
||||
|
||||
type MovePayload = {
|
||||
documentId?: string | null;
|
||||
parentId?: string | null;
|
||||
position?: number | null;
|
||||
};
|
||||
|
||||
type DeletePayload = {
|
||||
documentId?: string | null;
|
||||
};
|
||||
|
||||
type RestorePayload = {
|
||||
documentId?: string | null;
|
||||
};
|
||||
|
||||
type DuplicatePayload = {
|
||||
documentId?: string | null;
|
||||
};
|
||||
|
||||
type CopyTreeItem = {
|
||||
documentId: string;
|
||||
recursive: boolean;
|
||||
};
|
||||
|
||||
type CopyTreePayload = {
|
||||
items?: CopyTreeItem[] | null;
|
||||
targetParentId?: string | null;
|
||||
};
|
||||
|
||||
const documentsBaseDir = getDocumentsBaseDir();
|
||||
|
||||
function trimOrNull(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeTitle(title: string | null): string {
|
||||
const safe = title?.trim();
|
||||
return safe && safe.length > 0 ? safe : "无标题";
|
||||
}
|
||||
|
||||
function safeRandomId() {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: randomUUID();
|
||||
}
|
||||
|
||||
async function ensureDocumentScaffold(id: string, title: string | null) {
|
||||
const folder = path.join(documentsBaseDir, id);
|
||||
const indexFile = path.join(folder, "index.md");
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
try {
|
||||
await fs.access(indexFile);
|
||||
} catch {
|
||||
const safeTitle = normalizeTitle(title);
|
||||
await fs.writeFile(indexFile, `# ${safeTitle}\n`, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
async function copyMindmapIfExists(sourceId: string, targetId: string) {
|
||||
try {
|
||||
const srcDir = path.join(documentsBaseDir, sourceId);
|
||||
const destDir = path.join(documentsBaseDir, targetId);
|
||||
const entries = await fs.readdir(srcDir);
|
||||
const mindmapFiles = entries.filter((name) => name === "mindmap.json" || /^mindmap-.+\.json$/i.test(name));
|
||||
if (mindmapFiles.length === 0) return;
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
await Promise.all(
|
||||
mindmapFiles.map(async (name) => {
|
||||
try {
|
||||
const buf = await fs.readFile(path.join(srcDir, name));
|
||||
await fs.writeFile(path.join(destDir, name), buf);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// 如果源文件不存在就跳过
|
||||
}
|
||||
}
|
||||
|
||||
async function buildBridgeContext(request: Request, workspaceId: string | null, authUserId: string): Promise<BridgeContext> {
|
||||
const sessionId = trimOrNull(request.headers.get("x-session-id")) ?? trimOrNull(request.headers.get("x-mnote-session-id"));
|
||||
return buildDocumentBridgeContextWithActor({
|
||||
request,
|
||||
workspaceId,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: authUserId,
|
||||
sessionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveCommandPlan<TPayload>(input: {
|
||||
request: Request;
|
||||
workspaceId: string | null;
|
||||
authUserId: string;
|
||||
envelope: CommandEnvelope<TPayload>;
|
||||
}) {
|
||||
const context = await buildBridgeContext(input.request, input.workspaceId, input.authUserId);
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context,
|
||||
envelope: input.envelope,
|
||||
});
|
||||
return { context, plan };
|
||||
}
|
||||
|
||||
async function handleLifecycleError(error: unknown) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
|
||||
export async function handleDocumentCreateRequest(request: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const payload = (await request.json()) as CreatePayload;
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
|
||||
const parentId = trimOrNull(payload.parentId);
|
||||
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
||||
workspaceIdIfCreate: safeRandomId(),
|
||||
});
|
||||
|
||||
let workspaceId: string | null = null;
|
||||
let accessScope: "private" | "shared" | "public" = "private";
|
||||
|
||||
if (parentId) {
|
||||
const parentDoc = await client.query(api.documents.getMeta, { id: parentId });
|
||||
if (!parentDoc) {
|
||||
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
workspaceId = parentDoc.workspace_id;
|
||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||
} else {
|
||||
workspaceId = workspaceBootstrap.activeWorkspaceId || null;
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
|
||||
const documentId = safeRandomId();
|
||||
const context = await buildBridgeContext(request, workspaceId, auth.userId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.create",
|
||||
payload: {
|
||||
documentId,
|
||||
workspaceId,
|
||||
parentId,
|
||||
title: "无标题",
|
||||
accessScope,
|
||||
content: [],
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
pageId: documentId,
|
||||
},
|
||||
});
|
||||
const { context, plan } = await resolveCommandPlan({
|
||||
request,
|
||||
workspaceId,
|
||||
authUserId: auth.userId,
|
||||
envelope,
|
||||
});
|
||||
|
||||
const created = await executeRustBridgeMutationTransport<{
|
||||
id: string;
|
||||
title: string | null;
|
||||
parent_id: string | null;
|
||||
sort_order: number | null;
|
||||
is_starred: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
workspace_id: string;
|
||||
access_scope: "private" | "shared" | "public";
|
||||
is_template: boolean;
|
||||
}>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
|
||||
if (created?.id) {
|
||||
await ensureDocumentScaffold(created.id, created.title ?? "无标题");
|
||||
}
|
||||
|
||||
await recordBridgeCommandArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
|
||||
return NextResponse.json(created);
|
||||
} catch (error) {
|
||||
try {
|
||||
const payload = (await request.clone().json().catch(() => ({}))) as MovePayload;
|
||||
const documentId = trimOrNull(payload.documentId);
|
||||
if (documentId) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.move",
|
||||
payload: {
|
||||
documentId,
|
||||
parentId: trimOrNull(payload.parentId),
|
||||
sortOrder: Number.isFinite(payload.position ?? Number.NaN) ? Math.floor(payload.position ?? 0) : 0,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId: sourceDoc?.workspace_id ?? null,
|
||||
pageId: documentId,
|
||||
},
|
||||
});
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
error,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 忽略日志写失败,保留原始错误返回
|
||||
}
|
||||
return handleLifecycleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDocumentMoveRequest(request: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const payload = (await request.json()) as MovePayload;
|
||||
const documentId = trimOrNull(payload.documentId);
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||
}
|
||||
|
||||
const context = await buildBridgeContext(request, sourceDoc.workspace_id ?? null, auth.userId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.move",
|
||||
payload: {
|
||||
documentId,
|
||||
parentId: trimOrNull(payload.parentId),
|
||||
sortOrder: Number.isFinite(payload.position ?? Number.NaN) ? Math.floor(payload.position ?? 0) : 0,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
pageId: documentId,
|
||||
},
|
||||
});
|
||||
const { context: runtimeContext, plan } = await resolveCommandPlan({
|
||||
request,
|
||||
workspaceId: null,
|
||||
authUserId: auth.userId,
|
||||
envelope,
|
||||
});
|
||||
await executeRustBridgeMutationTransport({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: runtimeContext,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
try {
|
||||
const payload = (await request.clone().json().catch(() => ({}))) as DeletePayload;
|
||||
const documentId = trimOrNull(payload.documentId);
|
||||
if (documentId) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.delete",
|
||||
payload: { documentId },
|
||||
context,
|
||||
target: {
|
||||
workspaceId: sourceDoc?.workspace_id ?? null,
|
||||
pageId: documentId,
|
||||
},
|
||||
});
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
error,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 忽略日志写失败,保留原始错误返回
|
||||
}
|
||||
return handleLifecycleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDocumentDeleteRequest(request: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const payload = (await request.json()) as DeletePayload;
|
||||
const documentId = trimOrNull(payload.documentId);
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||
}
|
||||
|
||||
const context = await buildBridgeContext(request, sourceDoc.workspace_id ?? null, auth.userId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.delete",
|
||||
payload: { documentId },
|
||||
context,
|
||||
target: {
|
||||
pageId: documentId,
|
||||
},
|
||||
});
|
||||
const { context: runtimeContext, plan } = await resolveCommandPlan({
|
||||
request,
|
||||
workspaceId: null,
|
||||
authUserId: auth.userId,
|
||||
envelope,
|
||||
});
|
||||
await executeRustBridgeMutationTransport({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: runtimeContext,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
try {
|
||||
const payload = (await request.clone().json().catch(() => ({}))) as RestorePayload;
|
||||
const documentId = trimOrNull(payload.documentId);
|
||||
if (documentId) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.restore",
|
||||
payload: { documentId },
|
||||
context,
|
||||
target: {
|
||||
workspaceId: sourceDoc?.workspace_id ?? null,
|
||||
pageId: documentId,
|
||||
},
|
||||
});
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
error,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 忽略日志写失败,保留原始错误返回
|
||||
}
|
||||
return handleLifecycleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDocumentRestoreRequest(request: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const payload = (await request.json()) as RestorePayload;
|
||||
const documentId = trimOrNull(payload.documentId);
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||
}
|
||||
|
||||
const context = await buildBridgeContext(request, sourceDoc.workspace_id ?? null, auth.userId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.restore",
|
||||
payload: { documentId },
|
||||
context,
|
||||
target: {
|
||||
pageId: documentId,
|
||||
},
|
||||
});
|
||||
const { context: runtimeContext, plan } = await resolveCommandPlan({
|
||||
request,
|
||||
workspaceId: null,
|
||||
authUserId: auth.userId,
|
||||
envelope,
|
||||
});
|
||||
await executeRustBridgeMutationTransport({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: runtimeContext,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
try {
|
||||
const payload = (await request.clone().json().catch(() => ({}))) as DuplicatePayload;
|
||||
const documentId = trimOrNull(payload.documentId);
|
||||
if (documentId) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
const workspaceId = sourceDoc?.workspace_id ?? null;
|
||||
const context = await buildBridgeContext(request, workspaceId, auth.userId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.duplicate",
|
||||
payload: {
|
||||
sourceDocumentId: documentId,
|
||||
newDocumentId: "duplicate_failed",
|
||||
title: sourceDoc?.title ?? null,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
error,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 忽略日志写失败,保留原始错误返回
|
||||
}
|
||||
return handleLifecycleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDocumentDuplicateRequest(request: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const payload = (await request.json()) as DuplicatePayload;
|
||||
const documentId = trimOrNull(payload.documentId);
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||
}
|
||||
|
||||
const workspaceId = sourceDoc.workspace_id ?? null;
|
||||
const context = await buildBridgeContext(request, workspaceId, auth.userId);
|
||||
const fallbackTitle = normalizeTitle(sourceDoc.title);
|
||||
const duplicatedTitle = `${fallbackTitle} 副本`;
|
||||
const newId = safeRandomId();
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.duplicate",
|
||||
payload: {
|
||||
sourceDocumentId: documentId,
|
||||
newDocumentId: newId,
|
||||
title: duplicatedTitle,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
pageId: newId,
|
||||
},
|
||||
});
|
||||
const { context: runtimeContext, plan } = await resolveCommandPlan({
|
||||
request,
|
||||
workspaceId,
|
||||
authUserId: auth.userId,
|
||||
envelope,
|
||||
});
|
||||
const duplicated = await executeRustBridgeMutationTransport<{
|
||||
id: string;
|
||||
title: string | null;
|
||||
parent_id: string | null;
|
||||
sort_order: number | null;
|
||||
workspace_id: string;
|
||||
access_scope: "private" | "shared" | "public";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
|
||||
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
|
||||
await copyMindmapIfExists(documentId, duplicated.id);
|
||||
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: runtimeContext,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: duplicated.id,
|
||||
title: duplicated.title ?? duplicatedTitle,
|
||||
parent_id: duplicated.parent_id ?? null,
|
||||
sort_order: duplicated.sort_order ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
const payload = (await request.clone().json().catch(() => ({}))) as CopyTreePayload;
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const context = await buildBridgeContext(request, null, auth.userId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.copy_tree",
|
||||
payload: {
|
||||
items: (payload.items ?? []).map((item) => ({
|
||||
documentId: trimOrNull(item.documentId) ?? "unknown",
|
||||
recursive: Boolean(item.recursive),
|
||||
})),
|
||||
targetParentId: trimOrNull(payload.targetParentId),
|
||||
},
|
||||
context,
|
||||
});
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
error,
|
||||
});
|
||||
} catch {
|
||||
// 忽略日志写失败,保留原始错误返回
|
||||
}
|
||||
return handleLifecycleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDocumentCopyTreeRequest(request: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const payload = (await request.json()) as CopyTreePayload;
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
|
||||
const normalizedItems = (payload.items ?? []).filter((it) => trimOrNull(it?.documentId));
|
||||
if (normalizedItems.length === 0) {
|
||||
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const targetParentId = trimOrNull(payload.targetParentId);
|
||||
let workspaceId: string | null = null;
|
||||
|
||||
if (targetParentId) {
|
||||
const targetDoc = await client.query(api.documents.getMeta, { id: targetParentId });
|
||||
if (!targetDoc) {
|
||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
workspaceId = targetDoc.workspace_id;
|
||||
}
|
||||
|
||||
const sourceIds = Array.from(new Set(normalizedItems.map((it) => trimOrNull(it.documentId) as string)));
|
||||
const firstMeta = await client.query(api.documents.getMeta, { id: sourceIds[0] });
|
||||
if (!firstMeta) {
|
||||
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
workspaceId = firstMeta.workspace_id;
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
|
||||
const context = await buildBridgeContext(request, workspaceId, auth.userId);
|
||||
const outerEnvelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.copy_tree",
|
||||
payload: {
|
||||
items: normalizedItems.map((item) => ({
|
||||
documentId: trimOrNull(item.documentId) as string,
|
||||
recursive: Boolean(item.recursive),
|
||||
})),
|
||||
targetParentId,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
pageId: targetParentId ?? undefined,
|
||||
},
|
||||
});
|
||||
const { context: runtimeContext, plan } = await resolveCommandPlan({
|
||||
request,
|
||||
workspaceId,
|
||||
authUserId: auth.userId,
|
||||
envelope: outerEnvelope,
|
||||
});
|
||||
const result = await executeRustBridgeMutationTransport<{
|
||||
items: Array<{ oldId: string; newId: string }>;
|
||||
}>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
|
||||
const insertedDocs = Array.isArray(result?.items) ? result.items : [];
|
||||
for (const item of insertedDocs) {
|
||||
const nextDoc = await client.query(api.documents.getMeta, { id: item.newId });
|
||||
await ensureDocumentScaffold(item.newId, nextDoc?.title ?? null);
|
||||
await copyMindmapIfExists(item.oldId, item.newId);
|
||||
}
|
||||
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: runtimeContext,
|
||||
envelope: outerEnvelope,
|
||||
client,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
items: insertedDocs,
|
||||
});
|
||||
} catch (error) {
|
||||
return handleLifecycleError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import "server-only";
|
||||
|
||||
import path from "node:path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
|
||||
const documentsBaseDir = getDocumentsBaseDir();
|
||||
|
||||
export async function ensureDocumentScaffold(id: string, title: string | null) {
|
||||
const folder = path.join(documentsBaseDir, id);
|
||||
const indexFile = path.join(folder, "index.md");
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
|
||||
try {
|
||||
await fs.access(indexFile);
|
||||
} catch {
|
||||
const safeTitle = title && title.trim() ? title.trim() : "无标题";
|
||||
await fs.writeFile(indexFile, `# ${safeTitle}\n`, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyMindmapFilesIfExists(sourceId: string, targetId: string) {
|
||||
const srcDir = path.join(documentsBaseDir, sourceId);
|
||||
const destDir = path.join(documentsBaseDir, targetId);
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(srcDir);
|
||||
const mindmapFiles = entries.filter(
|
||||
(name) => name === "mindmap.json" || /^mindmap-.+\.json$/i.test(name),
|
||||
);
|
||||
if (mindmapFiles.length === 0) return;
|
||||
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
await Promise.all(
|
||||
mindmapFiles.map(async (name) => {
|
||||
try {
|
||||
const sourceFile = path.join(srcDir, name);
|
||||
const targetFile = path.join(destDir, name);
|
||||
const buffer = await fs.readFile(sourceFile);
|
||||
await fs.writeFile(targetFile, buffer);
|
||||
} catch {
|
||||
// 说明:本地思维导图副作用失败不应反向打断主页面操作。
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// 说明:源页面不存在本地思维导图文件时直接忽略。
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { access } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import {
|
||||
DocumentBridgeError,
|
||||
type BridgeTarget,
|
||||
type BridgeContext,
|
||||
type CommandEnvelope,
|
||||
type QueryEnvelope,
|
||||
} from "@/lib/documents/bridge";
|
||||
|
||||
export type RustRuntimeExecutedQuery<TResult = unknown> = {
|
||||
ok: true;
|
||||
result: TResult;
|
||||
};
|
||||
|
||||
type RustRuntimeErrorKind =
|
||||
| "validation"
|
||||
| "unauthorized"
|
||||
| "conflict"
|
||||
| "not_found"
|
||||
| "transport"
|
||||
| "rejected";
|
||||
|
||||
type RustRuntimeErrorPayload = {
|
||||
kind: RustRuntimeErrorKind | string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type RustRuntimeResponse =
|
||||
| {
|
||||
ok: true;
|
||||
plan: RustBridgeQueryPlan | RustBridgeCommandPlan | RustBridgeToolPlan | RustBridgeBuiltinToolPlan;
|
||||
}
|
||||
| RustRuntimeExecutedQuery
|
||||
| {
|
||||
ok: false;
|
||||
error: RustRuntimeErrorPayload;
|
||||
};
|
||||
|
||||
export type RustBridgeQueryPlan = {
|
||||
kind: "query";
|
||||
queryName: string;
|
||||
functionName: string;
|
||||
workspaceId: string | null;
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
actorId: string;
|
||||
payloadJson: string;
|
||||
argsJson: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type RustBridgeCommandPlan = {
|
||||
kind: "command";
|
||||
commandName: string;
|
||||
commandId: string;
|
||||
functionName: string;
|
||||
workspaceId: string | null;
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
actorId: string;
|
||||
idempotencyKey: string | null;
|
||||
payloadJson: string;
|
||||
argsJson: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type RustBridgeToolPlanStep = {
|
||||
kind: string;
|
||||
name: string;
|
||||
functionName: string | null;
|
||||
description: string;
|
||||
argsJson: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type RustBridgeToolPlan = {
|
||||
kind: "tool";
|
||||
toolName: string;
|
||||
invocationKind: string;
|
||||
executionMode: string;
|
||||
effect: string;
|
||||
toolsetId: string;
|
||||
requiresConfirmation: boolean;
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
actorId: string;
|
||||
validateOnly: boolean;
|
||||
dryRun: boolean;
|
||||
payloadJson: string;
|
||||
argsJson: Record<string, unknown>;
|
||||
target: Record<string, unknown> | null;
|
||||
steps: RustBridgeToolPlanStep[];
|
||||
};
|
||||
|
||||
export type RustBridgeBuiltinToolPlan = {
|
||||
kind: "builtin_tool";
|
||||
toolName: string;
|
||||
toolsetId: string;
|
||||
status: "rust" | "mixed" | "ts" | "transport";
|
||||
note: string;
|
||||
};
|
||||
|
||||
export type RustBridgeToolResult<TResult = unknown> = {
|
||||
plan: RustBridgeToolPlan;
|
||||
result: TResult;
|
||||
};
|
||||
|
||||
type RuntimeProcessResult = {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
type RuntimeInvocation = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
const RUST_RUNTIME_TIMEOUT_MS = 30_000;
|
||||
|
||||
async function pathExists(targetPath: string) {
|
||||
try {
|
||||
await access(targetPath, fsConstants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRepoRoot() {
|
||||
const candidates = [process.cwd(), path.resolve(process.cwd(), "..")];
|
||||
for (const candidate of candidates) {
|
||||
if (await pathExists(path.join(candidate, "rust", "Cargo.toml"))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new DocumentBridgeError("未找到 mnote 仓库根目录", 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
|
||||
async function resolveRuntimeInvocation(): Promise<RuntimeInvocation> {
|
||||
const explicitBin = process.env.MNOTE_RUST_BRIDGE_BIN?.trim();
|
||||
if (explicitBin) {
|
||||
return {
|
||||
command: explicitBin,
|
||||
args: [],
|
||||
};
|
||||
}
|
||||
|
||||
const repoRoot = await resolveRepoRoot();
|
||||
const builtBinary = path.join(repoRoot, "rust", "target", "debug", "bridge-runtime");
|
||||
if (await pathExists(builtBinary)) {
|
||||
return {
|
||||
command: builtBinary,
|
||||
args: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: "cargo",
|
||||
args: [
|
||||
"run",
|
||||
"--quiet",
|
||||
"--manifest-path",
|
||||
path.join(repoRoot, "rust", "Cargo.toml"),
|
||||
"-p",
|
||||
"bridge-runtime",
|
||||
"--",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function runRustRuntime(input: Record<string, unknown>): Promise<RustRuntimeResponse> {
|
||||
const invocation = await resolveRuntimeInvocation();
|
||||
const result = await new Promise<RuntimeProcessResult>((resolve, reject) => {
|
||||
const child = spawn(invocation.command, invocation.args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
CARGO_TERM_COLOR: "never",
|
||||
},
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGKILL");
|
||||
}, RUST_RUNTIME_TIMEOUT_MS);
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
if (timedOut) {
|
||||
reject(new DocumentBridgeError("Rust runtime 执行超时", 504, "TRANSPORT_ERROR"));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
code,
|
||||
signal,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
|
||||
child.stdin.end(JSON.stringify(input));
|
||||
}).catch((error: unknown) => {
|
||||
if (error instanceof DocumentBridgeError) {
|
||||
throw error;
|
||||
}
|
||||
throw new DocumentBridgeError(
|
||||
error instanceof Error ? `Rust runtime 启动失败: ${error.message}` : "Rust runtime 启动失败",
|
||||
500,
|
||||
"TRANSPORT_ERROR",
|
||||
);
|
||||
});
|
||||
|
||||
const raw = result.stdout.trim();
|
||||
if (!raw) {
|
||||
throw new DocumentBridgeError(
|
||||
result.stderr.trim() || "Rust runtime 未返回任何结果",
|
||||
500,
|
||||
"TRANSPORT_ERROR",
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: RustRuntimeResponse;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as RustRuntimeResponse;
|
||||
} catch (error) {
|
||||
throw new DocumentBridgeError(
|
||||
`Rust runtime 返回了非法 JSON: ${error instanceof Error ? error.message : "unknown"}`,
|
||||
500,
|
||||
"TRANSPORT_ERROR",
|
||||
{
|
||||
stdout: raw,
|
||||
stderr: result.stderr.trim() || null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (result.code !== 0 || !parsed.ok) {
|
||||
if (!parsed.ok) {
|
||||
throw toDocumentBridgeError("error" in parsed ? parsed.error : { kind: "transport", message: "Rust runtime 执行失败" });
|
||||
}
|
||||
throw new DocumentBridgeError(
|
||||
result.stderr.trim() ||
|
||||
`Rust runtime 执行失败(code=${String(result.code)}, signal=${String(result.signal)})`,
|
||||
500,
|
||||
"TRANSPORT_ERROR",
|
||||
);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function toDocumentBridgeError(error: RustRuntimeErrorPayload) {
|
||||
switch (error.kind) {
|
||||
case "validation":
|
||||
return new DocumentBridgeError(error.message, 400, "VALIDATION_ERROR");
|
||||
case "unauthorized":
|
||||
return new DocumentBridgeError(error.message, 401, "UNAUTHORIZED");
|
||||
case "not_found":
|
||||
return new DocumentBridgeError(error.message, 404, "NOT_FOUND");
|
||||
case "conflict":
|
||||
return new DocumentBridgeError(error.message, 409, "REJECTED");
|
||||
case "rejected":
|
||||
return new DocumentBridgeError(error.message, 409, "REJECTED");
|
||||
case "transport":
|
||||
default:
|
||||
return new DocumentBridgeError(error.message, 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
function assertObjectArgs(argsJson: Record<string, unknown>) {
|
||||
if (!argsJson || typeof argsJson !== "object" || Array.isArray(argsJson)) {
|
||||
throw new DocumentBridgeError("Rust runtime 返回了非法 transport args", 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
return argsJson;
|
||||
}
|
||||
|
||||
function assertToolPlan(plan: RustBridgeQueryPlan | RustBridgeCommandPlan | RustBridgeToolPlan): RustBridgeToolPlan {
|
||||
if (plan.kind !== "tool") {
|
||||
throw new DocumentBridgeError("Rust runtime 返回了错误的 tool plan", 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
return {
|
||||
...plan,
|
||||
argsJson: assertObjectArgs(plan.argsJson),
|
||||
target:
|
||||
plan.target && typeof plan.target === "object" && !Array.isArray(plan.target)
|
||||
? (plan.target as Record<string, unknown>)
|
||||
: null,
|
||||
steps: Array.isArray(plan.steps)
|
||||
? plan.steps.map((step) => ({
|
||||
kind: String(step.kind ?? ""),
|
||||
name: String(step.name ?? ""),
|
||||
functionName: typeof step.functionName === "string" ? step.functionName : null,
|
||||
description: String(step.description ?? ""),
|
||||
argsJson:
|
||||
step.argsJson && typeof step.argsJson === "object" && !Array.isArray(step.argsJson)
|
||||
? (step.argsJson as Record<string, unknown>)
|
||||
: {},
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function assertStringArg(argsJson: Record<string, unknown>, field: string) {
|
||||
const value = argsJson[field];
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readOptionalIntegerArg(argsJson: Record<string, unknown>, field: string) {
|
||||
const value = argsJson[field];
|
||||
if (value === null || typeof value === "undefined") {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "number" && Number.isInteger(value)) {
|
||||
return value;
|
||||
}
|
||||
throw new DocumentBridgeError(`Rust runtime 的 ${field} 非法`, 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
|
||||
function readOptionalStringArg(argsJson: Record<string, unknown>, field: string) {
|
||||
const value = argsJson[field];
|
||||
if (value === null || typeof value === "undefined") {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
throw new DocumentBridgeError(`Rust runtime 的 ${field} 非法`, 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
|
||||
function readRequiredNumberArg(argsJson: Record<string, unknown>, field: string) {
|
||||
const value = argsJson[field];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
|
||||
export async function resolveRustBridgeQueryPlan<TPayload>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: QueryEnvelope<TPayload>;
|
||||
}): Promise<RustBridgeQueryPlan> {
|
||||
const response = await runRustRuntime({
|
||||
kind: "query",
|
||||
context: input.context,
|
||||
query: input.envelope,
|
||||
});
|
||||
|
||||
if (!("plan" in response) || response.plan.kind !== "query") {
|
||||
throw new DocumentBridgeError("Rust runtime 返回了错误的 query plan", 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
|
||||
return {
|
||||
...response.plan,
|
||||
argsJson: assertObjectArgs(response.plan.argsJson),
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeRustBridgeQuery<TResult>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: QueryEnvelope<unknown>;
|
||||
data?: Record<string, unknown>;
|
||||
}): Promise<TResult> {
|
||||
const response = await runRustRuntime({
|
||||
kind: "query",
|
||||
context: input.context,
|
||||
query: input.envelope,
|
||||
data: input.data ?? {},
|
||||
});
|
||||
|
||||
if (!("result" in response)) {
|
||||
throw new DocumentBridgeError("Rust runtime 未返回 query result", 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
|
||||
return response.result as TResult;
|
||||
}
|
||||
|
||||
export async function resolveRustBridgeCommandPlan<TPayload>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<TPayload>;
|
||||
}): Promise<RustBridgeCommandPlan> {
|
||||
const response = await runRustRuntime({
|
||||
kind: "command",
|
||||
context: input.context,
|
||||
command: input.envelope,
|
||||
});
|
||||
|
||||
if (!("plan" in response) || response.plan.kind !== "command") {
|
||||
throw new DocumentBridgeError("Rust runtime 返回了错误的 command plan", 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
|
||||
return {
|
||||
...response.plan,
|
||||
argsJson: assertObjectArgs(response.plan.argsJson),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveRustBridgeToolPlan(input: {
|
||||
context: BridgeContext;
|
||||
toolName: string;
|
||||
invocationKind: "command" | "query" | "job";
|
||||
args: Record<string, unknown>;
|
||||
target?: BridgeTarget | null;
|
||||
reason?: string | null;
|
||||
refs?: string[];
|
||||
mode?: "plan" | "result" | "explain-plan";
|
||||
}): Promise<RustBridgeToolPlan> {
|
||||
const response = await runRustRuntime({
|
||||
kind: "tool",
|
||||
context: input.context,
|
||||
tool: {
|
||||
tool: input.toolName,
|
||||
kind: input.invocationKind,
|
||||
mode: input.mode ?? "plan",
|
||||
argsJson: input.args,
|
||||
target: input.target ?? null,
|
||||
reason: input.reason ?? null,
|
||||
refs: input.refs ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
if (!("plan" in response)) {
|
||||
throw new DocumentBridgeError("Rust runtime 未返回 tool plan", 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
|
||||
if (response.plan.kind !== "tool") {
|
||||
throw new DocumentBridgeError("Rust runtime 返回了错误的 tool plan", 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
return assertToolPlan(response.plan);
|
||||
}
|
||||
|
||||
export async function executeRustBridgeTool<TResult>(input: {
|
||||
context: BridgeContext;
|
||||
toolName: string;
|
||||
invocationKind: "command" | "query" | "job";
|
||||
args: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
target?: BridgeTarget | null;
|
||||
reason?: string | null;
|
||||
refs?: string[];
|
||||
mode?: "result" | "explain-plan";
|
||||
}): Promise<RustBridgeToolResult<TResult>> {
|
||||
const response = await runRustRuntime({
|
||||
kind: "tool",
|
||||
context: input.context,
|
||||
tool: {
|
||||
tool: input.toolName,
|
||||
kind: input.invocationKind,
|
||||
mode: input.mode ?? "result",
|
||||
argsJson: input.args,
|
||||
target: input.target ?? null,
|
||||
reason: input.reason ?? null,
|
||||
refs: input.refs ?? [],
|
||||
},
|
||||
data: input.data,
|
||||
});
|
||||
|
||||
if (!("result" in response)) {
|
||||
throw new DocumentBridgeError("Rust runtime 未返回 tool result", 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
|
||||
const plan = await resolveRustBridgeToolPlan({
|
||||
context: input.context,
|
||||
toolName: input.toolName,
|
||||
invocationKind: input.invocationKind,
|
||||
args: input.args,
|
||||
target: input.target,
|
||||
reason: input.reason,
|
||||
refs: input.refs,
|
||||
mode: input.mode === "explain-plan" ? "explain-plan" : "plan",
|
||||
});
|
||||
|
||||
return {
|
||||
plan,
|
||||
result: response.result as TResult,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeRustBridgeQueryTransport<TResult>(input: {
|
||||
client: ConvexHttpClient;
|
||||
plan: RustBridgeQueryPlan;
|
||||
}): Promise<TResult> {
|
||||
const bridgeLogsApi = api as any;
|
||||
const query = input.client.query.bind(input.client) as (
|
||||
queryReference: unknown,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<TResult>;
|
||||
|
||||
switch (input.plan.functionName) {
|
||||
case "documents:getContent":
|
||||
return query(api.documents.getContent, {
|
||||
id: assertStringArg(input.plan.argsJson, "id"),
|
||||
});
|
||||
case "mindmaps:get":
|
||||
return query(api.mindmaps.get, {
|
||||
docId: assertStringArg(input.plan.argsJson, "docId"),
|
||||
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
|
||||
});
|
||||
case "sidebar:datasetList":
|
||||
return query(api.sidebar.datasetList, {
|
||||
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||
});
|
||||
case "blocks:getById":
|
||||
return query(api.blocks.getById, {
|
||||
userId: assertStringArg(input.plan.argsJson, "userId"),
|
||||
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||
blockId: assertStringArg(input.plan.argsJson, "blockId"),
|
||||
});
|
||||
case "bridgeLogs:listByRequest":
|
||||
return query(bridgeLogsApi.bridgeLogs.listByRequest, {
|
||||
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||
requestId: assertStringArg(input.plan.argsJson, "requestId"),
|
||||
});
|
||||
case "bridgeLogs:listByTrace":
|
||||
return query(bridgeLogsApi.bridgeLogs.listByTrace, {
|
||||
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||
traceId: assertStringArg(input.plan.argsJson, "traceId"),
|
||||
});
|
||||
case "bridgeLogs:listByCommand":
|
||||
return query(bridgeLogsApi.bridgeLogs.listByCommand, {
|
||||
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||
commandId: assertStringArg(input.plan.argsJson, "commandId"),
|
||||
});
|
||||
case "bridgeLogs:listWorkspaceOverview":
|
||||
return query(bridgeLogsApi.bridgeLogs.listWorkspaceOverview, {
|
||||
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||
limit:
|
||||
typeof input.plan.argsJson.limit === "number" && Number.isFinite(input.plan.argsJson.limit)
|
||||
? input.plan.argsJson.limit
|
||||
: undefined,
|
||||
cursor: readOptionalStringArg(input.plan.argsJson, "cursor"),
|
||||
commandStatus: readOptionalStringArg(input.plan.argsJson, "commandStatus"),
|
||||
eventStatus: readOptionalStringArg(input.plan.argsJson, "eventStatus"),
|
||||
targetPageId: readOptionalStringArg(input.plan.argsJson, "targetPageId"),
|
||||
targetBlockId: readOptionalStringArg(input.plan.argsJson, "targetBlockId"),
|
||||
aggregateType: readOptionalStringArg(input.plan.argsJson, "aggregateType"),
|
||||
aggregateId: readOptionalStringArg(input.plan.argsJson, "aggregateId"),
|
||||
});
|
||||
default:
|
||||
throw new DocumentBridgeError(
|
||||
`未注册的 Rust query transport: ${input.plan.functionName}`,
|
||||
500,
|
||||
"TRANSPORT_ERROR",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeRustBridgeMutationTransport<TResult>(input: {
|
||||
client: ConvexHttpClient;
|
||||
plan: RustBridgeCommandPlan;
|
||||
}): Promise<TResult> {
|
||||
const mutation = input.client.mutation.bind(input.client) as (
|
||||
mutationReference: unknown,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<TResult>;
|
||||
|
||||
switch (input.plan.functionName) {
|
||||
case "documents:createWithParentReference":
|
||||
return mutation(api.documents.create, {
|
||||
id: assertStringArg(input.plan.argsJson, "id"),
|
||||
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||
parentId: readOptionalStringArg(input.plan.argsJson, "parentId"),
|
||||
title: assertStringArg(input.plan.argsJson, "title"),
|
||||
accessScope: assertStringArg(input.plan.argsJson, "accessScope"),
|
||||
content: input.plan.argsJson.content,
|
||||
});
|
||||
case "documents:move":
|
||||
return mutation(api.documents.move, {
|
||||
id: assertStringArg(input.plan.argsJson, "id"),
|
||||
parentId: readOptionalStringArg(input.plan.argsJson, "parentId"),
|
||||
sortOrder: readRequiredNumberArg(input.plan.argsJson, "sortOrder"),
|
||||
});
|
||||
case "documents:softDelete":
|
||||
return mutation(api.documents.softDelete, {
|
||||
id: assertStringArg(input.plan.argsJson, "id"),
|
||||
});
|
||||
case "documents:restore":
|
||||
return mutation(api.documents.restore, {
|
||||
id: assertStringArg(input.plan.argsJson, "id"),
|
||||
});
|
||||
case "documents:duplicateWithMindmaps":
|
||||
return mutation(api.documents.duplicate, {
|
||||
sourceId: assertStringArg(input.plan.argsJson, "sourceId"),
|
||||
newId: assertStringArg(input.plan.argsJson, "newId"),
|
||||
title: readOptionalStringArg(input.plan.argsJson, "title"),
|
||||
});
|
||||
case "documents:updateTitle":
|
||||
return mutation(api.documents.updateTitle, {
|
||||
id: assertStringArg(input.plan.argsJson, "id"),
|
||||
title: assertStringArg(input.plan.argsJson, "title"),
|
||||
});
|
||||
case "documents:updateContent":
|
||||
return mutation(api.documents.updateContent, {
|
||||
id: assertStringArg(input.plan.argsJson, "id"),
|
||||
content: input.plan.argsJson.content,
|
||||
expectedRevision: readOptionalIntegerArg(input.plan.argsJson, "expectedRevision"),
|
||||
conflictDetectionKey: readOptionalStringArg(input.plan.argsJson, "conflictDetectionKey"),
|
||||
});
|
||||
case "mindmaps:put":
|
||||
return mutation(api.mindmaps.put, {
|
||||
docId: assertStringArg(input.plan.argsJson, "docId"),
|
||||
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
|
||||
data: input.plan.argsJson.data,
|
||||
createOnly:
|
||||
typeof input.plan.argsJson.createOnly === "boolean"
|
||||
? input.plan.argsJson.createOnly
|
||||
: undefined,
|
||||
});
|
||||
default:
|
||||
throw new DocumentBridgeError(
|
||||
`未注册的 Rust mutation transport: ${input.plan.functionName}`,
|
||||
500,
|
||||
"TRANSPORT_ERROR",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
buildDocumentBridgeMutationRequest,
|
||||
executeDocumentBridgeMutationRequest,
|
||||
type CommandEnvelope,
|
||||
type BridgeContext,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
||||
import {
|
||||
recordBridgeCommandArtifacts,
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
import { DocumentBridgeError } from "@/lib/documents/bridge";
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
resolveRustBridgeCommandPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
|
||||
export type DocumentSaveExecutionResult = {
|
||||
requestId: string;
|
||||
@@ -24,25 +28,27 @@ export async function executeSaveBridgeCommand(input: {
|
||||
envelope: CommandEnvelope<DocumentSavePayload>;
|
||||
}): Promise<DocumentSaveExecutionResult> {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const mutationRequest = buildDocumentBridgeMutationRequest({
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
mapConvexArgs: (payload) => ({
|
||||
id: payload.documentId,
|
||||
content: payload.content,
|
||||
expectedRevision: payload.revision,
|
||||
conflictDetectionKey: payload.conflictDetectionKey,
|
||||
}),
|
||||
});
|
||||
|
||||
let mutationResult;
|
||||
try {
|
||||
mutationResult = await executeDocumentBridgeMutationRequest({
|
||||
mutationResult = await executeRustBridgeMutationTransport<{
|
||||
revision?: number | null;
|
||||
conflict_detection_key?: string | null;
|
||||
}>({
|
||||
client,
|
||||
mutation: api.documents.updateContent,
|
||||
request: mutationRequest,
|
||||
plan,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
client,
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
error,
|
||||
});
|
||||
if (error instanceof Error && /正文(内容已变更|冲突检测失败)/.test(error.message)) {
|
||||
throw new DocumentBridgeError(error.message, 409, "REJECTED", {
|
||||
reason: "content_conflict",
|
||||
|
||||
@@ -42,6 +42,8 @@ export type MindmapOp =
|
||||
| { op: "appendNote"; uid: string; markdown: string }
|
||||
| { op: "deleteNode"; uid: string };
|
||||
|
||||
export type MindmapOpErrorKind = "rejected" | "failed";
|
||||
|
||||
const createUid = () => {
|
||||
const anyCrypto = globalThis.crypto as unknown as { randomUUID?: () => string } | undefined;
|
||||
if (anyCrypto?.randomUUID) return anyCrypto.randomUUID();
|
||||
@@ -94,6 +96,20 @@ const safeUrlOrNull = (value: unknown) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const classifyMindmapOpErrors = (errors: string[]): MindmapOpErrorKind | null => {
|
||||
if (!Array.isArray(errors) || errors.length === 0) return null;
|
||||
const rejectedPatterns = [
|
||||
"无效 op",
|
||||
"找不到",
|
||||
"不能删除根节点",
|
||||
"不支持的 op",
|
||||
];
|
||||
const rejectedOnly = errors.every((error) =>
|
||||
rejectedPatterns.some((pattern) => String(error || "").includes(pattern)),
|
||||
);
|
||||
return rejectedOnly ? "rejected" : "failed";
|
||||
};
|
||||
|
||||
export const applyMindmapOps = (
|
||||
raw: unknown,
|
||||
ops: MindmapOp[],
|
||||
@@ -238,4 +254,3 @@ export const applyMindmapOps = (
|
||||
ensureMindmapUids(root);
|
||||
return { data: root, applied, errors };
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildOnlyOfficeCallbackUrl,
|
||||
buildOnlyOfficeForcesaveUrl,
|
||||
buildOnlyOfficeOpenFileId,
|
||||
docTypeFromExt,
|
||||
resolveOnlyOfficeDocumentUrl,
|
||||
} from "@/lib/onlyoffice/client-session";
|
||||
|
||||
describe("onlyoffice client session helpers", () => {
|
||||
it("docTypeFromExt maps office extensions", () => {
|
||||
expect(docTypeFromExt("docx")).toBe("word");
|
||||
expect(docTypeFromExt("xlsx")).toBe("cell");
|
||||
expect(docTypeFromExt("pptx")).toBe("slide");
|
||||
expect(docTypeFromExt("pdf")).toBe("pdf");
|
||||
});
|
||||
|
||||
it("resolveOnlyOfficeDocumentUrl wraps signed url with proxy", () => {
|
||||
const result = resolveOnlyOfficeDocumentUrl({
|
||||
effectiveFileUrl: "https://public.example.com/storage/v1/object/sign/documents/a.docx?token=abc",
|
||||
proxyOrigin: "https://app.example.com",
|
||||
storageHostOverride: "",
|
||||
runtimeSupabaseUrl: "https://public.example.com",
|
||||
useConvex: false,
|
||||
});
|
||||
expect(result).toContain("https://app.example.com/api/onlyoffice/proxy?u=");
|
||||
});
|
||||
|
||||
it("buildOnlyOfficeCallbackUrl keeps asset and user", () => {
|
||||
const result = buildOnlyOfficeCallbackUrl({
|
||||
callbackOrigin: "http://host.docker.internal:3000",
|
||||
proxyOrigin: "",
|
||||
windowOrigin: "https://app.example.com",
|
||||
assetId: "asset_1",
|
||||
userId: "user_1",
|
||||
});
|
||||
expect(result).toBe(
|
||||
"http://host.docker.internal:3000/api/onlyoffice/callback?assetId=asset_1&userId=user_1",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildOnlyOfficeForcesaveUrl keeps key", () => {
|
||||
const result = buildOnlyOfficeForcesaveUrl({
|
||||
windowOrigin: "https://app.example.com",
|
||||
assetId: "asset_1",
|
||||
key: "doc_key_1",
|
||||
});
|
||||
expect(result).toBe(
|
||||
"https://app.example.com/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key_1",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildOnlyOfficeOpenFileId prefers asset id", () => {
|
||||
expect(
|
||||
buildOnlyOfficeOpenFileId({
|
||||
assetId: "asset_1",
|
||||
docKey: "doc_key_1",
|
||||
resolvedFileUrl: "https://app.example.com/file.docx",
|
||||
fileName: "file.docx",
|
||||
}),
|
||||
).toBe("asset_1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||
|
||||
export const hashOnlyOfficeKey = (input: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash = (hash << 5) - hash + input.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash).toString();
|
||||
};
|
||||
|
||||
export const base64UrlEncodeUtf8 = (input: string) => {
|
||||
const bytes = new TextEncoder().encode(input);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i] as number);
|
||||
}
|
||||
return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
};
|
||||
|
||||
export const docTypeFromExt = (ext: string) => {
|
||||
const word = ["doc", "docx", "odt", "rtf"];
|
||||
const slide = ["ppt", "pptx", "odp"];
|
||||
const sheet = ["xls", "xlsx", "ods", "csv"];
|
||||
const pdf = ["pdf"];
|
||||
if (word.includes(ext)) return "word";
|
||||
if (slide.includes(ext)) return "slide";
|
||||
if (sheet.includes(ext)) return "cell";
|
||||
if (pdf.includes(ext)) return "pdf";
|
||||
return "word";
|
||||
};
|
||||
|
||||
export function resolveOnlyOfficeDocumentUrl(input: {
|
||||
effectiveFileUrl: string;
|
||||
proxyOrigin: string;
|
||||
storageHostOverride: string;
|
||||
runtimeSupabaseUrl: string;
|
||||
useConvex: boolean;
|
||||
}): string {
|
||||
const { effectiveFileUrl, proxyOrigin, storageHostOverride, runtimeSupabaseUrl, useConvex } = input;
|
||||
if (!effectiveFileUrl) return "";
|
||||
|
||||
const isConvexStorageUrl = (() => {
|
||||
try {
|
||||
const u = new URL(effectiveFileUrl);
|
||||
return u.pathname.startsWith("/api/storage/");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
let base =
|
||||
storageHostOverride || useConvex || isConvexStorageUrl
|
||||
? effectiveFileUrl
|
||||
: rewriteToPublicOrigin(effectiveFileUrl, runtimeSupabaseUrl);
|
||||
try {
|
||||
const raw = new URL(base);
|
||||
let alreadyProxy = raw.pathname.includes("/api/onlyoffice/proxy");
|
||||
const isLocalHost =
|
||||
raw.hostname === "127.0.0.1" || raw.hostname === "localhost" || raw.hostname === "host.docker.internal";
|
||||
|
||||
if (alreadyProxy && proxyOrigin) {
|
||||
const po = new URL(proxyOrigin);
|
||||
raw.protocol = po.protocol;
|
||||
raw.host = po.host;
|
||||
base = raw.toString();
|
||||
}
|
||||
|
||||
if (!alreadyProxy && proxyOrigin && isLocalHost) {
|
||||
const proxy = new URL("/api/onlyoffice/proxy", proxyOrigin || window.location.origin);
|
||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
|
||||
base = proxy.toString();
|
||||
alreadyProxy = true;
|
||||
}
|
||||
if (!alreadyProxy && raw.searchParams.has("token")) {
|
||||
const proxy = new URL("/api/onlyoffice/proxy", proxyOrigin || window.location.origin);
|
||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
|
||||
base = proxy.toString();
|
||||
alreadyProxy = true;
|
||||
}
|
||||
|
||||
const u = new URL(base);
|
||||
if (!storageHostOverride || alreadyProxy) return u.toString();
|
||||
|
||||
if (/^https?:\/\//i.test(storageHostOverride)) {
|
||||
const ov = new URL(storageHostOverride);
|
||||
u.protocol = ov.protocol;
|
||||
u.host = ov.host;
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
u.hostname = storageHostOverride;
|
||||
return u.toString();
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOnlyOfficeCallbackUrl(input: {
|
||||
callbackOrigin: string;
|
||||
proxyOrigin: string;
|
||||
windowOrigin: string;
|
||||
assetId: string;
|
||||
userId: string;
|
||||
}): string {
|
||||
const base = input.callbackOrigin || input.proxyOrigin || input.windowOrigin;
|
||||
const callback = new URL("/api/onlyoffice/callback", base);
|
||||
if (input.assetId) callback.searchParams.set("assetId", input.assetId);
|
||||
if (input.userId) callback.searchParams.set("userId", input.userId);
|
||||
return callback.toString();
|
||||
}
|
||||
|
||||
export function buildOnlyOfficeForcesaveUrl(input: {
|
||||
windowOrigin: string;
|
||||
assetId: string;
|
||||
key: string;
|
||||
}): string {
|
||||
const url = new URL("/api/onlyoffice/forcesave", input.windowOrigin);
|
||||
url.searchParams.set("assetId", input.assetId);
|
||||
url.searchParams.set("key", input.key);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function buildOnlyOfficeOpenFileId(input: {
|
||||
assetId: string;
|
||||
docKey: string;
|
||||
resolvedFileUrl: string;
|
||||
fileName: string;
|
||||
}): string {
|
||||
return input.assetId || `onlyoffice_${input.docKey || hashOnlyOfficeKey(`${input.resolvedFileUrl}-${input.fileName}`)}`;
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { MnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import {
|
||||
buildDocumentBridgeContextWithActor,
|
||||
type BridgeContext,
|
||||
type BridgeTarget,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
|
||||
|
||||
type OnlyOfficeHeader = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type OnlyOfficeServiceActorInput = {
|
||||
actorId?: string;
|
||||
actorType?: string;
|
||||
sessionId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
sourceChannel: string;
|
||||
sourceClient: string;
|
||||
idempotencyKey?: string | null;
|
||||
};
|
||||
|
||||
export type OnlyOfficeSignResult = {
|
||||
token: string | null;
|
||||
documentToken: string | null;
|
||||
editorConfigToken: string | null;
|
||||
};
|
||||
|
||||
export type OnlyOfficeProxyPreparationResult = {
|
||||
targetUrl: string;
|
||||
forwardHeaders: OnlyOfficeHeader[];
|
||||
};
|
||||
|
||||
export type OnlyOfficeCallbackPreparationResult = {
|
||||
shouldWrite: boolean;
|
||||
downloadUrl: string | null;
|
||||
idempotencyKey: string | null;
|
||||
locator: {
|
||||
assetId: string;
|
||||
workspaceId: string | null;
|
||||
documentId: string | null;
|
||||
};
|
||||
session: {
|
||||
sessionId: string;
|
||||
assetId: string;
|
||||
workspaceId: string | null;
|
||||
documentId: string | null;
|
||||
userId: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type OnlyOfficeForcesavePreparationResult = {
|
||||
requests: Array<{
|
||||
via: string;
|
||||
url: string;
|
||||
method: string;
|
||||
headers: OnlyOfficeHeader[];
|
||||
bodyJson: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function buildOnlyOfficeServiceContext(request: Request, input: OnlyOfficeServiceActorInput): BridgeContext {
|
||||
return buildDocumentBridgeContextWithActor({
|
||||
request,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
actor: {
|
||||
actorType: input.actorType ?? "service",
|
||||
actorId: input.actorId?.trim() || "onlyoffice-service",
|
||||
sessionId: input.sessionId ?? null,
|
||||
},
|
||||
source: {
|
||||
channel: input.sourceChannel,
|
||||
client: input.sourceClient,
|
||||
},
|
||||
idempotencyKey: input.idempotencyKey ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function runOnlyOfficeTool<TResult>(input: {
|
||||
request: Request;
|
||||
toolName:
|
||||
| "onlyoffice_session_resolve"
|
||||
| "onlyoffice_sign"
|
||||
| "onlyoffice_prepare_proxy"
|
||||
| "onlyoffice_prepare_callback"
|
||||
| "onlyoffice_prepare_forcesave";
|
||||
args?: Record<string, unknown>;
|
||||
data?: Record<string, unknown>;
|
||||
target?: BridgeTarget | null;
|
||||
actor?: Partial<OnlyOfficeServiceActorInput>;
|
||||
}): Promise<TResult> {
|
||||
const context = buildOnlyOfficeServiceContext(input.request, {
|
||||
actorId: input.actor?.actorId,
|
||||
actorType: input.actor?.actorType,
|
||||
sessionId: input.actor?.sessionId ?? null,
|
||||
workspaceId: input.actor?.workspaceId ?? null,
|
||||
sourceChannel: input.actor?.sourceChannel?.trim() || `onlyoffice:${input.toolName}`,
|
||||
sourceClient: input.actor?.sourceClient?.trim() || "wolai-frontend",
|
||||
idempotencyKey: input.actor?.idempotencyKey ?? null,
|
||||
});
|
||||
const result = await executeRustBridgeTool<TResult>({
|
||||
context,
|
||||
toolName: input.toolName,
|
||||
invocationKind: "job",
|
||||
args: input.args ?? {},
|
||||
data: input.data ?? {},
|
||||
target: input.target ?? null,
|
||||
mode: "result",
|
||||
});
|
||||
return result.result;
|
||||
}
|
||||
|
||||
export async function signOnlyOfficeConfig(input: {
|
||||
request: Request;
|
||||
config: Record<string, unknown>;
|
||||
secret?: string | null;
|
||||
}): Promise<OnlyOfficeSignResult> {
|
||||
return await runOnlyOfficeTool<OnlyOfficeSignResult>({
|
||||
request: input.request,
|
||||
toolName: "onlyoffice_sign",
|
||||
data: {
|
||||
config: input.config,
|
||||
secret: input.secret ?? "",
|
||||
},
|
||||
actor: {
|
||||
sourceChannel: "onlyoffice-sign",
|
||||
sourceClient: "onlyoffice-sign-route",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function prepareOnlyOfficeProxyRequest(input: {
|
||||
request: Request;
|
||||
encodedUrl: string;
|
||||
method: "GET" | "HEAD";
|
||||
range: string | null;
|
||||
runtimeConfig: MnoteRuntimeConfig;
|
||||
}): Promise<OnlyOfficeProxyPreparationResult> {
|
||||
return await runOnlyOfficeTool<OnlyOfficeProxyPreparationResult>({
|
||||
request: input.request,
|
||||
toolName: "onlyoffice_prepare_proxy",
|
||||
data: {
|
||||
encodedUrl: input.encodedUrl,
|
||||
method: input.method,
|
||||
range: input.range,
|
||||
supabaseUrl: input.runtimeConfig.supabaseUrl ?? "",
|
||||
supabaseInternalUrl:
|
||||
input.runtimeConfig.supabaseInternalUrl ?? process.env.SUPABASE_INTERNAL_URL ?? "",
|
||||
onlyofficeStorageHostOverride: input.runtimeConfig.onlyofficeStorageHostOverride ?? "",
|
||||
convexOrigin:
|
||||
process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL ?? "",
|
||||
supabaseAnonKey: input.runtimeConfig.supabaseAnonKey ?? "",
|
||||
},
|
||||
actor: {
|
||||
sourceChannel: "onlyoffice-proxy",
|
||||
sourceClient: "onlyoffice-proxy-route",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function prepareOnlyOfficeCallback(input: {
|
||||
request: Request;
|
||||
assetId: string;
|
||||
documentId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
userId?: string | null;
|
||||
sessionId?: string | null;
|
||||
status: number;
|
||||
url?: string | null;
|
||||
key?: string | null;
|
||||
onlyofficeInternalUrl: string;
|
||||
}): Promise<OnlyOfficeCallbackPreparationResult> {
|
||||
return await runOnlyOfficeTool<OnlyOfficeCallbackPreparationResult>({
|
||||
request: input.request,
|
||||
toolName: "onlyoffice_prepare_callback",
|
||||
data: {
|
||||
assetId: input.assetId,
|
||||
documentId: input.documentId ?? null,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
userId: input.userId ?? null,
|
||||
sessionId: input.sessionId ?? "onlyoffice-callback",
|
||||
status: input.status,
|
||||
url: input.url ?? null,
|
||||
key: input.key ?? null,
|
||||
onlyofficeInternalUrl: input.onlyofficeInternalUrl,
|
||||
},
|
||||
actor: {
|
||||
actorId: input.userId?.trim() || "onlyoffice-callback",
|
||||
sessionId: "onlyoffice-callback",
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
idempotencyKey: input.key ?? null,
|
||||
sourceChannel: "onlyoffice-callback",
|
||||
sourceClient: "onlyoffice-document-server",
|
||||
},
|
||||
target: {
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
pageId: input.documentId ?? null,
|
||||
blockId: input.assetId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function prepareOnlyOfficeForcesave(input: {
|
||||
request: Request;
|
||||
assetId: string;
|
||||
key: string;
|
||||
onlyofficeInternalUrl: string;
|
||||
secret?: string | null;
|
||||
actorId?: string;
|
||||
workspaceId?: string | null;
|
||||
documentId?: string | null;
|
||||
}): Promise<OnlyOfficeForcesavePreparationResult> {
|
||||
return await runOnlyOfficeTool<OnlyOfficeForcesavePreparationResult>({
|
||||
request: input.request,
|
||||
toolName: "onlyoffice_prepare_forcesave",
|
||||
data: {
|
||||
assetId: input.assetId,
|
||||
key: input.key,
|
||||
onlyofficeInternalUrl: input.onlyofficeInternalUrl,
|
||||
secret: input.secret ?? "",
|
||||
},
|
||||
actor: {
|
||||
actorId: input.actorId?.trim() || "onlyoffice-forcesave",
|
||||
sessionId: "onlyoffice-forcesave",
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
idempotencyKey: input.key,
|
||||
sourceChannel: "onlyoffice-forcesave",
|
||||
sourceClient: "onlyoffice-forcesave-route",
|
||||
},
|
||||
target: {
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
pageId: input.documentId ?? null,
|
||||
blockId: input.assetId,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import "server-only";
|
||||
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentQueryEnvelope,
|
||||
DocumentBridgeError,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { executeRustBridgeQuery } from "@/lib/documents/rust-runtime";
|
||||
import type { DocumentSearchFilters, DocumentSearchRequest, DocumentSearchResponse, DocumentSearchResult } from "@/types/search";
|
||||
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
const DEFAULT_FILTERS: DocumentSearchFilters = {
|
||||
titleOnly: false,
|
||||
exact: false,
|
||||
onlyCurrentPage: false,
|
||||
includeOcr: false,
|
||||
timeRange: "any",
|
||||
timeField: "updated",
|
||||
};
|
||||
|
||||
function parseDateToIso(value: string | undefined): string | null {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
type SearchDocumentsRustResult = {
|
||||
results: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
matchField: "title" | "content" | "recent";
|
||||
hasOcr: boolean;
|
||||
publicPath: string;
|
||||
score: number;
|
||||
}>;
|
||||
enqueueAssetIds?: string[];
|
||||
};
|
||||
|
||||
type SearchRecentRustResult = {
|
||||
items: Array<{
|
||||
documentId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type SearchDocumentRow = {
|
||||
id: string;
|
||||
workspace_id?: string | null;
|
||||
title?: string | null;
|
||||
raw_text?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
type SearchMindmapRow = {
|
||||
document_id?: string | null;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
type SearchTableRow = {
|
||||
id: string;
|
||||
document_id?: string | null;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
type SearchTableContentRow = {
|
||||
table_id?: string | null;
|
||||
document_id?: string | null;
|
||||
row_hash?: string | null;
|
||||
};
|
||||
|
||||
type SearchAssetRow = {
|
||||
id: string;
|
||||
document_id?: string | null;
|
||||
asset_type?: string | null;
|
||||
file_name?: string | null;
|
||||
mime_type?: string | null;
|
||||
ocr_text?: string | null;
|
||||
ocr_status?: string | null;
|
||||
};
|
||||
|
||||
type RecentRow = {
|
||||
document_id: string;
|
||||
workspace_id: string;
|
||||
last_accessed_at: string;
|
||||
};
|
||||
|
||||
function normalizeWorkspaceId(workspaceId: string | null | undefined) {
|
||||
const normalized = typeof workspaceId === "string" ? workspaceId.trim() : "";
|
||||
if (!normalized) {
|
||||
throw new DocumentBridgeError("缺少 workspaceId", 400, "VALIDATION_ERROR");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeQuery(value: string | null | undefined) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function normalizeLimit(value: number | null | undefined) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return 30;
|
||||
}
|
||||
return Math.max(1, Math.min(Math.floor(value), MAX_LIMIT));
|
||||
}
|
||||
|
||||
function normalizeDocumentId(value: string | null | undefined) {
|
||||
const normalized = typeof value === "string" ? value.trim() : "";
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function normalizeFilters(input: DocumentSearchRequest["filters"] | undefined): DocumentSearchFilters {
|
||||
return {
|
||||
...DEFAULT_FILTERS,
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
function mapRecentRowsToResults(input: {
|
||||
documentIds: string[];
|
||||
docs: SearchDocumentRow[];
|
||||
}): DocumentSearchResult[] {
|
||||
const docMap = new Map(input.docs.map((item) => [item.id, item]));
|
||||
return input.documentIds
|
||||
.map((documentId) => docMap.get(documentId))
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title ?? "无标题",
|
||||
snippet: "",
|
||||
updatedAt: item.updated_at ?? null,
|
||||
createdAt: item.created_at ?? null,
|
||||
matchField: "recent",
|
||||
hasOcr: false,
|
||||
publicPath: `/documents/${item.id}`,
|
||||
score: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function mapRustResultsToResponse(
|
||||
result: SearchDocumentsRustResult,
|
||||
): DocumentSearchResult[] {
|
||||
return (result.results ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title ?? "无标题",
|
||||
snippet: item.snippet,
|
||||
updatedAt: item.updatedAt ?? null,
|
||||
createdAt: item.createdAt ?? null,
|
||||
matchField: item.matchField,
|
||||
hasOcr: Boolean(item.hasOcr),
|
||||
publicPath: item.publicPath,
|
||||
score: item.score,
|
||||
}));
|
||||
}
|
||||
|
||||
async function enqueueOcrJobs(assetIds: string[]) {
|
||||
if (assetIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
await Promise.all(
|
||||
assetIds.slice(0, 3).map((assetId) =>
|
||||
client.mutation(api.mediaAssets.enqueueExtractText, {
|
||||
userId: auth.userId,
|
||||
id: assetId,
|
||||
}).catch(() => null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadRecentResults(input: {
|
||||
request: Request;
|
||||
workspaceId: string;
|
||||
docs: SearchDocumentRow[];
|
||||
}): Promise<DocumentSearchResult[]> {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request: input.request,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const envelope = buildDocumentQueryEnvelope({
|
||||
name: "search.recent",
|
||||
payload: {
|
||||
workspaceId: input.workspaceId,
|
||||
limit: 10,
|
||||
cursor: null,
|
||||
},
|
||||
});
|
||||
const recentRows = (await client.query(api.recents.listByWorkspace, {
|
||||
userId: context.actor.actorId,
|
||||
workspaceId: input.workspaceId,
|
||||
limit: 10,
|
||||
})) as RecentRow[];
|
||||
const result = await executeRustBridgeQuery<SearchRecentRustResult>({
|
||||
context,
|
||||
envelope,
|
||||
data: {
|
||||
recents: recentRows.map((row) => ({
|
||||
documentId: row.document_id,
|
||||
workspaceId: row.workspace_id,
|
||||
lastAccessedAt: row.last_accessed_at,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
return mapRecentRowsToResults({
|
||||
documentIds: (result.items ?? []).map((item) => item.documentId),
|
||||
docs: input.docs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeDocumentSearchQuery(input: {
|
||||
request: Request;
|
||||
payload: DocumentSearchRequest;
|
||||
}): Promise<{
|
||||
response: DocumentSearchResponse;
|
||||
meta: {
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
queryName: string;
|
||||
};
|
||||
}> {
|
||||
const workspaceId = normalizeWorkspaceId(input.payload.workspaceId);
|
||||
const normalizedQuery = normalizeQuery(input.payload.query);
|
||||
const filters = normalizeFilters(input.payload.filters);
|
||||
const limit = normalizeLimit(input.payload.limit);
|
||||
const documentId = filters.onlyCurrentPage ? normalizeDocumentId(input.payload.documentId) : null;
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request: input.request,
|
||||
workspaceId,
|
||||
});
|
||||
const customRangeFrom = parseDateToIso(filters.customRange?.from);
|
||||
const customRangeTo = parseDateToIso(filters.customRange?.to);
|
||||
|
||||
const docs = (await client.query(api.documents.listSearchDataByWorkspace, {
|
||||
workspaceId,
|
||||
})) as SearchDocumentRow[];
|
||||
const recent = await loadRecentResults({
|
||||
request: input.request,
|
||||
workspaceId,
|
||||
docs,
|
||||
});
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return {
|
||||
response: {
|
||||
results: [],
|
||||
recent,
|
||||
},
|
||||
meta: {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
queryName: "search.documents",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const [mindmaps, tables, tableRows, assets] = (await Promise.all([
|
||||
client.query(api.mindmaps.listByWorkspace, { workspaceId, includeDeleted: false }).catch(() => []),
|
||||
client.query(api.tables.listByWorkspaceForSearch, {
|
||||
userId: context.actor.actorId,
|
||||
workspaceId,
|
||||
includeArchived: false,
|
||||
limit: 3000,
|
||||
}).catch(() => []),
|
||||
client.query(api.tables.listRowsByWorkspaceForSearch, {
|
||||
userId: context.actor.actorId,
|
||||
workspaceId,
|
||||
limit: 8000,
|
||||
}).catch(() => []),
|
||||
client.query(api.mediaAssets.listSearchDataByWorkspace, {
|
||||
userId: context.actor.actorId,
|
||||
workspaceId,
|
||||
includeDeleted: false,
|
||||
limit: 5000,
|
||||
}).catch(() => []),
|
||||
])) as [SearchMindmapRow[], SearchTableRow[], SearchTableContentRow[], SearchAssetRow[]];
|
||||
|
||||
const envelope = buildDocumentQueryEnvelope({
|
||||
name: "search.documents",
|
||||
payload: {
|
||||
query: normalizedQuery,
|
||||
workspaceId,
|
||||
pageId: documentId,
|
||||
limit,
|
||||
cursor: null,
|
||||
titleOnly: filters.titleOnly,
|
||||
exact: filters.exact,
|
||||
includeOcr: filters.includeOcr,
|
||||
timeRange: filters.timeRange,
|
||||
timeField: filters.timeField,
|
||||
customRangeFrom,
|
||||
customRangeTo,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeRustBridgeQuery<SearchDocumentsRustResult>({
|
||||
context,
|
||||
envelope,
|
||||
data: {
|
||||
documents: docs.map((item) => ({
|
||||
id: item.id,
|
||||
workspaceId: item.workspace_id ?? workspaceId,
|
||||
title: item.title ?? null,
|
||||
rawText: item.raw_text ?? null,
|
||||
createdAt: item.created_at ?? null,
|
||||
updatedAt: item.updated_at ?? null,
|
||||
})),
|
||||
mindmaps: mindmaps.map((item) => ({
|
||||
documentId: item.document_id ?? "",
|
||||
data: item.data ?? null,
|
||||
})),
|
||||
tables: tables.map((item) => ({
|
||||
id: item.id,
|
||||
documentId: item.document_id ?? "",
|
||||
title: item.title ?? null,
|
||||
})),
|
||||
tableRows: tableRows.map((item) => ({
|
||||
tableId: item.table_id ?? "",
|
||||
documentId: item.document_id ?? "",
|
||||
rowHash: item.row_hash ?? null,
|
||||
})),
|
||||
assets: assets.map((item) => ({
|
||||
id: item.id,
|
||||
documentId: item.document_id ?? "",
|
||||
assetType: item.asset_type ?? null,
|
||||
fileName: item.file_name ?? null,
|
||||
mimeType: item.mime_type ?? null,
|
||||
ocrText: item.ocr_text ?? null,
|
||||
ocrStatus: item.ocr_status ?? null,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueOcrJobs(result.enqueueAssetIds ?? []);
|
||||
|
||||
return {
|
||||
response: {
|
||||
results: mapRustResultsToResponse(result),
|
||||
recent,
|
||||
},
|
||||
meta: {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
queryName: envelope.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user