feat: 完成 rust cutover phase 8 收口

This commit is contained in:
lix-2026
2026-04-15 20:01:12 +08:00
parent 822c730cd6
commit 98db79b301
104 changed files with 18777 additions and 3025 deletions
+204 -18
View File
@@ -6,7 +6,6 @@ import { createToolRegistry, resolveAllowedToolIds } from "@/lib/ai-agent/tools/
import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins";
import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { searchSearxng } from "@/lib/ai-agent/tools/builtins/searchWeb";
import { createMindmapServerTools, type MindmapSupabaseClient } from "@/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools";
import { createDocServerTools, type DocSupabaseClient } from "@/lib/ai-agent/tools/builtins/doc/docServerTools";
import { createRagServerTools } from "@/lib/ai-agent/tools/builtins/rag/lightragServerTools";
@@ -18,6 +17,13 @@ import { buildClientToolKey, registerClientToolCall } from "@/lib/ai-agent/runti
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import type { ConvexHttpClient } from "convex/browser";
import {
buildDocumentBridgeContextWithActor,
buildDocumentCommandEnvelope,
DocumentBridgeError,
} from "@/lib/documents/bridge";
import { recordBridgeCommandFailureArtifacts } from "@/lib/documents/bridge-log";
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
import {
DEFAULT_AGENT_MAX_STEPS,
MAX_AGENT_STEPS,
@@ -106,6 +112,26 @@ const makeRunId = () => {
};
const isOnlyOfficeClientTool = (toolId: string) => toolId.startsWith("oo_");
const isDocRustTool = (toolId: string): toolId is "doc_get" | "doc_find" | "doc_insert_blocks" | "doc_replace_range" =>
toolId === "doc_get" ||
toolId === "doc_find" ||
toolId === "doc_insert_blocks" ||
toolId === "doc_replace_range";
const isMindmapWriteTool = (toolId: string) =>
toolId === "mindmap_put" ||
toolId === "mindmap_apply_ops" ||
toolId === "mindmap_add_child" ||
toolId === "mindmap_add_sibling_after" ||
toolId === "mindmap_update_node_text" ||
toolId === "mindmap_set_hyperlink" ||
toolId === "mindmap_append_note" ||
toolId === "mindmap_set_refs" ||
toolId === "mindmap_delete_node" ||
toolId === "mindmap_add_attachment_ref" ||
toolId === "mindmap_add_attachment_child" ||
toolId === "mindmap_add_image_child" ||
toolId === "mindmap_append_image_note" ||
toolId === "mindmap_expand_node";
const sseHeaders = {
"Content-Type": "text/event-stream; charset=utf-8",
@@ -179,6 +205,20 @@ export async function POST(request: Request) {
return errorResponses.unauthorized();
}
const aiBridgeContext = buildDocumentBridgeContextWithActor({
request,
actor: {
actorType: "user",
actorId: userId,
sessionId: null,
},
workspaceId: null,
source: {
channel: "ai-agent-route",
client: "wolai-frontend",
},
});
const provider = normalizeProvider(payload.options?.ai?.provider);
const modelOverride = String(payload.options?.ai?.model ?? "").trim() || null;
@@ -587,10 +627,6 @@ export async function POST(request: Request) {
const blocks = normalizeBlocksForTools(res?.content ?? null);
return { blocks, source: "convex" };
},
saveBlocks: async (blocks: unknown[]) => {
if (!convexClient) throw new Error("Convex 未初始化");
await convexClient.mutation(api.documents.updateContent, { id: documentId, content: blocks });
},
}
: {}),
})
@@ -668,12 +704,12 @@ export async function POST(request: Request) {
: null;
const mediaTools = allowedToolIds.has("image_read")
? createMediaServerTools({
supabase: supabase as unknown as MediaSupabaseClient,
ctx: { userId, attachments },
allowedToolIds,
...(convexOn
? {
? createMediaServerTools({
supabase: supabase as unknown as MediaSupabaseClient,
ctx: { userId, attachments },
allowedToolIds,
...(convexOn
? {
loadById: async (id: string) => {
if (!convexClient) throw new Error("Convex 未初始化");
return await convexClient.query(api.mediaAssets.getById, { userId, id });
@@ -694,7 +730,7 @@ export async function POST(request: Request) {
: null;
const slashTools = allowedToolIds.has("slash_run")
? createSlashServerTools({
? createSlashServerTools({
supabase: supabase as unknown as SlashSupabaseClient,
ctx: { userId, currentDocumentId: documentId || undefined },
allowedToolIds,
@@ -755,7 +791,20 @@ export async function POST(request: Request) {
if (toolId === "search_web") {
const query = String(toolArgs.query ?? "").trim();
const count = Number(toolArgs.count ?? DEFAULT_SEARCH_COUNT);
return await searchSearxng(query, Number.isFinite(count) ? count : DEFAULT_SEARCH_COUNT);
const rustCount = Number.isFinite(count) ? count : DEFAULT_SEARCH_COUNT;
const rustResult = await executeRustBridgeTool<unknown>({
context: aiBridgeContext,
toolName: "search_web",
invocationKind: "query",
args: {
query,
count: rustCount,
},
data: {
source: "ai-agent-route",
},
});
return rustResult.result;
}
if (toolId.startsWith("rag_")) {
if (!ragTools) throw new Error(`工具未初始化:${toolId}`);
@@ -766,25 +815,162 @@ export async function POST(request: Request) {
return await docsTools.run(toolId, toolArgs);
}
if (toolId === "image_read") {
if (!mediaTools) throw new Error(`工具未初始化:${toolId}`);
return await mediaTools.run(toolId, toolArgs);
if (!mediaTools) throw new Error(`工具未初始化:${toolId}`);
const transport = await mediaTools.resolveImageReadTransport(toolArgs);
const rustResult = await executeRustBridgeTool<typeof transport>({
context: aiBridgeContext,
toolName: "image_read",
invocationKind: "query",
args: {
assetId: typeof toolArgs.assetId === "string" ? toolArgs.assetId : undefined,
fileUrl: typeof toolArgs.fileUrl === "string" ? toolArgs.fileUrl : undefined,
attachmentRef: typeof toolArgs.attachmentRef === "string" ? toolArgs.attachmentRef : undefined,
},
data: {
source: "ai-agent-route",
...transport,
},
});
return rustResult.result;
}
if (toolId.startsWith("asset_")) {
if (!onlyofficeTools) throw new Error(`工具未初始化:${toolId}`);
return await onlyofficeTools.run(toolId, toolArgs);
}
if (toolId === "slash_run") {
if (!slashTools) throw new Error(`工具未初始化:${toolId}`);
return await slashTools.run(toolId, toolArgs);
if (!slashTools) throw new Error(`工具未初始化:${toolId}`);
const rustResult = await executeRustBridgeTool<{
ok: boolean;
source?: string;
parsed?: unknown;
}>({
context: aiBridgeContext,
toolName: "slash_run",
invocationKind: "command",
args: {
text: typeof toolArgs.text === "string" ? toolArgs.text : undefined,
command: typeof toolArgs.command === "string" ? toolArgs.command : undefined,
params:
isPlainObject(toolArgs.params) || Array.isArray(toolArgs.params)
? toolArgs.params
: undefined,
},
data: {
source: "ai-agent-route",
},
});
const parsed = rustResult.result?.parsed;
if (!isPlainObject(parsed) || parsed.ok !== true) {
throw new Error("Rust runtime 未返回有效的 slash_run 解析结果");
}
return await slashTools.executeSlashTransport(parsed as {
ok: true;
command: "new_doc" | "rename_doc";
params: Record<string, unknown>;
});
}
if (toolId.startsWith("doc_")) {
if (!hasDocumentContext) throw new Error(`工具需要 document 上下文:${toolId}`);
if (convexOn && convexClient && isDocRustTool(toolId)) {
const base = normalizeBlocksForTools(documentBlocks);
const runtimeContext = buildDocumentBridgeContextWithActor({
request,
actor: {
actorType: "user",
actorId: userId,
sessionId: null,
},
workspaceId: null,
source: {
channel: "ai-agent-route",
client: "wolai-frontend",
},
});
const result = await executeRustBridgeTool({
context: runtimeContext,
toolName: toolId,
invocationKind:
toolId === "doc_get" || toolId === "doc_find" ? "query" : "command",
args: toolArgs,
data: {
source: base.length > 0 ? "client" : "convex",
blocks:
base.length > 0
? base
: normalizeBlocksForTools(
(await convexClient.query(api.documents.getContent, { id: documentId }))?.content ?? null,
),
},
target: {
pageId: documentId,
workspaceId: null,
blockId:
typeof toolArgs.blockId === "string"
? toolArgs.blockId
: typeof toolArgs.afterBlockId === "string"
? toolArgs.afterBlockId
: typeof toolArgs.beforeBlockId === "string"
? toolArgs.beforeBlockId
: null,
},
reason:
typeof toolArgs.reason === "string" && toolArgs.reason.trim()
? toolArgs.reason.trim()
: `ai-agent:${toolId}`,
refs: ["task-031", "ai-agent"],
});
return result.result;
}
if (!docTools) throw new Error(`工具需要 document 上下文:${toolId}`);
return await docTools.run(toolId, toolArgs);
}
if (!hasMindmapContext) throw new Error(`工具需要 mindmap 上下文:${toolId}`);
if (!mindmapTools) throw new Error(`工具需要 mindmap 上下文:${toolId}`);
return await mindmapTools.run(toolId, toolArgs);
try {
return await mindmapTools.run(toolId, toolArgs);
} catch (error) {
if (isMindmapWriteTool(toolId) && hasMindmapContext) {
const envelope = buildDocumentCommandEnvelope({
name: "documents.save",
payload: {
documentId,
workspaceId: null,
reason:
typeof toolArgs.reason === "string" && toolArgs.reason.trim()
? toolArgs.reason.trim()
: `ai-agent:${toolId}`,
toolId,
toolArgs,
},
context: aiBridgeContext,
target: {
workspaceId: null,
pageId: documentId,
blockId: mindmapId,
},
reason:
typeof toolArgs.reason === "string" && toolArgs.reason.trim()
? toolArgs.reason.trim()
: `ai-agent:${toolId}`,
refs: ["task-042", "ai-agent", toolId],
});
await recordBridgeCommandFailureArtifacts({
context: aiBridgeContext,
envelope,
client: convexClient ?? undefined,
error:
error instanceof DocumentBridgeError
? error
: new DocumentBridgeError(
error instanceof Error ? error.message : String(error),
500,
"TRANSPORT_ERROR",
{ reason: "mindmap_failed", toolId },
),
});
}
throw error;
}
};
const maxSteps = (() => {
@@ -1,9 +1,9 @@
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { getBlocksFromDocumentContent, findBlockInTree, withBlocksWrittenBack } from "@/lib/blocks";
import {
documentBridgeErrorResponse,
executeBlockEmbedCommand,
} from "@/lib/blocks/block-command-adapter";
type EmbedBlockPayload = {
sourceDocumentId: string;
@@ -24,42 +24,26 @@ export async function POST(request: Request) {
}
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const source = await client.query(api.documents.getContent, { id: sourceDocumentId });
if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
const sourceBlocks = getBlocksFromDocumentContent(source.content);
const hit = findBlockInTree(sourceBlocks, blockId);
if (!hit) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
const target = await client.query(api.documents.getContent, { id: targetDocumentId });
if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
const targetMeta = await client.query(api.documents.getMeta, { id: targetDocumentId });
const targetBlocks = getBlocksFromDocumentContent(target.content);
const anchorId = (targetMeta as any)?.embed_default_block_id as string | null | undefined;
const anchorIndex =
typeof anchorId === "string" && anchorId.trim()
? targetBlocks.findIndex((b) => String((b as any)?.id ?? "") === anchorId)
: -1;
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : targetBlocks.length;
const referenceBlock = {
id: randomUUID(),
type: "blockReference",
props: {
try {
const result = await executeBlockEmbedCommand({
request,
sourceDocumentId,
targetBlockId: blockId,
display: "embed",
},
content: [],
children: [],
};
const nextBlocks = [...targetBlocks.slice(0, insertIndex), referenceBlock, ...targetBlocks.slice(insertIndex)];
const payload = withBlocksWrittenBack(target.content, nextBlocks);
await client.mutation(api.documents.updateContent, { id: targetDocumentId, content: payload });
return NextResponse.json({ ok: true });
blockId,
targetDocumentId,
});
return NextResponse.json({
ok: true,
...(result.result && typeof result.result === "object" ? result.result : {}),
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -121,3 +105,5 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true });
*/
}
export const runtime = "nodejs";
+16 -10
View File
@@ -1,8 +1,9 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { getBlocksFromDocumentContent, findBlockInTree } from "@/lib/blocks";
import {
documentBridgeErrorResponse,
executeBlockGetQuery,
} from "@/lib/blocks/block-command-adapter";
export async function GET(request: Request) {
const url = new URL(request.url);
@@ -14,13 +15,16 @@ export async function GET(request: Request) {
}
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const doc = await client.query(api.documents.getContent, { id: sourceDocumentId });
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
const blocks = getBlocksFromDocumentContent(doc.content);
const hit = findBlockInTree(blocks, blockId);
if (!hit) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
return NextResponse.json({ ok: true, block: hit.block });
try {
const result = await executeBlockGetQuery({
request,
sourceDocumentId,
blockId,
});
return NextResponse.json({ ok: true, block: result.block, meta: result.meta });
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -47,3 +51,5 @@ export async function GET(request: Request) {
return NextResponse.json({ ok: true, block: hit.block });
*/
}
export const runtime = "nodejs";
+30 -22
View File
@@ -1,8 +1,9 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { getBlocksFromDocumentContent, removeBlockSubtree, withBlocksWrittenBack } from "@/lib/blocks";
import {
documentBridgeErrorResponse,
executeBlockMoveCommand,
} from "@/lib/blocks/block-command-adapter";
type MoveBlockPayload = {
sourceDocumentId: string;
@@ -24,25 +25,30 @@ export async function POST(request: Request) {
}
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const source = await client.query(api.documents.getContent, { id: sourceDocumentId });
if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
const sourceBlocks = getBlocksFromDocumentContent(source.content);
const removedRes = removeBlockSubtree(sourceBlocks, blockId);
if (!removedRes.removed) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
const target = await client.query(api.documents.getContent, { id: targetDocumentId });
if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
const targetBlocks = getBlocksFromDocumentContent(target.content);
const nextSourcePayload = withBlocksWrittenBack(source.content, removedRes.nextBlocks);
const nextTargetPayload = withBlocksWrittenBack(target.content, [...targetBlocks, removedRes.removed]);
await client.mutation(api.documents.updateContent, { id: sourceDocumentId, content: nextSourcePayload });
await client.mutation(api.documents.updateContent, { id: targetDocumentId, content: nextTargetPayload });
return NextResponse.json({ ok: true });
try {
const result = await executeBlockMoveCommand({
request,
sourceDocumentId,
blockId,
targetDocumentId,
});
const payload =
result.result && typeof result.result === "object"
? (result.result as Record<string, unknown>)
: {};
return NextResponse.json({
...payload,
ok: typeof payload.ok === "boolean" ? payload.ok : true,
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -98,3 +104,5 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true });
*/
}
export const runtime = "nodejs";
@@ -1,17 +1,9 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { getBlocksFromDocumentContent, replaceBlockInTree, withBlocksWrittenBack } from "@/lib/blocks";
import {
assertBlockId,
assertDocumentId,
assertNextBlock,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
executeBlockPatchCommand,
} from "@/lib/blocks/block-command-adapter";
type PatchPayload = {
sourceDocumentId: string;
@@ -24,78 +16,20 @@ export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const { sourceDocumentId, workspaceId, blockId, nextBlock }: PatchPayload = await request.json();
const normalizedDocumentId = assertDocumentId(sourceDocumentId);
const normalizedWorkspaceId = workspaceId?.trim() || null;
const normalizedBlockId = assertBlockId(blockId);
assertNextBlock(nextBlock);
const bridgeContext = await buildDocumentBridgeContext({
const result = await executeBlockPatchCommand({
request,
workspaceId: normalizedWorkspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: "blocks.patch",
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
blockId: normalizedBlockId,
nextBlock,
},
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: normalizedDocumentId,
blockId: normalizedBlockId,
},
});
const { client } = await getAuthedConvexClient();
const doc = await client.query(api.documents.getContent, { id: normalizedDocumentId });
if (!doc) {
return NextResponse.json(
{
error: "页面不存在或无权限",
meta: {
requestId: bridgeContext.requestId,
traceId: bridgeContext.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
},
},
{ status: 404 },
);
}
const blocks = getBlocksFromDocumentContent(doc.content);
const replaced = replaceBlockInTree(blocks, normalizedBlockId, nextBlock as any);
if (!replaced.ok) {
return NextResponse.json(
{
error: "块不存在或无权限",
meta: {
requestId: bridgeContext.requestId,
traceId: bridgeContext.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
},
},
{ status: 404 },
);
}
const payload = withBlocksWrittenBack(doc.content, replaced.nextBlocks);
await client.mutation(api.documents.updateContent, { id: normalizedDocumentId, content: payload });
await recordBridgeCommandArtifacts({
context: bridgeContext,
envelope,
sourceDocumentId,
workspaceId,
blockId,
nextBlock,
});
return NextResponse.json({
ok: true,
meta: {
requestId: bridgeContext.requestId,
traceId: bridgeContext.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
@@ -135,3 +69,5 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true });
*/
}
export const runtime = "nodejs";
@@ -1,26 +1,89 @@
import { NextResponse } from "next/server";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
import {
buildDocumentBridgeContext,
buildDocumentQueryEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
const bridgeLogsApi = api as any;
function sortByNewest<T extends Record<string, unknown>>(rows: T[]) {
return [...rows].sort((left, right) => {
const leftTime =
(typeof left.created_at === "string" && left.created_at) ||
(typeof left.finished_at === "string" && left.finished_at) ||
"";
const rightTime =
(typeof right.created_at === "string" && right.created_at) ||
(typeof right.finished_at === "string" && right.finished_at) ||
"";
return rightTime.localeCompare(leftTime);
});
}
function filterByCommandId<T extends Record<string, unknown>>(rows: T[], commandId: string | null) {
if (!commandId) return rows;
return rows.filter((row) => row.command_id === commandId);
}
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const workspaceId = url.searchParams.get("workspaceId")?.trim() ?? "";
const requestId = url.searchParams.get("requestId")?.trim() ?? "";
const commandId = url.searchParams.get("commandId")?.trim() ?? "";
if (!workspaceId || !requestId) {
return NextResponse.json({ error: "缺少 workspaceId 或 requestId" }, { status: 400 });
}
const { client } = await getAuthedConvexClient();
const result = await client.query(bridgeLogsApi.bridgeLogs.listByRequest, {
const context = await buildDocumentBridgeContext({
request,
workspaceId,
requestId,
});
const envelope = buildDocumentQueryEnvelope({
name: "bridge.request.get",
payload: {
workspaceId,
requestId,
commandId: commandId || null,
},
});
const plan = await resolveRustBridgeQueryPlan({
context,
envelope,
});
const result = await executeRustBridgeQueryTransport<{
request_id: string;
command_logs: Array<Record<string, unknown>>;
domain_events: Array<Record<string, unknown>>;
generated_at: string;
}>({
client,
plan,
});
const commandLogs = sortByNewest(filterByCommandId(result.command_logs ?? [], commandId || null));
const domainEvents = sortByNewest(filterByCommandId(result.domain_events ?? [], commandId || null));
return NextResponse.json(result);
return NextResponse.json({
request_id: result.request_id,
command_logs: commandLogs,
domain_events: domainEvents,
generated_at: result.generated_at,
counts: {
command_logs: commandLogs.length,
domain_events: domainEvents.length,
},
meta: {
requestId: context.requestId,
traceId: context.traceId,
queryName: envelope.name,
commandId: commandId || null,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
@@ -1,26 +1,89 @@
import { NextResponse } from "next/server";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
import {
buildDocumentBridgeContext,
buildDocumentQueryEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
const bridgeLogsApi = api as any;
function sortByNewest<T extends Record<string, unknown>>(rows: T[]) {
return [...rows].sort((left, right) => {
const leftTime =
(typeof left.created_at === "string" && left.created_at) ||
(typeof left.finished_at === "string" && left.finished_at) ||
"";
const rightTime =
(typeof right.created_at === "string" && right.created_at) ||
(typeof right.finished_at === "string" && right.finished_at) ||
"";
return rightTime.localeCompare(leftTime);
});
}
function filterByCommandId<T extends Record<string, unknown>>(rows: T[], commandId: string | null) {
if (!commandId) return rows;
return rows.filter((row) => row.command_id === commandId);
}
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const workspaceId = url.searchParams.get("workspaceId")?.trim() ?? "";
const traceId = url.searchParams.get("traceId")?.trim() ?? "";
const commandId = url.searchParams.get("commandId")?.trim() ?? "";
if (!workspaceId || !traceId) {
return NextResponse.json({ error: "缺少 workspaceId 或 traceId" }, { status: 400 });
}
const { client } = await getAuthedConvexClient();
const result = await client.query(bridgeLogsApi.bridgeLogs.listByTrace, {
const context = await buildDocumentBridgeContext({
request,
workspaceId,
traceId,
});
const envelope = buildDocumentQueryEnvelope({
name: "bridge.trace.get",
payload: {
workspaceId,
traceId,
commandId: commandId || null,
},
});
const plan = await resolveRustBridgeQueryPlan({
context,
envelope,
});
const result = await executeRustBridgeQueryTransport<{
trace_id: string;
command_logs: Array<Record<string, unknown>>;
domain_events: Array<Record<string, unknown>>;
generated_at: string;
}>({
client,
plan,
});
const commandLogs = sortByNewest(filterByCommandId(result.command_logs ?? [], commandId || null));
const domainEvents = sortByNewest(filterByCommandId(result.domain_events ?? [], commandId || null));
return NextResponse.json(result);
return NextResponse.json({
trace_id: result.trace_id,
command_logs: commandLogs,
domain_events: domainEvents,
generated_at: result.generated_at,
counts: {
command_logs: commandLogs.length,
domain_events: domainEvents.length,
},
meta: {
requestId: context.requestId,
traceId: context.traceId,
queryName: envelope.name,
commandId: commandId || null,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
@@ -0,0 +1,91 @@
import { NextResponse } from "next/server";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeContext,
buildDocumentQueryEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const workspaceId = url.searchParams.get("workspaceId")?.trim() ?? "";
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const limit = Number(url.searchParams.get("limit") ?? "50");
const cursor = url.searchParams.get("cursor")?.trim() ?? "";
const commandStatus = url.searchParams.get("commandStatus")?.trim() ?? "";
const eventStatus = url.searchParams.get("eventStatus")?.trim() ?? "";
const targetPageId = url.searchParams.get("targetPageId")?.trim() ?? "";
const targetBlockId = url.searchParams.get("targetBlockId")?.trim() ?? "";
const aggregateType = url.searchParams.get("aggregateType")?.trim() ?? "";
const aggregateId = url.searchParams.get("aggregateId")?.trim() ?? "";
const { client } = await getAuthedConvexClient();
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const envelope = buildDocumentQueryEnvelope({
name: "bridge.workspace.overview",
payload: {
workspaceId,
limit: Number.isFinite(limit) ? limit : 50,
cursor: cursor || null,
commandStatus: commandStatus || null,
eventStatus: eventStatus || null,
targetPageId: targetPageId || null,
targetBlockId: targetBlockId || null,
aggregateType: aggregateType || null,
aggregateId: aggregateId || null,
},
});
const plan = await resolveRustBridgeQueryPlan({
context,
envelope,
});
const result = await executeRustBridgeQueryTransport<{
workspace_id: string;
command_logs: Array<Record<string, unknown>>;
domain_events: Array<Record<string, unknown>>;
counts: {
command_logs: number;
domain_events: number;
};
filters: Record<string, unknown> | null;
next_cursor: string | null;
has_more: boolean;
generated_at: string;
}>({
client,
plan,
});
return NextResponse.json({
workspace_id: result.workspace_id,
command_logs: result.command_logs ?? [],
domain_events: result.domain_events ?? [],
counts: result.counts ?? {
command_logs: Array.isArray(result.command_logs) ? result.command_logs.length : 0,
domain_events: Array.isArray(result.domain_events) ? result.domain_events.length : 0,
},
filters: result.filters ?? null,
next_cursor: result.next_cursor ?? null,
has_more: Boolean(result.has_more),
generated_at: result.generated_at,
meta: {
requestId: context.requestId,
traceId: context.traceId,
queryName: envelope.name,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
@@ -1,15 +1,19 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentQueryEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET(request: Request) {
if (isConvexEnabled()) {
@@ -23,9 +27,17 @@ export async function GET(request: Request) {
name: "documents.content.get",
payload: { documentId, workspaceId },
});
const result = await client.query(api.documents.getContent, {
id: documentId,
const plan = await resolveRustBridgeQueryPlan({
context: bridgeContext,
envelope,
});
const result = await executeRustBridgeQueryTransport<{
content?: unknown;
revision?: number | null;
conflict_detection_key?: string | null;
} | null>({
client,
plan,
});
if (!result) {
@@ -1,12 +1,21 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import path from "path";
import { promises as fs } from "fs";
import type { Json } from "@/types/supabase";
import { makeUniqueTitle } from "@/lib/file-tree/naming";
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentCopyTreePayload,
} from "@/lib/documents/page-command-adapter";
import {
copyMindmapFilesIfExists,
ensureDocumentScaffold,
} from "@/lib/documents/page-lifecycle-side-effects";
export const dynamic = "force-dynamic";
@@ -20,525 +29,98 @@ type CopyTreePayload = {
targetParentId: string | null;
};
type DocRow = {
id: string;
title: string | null;
parent_id: string | null;
workspace_id: string;
access_scope: "private" | "shared" | "public" | null;
sort_order: number | null;
created_at: string | null;
content: Json | null;
};
type AssetRow = {
id: string;
workspace_id: string;
document_id: string;
asset_type: string | null;
file_name: string | null;
file_size: number | null;
mime_type: string | null;
file_url: string | null;
thumbnail_url: string | null;
bucket: string | null;
storage_path: string | null;
};
const documentsBaseDir = getDocumentsBaseDir();
const DEFAULT_DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
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() : "无标题";
const content = `# ${safeTitle}\n`;
await fs.writeFile(indexFile, content, "utf8");
}
}
async function copyMindmapIfExists(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 buf = await fs.readFile(path.join(srcDir, name));
await fs.writeFile(path.join(destDir, name), buf);
} catch {
// ignore
}
}),
);
} catch {
// 源不存在则忽略
}
}
function normalizeTitle(title: string | null): string {
const safe = title?.trim();
return safe && safe.length > 0 ? safe : "无标题";
}
function parseStoragePath(fileUrl: string): { bucket: string; path: string } | null {
try {
const url = new URL(fileUrl);
const segments = url.pathname.split("/").filter(Boolean);
const objectIdx = segments.findIndex((seg) => seg === "object");
if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null;
if (segments[objectIdx + 1] === "public") {
const bucket = segments[objectIdx + 2];
const p = segments.slice(objectIdx + 3).join("/");
return p ? { bucket, path: p } : null;
}
if (segments[objectIdx + 1] === "sign") {
const bucket = segments[objectIdx + 2];
const p = segments.slice(objectIdx + 3).join("/");
return p ? { bucket, path: p } : null;
}
return null;
} catch {
return null;
}
}
function getChildrenSorted(childrenByParent: Map<string | null, DocRow[]>, parentId: string | null): DocRow[] {
const list = childrenByParent.get(parentId) ?? [];
return [...list].sort((a, b) => {
const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
const timeA = new Date(a.created_at ?? 0).getTime();
const timeB = new Date(b.created_at ?? 0).getTime();
return timeA - timeB;
});
}
function replaceAssetRefsInContent(content: Json | null, assetMap: Map<string, { id: string; signedUrl: string }>, newDocId: string): Json | null {
if (!content) return content;
const transform = (value: unknown): unknown => {
if (Array.isArray(value)) {
return value.map(transform);
}
if (!value || typeof value !== "object") {
return value;
}
const obj = value as Record<string, unknown>;
const next: Record<string, unknown> = {};
Object.keys(obj).forEach((key) => {
next[key] = transform(obj[key]);
});
if (next.type === "media" && next.props && typeof next.props === "object") {
const props = next.props as Record<string, unknown>;
const oldId = typeof props.assetId === "string" ? props.assetId : null;
if (oldId && assetMap.has(oldId)) {
const mapped = assetMap.get(oldId)!;
props.assetId = mapped.id;
props.fileUrl = mapped.signedUrl;
props.thumbnailUrl = mapped.signedUrl;
props.documentId = newDocId;
next.props = props;
}
}
return next;
};
return transform(content) as Json;
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
try {
const { client } = await getAuthedConvexClient();
const payload = (await request.json()) as CopyTreePayload;
const normalizedItems = (payload.items ?? [])
.filter((item) => item?.documentId)
.map((item) => ({
documentId: assertDocumentId(item.documentId),
recursive: Boolean(item.recursive),
}));
const payload = (await request.json()) as CopyTreePayload;
if (!payload?.items?.length) {
return NextResponse.json({ error: "缺少 items" }, { status: 400 });
}
const normalizedItems = payload.items.filter((it) => it?.documentId);
if (normalizedItems.length === 0) {
return NextResponse.json({ error: "items 为空" }, { status: 400 });
}
const targetParentId = payload.targetParentId ?? null;
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 });
if (normalizedItems.length === 0) {
return NextResponse.json({ error: "items 为空" }, { status: 400 });
}
workspaceId = targetDoc.workspace_id;
}
const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId)));
const firstMeta = await client.query(api.documents.getMeta, { id: sourceIds[0] });
if (!firstMeta) {
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
}
const normalizedTargetParentId = payload.targetParentId?.trim() || null;
let workspaceId: string | null = null;
if (!workspaceId) {
workspaceId = firstMeta.workspace_id;
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const wid = workspaceId;
const allDocs = await client.query(api.documents.listAllForCopy, {
workspaceId: wid,
});
const sourceById = new Map<string, DocRow>();
(allDocs as unknown as DocRow[]).forEach((d) => sourceById.set(d.id, d));
const missing = sourceIds.find((id) => !sourceById.has(id));
if (missing) {
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
}
const childrenByParent = new Map<string | null, DocRow[]>();
(allDocs as unknown as DocRow[]).forEach((doc) => {
const list = childrenByParent.get(doc.parent_id) ?? [];
list.push(doc);
childrenByParent.set(doc.parent_id, list);
});
const existingTitleSetByParent = new Map<string | null, Set<string>>();
const seedTitleSet = (parent: string | null) => {
if (existingTitleSetByParent.has(parent)) return;
const titles = new Set<string>();
(childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title)));
existingTitleSetByParent.set(parent, titles);
};
seedTitleSet(targetParentId);
const newIdByOldId = new Map<string, string>();
const copyQueue: Array<{ old: DocRow; newParentId: string | null; parentKey: string | null }> = [];
const enqueueTree = (root: DocRow, newParent: string | null, recursive: boolean) => {
const visit = (node: DocRow, parentNewId: string | null, parentKey: string | null) => {
const newId =
typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`;
newIdByOldId.set(node.id, newId);
copyQueue.push({ old: node, newParentId: parentNewId, parentKey });
if (!recursive) return;
const children = getChildrenSorted(childrenByParent, node.id);
children.forEach((child) => visit(child, newId, newId));
};
visit(root, newParent, newParent);
};
normalizedItems.forEach((item) => {
const doc = sourceById.get(item.documentId) ?? null;
if (doc) {
enqueueTree(doc, targetParentId, Boolean(item.recursive));
if (normalizedTargetParentId) {
const targetDoc = await client.query(api.documents.getMeta, { id: normalizedTargetParentId });
if (!targetDoc) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
workspaceId = targetDoc.workspace_id?.trim() || null;
} else {
const firstDoc = await client.query(api.documents.getMeta, { id: normalizedItems[0].documentId });
if (!firstDoc) {
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
}
workspaceId = firstDoc.workspace_id?.trim() || null;
}
});
if (copyQueue.length === 0) {
return NextResponse.json({ error: "没有可复制的页面" }, { status: 400 });
}
const insertedDocs: Array<{ oldId: string; newId: string }> = [];
for (const item of copyQueue) {
const newId = newIdByOldId.get(item.old.id)!;
const parentId = item.newParentId;
if (!existingTitleSetByParent.has(parentId)) {
seedTitleSet(parentId);
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const titleSet = existingTitleSetByParent.get(parentId) ?? new Set<string>();
existingTitleSetByParent.set(parentId, titleSet);
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
titleSet.add(newTitle);
await client.mutation(api.documents.create, {
id: newId,
workspaceId: wid,
parentId,
accessScope: (item.old.access_scope ?? "private") as "private" | "shared" | "public",
title: newTitle,
content: item.old.content ?? [],
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId,
});
const result = await executePageLifecycleBridgeCommand<
DocumentCopyTreePayload,
{
items: Array<{
oldId: string;
newId: string;
title?: string | null;
}>;
}
>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.copy_tree",
payload: {
workspaceId,
targetParentId: normalizedTargetParentId,
items: normalizedItems,
},
context: bridgeContext,
target: {
workspaceId,
pageId: normalizedTargetParentId,
},
}),
});
await ensureDocumentScaffold(newId, newTitle);
await client.mutation(api.mindmaps.copyByDocument, {
sourceDocId: item.old.id,
targetDocId: newId,
});
insertedDocs.push({ oldId: item.old.id, newId });
}
await Promise.all(
result.result.items.map(async (item) => {
await ensureDocumentScaffold(item.newId, item.title ?? "无标题");
await copyMindmapFilesIfExists(item.oldId, item.newId);
}),
);
return NextResponse.json({
items: insertedDocs.map((d) => ({ oldId: d.oldId, newId: d.newId })),
});
return NextResponse.json({
items: result.result.items.map((item) => ({
oldId: item.oldId,
newId: item.newId,
})),
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as CopyTreePayload;
if (!payload?.items?.length) {
return NextResponse.json({ error: "缺少 items" }, { status: 400 });
}
const normalizedItems = payload.items.filter((it) => it?.documentId);
if (normalizedItems.length === 0) {
return NextResponse.json({ error: "items 为空" }, { status: 400 });
}
const targetParentId = payload.targetParentId ?? null;
let workspaceId: string | null = null;
let targetAccessScope: "private" | "shared" | "public" = "private";
if (targetParentId) {
const { data: targetDoc, error } = await supabase
.from("documents")
.select("workspace_id,access_scope")
.eq("id", targetParentId)
.eq("user_id", session.user.id)
.single();
if (error || !targetDoc) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
workspaceId = targetDoc.workspace_id;
targetAccessScope = (targetDoc.access_scope ?? "private") as typeof targetAccessScope;
}
const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId)));
const { data: sourceDocs, error: sourceErr } = await supabase
.from("documents")
.select("id,title,parent_id,workspace_id,access_scope,sort_order,created_at,content")
.in("id", sourceIds)
.eq("user_id", session.user.id);
if (sourceErr || !sourceDocs?.length) {
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
}
const sourceById = new Map<string, DocRow>();
(sourceDocs as DocRow[]).forEach((d) => sourceById.set(d.id, d));
if (!workspaceId) {
workspaceId = sourceDocs[0].workspace_id;
}
const { data: allDocs, error: allErr } = await supabase
.from("documents")
.select("id,title,parent_id,workspace_id,access_scope,sort_order,created_at,content")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.order("sort_order", { ascending: true, nullsFirst: false })
.order("created_at", { ascending: true });
if (allErr) {
return NextResponse.json({ error: allErr.message }, { status: 500 });
}
const childrenByParent = new Map<string | null, DocRow[]>();
(allDocs as DocRow[]).forEach((doc) => {
const list = childrenByParent.get(doc.parent_id) ?? [];
list.push(doc);
childrenByParent.set(doc.parent_id, list);
});
const existingTitleSetByParent = new Map<string | null, Set<string>>();
const seedTitleSet = (parent: string | null) => {
if (existingTitleSetByParent.has(parent)) return;
const titles = new Set<string>();
(childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title)));
existingTitleSetByParent.set(parent, titles);
};
seedTitleSet(targetParentId);
const newIdByOldId = new Map<string, string>();
const copyQueue: Array<{ old: DocRow; newParentId: string | null; parentKey: string | null }> = [];
const enqueueTree = (root: DocRow, newParent: string | null, recursive: boolean) => {
const visit = (node: DocRow, parentNewId: string | null, parentKey: string | null) => {
const newId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`;
newIdByOldId.set(node.id, newId);
copyQueue.push({ old: node, newParentId: parentNewId, parentKey });
if (!recursive) return;
const children = getChildrenSorted(childrenByParent, node.id);
children.forEach((child) => visit(child, newId, newId));
};
visit(root, newParent, newParent);
};
normalizedItems.forEach((item) => {
const doc = sourceById.get(item.documentId) ?? (allDocs as DocRow[]).find((d) => d.id === item.documentId) ?? null;
if (doc) {
enqueueTree(doc, targetParentId, Boolean(item.recursive));
}
});
if (copyQueue.length === 0) {
return NextResponse.json({ error: "没有可复制的页面" }, { status: 400 });
}
// 计算根级 sort_order 起点
const siblingQuery = supabase
.from("documents")
.select("id", { head: true, count: "exact" })
.eq("workspace_id", workspaceId);
if (targetParentId) {
siblingQuery.eq("parent_id", targetParentId);
} else {
siblingQuery.is("parent_id", null);
}
const { count: rawSiblingCount } = await siblingQuery;
const siblingCount = rawSiblingCount ?? 0;
const nextSortByParent = new Map<string | null, number>([[targetParentId, siblingCount]]);
const insertedDocs: Array<{ oldId: string; newId: string }> = [];
for (const item of copyQueue) {
const newId = newIdByOldId.get(item.old.id)!;
const parentId = item.newParentId;
if (!existingTitleSetByParent.has(parentId)) {
if (newIdByOldId.has(parentId ?? "")) {
existingTitleSetByParent.set(parentId, new Set());
} else {
seedTitleSet(parentId);
}
}
const titleSet = existingTitleSetByParent.get(parentId) ?? new Set<string>();
existingTitleSetByParent.set(parentId, titleSet);
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
const currentSort = nextSortByParent.get(parentId) ?? 0;
nextSortByParent.set(parentId, currentSort + 1);
const { error: insertError } = await supabase.from("documents").insert({
id: newId,
user_id: session.user.id,
workspace_id: workspaceId,
parent_id: parentId,
access_scope: (item.old.access_scope ?? targetAccessScope) as typeof targetAccessScope,
title: newTitle,
content: item.old.content ?? { blocks: [] },
sort_order: currentSort,
});
if (insertError) {
return NextResponse.json({ error: insertError.message }, { status: 500 });
}
await ensureDocumentScaffold(newId, newTitle);
await copyMindmapIfExists(item.old.id, newId);
insertedDocs.push({ oldId: item.old.id, newId });
}
// 复制附件并回写 content 中的 assetId
const oldDocIds = insertedDocs.map((d) => d.oldId);
const { data: assets, error: assetErr } = await supabase
.from("media_assets")
.select("id,workspace_id,document_id,asset_type,file_name,file_size,mime_type,file_url,thumbnail_url,bucket,storage_path")
.in("document_id", oldDocIds)
.eq("workspace_id", workspaceId)
.is("deleted_at", null);
if (assetErr) {
return NextResponse.json({ error: assetErr.message }, { status: 500 });
}
const assetMapByOldDoc = new Map<string, Map<string, { id: string; signedUrl: string }>>();
for (const asset of (assets as AssetRow[] | null) ?? []) {
const mappedDocId = newIdByOldId.get(asset.document_id);
if (!mappedDocId) continue;
const sourceLocation =
asset.storage_path
? { bucket: asset.bucket ?? DEFAULT_DOC_BUCKET, path: asset.storage_path }
: asset.file_url
? parseStoragePath(asset.file_url)
: null;
if (!sourceLocation) continue;
const fileName = (asset.file_name ?? "附件").replace(/[\\/]/g, "_");
const newAssetId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const targetPath = `${workspaceId}/${mappedDocId}/${newAssetId}-${fileName}`;
const bucket = sourceLocation.bucket || DEFAULT_DOC_BUCKET;
const map = assetMapByOldDoc.get(asset.document_id) ?? new Map<string, { id: string; signedUrl: string }>();
assetMapByOldDoc.set(asset.document_id, map);
const copyRes = await supabase.storage.from(bucket).copy(sourceLocation.path, targetPath);
if (copyRes.error) {
continue;
}
const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7);
const signedUrl = signed?.signedUrl ?? "";
const { error: insertAssetErr } = await supabase.from("media_assets").insert({
id: newAssetId,
workspace_id: workspaceId,
document_id: mappedDocId,
asset_type: asset.asset_type ?? "file",
file_name: asset.file_name ?? fileName,
file_size: asset.file_size ?? null,
mime_type: asset.mime_type ?? null,
bucket,
storage_path: targetPath,
file_url: signedUrl,
thumbnail_url: signedUrl,
created_by: session.user.id,
});
if (!insertAssetErr) {
map.set(asset.id, { id: newAssetId, signedUrl });
}
}
for (const pair of insertedDocs) {
const map = assetMapByOldDoc.get(pair.oldId);
if (!map || map.size === 0) continue;
const source = (allDocs as DocRow[]).find((d) => d.id === pair.oldId);
if (!source) continue;
const newContent = replaceAssetRefsInContent(source.content, map, pair.newId);
const { error: updateErr } = await supabase
.from("documents")
.update({ content: newContent })
.eq("id", pair.newId)
.eq("user_id", session.user.id);
if (updateErr) {
// ignore
}
}
return NextResponse.json({
items: insertedDocs.map((d) => ({ oldId: d.oldId, newId: d.newId })),
});
*/
}
export const runtime = "nodejs";
@@ -1,160 +1,13 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
function makeId(): string {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
type CreateChildPayload = {
parentId: string | null;
title?: string;
blocks?: unknown;
};
import { executeDocumentCreateChildBridgeCommand } from "@/lib/documents/page-command-adapter";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
const { parentId, title, blocks }: CreateChildPayload = await request.json();
if (typeof parentId === "undefined") {
return NextResponse.json({ error: "缺少 parentId" }, { status: 400 });
}
let workspaceId: string;
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 {
const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
fallbackName: auth.name ?? auth.email ?? "我的空间",
workspaceIdIfCreate: makeId(),
});
workspaceId = ensured.activeWorkspaceId;
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
}
const resolvedTitle = title && title.trim().length > 0 ? title.trim() : "未命名页面";
const contentPayload = Array.isArray(blocks) ? blocks : [];
const pageId = makeId();
const created = await client.mutation(api.documents.create, {
id: pageId,
workspaceId,
parentId: parentId ?? null,
title: resolvedTitle,
accessScope,
content: contentPayload,
});
return NextResponse.json({
pageId: created.id,
title: created.title ?? resolvedTitle,
});
return executeDocumentCreateChildBridgeCommand(request);
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录无法创建页面" }, { status: 401 });
}
const { parentId, title, blocks }: CreateChildPayload = await request.json();
if (typeof parentId === "undefined") {
return NextResponse.json({ error: "缺少 parentId" }, { status: 400 });
}
let workspaceId: string;
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
const { data: parentDoc, error: parentError } = await supabase
.from("documents")
.select("workspace_id,access_scope")
.eq("id", parentId)
.single();
if (parentError || !parentDoc) {
return NextResponse.json({ error: "父页面不存在" }, { status: 404 });
}
workspaceId = parentDoc.workspace_id;
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
} else {
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
}
const siblingQuery = supabase
.from("documents")
.select("id", { head: true, count: "exact" })
.eq("workspace_id", workspaceId);
if (parentId) {
siblingQuery.eq("parent_id", parentId);
} else {
siblingQuery.is("parent_id", null);
}
const { count: rawSiblingCount, error: countError } = await siblingQuery;
const siblingCount = rawSiblingCount ?? 0;
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 500 });
}
const resolvedTitle =
title && title.trim().length > 0 ? title.trim() : "未命名页面";
const contentPayload = Array.isArray(blocks) ? blocks : [];
const { data, error } = await supabase
.from("documents")
.insert({
user_id: session.user.id,
parent_id: parentId,
workspace_id: workspaceId,
access_scope: accessScope,
title: resolvedTitle,
content: contentPayload,
sort_order: siblingCount,
})
.select("id,title")
.single();
if (error || !data) {
return NextResponse.json(
{ error: error?.message ?? "创建子页面失败" },
{ status: 500 },
);
}
return NextResponse.json({
pageId: data.id,
title: data.title ?? resolvedTitle,
});
*/
}
@@ -1,45 +1,34 @@
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import type { Json } from "@/types/supabase";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import path from "path";
import { promises as fs } from "fs";
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
const documentsBaseDir = getDocumentsBaseDir();
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() : "无标题";
const content = `# ${safeTitle}\n`;
await fs.writeFile(indexFile, content, "utf8");
}
}
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentCreatePayload,
} from "@/lib/documents/page-command-adapter";
import { ensureDocumentScaffold } from "@/lib/documents/page-lifecycle-side-effects";
export async function POST(request: Request) {
try {
if (isConvexEnabled()) {
return await handleCreateRequestConvex(request);
}
return await handleCreateRequest(request);
return await handleCreateRequest();
} catch (error) {
console.error("创建页面失败", error);
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
return NextResponse.json({ error: message }, { status: 500 });
}
}
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
return NextResponse.json({ error: message }, { status: 500 });
}
}
async function handleCreateRequestConvex(request: Request) {
const { auth, client } = await getAuthedConvexClient();
const { parentId }: { parentId?: string | null } = await request.json();
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
@@ -48,7 +37,6 @@ async function handleCreateRequestConvex(request: Request) {
});
let workspaceId: string | null = null;
let parentContent: Json | null = null;
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
@@ -62,8 +50,6 @@ async function handleCreateRequestConvex(request: Request) {
workspaceId = parentDoc.workspace_id;
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
const parentContentRes = await client.query(api.documents.getContent, { id: parentId });
parentContent = (parentContentRes?.content as Json | null) ?? null;
} else {
workspaceId = workspaceBootstrap.activeWorkspaceId || null;
}
@@ -72,156 +58,62 @@ async function handleCreateRequestConvex(request: Request) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const id = randomUUID();
const data = await client.mutation(api.documents.create, {
id,
workspaceId,
parentId: parentId ?? null,
title: "无标题",
accessScope,
content: [],
});
// 为文件树创建本地目录和 index.md
if (data?.id) {
await ensureDocumentScaffold(data.id, data.title ?? "无标题");
}
if (parentId && data) {
const existingBlocks = extractBlocksFromContent(parentContent);
const pageReferenceBlock = {
id: randomUUID(),
type: "pageReference",
props: {
pageId: data.id,
title: data.title ?? "无标题",
},
};
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
const payload = composeContentWithBlocks(parentContent, nextBlocks);
await client.mutation(api.documents.updateContent, {
id: parentId,
content: payload,
try {
const id = randomUUID();
const normalizedWorkspaceId = workspaceId.trim();
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: normalizedWorkspaceId,
});
const result = await executePageLifecycleBridgeCommand<
DocumentCreatePayload,
{
id: string;
title?: string | null;
parent_id?: string | null;
sort_order?: number | null;
workspace_id?: string;
access_scope?: "private" | "shared" | "public";
is_template?: boolean;
}
>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.create",
payload: {
documentId: id,
workspaceId: normalizedWorkspaceId,
parentId: parentId?.trim() || null,
title: "无标题",
accessScope,
content: [],
},
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: id,
},
}),
});
}
return NextResponse.json(data);
await ensureDocumentScaffold(result.result.id, result.result.title ?? "无标题");
return NextResponse.json({
...result.result,
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
async function handleCreateRequest(request: Request) {
async function handleCreateRequest() {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { parentId }: { parentId?: string | null } = await request.json();
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
let workspaceId: string | null = null;
let parentContent: Json | null = null;
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
const { data: parentDoc, error: parentError } = await supabase
.from("documents")
.select("workspace_id,access_scope,content,user_id")
.eq("id", parentId)
.eq("user_id", session.user.id)
.single();
if (parentError || !parentDoc) {
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
}
workspaceId = parentDoc.workspace_id;
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
parentContent = parentDoc.content;
} else {
workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const siblingQuery = supabase
.from("documents")
.select("id", { head: true, count: "exact" })
.eq("workspace_id", workspaceId);
if (parentId) {
siblingQuery.eq("parent_id", parentId);
} else {
siblingQuery.is("parent_id", null);
}
const { count: rawSiblingCount, error: countError } = await siblingQuery;
const siblingCount = rawSiblingCount ?? 0;
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 500 });
}
const { data, error } = await supabase
.from("documents")
.insert({
user_id: session.user.id,
parent_id: parentId ?? null,
workspace_id: workspaceId,
title: "无标题",
content: { blocks: [] },
access_scope: accessScope,
sort_order: siblingCount,
})
.select(
"id,title,parent_id,sort_order,is_starred,created_at,updated_at,workspace_id,access_scope,is_template",
)
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
// 为文件树创建本地目录和 index.md
if (data?.id) {
await ensureDocumentScaffold(data.id, data.title ?? "无标题");
}
if (parentId && data) {
const existingBlocks = extractBlocksFromContent(parentContent);
const pageReferenceBlock = {
id: randomUUID(),
type: "pageReference",
props: {
pageId: data.id,
title: data.title ?? "无标题",
},
};
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
const payload = composeContentWithBlocks(parentContent, nextBlocks);
const timestamp = new Date().toISOString();
const { error: parentUpdateError } = await supabase
.from("documents")
.update({ content: payload, updated_at: timestamp })
.eq("id", parentId)
.eq("user_id", session.user.id);
if (parentUpdateError) {
return NextResponse.json({ error: parentUpdateError.message }, { status: 500 });
}
}
return NextResponse.json(data);
*/
}
export const runtime = "nodejs";
@@ -1,52 +1,59 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentDeletePayload,
} from "@/lib/documents/page-command-adapter";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
try {
const body = (await request.json()) as { documentId?: string; workspaceId?: string | null };
const normalizedDocumentId = assertDocumentId(body.documentId);
const normalizedWorkspaceId = body.workspaceId?.trim() || null;
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: normalizedWorkspaceId,
});
const result = await executePageLifecycleBridgeCommand<DocumentDeletePayload, { ok: boolean }>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.delete",
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
},
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: normalizedDocumentId,
},
}),
});
return NextResponse.json({
success: true,
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.softDelete, { id: documentId });
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { error } = await supabase
.from("documents")
.update({
deleted_at: new Date().toISOString(),
deleted_by: session.user.id,
})
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
*/
}
export const runtime = "nodejs";
@@ -2,165 +2,96 @@ import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import path from "path";
import { promises as fs } from "fs";
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentDuplicatePayload,
} from "@/lib/documents/page-command-adapter";
import {
copyMindmapFilesIfExists,
ensureDocumentScaffold,
} from "@/lib/documents/page-lifecycle-side-effects";
interface DuplicatePayload {
documentId: string;
}
const documentsBaseDir = getDocumentsBaseDir();
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 });
const safeTitle = title && title.trim() ? title.trim() : "无标题";
const content = `# ${safeTitle}\n`;
await fs.writeFile(indexFile, content, "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 {
// 如果源不存在则忽略
}
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
try {
const { client } = await getAuthedConvexClient();
const { documentId }: DuplicatePayload = await request.json();
const normalizedDocumentId = assertDocumentId(documentId);
const { documentId }: DuplicatePayload = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
const sourceDoc = await client.query(api.documents.getMeta, { id: normalizedDocumentId });
if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
const fallbackTitle =
sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0 ? sourceDoc.title.trim() : "无标题";
const duplicatedTitle = `${fallbackTitle} 副本`;
const newId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const normalizedWorkspaceId = sourceDoc.workspace_id?.trim() || null;
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: normalizedWorkspaceId,
});
const result = await executePageLifecycleBridgeCommand<
DocumentDuplicatePayload,
{
id: string;
title?: string | null;
parent_id?: string | null;
sort_order?: number | null;
}
>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.duplicate",
payload: {
sourceDocumentId: normalizedDocumentId,
newDocumentId: newId,
workspaceId: normalizedWorkspaceId,
title: duplicatedTitle,
},
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: newId,
},
}),
});
await ensureDocumentScaffold(result.result.id, result.result.title ?? duplicatedTitle);
await copyMindmapFilesIfExists(normalizedDocumentId, result.result.id);
return NextResponse.json({
id: result.result.id,
title: result.result.title ?? duplicatedTitle,
parent_id: result.result.parent_id ?? null,
sort_order: result.result.sort_order ?? null,
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
const fallbackTitle =
sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0 ? sourceDoc.title.trim() : "无标题";
const duplicatedTitle = `${fallbackTitle} 副本`;
const newId = typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const duplicated = await client.mutation(api.documents.duplicate, {
sourceId: documentId,
newId,
title: duplicatedTitle,
});
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
await client.mutation(api.mindmaps.copyByDocument, {
sourceDocId: documentId,
targetDocId: duplicated.id,
});
return NextResponse.json({
id: duplicated.id,
title: duplicated.title ?? duplicatedTitle,
parent_id: duplicated.parent_id ?? null,
sort_order: duplicated.sort_order ?? null,
});
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId }: DuplicatePayload = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { data: sourceDoc, error: sourceError } = await supabase
.from("documents")
.select("id,title,content,parent_id,workspace_id,access_scope")
.eq("id", documentId)
.eq("user_id", session.user.id)
.single();
if (sourceError || !sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
const siblingQuery = supabase
.from("documents")
.select("id", { head: true, count: "exact" })
.eq("workspace_id", sourceDoc.workspace_id);
if (sourceDoc.parent_id) {
siblingQuery.eq("parent_id", sourceDoc.parent_id);
} else {
siblingQuery.is("parent_id", null);
}
const { count: rawSiblingCount, error: countError } = await siblingQuery;
const siblingCount = rawSiblingCount ?? 0;
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 500 });
}
const fallbackTitle =
sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0
? sourceDoc.title.trim()
: "无标题";
const duplicatedTitle = `${fallbackTitle} 副本`;
const { data: duplicated, error: duplicateError } = await supabase
.from("documents")
.insert({
user_id: session.user.id,
workspace_id: sourceDoc.workspace_id,
parent_id: sourceDoc.parent_id,
access_scope: sourceDoc.access_scope ?? "private",
title: duplicatedTitle,
content: sourceDoc.content,
sort_order: siblingCount,
})
.select("id,title,parent_id,sort_order")
.single();
if (duplicateError || !duplicated) {
return NextResponse.json(
{ error: duplicateError?.message ?? "复制失败" },
{ status: 500 },
);
}
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
await copyMindmapIfExists(sourceDoc.id, duplicated.id);
return NextResponse.json(duplicated);
*/
}
export const runtime = "nodejs";
@@ -1,133 +1,11 @@
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import type { Json } from "@/types/supabase";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
interface EmbedPayload {
sourceId: string;
targetId: string;
}
import { executeDocumentEmbedBridgeCommand } from "@/lib/documents/page-command-adapter";
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
const { sourceId, targetId }: EmbedPayload = await request.json();
if (!sourceId || !targetId) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const sourceDoc = await client.query(api.documents.getMeta, { id: sourceId });
if (!sourceDoc) {
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
}
const targetContent = await client.query(api.documents.getContent, { id: targetId });
if (!targetContent) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
const targetMeta = await client.query(api.documents.getMeta, { id: targetId });
const currentBlocks = extractBlocksFromContent(targetContent.content);
const anchorId = (targetMeta as any)?.embed_default_block_id as string | null | undefined;
const anchorIndex =
typeof anchorId === "string" && anchorId.trim()
? currentBlocks.findIndex((b) => typeof b === "object" && b !== null && (b as any).id === anchorId)
: -1;
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
const nextBlocks: Json[] = [
...currentBlocks.slice(0, insertIndex),
{
id: randomUUID(),
type: "pageReference",
props: {
pageId: sourceDoc.id,
title: sourceDoc.title ?? "无标题",
},
},
...currentBlocks.slice(insertIndex),
];
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
await client.mutation(api.documents.updateContent, {
id: targetId,
content: payload,
});
return NextResponse.json({ ok: true });
return executeDocumentEmbedBridgeCommand(request);
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { sourceId, targetId }: EmbedPayload = await request.json();
if (!sourceId || !targetId) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const { data: sourceDoc, error: sourceError } = await supabase
.from("documents")
.select("id,title,user_id")
.eq("id", sourceId)
.eq("user_id", session.user.id)
.single();
if (sourceError || !sourceDoc) {
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
}
const { data: targetDoc, error: targetError } = await supabase
.from("documents")
.select("id,content,user_id")
.eq("id", targetId)
.eq("user_id", session.user.id)
.single();
if (targetError || !targetDoc) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
const currentBlocks = extractBlocksFromContent(targetDoc.content);
const nextBlocks: Json[] = [
...currentBlocks,
{
id: randomUUID(),
type: "pageReference",
props: {
pageId: sourceDoc.id,
title: sourceDoc.title ?? "无标题",
},
},
];
const payload: Json = composeContentWithBlocks(targetDoc.content, nextBlocks);
const { error: updateError } = await supabase
.from("documents")
.update({ content: payload })
.eq("id", targetDoc.id)
.eq("user_id", session.user.id);
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 });
}
return NextResponse.json({ ok: true });
*/
}
@@ -1,69 +1,13 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { executeDocumentEmptyTrashBridgeCommand } from "@/lib/documents/page-command-adapter";
export const dynamic = "force-dynamic";
interface EmptyTrashPayload {
workspaceId?: string;
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { workspaceId }: EmptyTrashPayload = await request.json();
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.emptyTrashByWorkspace, { workspaceId });
return NextResponse.json({ success: true });
return executeDocumentEmptyTrashBridgeCommand(request);
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { workspaceId }: EmptyTrashPayload = await request.json();
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const { data: membership, error: membershipError } = await supabase
.from("workspace_members")
.select("workspace_id")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.limit(1);
if (membershipError) {
return NextResponse.json({ error: membershipError.message }, { status: 500 });
}
if (!membership || membership.length === 0) {
return NextResponse.json({ error: "无权操作该工作空间" }, { status: 403 });
}
const { error } = await supabase
.from("documents")
.delete()
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.not("deleted_at", "is", null);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
*/
}
@@ -1,55 +1,67 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentMovePayload,
} from "@/lib/documents/page-command-adapter";
interface MovePayload {
documentId: string;
parentId?: string | null;
position: number;
workspaceId?: string | null;
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { documentId, parentId = null, position }: MovePayload = await request.json();
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.move, {
id: documentId,
parentId,
sortOrder,
});
return NextResponse.json({ ok: true });
try {
const { documentId, parentId = null, position, workspaceId }: MovePayload = await request.json();
const normalizedDocumentId = assertDocumentId(documentId);
const normalizedWorkspaceId = workspaceId?.trim() || null;
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: normalizedWorkspaceId,
});
const result = await executePageLifecycleBridgeCommand<DocumentMovePayload, { ok: boolean }>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.move",
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
parentId: parentId?.trim() || null,
sortOrder,
},
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: normalizedDocumentId,
},
}),
});
return NextResponse.json({
ok: true,
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId, parentId = null, position }: MovePayload = await request.json();
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
const { error } = await supabase
.from("documents")
.update({
parent_id: parentId,
sort_order: sortOrder,
})
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json({ ok: true });
*/
}
export const runtime = "nodejs";
@@ -1,49 +1,13 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { executeDocumentPurgeBridgeCommand } from "@/lib/documents/page-command-adapter";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.purge, { id: documentId });
return NextResponse.json({ success: true });
return executeDocumentPurgeBridgeCommand(request);
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { error } = await supabase
.from("documents")
.delete()
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
*/
}
@@ -1,54 +1,59 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentRestorePayload,
} from "@/lib/documents/page-command-adapter";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
try {
const body = (await request.json()) as { documentId?: string; workspaceId?: string | null };
const normalizedDocumentId = assertDocumentId(body.documentId);
const normalizedWorkspaceId = body.workspaceId?.trim() || null;
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: normalizedWorkspaceId,
});
const result = await executePageLifecycleBridgeCommand<DocumentRestorePayload, { ok: boolean }>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.restore",
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
},
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: normalizedDocumentId,
},
}),
});
return NextResponse.json({
success: true,
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.restore, { id: documentId });
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { error } = await supabase
.from("documents")
.update({
deleted_at: null,
deleted_by: null,
parent_id: null,
access_scope: "private",
})
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
*/
}
export const runtime = "nodejs";
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: vi.fn(() => true),
}));
vi.mock("@/lib/documents/page-command-adapter", () => ({
executeDocumentCreateChildBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
executeDocumentEmbedBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
executeDocumentTemplateBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
executeDocumentEmptyTrashBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
executeDocumentPurgeBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
}));
import { POST as postCreateChild } from "@/app/api/documents/create-child/route";
import { POST as postEmbed } from "@/app/api/documents/embed/route";
import { POST as postTemplate } from "@/app/api/documents/template/route";
import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/route";
import { POST as postPurge } from "@/app/api/documents/purge/route";
import {
executeDocumentCreateChildBridgeCommand,
executeDocumentEmbedBridgeCommand,
executeDocumentTemplateBridgeCommand,
executeDocumentEmptyTrashBridgeCommand,
executeDocumentPurgeBridgeCommand,
} from "@/lib/documents/page-command-adapter";
describe("documents route adapters", () => {
it("creates child route delegates to unified adapter", async () => {
await postCreateChild(new Request("http://localhost/api/documents/create-child", {
method: "POST",
body: JSON.stringify({ parentId: null }),
}));
expect(executeDocumentCreateChildBridgeCommand).toHaveBeenCalled();
});
it("embed route delegates to unified adapter", async () => {
await postEmbed(new Request("http://localhost/api/documents/embed", {
method: "POST",
body: JSON.stringify({ sourceId: "doc_1", targetId: "doc_2" }),
}));
expect(executeDocumentEmbedBridgeCommand).toHaveBeenCalled();
});
it("template route delegates to unified adapter", async () => {
await postTemplate(new Request("http://localhost/api/documents/template", {
method: "POST",
body: JSON.stringify({ documentId: "doc_1", isTemplate: true }),
}));
expect(executeDocumentTemplateBridgeCommand).toHaveBeenCalled();
});
it("empty trash route delegates to unified adapter", async () => {
await postEmptyTrash(new Request("http://localhost/api/documents/empty-trash", {
method: "POST",
body: JSON.stringify({ workspaceId: "ws_1" }),
}));
expect(executeDocumentEmptyTrashBridgeCommand).toHaveBeenCalled();
});
it("purge route delegates to unified adapter", async () => {
await postPurge(new Request("http://localhost/api/documents/purge", {
method: "POST",
body: JSON.stringify({ documentId: "doc_1" }),
}));
expect(executeDocumentPurgeBridgeCommand).toHaveBeenCalled();
});
});
@@ -58,3 +58,5 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
export const runtime = "nodejs";
@@ -1,26 +1,11 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
type TemplatePayload = {
documentId: string;
isTemplate: boolean;
};
import { executeDocumentTemplateBridgeCommand } from "@/lib/documents/page-command-adapter";
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { documentId, isTemplate }: TemplatePayload = await request.json();
if (!documentId || typeof isTemplate !== "boolean") {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.setTemplate, { id: documentId, isTemplate });
return NextResponse.json({ ok: true });
return executeDocumentTemplateBridgeCommand(request);
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
@@ -60,3 +60,5 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
export const runtime = "nodejs";
@@ -2,7 +2,8 @@ import { NextResponse } from "next/server";
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
import { loadLocalAiConfig } from "@/lib/ai/localAiConfig";
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
import { applyMindmapOps, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
export const dynamic = "force-dynamic";
@@ -1,8 +1,9 @@
import { NextResponse } from "next/server";
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
import { applyMindmapOps, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
import { readMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
type RequestPayload = {
documentId: string;
@@ -81,9 +82,9 @@ const searchSearxng = async (q: string, count = 5): Promise<SearxResult[]> => {
return mapped;
};
const coerceOpsFromAiJson = (raw: Record<string, unknown>): MindmapOp[] => {
const coerceOpsFromAiJson = (raw: Record<string, unknown>) => {
const ops = (raw as any)?.ops;
return Array.isArray(ops) ? (ops as MindmapOp[]) : [];
return Array.isArray(ops) ? ops : [];
};
export async function POST(request: Request) {
@@ -294,16 +295,58 @@ export async function POST(request: Request) {
}
}
const { data: nextData, applied, errors } = applyMindmapOps(baseData, fixed);
await writeMindmapLocal(payload.documentId, payload.mindmapId, nextData, doc.title ?? "无标题");
const context = buildDocumentBridgeContextWithActor({
request,
actor: {
actorType: "service",
actorId: "mindmap-expand-node",
sessionId: null,
},
workspaceId: (doc as any).workspace_id ?? null,
source: {
channel: "mindmap-expand-route",
client: "wolai-frontend",
},
});
const result = await executeRustBridgeTool<{
ok: boolean;
applied?: number;
errors?: string[];
ops?: unknown[];
data?: unknown;
meta?: unknown;
}>({
context,
toolName: "mindmap_expand_node",
invocationKind: "command",
args: {
documentId: payload.documentId,
mindmapId: payload.mindmapId,
targetUid: payload.targetUid,
instruction: payload.instruction ?? "",
ops: fixed,
searchResults: searxResults.map((r) => ({
title: r.title,
url: r.url,
snippet: r.snippet ?? "",
})),
reason: payload.instruction ?? "",
},
data: {
data: baseData,
source: "mindmap-expand-node",
},
mode: "result",
});
return NextResponse.json({
ok: true,
providerUsed: "online",
applied,
errors,
ops: fixed,
data: nextData,
applied: (result.result as any)?.applied ?? 0,
errors: (result.result as any)?.errors ?? [],
ops: (result.result as any)?.ops ?? fixed,
data: (result.result as any)?.data ?? baseData,
meta: {
finishReason,
searched: useSearx,
@@ -4,6 +4,8 @@ import path from "path";
import os from "os";
import { execFile } from "child_process";
import { promisify } from "util";
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
@@ -877,6 +879,7 @@ export async function POST(request: Request) {
let pdfTitle = "文档";
let pdfUrlForLink: ((p: number) => string) | null = null;
let pageLinkPattern = "#page={page}";
let cleanupDir: string | null = null;
let pdfFile: string | null = null;
@@ -893,6 +896,7 @@ export async function POST(request: Request) {
pdfFile = file;
pdfTitle = testName.replace(/\.pdf$/i, "");
pdfUrlForLink = (p) => `/api/mindmap-ai/test-pdf?name=${encodeURIComponent(testName)}#page=${p}`;
pageLinkPattern = `/api/mindmap-ai/test-pdf?name=${encodeURIComponent(testName)}#page={page}`;
} else if (payload.source.kind === "url") {
const safe = safeFetchableUrl(payload.source.fileUrl);
if (!safe) {
@@ -903,6 +907,7 @@ export async function POST(request: Request) {
pdfFile = downloaded.file;
pdfTitle = payload.source.title?.trim() || "文档";
pdfUrlForLink = (p) => `${safe}#page=${p}`;
pageLinkPattern = `${safe}#page={page}`;
} else {
return NextResponse.json({ error: "不支持的 source.kind" }, { status: 400 });
}
@@ -1104,13 +1109,61 @@ export async function POST(request: Request) {
]);
}
const mindmapData = plan
? buildMindmapFromPlan(pdfTitle, plan, linkForPage)
: buildMindmapTree(pdfTitle, outline ?? [], linkForPage);
const finalOutline = plan
? plan.chapters.flatMap((chapter, chapterIndex) => [
{
title: chapter.title,
level: 1,
page: chapter.page,
},
...((chapter.sections ?? []).map((section) => ({
title: section.title,
level: 2,
page: section.page,
}))),
])
: (outline ?? []);
const context = buildDocumentBridgeContextWithActor({
request,
actor: {
actorType: "service",
actorId: "mindmap-outline-to-mindmap",
sessionId: null,
},
workspaceId: null,
source: {
channel: "mindmap-outline-route",
client: "wolai-frontend",
},
});
const payload = {
documentId: "mindmap-outline-to-mindmap",
mindmapId: "mindmap-outline-to-mindmap",
rootTitle: pdfTitle,
pageLinkPattern,
outline: finalOutline,
};
const result = await executeRustBridgeTool<{
ok: boolean;
data?: unknown;
meta?: { title?: string | null; outlineCount?: number | null } | null;
}>({
context,
toolName: "mindmap_outline_to_mindmap",
invocationKind: "command",
args: payload,
data: payload,
mode: "result",
});
const mindmapData = (result as any)?.data ?? {
data: { text: pdfTitle },
children: [],
};
return NextResponse.json({
mindmapData,
outline: plan ? null : outline,
outline: null,
plan,
candidates,
tocEntries,
@@ -1122,6 +1175,7 @@ export async function POST(request: Request) {
onlineAttempted,
onlineSucceeded,
onlineError,
outlineCount: Array.isArray(finalOutline) ? finalOutline.length : 0,
},
});
} catch (error) {
@@ -2,6 +2,8 @@ import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
export const dynamic = "force-dynamic";
@@ -17,6 +19,28 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const context = buildDocumentBridgeContextWithActor({
request,
actor: {
actorType: "service",
actorId: "mindmap-trash-empty",
sessionId: null,
},
workspaceId,
source: {
channel: "mindmap-trash-route",
client: "wolai-frontend",
},
});
await executeRustBridgeTool({
context,
toolName: "mindmap_empty_trash",
invocationKind: "command",
args: { workspaceId },
data: { workspaceId, source: "mindmap-trash-empty" },
mode: "result",
});
try {
const result = await client.mutation(api.mindmaps.emptyTrashByWorkspace, { workspaceId });
return NextResponse.json({ ok: true, removed: result?.deletedCount ?? 0 });
@@ -27,4 +51,3 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
@@ -3,6 +3,18 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
import {
buildDocumentBridgeContextWithActor,
buildDocumentCommandEnvelope,
buildDocumentQueryEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeRustBridgeMutationTransport,
executeRustBridgeQueryTransport,
resolveRustBridgeCommandPlan,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
const defaultMindmapData = {
data: { text: "中心主题" },
@@ -17,23 +29,62 @@ export async function GET(
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
const res = await client.query(api.mindmaps.get, { docId, mindmapId });
return NextResponse.json({
data: res?.data ?? defaultMindmapData,
source: "convex",
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: res?.meta?.workspace_id ?? null,
try {
const context = buildDocumentBridgeContextWithActor({
request,
actor: {
actorType: "user",
actorId: auth.userId,
sessionId: null,
},
workspaceId: null,
source: {
channel: "mindmap-route",
client: "wolai-frontend",
},
});
const envelope = buildDocumentQueryEnvelope({
name: "mindmaps.get",
payload: {
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
exists: Boolean(res?.meta?.exists),
deletedAt: res?.meta?.deleted_at ?? null,
createdAt: res?.meta?.created_at ?? null,
updatedAt: res?.meta?.updated_at ?? null,
},
});
workspaceId: null,
},
});
const plan = await resolveRustBridgeQueryPlan({ context, envelope });
const res = await executeRustBridgeQueryTransport<{
ok?: boolean;
data?: unknown;
meta?: {
workspace_id?: string | null;
exists?: boolean;
deleted_at?: string | null;
created_at?: string | null;
updated_at?: string | null;
} | null;
}>({
client,
plan,
});
return NextResponse.json({
data: res?.data ?? defaultMindmapData,
source: "convex",
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: res?.meta?.workspace_id ?? null,
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
exists: Boolean(res?.meta?.exists),
deletedAt: res?.meta?.deleted_at ?? null,
createdAt: res?.meta?.created_at ?? null,
updatedAt: res?.meta?.updated_at ?? null,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -53,11 +104,44 @@ export async function POST(
};
try {
const result = await client.mutation(api.mindmaps.put, {
docId,
mindmapId,
data: data ?? defaultMindmapData,
...(typeof createOnly === "boolean" ? { createOnly } : {}),
const context = buildDocumentBridgeContextWithActor({
request,
actor: {
actorType: "user",
actorId: auth.userId,
sessionId: null,
},
workspaceId: null,
source: {
channel: "mindmap-route",
client: "wolai-frontend",
},
});
const envelope = buildDocumentCommandEnvelope({
name: "mindmaps.put",
payload: {
documentId: docId,
mindmapId,
data: data ?? defaultMindmapData,
createOnly: typeof createOnly === "boolean" ? createOnly : false,
},
context,
target: {
workspaceId: null,
pageId: docId,
blockId: mindmapId,
},
reason: "mindmap-route:put",
refs: ["task-032", "mindmap-route"],
});
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
const result = await executeRustBridgeMutationTransport<{
ok?: boolean;
workspace_id?: string | null;
updated_at?: string | null;
}>({
client,
plan,
});
return NextResponse.json({
...(result ?? { ok: true }),
@@ -72,7 +156,7 @@ export async function POST(
},
});
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
return documentBridgeErrorResponse(error);
}
}
@@ -2,6 +2,18 @@ import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import {
buildDocumentBridgeContextWithActor,
buildDocumentCommandEnvelope,
buildDocumentQueryEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeRustBridgeMutationTransport,
executeRustBridgeQueryTransport,
resolveRustBridgeCommandPlan,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
const defaultMindmapData = {
@@ -9,6 +21,10 @@ const defaultMindmapData = {
children: [],
};
function getLegacyMindmapId(docId: string) {
return `legacy-${docId}`;
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ docId: string }> },
@@ -17,24 +33,64 @@ export async function GET(
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
const mindmapId = `legacy-${docId}`;
const res = await client.query(api.mindmaps.get, { docId, mindmapId });
return NextResponse.json({
data: res?.data ?? defaultMindmapData,
source: "convex",
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: res?.meta?.workspace_id ?? null,
const mindmapId = getLegacyMindmapId(docId);
try {
const context = buildDocumentBridgeContextWithActor({
request,
actor: {
actorType: "user",
actorId: auth.userId,
sessionId: null,
},
workspaceId: null,
source: {
channel: "mindmap-route",
client: "wolai-frontend",
},
});
const envelope = buildDocumentQueryEnvelope({
name: "mindmaps.get",
payload: {
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
exists: Boolean(res?.meta?.exists),
deletedAt: res?.meta?.deleted_at ?? null,
createdAt: res?.meta?.created_at ?? null,
updatedAt: res?.meta?.updated_at ?? null,
},
});
workspaceId: null,
},
});
const plan = await resolveRustBridgeQueryPlan({ context, envelope });
const res = await executeRustBridgeQueryTransport<{
ok?: boolean;
data?: unknown;
meta?: {
workspace_id?: string | null;
exists?: boolean;
deleted_at?: string | null;
created_at?: string | null;
updated_at?: string | null;
} | null;
}>({
client,
plan,
});
return NextResponse.json({
data: res?.data ?? defaultMindmapData,
source: "convex",
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: res?.meta?.workspace_id ?? null,
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
exists: Boolean(res?.meta?.exists),
deletedAt: res?.meta?.deleted_at ?? null,
createdAt: res?.meta?.created_at ?? null,
updatedAt: res?.meta?.updated_at ?? null,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -48,25 +104,67 @@ export async function POST(
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
const mindmapId = `legacy-${docId}`;
const payload = (await request.json().catch(() => ({}))) as { data?: unknown };
const result = await client.mutation(api.mindmaps.put, {
docId,
mindmapId,
data: payload.data ?? defaultMindmapData,
});
return NextResponse.json({
...(result ?? { ok: true }),
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: result?.workspace_id ?? null,
const mindmapId = getLegacyMindmapId(docId);
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
data?: unknown;
createOnly?: boolean;
};
try {
const context = buildDocumentBridgeContextWithActor({
request,
actor: {
actorType: "user",
actorId: auth.userId,
sessionId: null,
},
workspaceId: null,
source: {
channel: "mindmap-route",
client: "wolai-frontend",
},
});
const envelope = buildDocumentCommandEnvelope({
name: "mindmaps.put",
payload: {
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
updatedAt: result?.updated_at ?? null,
},
});
data: data ?? defaultMindmapData,
createOnly: typeof createOnly === "boolean" ? createOnly : false,
},
context,
target: {
workspaceId: null,
pageId: docId,
blockId: mindmapId,
},
reason: "mindmap-route:put",
refs: ["task-032", "mindmap-route"],
});
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
const result = await executeRustBridgeMutationTransport<{
ok?: boolean;
workspace_id?: string | null;
updated_at?: string | null;
}>({
client,
plan,
});
return NextResponse.json({
...(result ?? { ok: true }),
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: result?.workspace_id ?? null,
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
updatedAt: result?.updated_at ?? null,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -0,0 +1,198 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockQuery = vi.fn();
const mockMutation = vi.fn();
const mockExecuteMediaAssetWritebackBridgeCommand = vi.fn();
const mockPrepareOnlyOfficeCallback = vi.fn();
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: vi.fn(),
}));
vi.mock("@/lib/api-utils", () => ({
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
message,
status,
details,
})),
}));
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => true,
}));
vi.mock("@/lib/convex/server", () => ({
getConvexHttpClient: () => ({
query: mockQuery,
mutation: mockMutation,
}),
}));
vi.mock("@/lib/convex/api", () => ({
api: {
mediaAssets: {
getById: { name: "mediaAssets.getById" },
generateUploadUrl: { name: "mediaAssets.generateUploadUrl" },
replaceStorageFromUpload: { name: "mediaAssets.replaceStorageFromUpload" },
},
documents: {
getPermissionForUser: { name: "documents.getPermissionForUser" },
},
},
}));
vi.mock("@/lib/onlyoffice/internal-url", () => ({
resolveOnlyOfficeInternalUrl: vi.fn().mockResolvedValue("http://127.0.0.1:8082"),
}));
vi.mock("@/lib/documents/bridge", async () => {
const actual = await vi.importActual<typeof import("@/lib/documents/bridge")>("@/lib/documents/bridge");
return {
...actual,
buildDocumentBridgeContextWithActor: vi.fn((input) => ({
requestId: "req_test_onlyoffice_callback",
traceId: "trace_test_onlyoffice_callback",
deploymentId: null,
projectId: null,
workspaceId: input.workspaceId ?? null,
actor: input.actor,
source: input.source ?? { channel: "onlyoffice-callback", client: "vitest" },
tenantId: null,
authToken: null,
idempotencyKey: input.idempotencyKey ?? null,
validateOnly: false,
dryRun: false,
})),
};
});
vi.mock("@/lib/documents/media-asset-command-adapter", () => ({
executeMediaAssetWritebackBridgeCommand: mockExecuteMediaAssetWritebackBridgeCommand,
}));
vi.mock("@/lib/onlyoffice/rust-adapter", () => ({
prepareOnlyOfficeCallback: mockPrepareOnlyOfficeCallback,
}));
describe("/api/onlyoffice/callback route", () => {
beforeEach(() => {
mockQuery.mockReset();
mockMutation.mockReset();
mockExecuteMediaAssetWritebackBridgeCommand.mockReset();
mockPrepareOnlyOfficeCallback.mockReset();
vi.unstubAllGlobals();
});
it("uses media asset bridge adapter for callback writeback", async () => {
const { POST } = await import("./route");
mockQuery
.mockResolvedValueOnce({
id: "asset_1",
document_id: "doc_1",
workspace_id: "ws_1",
mime_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
})
.mockResolvedValueOnce({
permission: "edit",
});
mockMutation.mockResolvedValueOnce("http://127.0.0.1:3210/api/upload");
mockExecuteMediaAssetWritebackBridgeCommand.mockResolvedValue({
requestId: "req_test_onlyoffice_callback",
traceId: "trace_test_onlyoffice_callback",
commandId: "cmd_test_onlyoffice_callback",
commandName: "media.assets.replace_storage",
});
mockPrepareOnlyOfficeCallback.mockResolvedValue({
shouldWrite: true,
downloadUrl: "http://127.0.0.1:8082/cache/files/output.docx",
idempotencyKey: "doc_key_1",
locator: {
assetId: "asset_1",
workspaceId: "ws_1",
documentId: "doc_1",
},
session: {
sessionId: "onlyoffice-callback",
assetId: "asset_1",
workspaceId: "ws_1",
documentId: "doc_1",
userId: "user_1",
},
});
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "http://127.0.0.1:8082/cache/files/output.docx") {
return new Response(Buffer.from("docx-binary"), {
status: 200,
headers: { "Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document" },
});
}
if (url === "http://127.0.0.1:3210/api/upload") {
return Response.json({ storageId: "storage_new_1" }, { status: 200 });
}
throw new Error(`unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const request = new Request(
"http://127.0.0.1:3001/api/onlyoffice/callback?assetId=asset_1&userId=user_1",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
status: 2,
key: "doc_key_1",
url: "http://127.0.0.1:8082/cache/files/output.docx",
}),
},
);
const response = await POST(request);
const payload = await response.json();
expect(payload).toEqual({ error: 0 });
expect(mockMutation).toHaveBeenCalledTimes(1);
expect(mockMutation.mock.calls[0]?.[1]).toEqual({ userId: "user_1" });
expect(mockExecuteMediaAssetWritebackBridgeCommand).toHaveBeenCalledTimes(1);
expect(mockExecuteMediaAssetWritebackBridgeCommand).toHaveBeenCalledWith({
client: expect.any(Object),
context: expect.objectContaining({
workspaceId: "ws_1",
actor: {
actorType: "service",
actorId: "user_1",
sessionId: "onlyoffice-callback",
},
source: {
channel: "onlyoffice-callback",
client: "onlyoffice-document-server",
},
idempotencyKey: "doc_key_1",
}),
envelope: expect.objectContaining({
name: "media.assets.replace_storage",
payload: {
assetId: "asset_1",
documentId: "doc_1",
workspaceId: "ws_1",
storageId: "storage_new_1",
userId: "user_1",
},
target: {
workspaceId: "ws_1",
pageId: "doc_1",
},
}),
});
});
});
@@ -3,6 +3,7 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
import { prepareOnlyOfficeCallback } from "@/lib/onlyoffice/rust-adapter";
import {
buildDocumentBridgeContextWithActor,
buildDocumentCommandEnvelope,
@@ -45,61 +46,32 @@ const normalizeSecret = (raw: string) => {
return trimmed;
};
const tryRewriteOnlyOfficeDownloadUrl = (raw: string, onlyofficeInternalUrl: string) => {
export async function POST(request: Request) {
try {
const u = new URL(raw);
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
const { searchParams } = new URL(request.url);
const assetId = searchParams.get("assetId") || "";
// 说明:如果回传的是通过 /onlyoffice-server 访问的地址,
// 服务端下载时优先改写为本机 ONLYOFFICE_INTERNAL_URL(避免绕公网/证书问题)
const prefix = "/onlyoffice-server";
if (u.pathname.startsWith(prefix)) {
const nextPath = u.pathname.slice(prefix.length).replace(/^\/+/, "");
return `${onlyofficeInternalUrl}/${nextPath}${u.search}`;
// 说明:ONLYOFFICE 回调由文档服务器触发,不一定携带用户态;这里提供一个可选的共享密钥校验。
// 若未配置 ONLYOFFICE_CALLBACK_SECRET,则保持兼容不校验
if (ONLYOFFICE_CALLBACK_SECRET) {
const got = normalizeSecret(searchParams.get("token") || "");
const expected = normalizeSecret(ONLYOFFICE_CALLBACK_SECRET);
if (!got || got !== expected) {
return NextResponse.json({ error: 1 });
}
}
return raw;
} catch {
return raw;
}
};
const body = (await request.json().catch(() => null)) as OnlyOfficeCallbackBody | null;
if (!body) {
return NextResponse.json({ error: 0 });
}
export async function POST(request: Request) {
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
const { searchParams } = new URL(request.url);
const assetId = searchParams.get("assetId") || "";
// 说明:ONLYOFFICE 回调由文档服务器触发,不一定携带用户态;这里提供一个可选的共享密钥校验。
// 若未配置 ONLYOFFICE_CALLBACK_SECRET,则保持兼容不校验。
if (ONLYOFFICE_CALLBACK_SECRET) {
const got = normalizeSecret(searchParams.get("token") || "");
const expected = normalizeSecret(ONLYOFFICE_CALLBACK_SECRET);
if (!got || got !== expected) {
if (!assetId) {
return NextResponse.json({ error: 1 });
}
}
const body = (await request.json().catch(() => null)) as OnlyOfficeCallbackBody | null;
if (!body) {
return NextResponse.json({ error: 0 });
}
const status = Number(body.status ?? -1);
// 说明:仅在文档需要保存时处理(2=ready for saving6=force save)。
if (status !== 2 && status !== 6) {
return NextResponse.json({ error: 0 });
}
if (!assetId) {
// 说明:缺少 assetId 无法定位存储路径,返回非 0 让 ONLYOFFICE 显示保存失败。
return NextResponse.json({ error: 1 });
}
if (!body.url) {
return NextResponse.json({ error: 1 });
}
if (isConvexEnabled()) {
try {
if (isConvexEnabled()) {
// 说明:Convex 模式下,保存写回 Convex Files,并更新 media_assets.storage_id/file_url。
// 优先使用前端在 /onlyoffice 页面透传的 userId;避免固定 dev-user 导致 workspace membership 校验失败。
const userId =
@@ -118,6 +90,22 @@ export async function POST(request: Request) {
const workspaceId = String(asset.workspace_id || "").trim() || null;
const mimeType = String(asset.mime_type || "").trim() || "application/octet-stream";
const prepared = await prepareOnlyOfficeCallback({
request,
assetId,
documentId,
workspaceId,
userId,
sessionId: "onlyoffice-callback",
status: Number(body.status ?? -1),
url: body.url ?? null,
key: body.key ?? null,
onlyofficeInternalUrl,
});
if (!prepared.shouldWrite) {
return NextResponse.json({ error: 0 });
}
// 只读权限:不允许通过 ONLYOFFICE 回调写回
try {
const perm = (await client.query(api.documents.getPermissionForUser, {
@@ -131,7 +119,10 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 1 });
}
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url, onlyofficeInternalUrl);
const downloadUrl = String(prepared.downloadUrl || "").trim();
if (!downloadUrl) {
return NextResponse.json({ error: 1 });
}
const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
if (!upstream.ok) {
return NextResponse.json({ error: 1 });
@@ -172,7 +163,7 @@ export async function POST(request: Request) {
channel: "onlyoffice-callback",
client: "onlyoffice-document-server",
},
idempotencyKey: String(body.key || assetId || "").trim() || null,
idempotencyKey: String(prepared.idempotencyKey || assetId || "").trim() || null,
});
const envelope = buildDocumentCommandEnvelope({
name: "media.assets.replace_storage",
@@ -199,11 +190,12 @@ export async function POST(request: Request) {
});
return NextResponse.json({ error: 0 });
} catch (error) {
// 说明:避免异常导致 ONLYOFFICE 重试/阻塞(例如 Convex 未部署新 mutation)。
console.error("[onlyoffice/callback] convex writeback failed:", error);
return NextResponse.json({ error: 1 });
}
return NextResponse.json({ error: 1 });
} catch (error) {
// 说明:避免异常导致 ONLYOFFICE 重试/阻塞(例如 Convex 未部署新 mutation)。
console.error("[onlyoffice/callback] convex writeback failed:", error);
return NextResponse.json({ error: 1 });
}
return NextResponse.json({ error: 1 });
}
@@ -1,41 +1,13 @@
import { NextResponse } from "next/server";
import crypto from "crypto";
import { api } from "@/lib/convex/api";
import { HttpError } from "@/lib/auth/authContext";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
import { prepareOnlyOfficeForcesave } from "@/lib/onlyoffice/rust-adapter";
export const dynamic = "force-dynamic";
const base64Url = (input: Buffer | string) =>
Buffer.from(input)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
const signHs256 = (payload: unknown, secret: string) => {
const header = { alg: "HS256", typ: "JWT" };
const headerPart = base64Url(JSON.stringify(header));
const payloadPart = base64Url(JSON.stringify(payload));
const signingInput = `${headerPart}.${payloadPart}`;
const signature = crypto.createHmac("sha256", secret).update(signingInput).digest();
return `${signingInput}.${base64Url(signature)}`;
};
const normalizeSecret = (raw: string) => {
const trimmed = String(raw || "").trim();
if (!trimmed) return "";
// 兼容用户把 .env 的值写成 "xxx" / 'xxx'
if (
(trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length >= 2) ||
(trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)
) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
};
export async function POST(request: Request) {
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
let auth;
@@ -72,70 +44,43 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "无权修改(只读共享的文件)" }, { status: 403 });
}
const payload = { c: "forcesave", key, userdata: `asset:${assetId}` };
const secret = normalizeSecret(process.env.ONLYOFFICE_JWT_SECRET || "");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tryJson = async (res: Response) => (await res.json().catch(() => null)) as any;
try {
// 优先按文档推荐:使用 /command + token
if (secret) {
const token = signHs256(payload, secret);
const r = await fetch(`${onlyofficeInternalUrl}/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const j = await tryJson(r);
if (r.ok && Number(j?.error ?? 0) === 0) {
return NextResponse.json({ ok: true, via: "command", result: j });
}
// 兜底:部分环境可能暴露 /forcesave 直连接口
const r2 = await fetch(`${onlyofficeInternalUrl}/forcesave`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const j2 = await tryJson(r2);
if (r2.ok && Number(j2?.error ?? 0) === 0) {
return NextResponse.json({ ok: true, via: "forcesave", result: j2 });
}
return NextResponse.json(
{ error: "触发 forcesave 失败", detail: { command: j, forcesave: j2 } },
{ status: 502 },
);
}
// JWT 未启用:尝试 /forcesave 直连
const r = await fetch(`${onlyofficeInternalUrl}/forcesave`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
const prepared = await prepareOnlyOfficeForcesave({
request,
assetId,
key,
onlyofficeInternalUrl,
secret: process.env.ONLYOFFICE_JWT_SECRET || "",
actorId: auth.userId,
documentId: docId,
workspaceId: String((asset as any).workspace_id || "").trim() || null,
});
const j = await tryJson(r);
if (r.ok && Number(j?.error ?? 0) === 0) {
return NextResponse.json({ ok: true, via: "forcesave", result: j });
}
// 最后兜底:部分部署可能仍接受不带 token 的 /command(不保证)
const r2 = await fetch(`${onlyofficeInternalUrl}/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const j2 = await tryJson(r2);
if (r2.ok && Number(j2?.error ?? 0) === 0) {
return NextResponse.json({ ok: true, via: "command_no_token", result: j2 });
const errors: Record<string, unknown> = {};
for (const outbound of prepared.requests) {
const response = await fetch(outbound.url, {
method: outbound.method,
headers: Object.fromEntries(outbound.headers.map((item) => [item.name, item.value])),
body: outbound.bodyJson,
});
const payload = await tryJson(response);
if (response.ok && Number(payload?.error ?? 0) === 0) {
return NextResponse.json({ ok: true, via: outbound.via, result: payload });
}
errors[outbound.via] = payload;
}
return NextResponse.json(
{ error: "触发 forcesave 失败", detail: { forcesave: j, command: j2 } },
{ error: "触发 forcesave 失败", detail: errors },
{ status: 502 },
);
} catch (error) {
if (error instanceof Error && error.name === "DocumentBridgeError") {
return documentBridgeErrorResponse(error);
}
console.error("[onlyoffice/forcesave] failed:", error);
return NextResponse.json({ error: "触发 forcesave 失败" }, { status: 502 });
}
@@ -1,236 +1,66 @@
import { NextResponse } from "next/server";
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
import { prepareOnlyOfficeProxyRequest } from "@/lib/onlyoffice/rust-adapter";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isIP } from "node:net";
export const dynamic = "force-dynamic";
const isLocalHostname = (hostname: string) =>
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";
const isPrivateIpv4 = (hostname: string) => {
if (isIP(hostname) !== 4) return false;
const parts = hostname.split(".").map((v) => Number(v));
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true;
const [a, b] = parts;
// 10.0.0.0/8
if (a === 10) return true;
// 127.0.0.0/8
if (a === 127) return true;
// 169.254.0.0/16
if (a === 169 && b === 254) return true;
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return true;
// 192.168.0.0/16
if (a === 192 && b === 168) return true;
// 0.0.0.0/8
if (a === 0) return true;
return false;
};
const tryParseOriginHost = (raw?: string) => {
const value = String(raw || "").trim();
if (!value) return null;
try {
if (/^https?:\/\//i.test(value)) {
const u = new URL(value);
return { hostname: u.hostname, port: u.port || "" };
}
return { hostname: value, port: "" };
} catch {
return null;
}
};
const decodeBase64UrlToUtf8 = (input: string) => {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const padLen = (4 - (normalized.length % 4)) % 4;
const padded = normalized + "=".repeat(padLen);
return Buffer.from(padded, "base64").toString("utf8");
};
const tryParseOriginUrl = (raw?: string) => {
const value = String(raw || "").trim();
if (!value) return null;
try {
if (/^https?:\/\//i.test(value)) return new URL(value);
// 说明:仅给 host:port 的写法一个默认协议(http)
return new URL(`http://${value}`);
} catch {
return null;
const toHeaders = (items: Array<{ name: string; value: string }>) => {
const headers = new Headers();
for (const item of items) {
if (!item.name || !item.value) continue;
headers.set(item.name, item.value);
}
return headers;
};
const handle = async (request: Request, method: "GET" | "HEAD") => {
const { searchParams } = new URL(request.url);
const encoded = searchParams.get("u");
if (!encoded) {
return NextResponse.json({ error: "缺少 u" }, { status: 400 });
}
let targetUrl: string;
try {
targetUrl = decodeBase64UrlToUtf8(encoded);
new URL(targetUrl);
} catch {
return NextResponse.json({ error: "u 不是有效的 base64url URL" }, { status: 400 });
}
const runtimeCfg = getMnoteRuntimeConfig();
let target: URL;
try {
target = new URL(targetUrl);
} catch {
return NextResponse.json({ error: "u 不是有效的 URL" }, { status: 400 });
}
if (target.protocol !== "http:" && target.protocol !== "https:") {
return NextResponse.json({ error: "仅支持 http/https URL" }, { status: 400 });
}
// 安全:避免把该接口变成通用 SSRF 代理。
// 当前仅用于代理 Supabase Storage 的签名 URL(以及可能的 storage 回源 host override)。
const supa = tryParseOriginHost(runtimeCfg.supabaseUrl);
const supaInternalOrigin = tryParseOriginUrl(
runtimeCfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL,
);
const storageOverride = tryParseOriginHost(runtimeCfg.onlyofficeStorageHostOverride);
const convexOrigin = tryParseOriginUrl(process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL);
const isSupabasePath =
target.pathname.startsWith("/storage/v1/") ||
target.pathname.startsWith("/auth/v1/") ||
target.pathname.startsWith("/rest/v1/") ||
target.pathname.startsWith("/functions/v1/") ||
target.pathname.startsWith("/realtime/v1/");
// 说明:历史数据里可能残留旧的 Supabase 域名(例如 supabase.aichem.dpdns.org)。
// 为了保证 ONLYOFFICE 拉取文件稳定,这里统一把 Supabase 典型路径的 host 改为当前配置的 Supabase
// 并优先使用“内部 HTTP”回源,避免 Node fetch 因自签证书失败。
if (isSupabasePath) {
if (supaInternalOrigin) {
target.protocol = supaInternalOrigin.protocol;
target.host = supaInternalOrigin.host;
targetUrl = target.toString();
} else {
const supaPublicOrigin = tryParseOriginUrl(runtimeCfg.supabaseUrl);
if (supaPublicOrigin) {
target.protocol = supaPublicOrigin.protocol;
target.host = supaPublicOrigin.host;
targetUrl = target.toString();
}
const { searchParams } = new URL(request.url);
const encodedUrl = String(searchParams.get("u") || "").trim();
if (!encodedUrl) {
return NextResponse.json({ error: "缺少 u" }, { status: 400 });
}
}
const allowedHostnames = new Set<string>();
const allowedPortsByHostname = new Map<string, Set<string>>();
const prepared = await prepareOnlyOfficeProxyRequest({
request,
encodedUrl,
method,
range: request.headers.get("range"),
runtimeConfig: getMnoteRuntimeConfig(),
});
const addAllowed = (hostname: string, port: string) => {
allowedHostnames.add(hostname);
if (!allowedPortsByHostname.has(hostname)) allowedPortsByHostname.set(hostname, new Set<string>());
if (port) allowedPortsByHostname.get(hostname)!.add(port);
};
const upstream = await fetch(prepared.targetUrl, {
method,
headers: toHeaders(prepared.forwardHeaders),
redirect: "follow",
});
if (supa?.hostname) {
addAllowed(supa.hostname, supa.port);
// 如果 Supabase 配置就是本地/内网回源(例如 host.docker.internal/127.0.0.1),则允许这些 hostname
// 但仍然限制端口只能是 Supabase URL 的端口,避免误用。
if (isLocalHostname(supa.hostname)) {
addAllowed("127.0.0.1", supa.port);
addAllowed("localhost", supa.port);
addAllowed("host.docker.internal", supa.port);
const headers = new Headers(upstream.headers);
headers.delete("set-cookie");
if (method === "HEAD") {
return new Response(null, {
status: upstream.status,
statusText: upstream.statusText,
headers,
});
}
}
if (supaInternalOrigin?.hostname) {
addAllowed(supaInternalOrigin.hostname, supaInternalOrigin.port || "");
if (isLocalHostname(supaInternalOrigin.hostname)) {
addAllowed("127.0.0.1", supaInternalOrigin.port || "");
addAllowed("localhost", supaInternalOrigin.port || "");
addAllowed("host.docker.internal", supaInternalOrigin.port || "");
}
}
if (storageOverride?.hostname) {
addAllowed(storageOverride.hostname, storageOverride.port);
}
if (convexOrigin?.hostname) {
addAllowed(convexOrigin.hostname, convexOrigin.port || "");
if (isLocalHostname(convexOrigin.hostname)) {
addAllowed("127.0.0.1", convexOrigin.port || "");
addAllowed("localhost", convexOrigin.port || "");
addAllowed("host.docker.internal", convexOrigin.port || "");
}
}
// 说明:默认禁止代理到内网/私有地址;但如果该 hostname 被显式配置为允许(例如 Docker bridge/host 回源),则放行。
if (isPrivateIpv4(target.hostname) && !isLocalHostname(target.hostname) && !allowedHostnames.has(target.hostname)) {
return NextResponse.json({ error: "禁止访问内网/私有地址" }, { status: 403 });
}
if (allowedHostnames.size > 0 && !allowedHostnames.has(target.hostname)) {
return NextResponse.json({ error: "禁止代理到非允许的主机" }, { status: 403 });
}
const allowedPorts = allowedPortsByHostname.get(target.hostname);
if (allowedPorts && allowedPorts.size > 0) {
const port = target.port || (target.protocol === "https:" ? "443" : "80");
if (!allowedPorts.has(port)) {
return NextResponse.json({ error: "禁止代理到该端口" }, { status: 403 });
}
}
// 说明:
// - Supabase Storage 的 signedUrl 会携带 `?token=...`,而 ONLYOFFICE 会把它当作
// 自己的 JWT token 参数去校验,导致报 “文档安全令牌的格式不正确/invalid signature”。
// - 这里用反向代理把 signedUrl 藏在 `u=...` 里(参数名不叫 token),ONLYOFFICE 拉取的
// URL 就不会出现 `token` 参数,从而避免冲突。
const forwardHeaders = new Headers();
const range = request.headers.get("range");
if (range) forwardHeaders.set("range", range);
// 兼容部分 Supabase/Kong 配置:某些环境下下载 Storage 资源仍要求 apikey header。
if (
runtimeCfg.supabaseAnonKey &&
((supa?.hostname && target.hostname === supa.hostname) ||
(supaInternalOrigin?.hostname && target.hostname === supaInternalOrigin.hostname))
) {
forwardHeaders.set("apikey", runtimeCfg.supabaseAnonKey);
}
const upstream = await fetch(targetUrl, {
method,
headers: forwardHeaders,
redirect: "follow",
});
const headers = new Headers(upstream.headers);
// 避免把上游 cookie 透传给文档服务器
headers.delete("set-cookie");
// 说明:ONLYOFFICE 可能会对 document.url 发起 HEAD 预检(获取 content-length/etag)。
// 若不支持 HEAD,会导致编辑器报 “下载失败”。
if (method === "HEAD") {
return new Response(null, {
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers,
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers,
});
};
export async function GET(request: Request) {
return handle(request, "GET");
return await handle(request, "GET");
}
export async function HEAD(request: Request) {
return handle(request, "HEAD");
return await handle(request, "HEAD");
}
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockSignOnlyOfficeConfig = vi.fn();
vi.mock("@/lib/api-utils", () => ({
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
message,
status,
details,
})),
}));
vi.mock("@/lib/onlyoffice/rust-adapter", () => ({
signOnlyOfficeConfig: mockSignOnlyOfficeConfig,
}));
describe("/api/onlyoffice/sign route", () => {
beforeEach(() => {
mockSignOnlyOfficeConfig.mockReset();
});
it("delegates signing to rust adapter", async () => {
const { POST } = await import("./route");
mockSignOnlyOfficeConfig.mockResolvedValue({
token: "token_all",
documentToken: "token_doc",
editorConfigToken: "token_editor",
});
const response = await POST(
new Request("http://127.0.0.1:3001/api/onlyoffice/sign", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
config: {
document: { title: "A" },
editorConfig: { mode: "edit" },
},
}),
}),
);
const payload = await response.json();
expect(payload).toEqual({
token: "token_all",
documentToken: "token_doc",
editorConfigToken: "token_editor",
});
expect(mockSignOnlyOfficeConfig).toHaveBeenCalledTimes(1);
});
});
@@ -1,52 +1,26 @@
import { NextResponse } from "next/server";
import crypto from "crypto";
import { signOnlyOfficeConfig } from "@/lib/onlyoffice/rust-adapter";
export const dynamic = "force-dynamic";
const base64Url = (input: Buffer | string) =>
Buffer.from(input)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
const signHs256 = (payload: unknown, secret: string) => {
const header = { alg: "HS256", typ: "JWT" };
const headerPart = base64Url(JSON.stringify(header));
const payloadPart = base64Url(JSON.stringify(payload));
const signingInput = `${headerPart}.${payloadPart}`;
const signature = crypto.createHmac("sha256", secret).update(signingInput).digest();
return `${signingInput}.${base64Url(signature)}`;
};
const normalizeSecret = (raw: string) => {
const trimmed = String(raw || "").trim();
if (!trimmed) return "";
// 兼容用户把 .env 的值写成 "xxx" / 'xxx'
if (
(trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length >= 2) ||
(trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)
) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
};
export async function POST(request: Request) {
const secret = normalizeSecret(process.env.ONLYOFFICE_JWT_SECRET || "");
const body = (await request.json().catch(() => null)) as { config?: any } | null;
if (!body?.config) {
return NextResponse.json({ error: "缺少 config" }, { status: 400 });
}
if (!secret) {
// 兼容:如果 ONLYOFFICE 没开启 JWT,可以不需要 token。
return NextResponse.json({ token: null, documentToken: null, editorConfigToken: null });
}
try {
const body = (await request.json().catch(() => null)) as { config?: Record<string, unknown> } | null;
if (!body?.config) {
return NextResponse.json({ error: "缺少 config" }, { status: 400 });
}
const config = body.config;
const token = signHs256(config, secret);
const documentToken = config?.document ? signHs256(config.document, secret) : null;
const editorConfigToken = config?.editorConfig ? signHs256(config.editorConfig, secret) : null;
const result = await signOnlyOfficeConfig({
request,
config: body.config,
secret: process.env.ONLYOFFICE_JWT_SECRET || "",
});
return NextResponse.json({ token, documentToken, editorConfigToken });
return NextResponse.json(result);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "OnlyOffice 签名失败" },
{ status: 500 },
);
}
}
@@ -1,84 +1,11 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { HttpError } from "@/lib/auth/authContext";
import type {
DocumentSearchFilters,
DocumentSearchRequest,
DocumentSearchResponse,
DocumentSearchResult,
DocumentSearchTimeRange,
} from "@/types/search";
import { buildSnippet } from "@/lib/search/snippet";
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
import { executeDocumentSearchQuery } from "@/lib/search/search-query-adapter";
import type { DocumentSearchRequest } from "@/types/search";
const MAX_LIMIT = 50;
const DEFAULT_FILTERS: DocumentSearchFilters = {
titleOnly: false,
exact: false,
onlyCurrentPage: false,
includeOcr: false,
timeRange: "any",
timeField: "updated",
};
const TIME_RANGE_TO_MS: Record<DocumentSearchTimeRange, number | null> = {
any: null,
"7d": 1000 * 60 * 60 * 24 * 7,
"30d": 1000 * 60 * 60 * 24 * 30,
};
const TIME_FIELD_COLUMN = {
updated: "updated_at",
created: "created_at",
} as const;
type RangeIso = { fromIso: string | null; toIso: string | null };
function parseDateToIso(value: string | undefined): string | null {
const raw = String(value ?? "").trim();
if (!raw) return null;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return null;
return d.toISOString();
}
function resolveCustomRangeIso(filters: DocumentSearchFilters): RangeIso {
const fromIso = parseDateToIso(filters.customRange?.from);
const toIso = parseDateToIso(filters.customRange?.to);
return { fromIso, toIso };
}
type MindmapNode = { data?: { text?: unknown }; children?: unknown[] };
function extractTextFromMindmapData(data: unknown, maxChars = 60000): string {
const out: string[] = [];
const pushText = (value: unknown) => {
const s = typeof value === "string" ? value : null;
if (!s) return;
const trimmed = s.replace(/\s+/g, " ").trim();
if (!trimmed) return;
out.push(trimmed);
};
const walk = (node: unknown) => {
if (out.join("\n").length >= maxChars) return;
if (!node || typeof node !== "object") return;
const n = node as MindmapNode;
pushText(n.data?.text);
if (Array.isArray(n.children)) {
for (const c of n.children) {
walk(c);
if (out.join("\n").length >= maxChars) return;
}
}
};
walk(data);
const joined = out.join("\n").trim();
return joined.length > maxChars ? `${joined.slice(0, maxChars)}` : joined;
}
export const runtime = "nodejs";
export async function POST(request: Request) {
try {
@@ -86,337 +13,23 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
const { auth, client } = await getAuthedConvexClient();
const payload = (await request.json()) as DocumentSearchRequest;
const workspaceId = payload.workspaceId;
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const filters: DocumentSearchFilters = {
...DEFAULT_FILTERS,
...payload.filters,
};
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
const normalizedQuery = payload.query?.trim() ?? "";
const normalizedLower = normalizedQuery.toLowerCase();
const docs = await client.query(api.documents.listSearchDataByWorkspace, { workspaceId });
const docMap = new Map(docs.map((d) => [d.id, d]));
const recentRows = await client.query(api.recents.listByWorkspace, {
userId: auth.userId,
workspaceId,
limit: 10,
});
const recent: DocumentSearchResult[] = recentRows
.map((r) => docMap.get(r.document_id))
.filter((row): row is (typeof docs)[number] => Boolean(row))
.map((row) => ({
id: row.id,
title: row.title ?? "无标题",
snippet: "",
updatedAt: row.updated_at ?? null,
createdAt: row.created_at ?? null,
matchField: "recent",
hasOcr: false,
publicPath: `/documents/${row.id}`,
score: 0,
}));
if (!normalizedQuery) {
const response: DocumentSearchResponse = { results: [], recent };
return NextResponse.json(response);
}
const timeRangeMs = TIME_RANGE_TO_MS[filters.timeRange];
const timeField = TIME_FIELD_COLUMN[filters.timeField];
const boundaryIso =
typeof timeRangeMs === "number"
? new Date(Date.now() - timeRangeMs).toISOString()
: null;
const { fromIso, toIso } = resolveCustomRangeIso(filters);
const eligibleDocs = [...docs]
.sort((a, b) => {
const ta = a.updated_at ?? a.created_at ?? "";
const tb = b.updated_at ?? b.created_at ?? "";
return tb.localeCompare(ta);
})
.filter((row) => {
if (filters.onlyCurrentPage && payload.documentId) {
if (row.id !== payload.documentId) return false;
}
if (boundaryIso) {
const ts = (row as any)?.[timeField] ?? null;
if (!ts || typeof ts !== "string") return false;
if (ts < boundaryIso) return false;
}
if (fromIso || toIso) {
const ts = (row as any)?.[timeField] ?? null;
if (!ts || typeof ts !== "string") return false;
if (fromIso && ts < fromIso) return false;
if (toIso && ts > toIso) return false;
}
return true;
});
const eligibleDocIds = new Set(eligibleDocs.map((d) => d.id));
type MatchInfo = {
score: number;
matchField: "title" | "content";
snippet: string;
hasOcr: boolean;
};
const matches = new Map<string, MatchInfo>();
const upsertMatch = (docId: string, patch: Partial<MatchInfo>) => {
const prev = matches.get(docId);
if (!prev) {
const score = typeof patch.score === "number" ? patch.score : 0;
const matchField = patch.matchField ?? "content";
const snippet = patch.snippet ?? "暂无正文内容";
const hasOcr = Boolean(patch.hasOcr);
matches.set(docId, { score, matchField, snippet, hasOcr });
return;
}
const next: MatchInfo = {
score: typeof patch.score === "number" ? Math.max(prev.score, patch.score) : prev.score,
matchField: patch.matchField ?? prev.matchField,
snippet: patch.snippet ?? prev.snippet,
hasOcr: prev.hasOcr || Boolean(patch.hasOcr),
};
// 若现有是标题匹配,不用较低分覆盖文案。
if (prev.matchField === "title" && next.matchField !== "title") {
next.matchField = "title";
next.snippet = prev.snippet;
}
// 若新分数更高,则用新片段(除标题外)
if (typeof patch.score === "number" && patch.score > prev.score && prev.matchField !== "title") {
next.snippet = patch.snippet ?? next.snippet;
next.matchField = patch.matchField ?? next.matchField;
}
matches.set(docId, next);
};
for (const row of eligibleDocs) {
const title = (row.title ?? "无标题").trim() || "无标题";
const titleLower = title.toLowerCase();
const hitTitle = filters.exact ? titleLower === normalizedLower : titleLower.includes(normalizedLower);
if (hitTitle) {
upsertMatch(row.id, {
score: 3,
matchField: "title",
snippet: buildSnippet(title, normalizedQuery),
});
}
if (filters.titleOnly) continue;
const rawText = String(row.raw_text ?? "").trim();
if (rawText) {
const hitContent = rawText.toLowerCase().includes(normalizedLower);
if (hitContent) {
upsertMatch(row.id, {
score: 2,
matchField: "content",
snippet: buildSnippet(rawText, normalizedQuery),
});
}
}
}
if (!filters.titleOnly) {
// 思维导图(默认纳入“正文”搜索)
const mindmaps = await client
.query(api.mindmaps.listByWorkspace, { workspaceId, includeDeleted: false })
.catch(() => []);
for (const m of mindmaps) {
const docId = String(m.document_id ?? "").trim();
if (!docId || !eligibleDocIds.has(docId)) continue;
if (!docMap.has(docId)) continue;
const text = extractTextFromMindmapData(m.data ?? null);
if (!text) continue;
if (!text.toLowerCase().includes(normalizedLower)) continue;
upsertMatch(docId, {
score: 1.6,
matchField: "content",
snippet: buildSnippet(`思维导图:${text}`, normalizedQuery),
});
}
// Luckysheet 在线表格(标题 + 行内容)
const [tables, tableRows] = await Promise.all([
client
.query(api.tables.listByWorkspaceForSearch, {
userId: auth.userId,
workspaceId,
includeArchived: false,
limit: 3000,
})
.catch(() => []),
client
.query(api.tables.listRowsByWorkspaceForSearch, {
userId: auth.userId,
workspaceId,
limit: 8000,
})
.catch(() => []),
]);
const tableTitleById = new Map<string, string>();
for (const t of tables) {
if (!eligibleDocIds.has(t.document_id)) continue;
tableTitleById.set(t.id, String(t.title ?? "").trim() || "未命名表格");
const hitTableTitle = String(t.title ?? "").toLowerCase().includes(normalizedLower);
if (hitTableTitle && docMap.has(t.document_id)) {
upsertMatch(t.document_id, {
score: 1.5,
matchField: "content",
snippet: buildSnippet(`表格:${t.title}`, normalizedQuery),
});
}
}
for (const r of tableRows) {
const docId = String(r.document_id ?? "").trim();
if (!docId || !eligibleDocIds.has(docId)) continue;
if (!docMap.has(docId)) continue;
const rowHash = String(r.row_hash ?? "").trim();
if (!rowHash) continue;
if (!rowHash.toLowerCase().includes(normalizedLower)) continue;
const tableTitle = tableTitleById.get(String(r.table_id ?? "").trim()) ?? "未命名表格";
upsertMatch(docId, {
score: 1.4,
matchField: "content",
snippet: buildSnippet(`表格:${tableTitle}\n${rowHash}`, normalizedQuery),
});
}
// 附件/图片:默认搜索文件名;勾选“搜索附件内容”后再纳入 OCR/解析文本。
const assets = await client
.query(api.mediaAssets.listSearchDataByWorkspace, {
userId: auth.userId,
workspaceId,
includeDeleted: false,
limit: 5000,
})
.catch(() => []);
const toEnqueueExtract: { id: string; mime: string | null; name: string | null; status: string | null; type: string | null }[] = [];
for (const a of assets) {
const docId = String(a.document_id ?? "").trim();
if (!docId || !eligibleDocIds.has(docId)) continue;
if (!docMap.has(docId)) continue;
const fileName = String(a.file_name ?? "").trim();
if (fileName && fileName.toLowerCase().includes(normalizedLower)) {
upsertMatch(docId, {
score: 1.2,
matchField: "content",
snippet: buildSnippet(`附件:${fileName}`, normalizedQuery),
});
}
if (!filters.includeOcr) continue;
// 说明:勾选“搜索附件内容”时,若附件尚未生成 ocr_text,则后台排队解析(避免用户长期搜不到)。
const ocrTextValue = String(a.ocr_text ?? "").trim();
if (!ocrTextValue) {
const assetType = String((a as any).asset_type ?? "").trim() || null;
const mimeType = String((a as any).mime_type ?? "").toLowerCase().trim() || null;
const ocrStatus = String((a as any).ocr_status ?? "").trim() || null;
const nameLower = String(a.file_name ?? "").toLowerCase().trim() || null;
const supported =
assetType === "file" &&
(mimeType === "application/pdf" ||
mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
mimeType === "application/vnd.openxmlformats-officedocument.presentationml.presentation" ||
mimeType === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
(nameLower ? [".pdf", ".docx", ".pptx", ".xlsx"].some((ext) => nameLower.endsWith(ext)) : false));
const busy = ocrStatus === "queued" || ocrStatus === "running";
if (supported && !busy) {
toEnqueueExtract.push({ id: a.id, mime: mimeType, name: a.file_name ?? null, status: ocrStatus, type: assetType });
}
continue;
}
if (!ocrTextValue.toLowerCase().includes(normalizedLower)) continue;
upsertMatch(docId, {
score: 1.7,
matchField: "content",
snippet: buildSnippet(`附件:${fileName || a.id}\n${ocrTextValue}`, normalizedQuery),
hasOcr: true,
});
}
if (toEnqueueExtract.length > 0) {
// 说明:限制每次搜索触发的解析数量,避免请求变慢;剩余附件可继续通过下一次搜索逐步排队。
await Promise.all(
toEnqueueExtract
.slice(0, 3)
.map((item) =>
client.mutation(api.mediaAssets.enqueueExtractText, { userId: auth.userId, id: item.id }).catch(() => null),
),
);
}
}
const results: DocumentSearchResult[] = [];
for (const [docId, match] of matches.entries()) {
const row = docMap.get(docId);
if (!row) continue;
results.push({
id: row.id,
title: row.title ?? "无标题",
snippet: match.snippet,
updatedAt: row.updated_at ?? null,
createdAt: row.created_at ?? null,
matchField: match.matchField,
hasOcr: match.hasOcr,
publicPath: `/documents/${row.id}`,
score: match.score,
});
}
results.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const ta = a.updatedAt ?? a.createdAt ?? "";
const tb = b.updatedAt ?? b.createdAt ?? "";
const byTime = tb.localeCompare(ta);
if (byTime) return byTime;
return String(a.title ?? "").localeCompare(String(b.title ?? ""));
const { response, meta } = await executeDocumentSearchQuery({
request,
payload,
});
const limitedResults = results.slice(0, limit);
const response: DocumentSearchResponse = { results: limitedResults, recent };
return NextResponse.json(response);
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: err.message }, { status: err.status });
return NextResponse.json(response, {
headers: {
"x-request-id": meta.requestId,
"x-trace-id": meta.traceId,
"x-query-name": meta.queryName,
},
});
} catch (error) {
if (error instanceof HttpError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
const message = err instanceof Error ? err.message : "搜索失败,请稍后再试";
console.error("[search/documents] failed:", err);
return NextResponse.json({ error: message }, { status: 500 });
return documentBridgeErrorResponse(error);
}
}
+21 -11
View File
@@ -1,15 +1,22 @@
import { NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import {
buildDocumentBridgeContext,
buildDocumentQueryEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import {
buildSidebarDatasetListQueryPayload,
mapSidebarDatasetListQueryResultToInitialData,
type SidebarDatasetListQueryResult,
} from "@/lib/sidebar-data";
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
export const dynamic = "force-dynamic";
@@ -19,14 +26,11 @@ export async function GET(request: Request) {
const url = new URL(request.url);
const workspaceIdParam = url.searchParams.get("workspaceId");
const {
targetWorkspaceId,
sidebarInitialData,
} = await loadSidebarDataFromConvex({
client,
const bootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
fallbackName: auth.email ?? auth.name ?? "我的空间",
requestedWorkspaceId: workspaceIdParam,
workspaceIdIfCreate: randomUUID(),
});
const targetWorkspaceId = workspaceIdParam?.trim() || bootstrap.activeWorkspaceId || null;
if (!targetWorkspaceId) {
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
@@ -41,12 +45,18 @@ export async function GET(request: Request) {
name: "sidebar.dataset.list",
payload: buildSidebarDatasetListQueryPayload(targetWorkspaceId),
});
if (!sidebarInitialData) {
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
}
const plan = await resolveRustBridgeQueryPlan({
context: bridgeContext,
envelope,
});
const sidebarDataset = await executeRustBridgeQueryTransport<SidebarDatasetListQueryResult>({
client,
plan,
});
const result = mapSidebarDatasetListQueryResultToInitialData(sidebarDataset);
return NextResponse.json({
...sidebarInitialData,
...result,
meta: {
requestId: bridgeContext.requestId,
traceId: bridgeContext.traceId,
@@ -3,10 +3,17 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { OnlyOfficeAiAgentPanel } from "@/components/onlyoffice/OnlyOfficeAiAgentPanel";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { useConvex } from "convex/react";
import { api } from "@/lib/convex/api";
import {
buildOnlyOfficeCallbackUrl,
buildOnlyOfficeForcesaveUrl,
buildOnlyOfficeOpenFileId,
docTypeFromExt,
hashOnlyOfficeKey,
resolveOnlyOfficeDocumentUrl,
} from "@/lib/onlyoffice/client-session";
type EditorMode = "view" | "edit";
@@ -146,25 +153,6 @@ const loadScriptCandidates = async (candidates: string[]) => {
throw lastError ?? new Error("加载 ONLYOFFICE 脚本失败");
};
const hashKey = (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();
};
const base64UrlEncodeUtf8 = (input: string) => {
// 说明:浏览器端 base64urlUTF-8)编码,用于把带 `?token=...` 的 URL 藏到 `u=...` 里。
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, "_");
};
const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUrlDesktop?: string | null) => {
// 说明:当我们用 `/onlyoffice-server` 反代 ONLYOFFICE 时,编辑器运行期仍可能发起指向
// `http://127.0.0.1:8081/cache/...` 的绝对请求(来自 ONLYOFFICE 内部),导致浏览器跨域被 CORS 拦截。
@@ -348,19 +336,6 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr
}
};
const docTypeFromExt = (ext: string) => {
const word = ["doc", "docx", "odt", "rtf"];
const slide = ["ppt", "pptx", "odp"];
const sheet = ["xls", "xlsx", "ods", "csv"];
const pdf = ["pdf"];
// 说明:ONLYOFFICE 文档类型使用 word/cell/slide/pdf(旧的 text/spreadsheet/presentation 已逐步弃用)
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";
};
const waitForDocEditorReady = async (timeoutMs = 120_000) => {
const start = Date.now();
@@ -689,88 +664,18 @@ export default function OnlyOfficePage() {
const sid = String(assetStorageId || "").trim();
// 说明:ONLYOFFICE 的 document.key 允许字符集为 [0-9a-zA-Z_.=-],不包含 ":" 等字符;
// 否则可能报错(例如 errorCode=-23)。这里用 hash 生成安全 key,同时在 storage_id 变化时自动失效。
return sid ? `${assetId}_${hashKey(sid)}` : assetId;
return sid ? `${assetId}_${hashOnlyOfficeKey(sid)}` : assetId;
}
return hashKey(`${effectiveFileUrl}-${fileName}`);
return hashOnlyOfficeKey(`${effectiveFileUrl}-${fileName}`);
}, [assetId, assetStorageId, effectiveFileUrl, fileName]);
const resolvedFileUrl = useMemo(() => {
if (!effectiveFileUrl) return "";
// 说明:OnlyOffice 的 document.url 由“文档服务器”拉取(不是浏览器)。
// 如果我们已经配置了专用回源(storageHostOverride),就不要再把 URL 改写成公网,
// 否则会把 http://host.docker.internal:18000 错误改成 https://host.docker.internal:18000
// 导致 ONLYOFFICE 报 “下载失败(EPROTO wrong version number)”。
const isConvexStorageUrl = (() => {
// 说明:Convex Files 的直链通常形如:
// - http://127.0.0.1:3210/api/storage/<id>
// - https://<convex-host>/api/storage/<id>
// 这类 URL 不应套用 Supabase 的 rewriteToPublicOrigin,否则会被误改写到 supabaseInternalUrl(例如 18000),
// 进而导致 ONLYOFFICE 报 “下载失败(-4)”。
try {
const u = new URL(effectiveFileUrl);
return u.pathname.startsWith("/api/storage/");
} catch {
return false;
}
})();
let base =
storageHostOverride || runtimeConfig.useConvex || isConvexStorageUrl
? effectiveFileUrl
: rewriteToPublicOrigin(effectiveFileUrl, runtimeConfig.supabaseUrl);
try {
// 关键兜底:即使外部传进来的 fileUrl 是 Supabase signedUrl(含 token=...),也要避免 token 参数
// 出现在 document.url 上,否则 ONLYOFFICE 会把它当作 JWT 去解析并报
// “文档安全令牌格式不正确 / invalid compact jws / invalid signature”。
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";
// 关键修复:当 fileUrl 已经是 /api/onlyoffice/proxy,但来源是外网 https(例如 frp/隧道域名)时,
// ONLYOFFICE 容器会去请求该 https 地址并因证书/自签失败,从而报“下载失败(-4)”。
// 因此这里强制把 proxy 的 origin 改写成我们显式配置的回源(通常是容器可达的 http://172.31.224.1:3000)。
if (alreadyProxy && proxyOrigin) {
const po = new URL(proxyOrigin);
raw.protocol = po.protocol;
raw.host = po.host;
base = raw.toString();
}
// 关键兜底:OnlyOffice 的 document.url 由“文档服务器容器”去拉取。
// 如果这里是 localhost/127.0.0.1(对容器而言指向它自己),会导致“下载失败(-4)”。
// 因此在配置了 proxyOrigin 时,强制走 /api/onlyoffice/proxy 把回源留给 Next 服务端完成。
if (!alreadyProxy && proxyOrigin && isLocalHost) {
const proxyBase = proxyOrigin || window.location.origin;
const proxy = new URL("/api/onlyoffice/proxy", proxyBase);
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
base = proxy.toString();
alreadyProxy = true;
}
if (!alreadyProxy && raw.searchParams.has("token")) {
const proxyBase = proxyOrigin || window.location.origin;
const proxy = new URL("/api/onlyoffice/proxy", proxyBase);
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
base = proxy.toString();
alreadyProxy = true;
}
const u = new URL(base);
if (!storageHostOverride || alreadyProxy) return u.toString();
// 兼容两种写法:hostname 或完整 originhttps://xxx
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;
}
return resolveOnlyOfficeDocumentUrl({
effectiveFileUrl,
proxyOrigin,
storageHostOverride,
runtimeSupabaseUrl: runtimeConfig.supabaseUrl || "",
useConvex: Boolean(runtimeConfig.useConvex),
});
}, [effectiveFileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl, runtimeConfig.useConvex]);
useEffect(() => {
@@ -933,11 +838,13 @@ export default function OnlyOfficePage() {
// 说明:ONLYOFFICE 文档服务器会通过 callbackUrl 回传保存事件,
// 我们在 /api/onlyoffice/callback 中接收并回写到 Supabase Storage。
callbackUrl: (() => {
const base = callbackOrigin || proxyOrigin || window.location.origin;
const cb = new URL("/api/onlyoffice/callback", base);
if (assetId) cb.searchParams.set("assetId", assetId);
if (authedUserId) cb.searchParams.set("userId", authedUserId);
return cb.toString();
return buildOnlyOfficeCallbackUrl({
callbackOrigin,
proxyOrigin,
windowOrigin: window.location.origin,
assetId,
userId: authedUserId,
});
})(),
customization: {
feedback: { visible: false },
@@ -1068,10 +975,14 @@ export default function OnlyOfficePage() {
if (forceSaveState.busy) return;
setForceSaveState({ busy: true, message: "正在触发同步保存…", ok: null });
try {
const url = new URL("/api/onlyoffice/forcesave", window.location.origin);
url.searchParams.set("assetId", assetId);
url.searchParams.set("key", docKey);
const r = await fetch(url.toString(), { method: "POST" });
const r = await fetch(
buildOnlyOfficeForcesaveUrl({
windowOrigin: window.location.origin,
assetId,
key: docKey,
}),
{ method: "POST" },
);
if (!r.ok) {
const payload = (await r.json().catch(() => null)) as { error?: string } | null;
throw new Error(payload?.error ?? "触发失败");
@@ -1129,7 +1040,12 @@ export default function OnlyOfficePage() {
)}
<OnlyOfficeAiAgentPanel
openFile={{
id: assetId || `onlyoffice_${docKey || hashKey(`${resolvedFileUrl}-${fileName}`)}`,
id: buildOnlyOfficeOpenFileId({
assetId,
docKey,
resolvedFileUrl,
fileName,
}),
title: fileName,
fileUrl: resolvedFileUrl,
mimeType: null,
@@ -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 = `![${alt}](${url})`;
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 runtimeTS 仅保留 route transport 壳。",
},
image_read: {
rustToolsetId: "toolset.media_read",
rustToolName: "image_read",
status: "rust",
note: "图片/附件 OCR 结果归一化已切到 Rust runtimeTS 只负责最小 transport 取数。",
},
slash_run: {
rustToolsetId: "toolset.slash_write",
rustToolName: "slash_run",
status: "rust",
note: "斜杠命令解析已切到 Rust runtimeTS 仅保留创建/重命名 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);
}
+87 -9
View File
@@ -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);
}
}
+241 -17
View File
@@ -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",
+16 -1
View File
@@ -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,
},
};
}