feat: 提交 task-045 至 task-058 收口产物

- 收口 rust final closure checklist,推进页面/块系统/Mindmap/CLI/AI tools 到最终 cutover 状态

- 按 ai-frontend-simplification-plan-v1 接入 Hermes bridge,合并 AI 面板并清理旧前端编排残留

- 补充 harness 任务与进度记录,加入 CLI smoke 夹具/脚本,并修正文档页 bridge SSR 自请求回退逻辑
This commit is contained in:
lix-2026
2026-04-16 15:24:37 +08:00
parent 98db79b301
commit 2ff10fa86c
47 changed files with 6494 additions and 2674 deletions
@@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
@@ -18,7 +17,7 @@ import {
resolveRustBridgeCommandPlan,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import { extractBlocksFromContent } from "@/lib/document-content";
type BlockLike = {
id: string;
@@ -91,20 +90,6 @@ function replaceBlockInTree(blocks: BlockLike[], blockId: string, nextBlock: unk
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 });
}
@@ -183,10 +168,6 @@ export async function executeBlockPatchBridgeCommand(input: {
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({
@@ -220,9 +201,6 @@ export async function executeBlockMoveBridgeCommand(input: {
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",
@@ -240,8 +218,6 @@ export async function executeBlockMoveBridgeCommand(input: {
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({
@@ -276,18 +252,14 @@ export async function executeBlockEmbedBridgeCommand(input: {
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)
? extractBlocksFromContent(target.content).findIndex((block) => String((block as BlockLike).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 targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
void targetBlocks;
void anchorIndex;
const context = await buildBridgeContext(input.request, null);
const envelope = buildDocumentCommandEnvelope({
name: "blocks.embed",
@@ -305,10 +277,6 @@ export async function executeBlockEmbedBridgeCommand(input: {
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({
+100 -17
View File
@@ -12,16 +12,74 @@ type DocumentMetaResponse<T> = {
meta: BridgeMeta;
};
function getServerRequestOrigin(headerList: Headers): string {
const forwardedProto = headerList.get("x-forwarded-proto")?.split(",")[0]?.trim();
const forwardedHost = headerList.get("x-forwarded-host")?.split(",")[0]?.trim();
const host = forwardedHost || headerList.get("host");
function getHeaderFirstValue(value: string | null): string {
return String(value || "")
.split(",")[0]
.trim();
}
function parseHostParts(host: string): { hostname: string; port: string } {
try {
const parsed = new URL(`http://${host}`);
return {
hostname: parsed.hostname,
port: parsed.port,
};
} catch {
return {
hostname: host,
port: "",
};
}
}
function isLocalHostname(hostname: string): boolean {
const normalized = hostname.replace(/^\[(.*)\]$/, "$1").trim().toLowerCase();
return (
normalized === "localhost" ||
normalized === "127.0.0.1" ||
normalized === "::1" ||
normalized === "0.0.0.0" ||
normalized === "::"
);
}
function buildServerRequestOriginCandidates(headerList: Headers): string[] {
const forwardedProto = getHeaderFirstValue(headerList.get("x-forwarded-proto"));
const forwardedHost = getHeaderFirstValue(headerList.get("x-forwarded-host"));
const host = forwardedHost || getHeaderFirstValue(headerList.get("host"));
if (!host) {
throw new Error("缺少 host 头,无法构造 bridge 请求地址");
}
return `${forwardedProto || "http"}://${host}`;
const { hostname, port } = parseHostParts(host);
const candidates = new Set<string>();
const addCandidate = (proto: string, candidateHost: string) => {
const normalizedProto = String(proto || "http").trim().toLowerCase() || "http";
const normalizedHost = String(candidateHost || "").trim();
if (!normalizedHost) return;
candidates.add(`${normalizedProto}://${normalizedHost}`);
};
// 说明:优先保留浏览器实际请求的 origin,用于正常的同源自调用。
addCandidate(forwardedProto || "http", host);
// 说明:某些反代会把 x-forwarded-proto 设成 https,但本机 dev server 实际只监听 http。
// 这里补一个 http 候选,避免 SSR 自请求被错误协议直接打挂。
if ((forwardedProto || "").trim().toLowerCase() === "https") {
addCandidate("http", host);
}
// 说明:localhost / 0.0.0.0 / ::1 这类本机地址在 Node 侧自调用时最容易踩解析差异,
// 统一补 127.0.0.1 与 localhost 两个稳定候选,避免 fetch 因回环地址选择失败。
if (isLocalHostname(hostname)) {
const portSuffix = port ? `:${port}` : "";
addCandidate("http", `127.0.0.1${portSuffix}`);
addCandidate("http", `localhost${portSuffix}`);
}
return [...candidates];
}
function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
@@ -31,19 +89,21 @@ function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
}
}
async function fetchDocumentMetaResponse(url: URL, headersToSend: Headers): Promise<Response> {
return await fetch(url, {
method: "GET",
headers: headersToSend,
cache: "no-store",
});
}
export async function fetchDocumentMetaViaBridge<T>(input: {
documentId: string;
workspaceId?: string | null;
}): Promise<DocumentMetaResponse<T> | null> {
const headerList = await headers();
const requestHeaders = new Headers();
const origin = getServerRequestOrigin(headerList);
const url = new URL("/api/documents/meta", origin);
url.searchParams.set("documentId", input.documentId);
if (input.workspaceId?.trim()) {
url.searchParams.set("workspaceId", input.workspaceId.trim());
}
const originCandidates = buildServerRequestOriginCandidates(headerList);
copyHeaderIfPresent(requestHeaders, headerList, "cookie");
copyHeaderIfPresent(requestHeaders, headerList, "authorization");
@@ -54,11 +114,34 @@ export async function fetchDocumentMetaViaBridge<T>(input: {
copyHeaderIfPresent(requestHeaders, headerList, "x-source-client");
copyHeaderIfPresent(requestHeaders, headerList, "user-agent");
const response = await fetch(url, {
method: "GET",
headers: requestHeaders,
cache: "no-store",
});
let response: Response | null = null;
let lastError: unknown = null;
const errorMessages: string[] = [];
for (const origin of originCandidates) {
const url = new URL("/api/documents/meta", origin);
url.searchParams.set("documentId", input.documentId);
if (input.workspaceId?.trim()) {
url.searchParams.set("workspaceId", input.workspaceId.trim());
}
try {
response = await fetchDocumentMetaResponse(url, requestHeaders);
break;
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
errorMessages.push(`${url.toString()} => ${message}`);
}
}
if (!response) {
const message =
errorMessages.length > 0
? `bridge 自请求失败:${errorMessages.join(" | ")}`
: "bridge 自请求失败:未生成可用的请求地址";
throw new Error(message, { cause: lastError instanceof Error ? lastError : undefined });
}
if (response.status === 404) {
return null;
@@ -123,6 +123,9 @@ const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
"documents.restore": "documents:restore",
"documents.duplicate": "documents:duplicateWithMindmaps",
"documents.copy_tree": "documents:copyTree",
"documents.template": "documents:setTemplate",
"documents.emptyTrashByWorkspace": "documents:emptyTrashByWorkspace",
"documents.purge": "documents:purge",
"blocks.patch": "documents:updateContent",
"blocks.move": "documents:updateContent",
"blocks.embed": "documents:updateContent",
@@ -130,6 +133,10 @@ const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
"documents.stats.update": "documents:updateStats",
"documents.options.update": "documents:updateOptions",
"documents.save": "documents:updateContent",
"mindmaps.delete": "mindmaps:softDelete",
"mindmaps.restore": "mindmaps:restore",
"mindmaps.purge": "mindmaps:purge",
"mindmaps.emptyTrashByWorkspace": "mindmaps:emptyTrashByWorkspace",
"media.assets.replace_storage": "mediaAssets:replaceStorageFromUpload",
} as const satisfies Record<string, string>;
@@ -20,6 +20,8 @@ import {
executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import type { Json } from "@/types/supabase";
export type DocumentCreatePayload = {
@@ -64,6 +66,19 @@ export type DocumentCopyTreePayload = {
}>;
};
export type DocumentTemplatePayload = {
documentId: string;
isTemplate: boolean;
};
export type DocumentEmptyTrashPayload = {
workspaceId: string;
};
export type DocumentPurgePayload = {
documentId: string;
};
export type PageCommandExecutionResult<TResult> = {
requestId: string;
traceId: string;
@@ -103,52 +118,37 @@ async function buildRuntimeContext(request: Request, workspaceId: string | null)
});
}
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,
});
let result;
try {
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
});
result = await executeRustBridgeMutationTransport<TResult>({
client,
plan,
});
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
client,
});
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
client,
});
} catch (error) {
await recordBridgeCommandFailureArtifacts({
context: input.context,
envelope: input.envelope,
client,
error,
});
throw error;
}
return {
requestId: input.context.requestId,
@@ -200,7 +200,7 @@ export async function executeDocumentCreateChildBridgeCommand(request: Request):
const pageId = safeRandomId();
const context = await buildRuntimeContext(request, workspaceId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.createChild",
name: "documents.create",
payload: {
documentId: pageId,
workspaceId,
@@ -216,40 +216,23 @@ export async function executeDocumentCreateChildBridgeCommand(request: Request):
},
});
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;
}
const created = await executePageLifecycleBridgeCommand<DocumentCreatePayload, {
id: string;
title?: string | null;
}>({
context,
envelope,
client,
});
return NextResponse.json({
pageId: created.id,
title: created.title ?? resolvedTitle,
pageId: created.result.id,
title: created.result.title ?? resolvedTitle,
meta: {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
requestId: created.requestId,
traceId: created.traceId,
commandId: created.commandId,
commandName: created.commandName,
},
});
} catch (error) {
@@ -304,12 +287,23 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
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 savePayload = buildDocumentSavePayload({
documentId: normalizedTargetId,
workspaceId,
revision:
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
? targetContent.revision
: null,
content: payload,
conflictDetectionKey:
typeof targetContent.conflict_detection_key === "string"
? targetContent.conflict_detection_key
: null,
blockCount: nextBlocks.length,
});
const envelope = buildDocumentCommandEnvelope({
name: "documents.embed",
payload: {
sourceId: normalizedSourceId,
targetId: normalizedTargetId,
},
name: "documents.save",
payload: savePayload,
context,
target: {
workspaceId,
@@ -317,34 +311,18 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
},
});
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;
}
const result = await executeSaveBridgeCommand({
context,
envelope,
});
return NextResponse.json({
ok: true,
meta: {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
@@ -384,34 +362,22 @@ export async function executeDocumentTemplateBridgeCommand(request: Request): Pr
},
});
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;
}
const result = await executePageLifecycleBridgeCommand<DocumentTemplatePayload, {
ok?: boolean;
updated_at?: string | null;
}>({
context,
envelope,
client,
});
return NextResponse.json({
ok: true,
meta: {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
@@ -442,31 +408,23 @@ export async function executeDocumentEmptyTrashBridgeCommand(request: Request):
},
});
try {
await client.mutation(api.documents.emptyTrashByWorkspace, { workspaceId });
await recordLifecycleArtifacts({
context,
envelope,
client,
});
} catch (error) {
await recordLifecycleFailureArtifacts({
context,
envelope,
client,
error,
});
throw error;
}
const result = await executePageLifecycleBridgeCommand<DocumentEmptyTrashPayload, {
ok?: boolean;
deletedCount?: number;
}>({
context,
envelope,
client,
});
return NextResponse.json({
success: true,
removed: typeof result.result?.deletedCount === "number" ? result.result.deletedCount : 0,
meta: {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
@@ -501,31 +459,24 @@ export async function executeDocumentPurgeBridgeCommand(request: Request): Promi
},
});
try {
await client.mutation(api.documents.purge, { id: normalizedDocumentId });
await recordLifecycleArtifacts({
context,
envelope,
client,
});
} catch (error) {
await recordLifecycleFailureArtifacts({
context,
envelope,
client,
error,
});
throw error;
}
const result = await executePageLifecycleBridgeCommand<DocumentPurgePayload, {
ok?: boolean;
purged?: boolean;
purged_at?: string | null;
}>({
context,
envelope,
client,
});
return NextResponse.json({
success: true,
purged: result.result?.purged ?? true,
meta: {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
@@ -606,6 +606,19 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
newId: assertStringArg(input.plan.argsJson, "newId"),
title: readOptionalStringArg(input.plan.argsJson, "title"),
});
case "documents:setTemplate":
return mutation(api.documents.setTemplate, {
id: assertStringArg(input.plan.argsJson, "id"),
isTemplate: Boolean(input.plan.argsJson.isTemplate),
});
case "documents:emptyTrashByWorkspace":
return mutation(api.documents.emptyTrashByWorkspace, {
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
});
case "documents:purge":
return mutation(api.documents.purge, {
id: assertStringArg(input.plan.argsJson, "id"),
});
case "documents:updateTitle":
return mutation(api.documents.updateTitle, {
id: assertStringArg(input.plan.argsJson, "id"),
@@ -628,6 +641,25 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
? input.plan.argsJson.createOnly
: undefined,
});
case "mindmaps:softDelete":
return mutation(api.mindmaps.softDelete, {
docId: assertStringArg(input.plan.argsJson, "docId"),
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
});
case "mindmaps:restore":
return mutation(api.mindmaps.restore, {
docId: assertStringArg(input.plan.argsJson, "docId"),
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
});
case "mindmaps:purge":
return mutation(api.mindmaps.purge, {
docId: assertStringArg(input.plan.argsJson, "docId"),
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
});
case "mindmaps:emptyTrashByWorkspace":
return mutation(api.mindmaps.emptyTrashByWorkspace, {
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
});
default:
throw new DocumentBridgeError(
`未注册的 Rust mutation transport: ${input.plan.functionName}`,