feat(kernel): finish phase3 bridge cutover and phase4 tree projection

- add generic mnote-web query transport and bridge routes for workspace/request/trace

- route sidebar compat traffic through mnote-web and tighten fixture fallback to dev/test

- unify sidebar/page tree/file tree/picker consumers on page_tree projection

- sync phase3/phase4 checklist, breakdown docs, and harness progress state
This commit is contained in:
lix-2026
2026-04-17 00:25:28 +08:00
parent b1d5d97142
commit ddf285b82d
27 changed files with 2650 additions and 340 deletions
+2 -1
View File
@@ -15,6 +15,8 @@ export interface DocumentNode extends DocumentRecord {
children: DocumentNode[];
}
// 过渡兼容 helper:保留给旧单测和非树域场景使用。
// Sidebar / 页面树 / 文件树 / move-embed picker 主路径已统一改读 projection family。
export function buildDocumentTree(records: DocumentRecord[]): DocumentNode[] {
// 防御性处理:当上游数据意外包含重复 id 时,避免生成重复节点导致渲染 key 冲突。
// 以“最后一次出现”为准(与原先 nodeMap.set 的覆盖行为保持一致)。
@@ -33,7 +35,6 @@ export function buildDocumentTree(records: DocumentRecord[]): DocumentNode[] {
uniqueRecords.reverse();
if (duplicatedIds.size > 0 && process.env.NODE_ENV !== "production") {
// eslint-disable-next-line no-console
console.warn(
`[buildDocumentTree] 检测到重复文档 id(已自动去重):${Array.from(duplicatedIds).slice(0, 10).join(", ")}${duplicatedIds.size > 10 ? "…" : ""}`,
);
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildPageTreeProjectionItems } from "@/lib/tree-projection";
import { buildVisibleRows } from "./rows";
import { parseFileTreeRowId } from "./types";
@@ -33,7 +34,7 @@ describe("buildVisibleRows", () => {
};
const rows = buildVisibleRows({
nodes: [a],
pageRows: buildPageTreeProjectionItems([a]),
expanded: new Set(["a"]),
assetsByDoc: {
a: [
@@ -108,7 +109,7 @@ describe("buildVisibleRows", () => {
};
const rows = buildVisibleRows({
nodes: [a, a],
pageRows: buildPageTreeProjectionItems([a, a]),
expanded: new Set(["a"]),
assetsByDoc: {},
});
+31 -38
View File
@@ -1,55 +1,52 @@
"use client";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
import type { MediaAsset } from "@/types/media";
import type { FileTreeRow } from "./types";
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
export function buildVisibleRows({
nodes,
pageRows,
expanded,
assetsByDoc,
assetChildrenByAssetId,
expandedAssetFolderIds,
}: {
nodes: SidebarTreeNode[];
// 只消费统一 page_tree projection;结构真相不再由文件树自行定义。
pageRows: PageTreeProjectionItem[];
expanded: Set<string>;
assetsByDoc: Record<string, MediaAsset[]>;
assetChildrenByAssetId?: Record<string, MediaAsset[]>;
expandedAssetFolderIds?: Set<string>;
}): FileTreeRow[] {
const rows: FileTreeRow[] = [];
const visitedDocIds = new Set<string>();
const walk = (node: SidebarTreeNode, depth: number) => {
// 防御性处理:上游数据异常时(例如同一 docId 在树中重复出现),避免生成重复 rowId 导致 React key 冲突。
// 同时也能避免潜在的“循环引用/重复引用”导致的递归问题。
if (visitedDocIds.has(node.id)) return;
visitedDocIds.add(node.id);
const assets = assetsByDoc[node.id] ?? [];
const hasChildren = node.children.length > 0 || assets.length > 0;
const isExpanded = expanded.has(node.id);
pageRows.forEach((item) => {
const assets = assetsByDoc[item.nodeId] ?? [];
const hasChildren = item.childCount > 0 || assets.length > 0;
const isExpanded = expanded.has(item.nodeId);
rows.push({
kind: "doc",
rowId: makeDocRowId(node.id),
depth,
docId: node.id,
parentDocId: node.parent_id,
node,
rowId: makeDocRowId(item.nodeId),
depth: item.depth,
docId: item.nodeId,
parentDocId: item.parentNodeId,
node: item.node,
hasChildren,
isExpanded,
});
if (!isExpanded) return;
if (!isExpanded) {
return;
}
rows.push({
kind: "index",
rowId: makeIndexRowId(node.id),
depth: depth + 1,
docId: node.id,
parentDocId: node.id,
node,
rowId: makeIndexRowId(item.nodeId),
depth: item.depth + 1,
docId: item.nodeId,
parentDocId: item.nodeId,
node: item.node,
});
assets.forEach((asset) => {
@@ -60,9 +57,9 @@ export function buildVisibleRows({
rows.push({
kind: "asset-folder",
rowId: makeAssetFolderRowId(asset.id),
depth: depth + 1,
docId: node.id,
parentDocId: node.id,
depth: item.depth + 1,
docId: item.nodeId,
parentDocId: item.nodeId,
asset,
hasChildren,
isExpanded,
@@ -72,9 +69,9 @@ export function buildVisibleRows({
rows.push({
kind: "asset",
rowId: makeAssetRowId(child.id),
depth: depth + 2,
docId: node.id,
parentDocId: node.id,
depth: item.depth + 2,
docId: item.nodeId,
parentDocId: item.nodeId,
asset: child,
});
});
@@ -85,16 +82,12 @@ export function buildVisibleRows({
rows.push({
kind: "asset",
rowId: makeAssetRowId(asset.id),
depth: depth + 1,
docId: node.id,
parentDocId: node.id,
depth: item.depth + 1,
docId: item.nodeId,
parentDocId: item.nodeId,
asset,
});
});
node.children.forEach((child) => walk(child, depth + 1));
};
nodes.forEach((node) => walk(node, 0));
});
return rows;
}
+113
View File
@@ -0,0 +1,113 @@
import { headers } from "next/headers";
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
import { isDevAuthEnabled } from "@/lib/auth/devUser";
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
type MnoteWebSidebarCompatResponse = {
ok?: boolean;
requestId?: string;
traceId?: string;
workspaceId?: string;
result?: SidebarDatasetListQueryResult;
};
const MNOTE_WEB_BASE_URL = (
process.env.MNOTE_WEB_BASE_URL ??
process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL ??
""
).trim();
function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
const value = source.get(name);
if (value) {
target.set(name, value);
}
}
async function buildForwardHeaders(request?: Request): Promise<Headers> {
const source = request?.headers ?? new Headers(await headers());
const forwarded = new Headers();
copyHeaderIfPresent(forwarded, source, "cookie");
copyHeaderIfPresent(forwarded, source, "authorization");
copyHeaderIfPresent(forwarded, source, "x-request-id");
copyHeaderIfPresent(forwarded, source, "x-trace-id");
copyHeaderIfPresent(forwarded, source, "x-session-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-workspace-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-channel");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-client");
copyHeaderIfPresent(forwarded, source, "user-agent");
if (!forwarded.has("authorization") && !isDevAuthEnabled()) {
const token = await convexAuthNextjsToken();
if (token?.trim()) {
forwarded.set("authorization", `Bearer ${token.trim()}`);
}
}
if (!forwarded.has("x-mnote-source-channel")) {
forwarded.set("x-mnote-source-channel", request ? "next_route" : "next_server_component");
}
if (!forwarded.has("x-mnote-source-client")) {
forwarded.set("x-mnote-source-client", "wolai-frontend");
}
return forwarded;
}
export function getMnoteWebBaseUrl(): string | null {
return MNOTE_WEB_BASE_URL || null;
}
export async function fetchSidebarDatasetFromMnoteWeb(input: {
workspaceId: string;
request?: Request;
}): Promise<{
dataset: SidebarDatasetListQueryResult;
meta: {
requestId: string | null;
traceId: string | null;
workspaceId: string;
};
}> {
const baseUrl = getMnoteWebBaseUrl();
if (!baseUrl) {
throw new Error("未配置 MNOTE_WEB_BASE_URL");
}
const url = new URL("/api/compat/next/sidebar", baseUrl);
url.searchParams.set("workspaceId", input.workspaceId);
const forwardedHeaders = await buildForwardHeaders(input.request);
forwardedHeaders.set("x-mnote-workspace-id", input.workspaceId);
const response = await fetch(url, {
method: "GET",
headers: forwardedHeaders,
cache: "no-store",
});
const payload = (await response
.json()
.catch(() => null)) as MnoteWebSidebarCompatResponse | null;
if (!response.ok) {
const message =
payload && typeof (payload as Record<string, unknown>).message === "string"
? String((payload as Record<string, unknown>).message)
: "mnote-web 侧边栏兼容接口请求失败";
throw new Error(message);
}
if (!payload?.result) {
throw new Error("mnote-web /api/compat/next/sidebar 未返回 result");
}
return {
dataset: payload.result,
meta: {
requestId: payload.requestId ?? null,
traceId: payload.traceId ?? null,
workspaceId: payload.workspaceId?.trim() || input.workspaceId,
},
};
}
+13 -3
View File
@@ -9,6 +9,10 @@ import {
type SidebarDatasetListQueryResult,
} from "@/lib/sidebar-data";
import type { WorkspaceSummary } from "@/lib/workspaces";
import {
fetchSidebarDatasetFromMnoteWeb,
getMnoteWebBaseUrl,
} from "@/lib/server/mnote-web";
type LoadSidebarDataFromConvexInput = {
client: ConvexHttpClient;
@@ -57,9 +61,15 @@ export async function loadSidebarDataFromConvex(
};
}
const sidebarDataset = (await input.client.query(sidebarDatasetListQuery, {
workspaceId: targetWorkspaceId,
})) as SidebarDatasetListQueryResult;
const sidebarDataset = getMnoteWebBaseUrl()
? (
await fetchSidebarDatasetFromMnoteWeb({
workspaceId: targetWorkspaceId,
})
).dataset
: ((await input.client.query(sidebarDatasetListQuery, {
workspaceId: targetWorkspaceId,
})) as SidebarDatasetListQueryResult);
const normalizedDocuments = (sidebarDataset.documents ?? []) as DocumentRecord[];
return {
+3 -5
View File
@@ -51,7 +51,7 @@ export type SidebarDatasetListQueryResult = {
active_workspace_id: string;
workspaces: WorkspaceSummary[];
documents: DocumentRecord[];
kernel_sidebar_projection?: KernelSidebarProjection | null;
kernel_sidebar_projection: KernelSidebarProjection;
trashed_documents: SidebarInitialData["trashedDocuments"];
media_assets: MediaAsset[];
trashed_media_assets: MediaAsset[];
@@ -241,16 +241,14 @@ export function buildSidebarDatasetListQueryResult(
export function mapSidebarDatasetListQueryResultToInitialData(
result: SidebarDatasetListQueryResult,
): SidebarInitialData {
const kernelSidebarProjection =
result.kernel_sidebar_projection ?? buildKernelSidebarProjection(result.documents);
return {
activeWorkspaceId: result.active_workspace_id,
workspaces: [...result.workspaces],
documents: [...result.documents],
kernelSidebarProjection,
kernelSidebarProjection: result.kernel_sidebar_projection,
kernelSidebarTree: buildSidebarTreeFromKernelProjection({
records: result.documents,
projection: kernelSidebarProjection,
projection: result.kernel_sidebar_projection,
}),
trashedDocuments: [...result.trashed_documents],
trashedMediaAssets: [...result.trashed_media_assets],
@@ -0,0 +1,162 @@
import { describe, expect, it } from "vitest";
import {
buildPageTreeProjectionItems,
buildPickerTreeItems,
filterVisiblePageTreeProjectionItems,
} from "./tree-projection";
describe("buildPageTreeProjectionItems", () => {
it("按当前树结构重建 parent/depth,而不是信任陈旧节点字段", () => {
const rows = buildPageTreeProjectionItems([
{
access_scope: "private",
id: "root",
workspace_id: "w",
title: "Root",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "",
updated_at: null,
kernel: {
nodeType: "page",
depth: 0,
position: 0,
childCount: 1,
expandedByDefault: true,
},
children: [
{
access_scope: "private",
id: "child",
workspace_id: "w",
title: "Child",
parent_id: null,
sort_order: 9,
is_starred: false,
is_template: false,
created_at: "",
updated_at: null,
kernel: {
nodeType: "page",
depth: 0,
position: 9,
childCount: 0,
expandedByDefault: true,
},
children: [],
},
],
},
]);
expect(rows.map((item) => ({
nodeId: item.nodeId,
parentNodeId: item.parentNodeId,
depth: item.depth,
}))).toEqual([
{ nodeId: "root", parentNodeId: null, depth: 0 },
{ nodeId: "child", parentNodeId: "root", depth: 1 },
]);
expect(rows[1]?.node.parent_id).toBe("root");
expect(rows[1]?.node.kernel?.depth).toBe(1);
});
});
describe("filterVisiblePageTreeProjectionItems", () => {
it("只返回当前展开路径上的可见页面", () => {
const items = buildPageTreeProjectionItems([
{
access_scope: "private",
id: "root",
workspace_id: "w",
title: "Root",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "",
updated_at: null,
children: [
{
access_scope: "private",
id: "child",
workspace_id: "w",
title: "Child",
parent_id: "root",
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "",
updated_at: null,
children: [
{
access_scope: "private",
id: "leaf",
workspace_id: "w",
title: "Leaf",
parent_id: "child",
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "",
updated_at: null,
children: [],
},
],
},
],
},
]);
expect(filterVisiblePageTreeProjectionItems(items, new Set()).map((item) => item.nodeId)).toEqual(["root"]);
expect(filterVisiblePageTreeProjectionItems(items, new Set(["root"])).map((item) => item.nodeId)).toEqual([
"root",
"child",
]);
expect(
filterVisiblePageTreeProjectionItems(items, new Set(["root", "child"])).map(
(item) => item.nodeId,
),
).toEqual(["root", "child", "leaf"]);
});
});
describe("buildPickerTreeItems", () => {
it("复用 page_tree projection 生成 picker 列表", () => {
const items = buildPageTreeProjectionItems([
{
access_scope: "private",
id: "a",
workspace_id: "w",
title: "A",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "",
updated_at: null,
children: [
{
access_scope: "private",
id: "b",
workspace_id: "w",
title: null,
parent_id: "a",
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "",
updated_at: null,
children: [],
},
],
},
]);
expect(buildPickerTreeItems(items, ["a"])).toEqual([
{ id: "b", title: "无标题", depth: 1 },
]);
});
});
+158
View File
@@ -0,0 +1,158 @@
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
export type TreeProjectionKind = "page_tree";
export type TreeProjectionNodeType = "page";
export type TreeProjectionCapability =
| "expand"
| "open"
| "drag"
| "drop"
| "select"
| "create-child";
export type TreeProjectionResourceMeta = {
resourceKind: "document";
documentId: string;
};
export type PageTreeProjectionItem = {
rowId: `page:${string}`;
nodeId: string;
parentNodeId: string | null;
nodeType: TreeProjectionNodeType;
projectionKind: TreeProjectionKind;
depth: number;
position: number | null;
title: string;
childCount: number;
capabilities: TreeProjectionCapability[];
resourceMeta: TreeProjectionResourceMeta;
node: SidebarTreeNode;
};
export type PickerTreeItem = {
id: string;
title: string;
depth: number;
};
function buildPageCapabilities(childCount: number): TreeProjectionCapability[] {
const capabilities: TreeProjectionCapability[] = [
"open",
"drag",
"drop",
"select",
"create-child",
];
if (childCount > 0) {
capabilities.unshift("expand");
}
return capabilities;
}
export function buildPageTreeProjectionItems(
nodes: SidebarTreeNode[],
): PageTreeProjectionItem[] {
const items: PageTreeProjectionItem[] = [];
const visited = new Set<string>();
const walk = (
entries: SidebarTreeNode[],
depth: number,
parentNodeId: string | null,
) => {
entries.forEach((node, index) => {
if (visited.has(node.id)) {
return;
}
visited.add(node.id);
const childCount = node.children.length;
items.push({
rowId: `page:${node.id}`,
nodeId: node.id,
parentNodeId,
nodeType: "page",
projectionKind: "page_tree",
depth,
position: node.kernel?.position ?? node.sort_order ?? index,
title: node.title ?? "无标题",
childCount,
capabilities: buildPageCapabilities(childCount),
resourceMeta: {
resourceKind: "document",
documentId: node.id,
},
node: {
...node,
parent_id: parentNodeId,
kernel: node.kernel
? {
...node.kernel,
depth,
position: node.kernel.position ?? node.sort_order ?? index,
childCount,
}
: {
nodeType: "page",
depth,
position: node.sort_order ?? index,
childCount,
expandedByDefault: true,
},
},
});
if (childCount > 0) {
walk(node.children, depth + 1, node.id);
}
});
};
walk(nodes, 0, null);
return items;
}
export function filterVisiblePageTreeProjectionItems(
items: PageTreeProjectionItem[],
expanded: Set<string>,
): PageTreeProjectionItem[] {
const visible: PageTreeProjectionItem[] = [];
const visibleIds = new Set<string>();
items.forEach((item) => {
if (!item.parentNodeId) {
visible.push(item);
visibleIds.add(item.nodeId);
return;
}
if (!visibleIds.has(item.parentNodeId)) {
return;
}
if (!expanded.has(item.parentNodeId)) {
return;
}
visible.push(item);
visibleIds.add(item.nodeId);
});
return visible;
}
export function buildPickerTreeItems(
items: PageTreeProjectionItem[],
excludeIds: Iterable<string> = [],
): PickerTreeItem[] {
const excluded = new Set(excludeIds);
return items
.filter((item) => !excluded.has(item.nodeId))
.map((item) => ({
id: item.nodeId,
title: item.title,
depth: item.depth,
}));
}