feat(kernel): complete tree-first graph tasks 074-080
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
export type PageSubtreeInlineNode = {
|
||||
type?: string;
|
||||
text?: string;
|
||||
href?: string;
|
||||
content?: unknown;
|
||||
styles?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PageSubtreeBlock = {
|
||||
id?: string;
|
||||
type?: string;
|
||||
props?: Record<string, unknown>;
|
||||
content?: unknown;
|
||||
children?: unknown;
|
||||
};
|
||||
|
||||
export type PageSubtreeNodeType =
|
||||
| "page"
|
||||
| "section"
|
||||
| "content_node"
|
||||
| "reference_anchor"
|
||||
| "mindmap";
|
||||
|
||||
export type PageSubtreeNode = {
|
||||
id: string;
|
||||
parentNodeId: string | null;
|
||||
nodeType: PageSubtreeNodeType;
|
||||
blockId: string | null;
|
||||
anchorBlockId: string | null;
|
||||
depth: number;
|
||||
metadata: {
|
||||
title: string | null;
|
||||
textSnippet: string | null;
|
||||
blockType: string | null;
|
||||
headingLevel: number | null;
|
||||
numbering: string | null;
|
||||
childCount: number;
|
||||
order: number;
|
||||
path: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type PageOutlineEntry = {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
anchorBlockId: string | null;
|
||||
title: string;
|
||||
level: number;
|
||||
numbering: string;
|
||||
};
|
||||
|
||||
export type PageEvidenceItem = {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
blockId: string | null;
|
||||
kind:
|
||||
| "page"
|
||||
| "heading"
|
||||
| "paragraph"
|
||||
| "list"
|
||||
| "todo"
|
||||
| "quote"
|
||||
| "code"
|
||||
| "media"
|
||||
| "reference"
|
||||
| "table"
|
||||
| "mindmap"
|
||||
| "text";
|
||||
snippet: string;
|
||||
};
|
||||
|
||||
export type PageSubtreeProjection = {
|
||||
projectionId: string;
|
||||
projection: "page_tree";
|
||||
rootNodeId: string;
|
||||
rootNode: PageSubtreeNode;
|
||||
subtree: {
|
||||
rootNodeId: string;
|
||||
nodes: PageSubtreeNode[];
|
||||
};
|
||||
outline: PageOutlineEntry[];
|
||||
evidence: PageEvidenceItem[];
|
||||
stats: {
|
||||
blockCount: number;
|
||||
headingCount: number;
|
||||
evidenceCount: number;
|
||||
maxDepth: number;
|
||||
};
|
||||
};
|
||||
|
||||
const SNIPPET_MAX_LENGTH = 220;
|
||||
|
||||
const pickFirstText = (...values: unknown[]) => {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const normalizeSnippet = (value: string) => value.replace(/\s+/g, " ").trim().slice(0, SNIPPET_MAX_LENGTH);
|
||||
|
||||
export const clampHeadingLevel = (value: unknown) => {
|
||||
const level = Number(value);
|
||||
if (Number.isNaN(level) || !Number.isFinite(level)) {
|
||||
return 1;
|
||||
}
|
||||
return Math.min(5, Math.max(1, Math.trunc(level)));
|
||||
};
|
||||
|
||||
export const extractPageBlocks = (content: unknown): PageSubtreeBlock[] => {
|
||||
if (Array.isArray(content)) {
|
||||
return content as PageSubtreeBlock[];
|
||||
}
|
||||
if (content && typeof content === "object") {
|
||||
const blocks = (content as { blocks?: unknown }).blocks;
|
||||
if (Array.isArray(blocks)) {
|
||||
return blocks as PageSubtreeBlock[];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getPageBlockChildren = (value: unknown): PageSubtreeBlock[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value as PageSubtreeBlock[];
|
||||
};
|
||||
|
||||
export const getInlineText = (value: unknown): string => {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return "";
|
||||
}
|
||||
return value
|
||||
.map((node) => {
|
||||
if (typeof node === "string") {
|
||||
return node;
|
||||
}
|
||||
if (!node || typeof node !== "object") {
|
||||
return "";
|
||||
}
|
||||
const typedNode = node as PageSubtreeInlineNode;
|
||||
if (typedNode.type === "link") {
|
||||
return getInlineText(typedNode.content);
|
||||
}
|
||||
return typeof typedNode.text === "string" ? typedNode.text : "";
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
const getBlockSnippet = (block: PageSubtreeBlock): string => {
|
||||
const props = block.props ?? {};
|
||||
const inlineText = normalizeSnippet(getInlineText(block.content));
|
||||
if (inlineText) {
|
||||
return inlineText;
|
||||
}
|
||||
return normalizeSnippet(
|
||||
pickFirstText(
|
||||
props.title,
|
||||
props.caption,
|
||||
props.summary,
|
||||
props.fileName,
|
||||
props.name,
|
||||
props.alt,
|
||||
props.status,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const getBlockDisplayTitle = (block: PageSubtreeBlock, snippet: string) => {
|
||||
const props = block.props ?? {};
|
||||
switch (block.type) {
|
||||
case "heading":
|
||||
return snippet || "未命名标题";
|
||||
case "pageReference":
|
||||
return pickFirstText(props.title, snippet, "页面引用");
|
||||
case "blockReference":
|
||||
return pickFirstText(props.title, snippet, "块引用");
|
||||
case "onlineTable":
|
||||
return pickFirstText(props.title, snippet, "在线表格");
|
||||
case "mindmap":
|
||||
return pickFirstText(props.title, snippet, "思维导图");
|
||||
case "media":
|
||||
return pickFirstText(props.caption, props.fileName, snippet, "附件");
|
||||
case "codeBlock":
|
||||
return snippet || "代码块";
|
||||
case "advancedTodo":
|
||||
return snippet || "任务";
|
||||
case "quote":
|
||||
return snippet || "引用";
|
||||
default:
|
||||
return snippet || null;
|
||||
}
|
||||
};
|
||||
|
||||
const getNodeType = (block: PageSubtreeBlock): PageSubtreeNodeType => {
|
||||
switch (block.type) {
|
||||
case "heading":
|
||||
return "section";
|
||||
case "blockReference":
|
||||
case "pageReference":
|
||||
return "reference_anchor";
|
||||
case "mindmap":
|
||||
return "mindmap";
|
||||
default:
|
||||
return "content_node";
|
||||
}
|
||||
};
|
||||
|
||||
const getEvidenceKind = (block: PageSubtreeBlock): PageEvidenceItem["kind"] => {
|
||||
switch (block.type) {
|
||||
case "heading":
|
||||
return "heading";
|
||||
case "paragraph":
|
||||
return "paragraph";
|
||||
case "bulletListItem":
|
||||
case "numberedListItem":
|
||||
case "checkListItem":
|
||||
return "list";
|
||||
case "advancedTodo":
|
||||
return "todo";
|
||||
case "quote":
|
||||
return "quote";
|
||||
case "codeBlock":
|
||||
return "code";
|
||||
case "media":
|
||||
return "media";
|
||||
case "pageReference":
|
||||
case "blockReference":
|
||||
return "reference";
|
||||
case "onlineTable":
|
||||
return "table";
|
||||
case "mindmap":
|
||||
return "mindmap";
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
};
|
||||
|
||||
export function buildPageSubtreeProjection(input: {
|
||||
documentId: string;
|
||||
title: string | null;
|
||||
content: unknown;
|
||||
}): PageSubtreeProjection {
|
||||
const documentId = String(input.documentId ?? "").trim();
|
||||
const rootNodeId = documentId || "page:unknown";
|
||||
const blocks = extractPageBlocks(input.content);
|
||||
const rootTitle = pickFirstText(input.title, "无标题");
|
||||
const rootNode: PageSubtreeNode = {
|
||||
id: rootNodeId,
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
blockId: null,
|
||||
anchorBlockId: null,
|
||||
depth: 0,
|
||||
metadata: {
|
||||
title: rootTitle,
|
||||
textSnippet: null,
|
||||
blockType: "page",
|
||||
headingLevel: null,
|
||||
numbering: null,
|
||||
childCount: blocks.length,
|
||||
order: 0,
|
||||
path: [rootNodeId],
|
||||
},
|
||||
};
|
||||
|
||||
const nodes: PageSubtreeNode[] = [rootNode];
|
||||
const outline: PageOutlineEntry[] = [];
|
||||
const evidence: PageEvidenceItem[] = [];
|
||||
const headingCounters = [0, 0, 0, 0, 0];
|
||||
const headingStack: Array<{ level: number; nodeId: string }> = [];
|
||||
let order = 0;
|
||||
let maxDepth = 0;
|
||||
|
||||
const walk = (items: PageSubtreeBlock[], parentBlockNodeId: string | null, depth: number, path: number[]) => {
|
||||
items.forEach((block, index) => {
|
||||
const blockId = typeof block.id === "string" && block.id.trim() ? block.id.trim() : null;
|
||||
const nodeId = blockId ? `block:${blockId}` : `block:auto:${[...path, index].join(".")}`;
|
||||
const snippet = getBlockSnippet(block);
|
||||
const headingLevel = block.type === "heading" ? clampHeadingLevel(block.props?.level) : null;
|
||||
let parentNodeId = parentBlockNodeId ?? headingStack.at(-1)?.nodeId ?? rootNodeId;
|
||||
let numbering: string | null = null;
|
||||
|
||||
if (headingLevel != null) {
|
||||
while (headingStack.length > 0 && headingStack[headingStack.length - 1]!.level >= headingLevel) {
|
||||
headingStack.pop();
|
||||
}
|
||||
parentNodeId = headingStack.at(-1)?.nodeId ?? parentBlockNodeId ?? rootNodeId;
|
||||
headingCounters[headingLevel - 1] += 1;
|
||||
for (let counterIndex = headingLevel; counterIndex < headingCounters.length; counterIndex += 1) {
|
||||
headingCounters[counterIndex] = 0;
|
||||
}
|
||||
numbering = headingCounters
|
||||
.slice(0, headingLevel)
|
||||
.filter((value) => value > 0)
|
||||
.join(".");
|
||||
}
|
||||
|
||||
order += 1;
|
||||
const children = getPageBlockChildren(block.children);
|
||||
const node: PageSubtreeNode = {
|
||||
id: nodeId,
|
||||
parentNodeId,
|
||||
nodeType: getNodeType(block),
|
||||
blockId,
|
||||
anchorBlockId: blockId,
|
||||
depth: depth + 1,
|
||||
metadata: {
|
||||
title: getBlockDisplayTitle(block, snippet),
|
||||
textSnippet: snippet || null,
|
||||
blockType: typeof block.type === "string" ? block.type : null,
|
||||
headingLevel,
|
||||
numbering,
|
||||
childCount: children.length,
|
||||
order,
|
||||
path: [rootNodeId, ...path.map(String), String(index)],
|
||||
},
|
||||
};
|
||||
|
||||
nodes.push(node);
|
||||
maxDepth = Math.max(maxDepth, node.depth);
|
||||
|
||||
if (headingLevel != null) {
|
||||
outline.push({
|
||||
id: blockId ?? node.id,
|
||||
nodeId: node.id,
|
||||
anchorBlockId: blockId,
|
||||
title: node.metadata.title ?? "未命名标题",
|
||||
level: headingLevel,
|
||||
numbering: numbering ?? "",
|
||||
});
|
||||
headingStack.push({ level: headingLevel, nodeId: node.id });
|
||||
}
|
||||
|
||||
if (snippet) {
|
||||
evidence.push({
|
||||
id: `evidence:${node.id}`,
|
||||
nodeId: node.id,
|
||||
blockId,
|
||||
kind: getEvidenceKind(block),
|
||||
snippet,
|
||||
});
|
||||
}
|
||||
|
||||
if (children.length > 0) {
|
||||
walk(children, node.id, depth + 1, [...path, index]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
walk(blocks, null, 0, []);
|
||||
|
||||
if (rootTitle) {
|
||||
evidence.unshift({
|
||||
id: `evidence:${rootNodeId}`,
|
||||
nodeId: rootNodeId,
|
||||
blockId: null,
|
||||
kind: "page",
|
||||
snippet: rootTitle,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
projectionId: `page_subtree:${rootNodeId}`,
|
||||
projection: "page_tree",
|
||||
rootNodeId,
|
||||
rootNode,
|
||||
subtree: {
|
||||
rootNodeId,
|
||||
nodes,
|
||||
},
|
||||
outline,
|
||||
evidence,
|
||||
stats: {
|
||||
blockCount: Math.max(0, nodes.length - 1),
|
||||
headingCount: outline.length,
|
||||
evidenceCount: evidence.length,
|
||||
maxDepth,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { DocumentNode } from "@/lib/documents";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import type { FileTreeRow } from "./types";
|
||||
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
|
||||
@@ -12,7 +12,7 @@ export function buildVisibleRows({
|
||||
assetChildrenByAssetId,
|
||||
expandedAssetFolderIds,
|
||||
}: {
|
||||
nodes: DocumentNode[];
|
||||
nodes: SidebarTreeNode[];
|
||||
expanded: Set<string>;
|
||||
assetsByDoc: Record<string, MediaAsset[]>;
|
||||
assetChildrenByAssetId?: Record<string, MediaAsset[]>;
|
||||
@@ -21,7 +21,7 @@ export function buildVisibleRows({
|
||||
const rows: FileTreeRow[] = [];
|
||||
const visitedDocIds = new Set<string>();
|
||||
|
||||
const walk = (node: DocumentNode, depth: number) => {
|
||||
const walk = (node: SidebarTreeNode, depth: number) => {
|
||||
// 防御性处理:上游数据异常时(例如同一 docId 在树中重复出现),避免生成重复 rowId 导致 React key 冲突。
|
||||
// 同时也能避免潜在的“循环引用/重复引用”导致的递归问题。
|
||||
if (visitedDocIds.has(node.id)) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { DocumentNode } from "@/lib/documents";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
export type FileTreeRowKind = "doc" | "index" | "asset" | "asset-folder";
|
||||
@@ -60,7 +60,7 @@ export type FileTreeRow =
|
||||
depth: number;
|
||||
docId: string;
|
||||
parentDocId: string | null;
|
||||
node: DocumentNode;
|
||||
node: SidebarTreeNode;
|
||||
hasChildren: boolean;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export type FileTreeRow =
|
||||
depth: number;
|
||||
docId: string;
|
||||
parentDocId: string;
|
||||
node: DocumentNode;
|
||||
node: SidebarTreeNode;
|
||||
}
|
||||
| {
|
||||
kind: "asset-folder";
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
|
||||
export type KernelSidebarProjectionEdge = {
|
||||
id: string;
|
||||
edgeType: "parent_of";
|
||||
workspaceId: string | null;
|
||||
fromNodeId: string;
|
||||
toNodeId: string;
|
||||
};
|
||||
|
||||
export type KernelSidebarProjectionItem = {
|
||||
nodeId: string;
|
||||
parentNodeId: string | null;
|
||||
nodeType: "page";
|
||||
title: string | null;
|
||||
depth: number;
|
||||
position: number | null;
|
||||
childCount: number;
|
||||
expandedByDefault: boolean;
|
||||
};
|
||||
|
||||
export type KernelSidebarProjection = {
|
||||
projectionId: string;
|
||||
projection: "sidebar_tree";
|
||||
rootNodeId: string | null;
|
||||
items: KernelSidebarProjectionItem[];
|
||||
edges: KernelSidebarProjectionEdge[];
|
||||
};
|
||||
|
||||
export type SidebarTreeNode = DocumentRecord & {
|
||||
children: SidebarTreeNode[];
|
||||
kernel?: {
|
||||
nodeType: "page";
|
||||
depth: number;
|
||||
position: number | null;
|
||||
childCount: number;
|
||||
expandedByDefault: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function sortRecords(records: DocumentRecord[]) {
|
||||
return [...records].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;
|
||||
}
|
||||
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeRecords(records: DocumentRecord[]) {
|
||||
const seen = new Set<string>();
|
||||
const unique: DocumentRecord[] = [];
|
||||
for (let index = records.length - 1; index >= 0; index -= 1) {
|
||||
const record = records[index]!;
|
||||
if (seen.has(record.id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(record.id);
|
||||
unique.push(record);
|
||||
}
|
||||
unique.reverse();
|
||||
return unique;
|
||||
}
|
||||
|
||||
function buildChildrenByParent(records: DocumentRecord[]) {
|
||||
const childrenByParentId = new Map<string | null, DocumentRecord[]>();
|
||||
const recordIds = new Set(records.map((record) => record.id));
|
||||
for (const record of records) {
|
||||
const parentId =
|
||||
record.parent_id && recordIds.has(record.parent_id) ? record.parent_id : null;
|
||||
const bucket = childrenByParentId.get(parentId) ?? [];
|
||||
bucket.push(record);
|
||||
childrenByParentId.set(parentId, bucket);
|
||||
}
|
||||
for (const [parentId, bucket] of childrenByParentId.entries()) {
|
||||
childrenByParentId.set(parentId, sortRecords(bucket));
|
||||
}
|
||||
return childrenByParentId;
|
||||
}
|
||||
|
||||
export function buildKernelSidebarProjection(
|
||||
records: DocumentRecord[],
|
||||
): KernelSidebarProjection {
|
||||
const uniqueRecords = dedupeRecords(records);
|
||||
const recordById = new Map(uniqueRecords.map((record) => [record.id, record]));
|
||||
const childrenByParentId = buildChildrenByParent(uniqueRecords);
|
||||
const items: KernelSidebarProjectionItem[] = [];
|
||||
const edges: KernelSidebarProjectionEdge[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
const walk = (parentId: string | null, depth: number) => {
|
||||
const children = childrenByParentId.get(parentId) ?? [];
|
||||
for (const child of children) {
|
||||
if (visited.has(child.id)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(child.id);
|
||||
const childNodes = childrenByParentId.get(child.id) ?? [];
|
||||
items.push({
|
||||
nodeId: child.id,
|
||||
parentNodeId: parentId,
|
||||
nodeType: "page",
|
||||
title: child.title ?? "无标题",
|
||||
depth,
|
||||
position: child.sort_order ?? null,
|
||||
childCount: childNodes.length,
|
||||
expandedByDefault: true,
|
||||
});
|
||||
if (parentId && recordById.has(parentId)) {
|
||||
edges.push({
|
||||
id: `edge:${parentId}:${child.id}:parent_of`,
|
||||
edgeType: "parent_of",
|
||||
workspaceId: child.workspace_id ?? null,
|
||||
fromNodeId: parentId,
|
||||
toNodeId: child.id,
|
||||
});
|
||||
}
|
||||
walk(child.id, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
walk(null, 0);
|
||||
|
||||
return {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
items,
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSidebarTreeFromKernelProjection(input: {
|
||||
records: DocumentRecord[];
|
||||
projection: KernelSidebarProjection;
|
||||
}): SidebarTreeNode[] {
|
||||
const recordById = new Map(input.records.map((record) => [record.id, record]));
|
||||
const nodeMap = new Map<string, SidebarTreeNode>();
|
||||
const itemById = new Map(input.projection.items.map((item) => [item.nodeId, item]));
|
||||
|
||||
for (const item of input.projection.items) {
|
||||
const record = recordById.get(item.nodeId);
|
||||
nodeMap.set(item.nodeId, {
|
||||
access_scope: record?.access_scope ?? "private",
|
||||
id: item.nodeId,
|
||||
workspace_id: record?.workspace_id ?? "",
|
||||
title: record?.title ?? item.title ?? "无标题",
|
||||
parent_id: record?.parent_id ?? item.parentNodeId,
|
||||
sort_order: record?.sort_order ?? item.position,
|
||||
is_starred: record?.is_starred ?? false,
|
||||
is_template: record?.is_template ?? false,
|
||||
created_at: record?.created_at ?? "",
|
||||
updated_at: record?.updated_at ?? null,
|
||||
children: [],
|
||||
kernel: {
|
||||
nodeType: item.nodeType,
|
||||
depth: item.depth,
|
||||
position: item.position,
|
||||
childCount: item.childCount,
|
||||
expandedByDefault: item.expandedByDefault,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const roots: SidebarTreeNode[] = [];
|
||||
for (const item of input.projection.items) {
|
||||
const node = nodeMap.get(item.nodeId);
|
||||
if (!node) continue;
|
||||
const parentId = item.parentNodeId;
|
||||
if (parentId && nodeMap.has(parentId)) {
|
||||
nodeMap.get(parentId)!.children.push(node);
|
||||
continue;
|
||||
}
|
||||
roots.push(node);
|
||||
}
|
||||
|
||||
const sortTree = (nodes: SidebarTreeNode[]) => {
|
||||
nodes.sort((a, b) => {
|
||||
const orderA = a.kernel?.position ?? a.sort_order ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = b.kernel?.position ?? b.sort_order ?? Number.MAX_SAFE_INTEGER;
|
||||
if (orderA !== orderB) {
|
||||
return orderA - orderB;
|
||||
}
|
||||
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
});
|
||||
nodes.forEach((node) => sortTree(node.children));
|
||||
};
|
||||
|
||||
sortTree(roots);
|
||||
|
||||
// 防御性处理:如果 projection 丢了节点,但 records 里还在,补到根节点,避免页面从主导航消失。
|
||||
const missingRoots = input.records
|
||||
.filter((record) => !itemById.has(record.id))
|
||||
.map((record) => ({
|
||||
...record,
|
||||
children: [],
|
||||
kernel: {
|
||||
nodeType: "page" as const,
|
||||
depth: 0,
|
||||
position: record.sort_order ?? null,
|
||||
childCount: 0,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
}));
|
||||
if (missingRoots.length > 0) {
|
||||
roots.push(...missingRoots);
|
||||
sortTree(roots);
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
export type MindMapData = {
|
||||
data: Record<string, unknown>;
|
||||
children?: unknown[];
|
||||
};
|
||||
|
||||
export type MindmapRouteMeta = {
|
||||
requestId?: string;
|
||||
traceId?: string;
|
||||
workspaceId?: string | null;
|
||||
documentId?: string;
|
||||
pageId?: string;
|
||||
mindmapId?: string;
|
||||
attachmentId?: string;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export type MindmapProjectionNode = {
|
||||
uid: string;
|
||||
text: string;
|
||||
depth: number;
|
||||
childCount: number;
|
||||
};
|
||||
|
||||
export type MindmapProjection = {
|
||||
projectionId: string;
|
||||
projection: "mindmap_subtree";
|
||||
documentId: string;
|
||||
mindmapId: string;
|
||||
rootNodeId: string | null;
|
||||
title: string;
|
||||
nodeCount: number;
|
||||
nodes: MindmapProjectionNode[];
|
||||
data: MindMapData;
|
||||
meta: MindmapRouteMeta | null;
|
||||
};
|
||||
|
||||
export const defaultMindmapData: MindMapData = {
|
||||
data: { text: "中心主题" },
|
||||
children: [],
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
// simple-mind-map 在文本字段缺失时会直接崩溃,这里统一做兜底归一化。
|
||||
export const normalizeMindmapData = (input: unknown): unknown => {
|
||||
if (!input || typeof input !== "object") return defaultMindmapData;
|
||||
const root = (input as { root?: unknown }).root ?? input;
|
||||
|
||||
const walk = (node: unknown) => {
|
||||
if (!isRecord(node)) return;
|
||||
if (!isRecord(node.data)) {
|
||||
node.data = {};
|
||||
}
|
||||
const rawText = node.data.text;
|
||||
node.data.text = typeof rawText === "string" ? rawText : String(rawText ?? "");
|
||||
|
||||
const gen = node.data.generalization;
|
||||
const fixGen = (value: unknown) => {
|
||||
if (!isRecord(value)) return;
|
||||
const text = value.text;
|
||||
value.text = typeof text === "string" ? text : String(text ?? "");
|
||||
};
|
||||
|
||||
if (Array.isArray(gen)) gen.forEach(fixGen);
|
||||
else fixGen(gen);
|
||||
|
||||
if (Array.isArray(node.children)) {
|
||||
node.children.forEach(walk);
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
return input;
|
||||
};
|
||||
|
||||
// 持久化/初始化统一使用根节点对象,避免 wrapper 误传给 simple-mind-map。
|
||||
export const canonicalizeMindmapData = (input: unknown): MindMapData => {
|
||||
const normalized = (normalizeMindmapData(input) ?? defaultMindmapData) as Record<string, unknown>;
|
||||
const root =
|
||||
normalized && typeof normalized === "object" && "root" in normalized
|
||||
? normalized.root
|
||||
: normalized;
|
||||
return (normalizeMindmapData(root) ?? defaultMindmapData) as MindMapData;
|
||||
};
|
||||
|
||||
export const extractMindmapTitle = (input: unknown): string => {
|
||||
const canonical = canonicalizeMindmapData(input);
|
||||
const text = canonical?.data?.text;
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
return text.trim();
|
||||
}
|
||||
return "未命名导图";
|
||||
};
|
||||
|
||||
export const summarizeMindmapProjectionNodes = (input: unknown): MindmapProjectionNode[] => {
|
||||
const root = canonicalizeMindmapData(input);
|
||||
const queue: Array<{ node: MindMapData; depth: number }> = [{ node: root, depth: 0 }];
|
||||
const nodes: MindmapProjectionNode[] = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current) break;
|
||||
const uidRaw = current.node?.data?.uid;
|
||||
const textRaw = current.node?.data?.text;
|
||||
const children = Array.isArray(current.node?.children) ? current.node.children : [];
|
||||
nodes.push({
|
||||
uid:
|
||||
typeof uidRaw === "string" && uidRaw.trim()
|
||||
? uidRaw
|
||||
: `depth:${current.depth}:index:${nodes.length}`,
|
||||
text: typeof textRaw === "string" && textRaw.trim() ? textRaw.trim() : "未命名节点",
|
||||
depth: current.depth,
|
||||
childCount: children.length,
|
||||
});
|
||||
children.forEach((child) => {
|
||||
queue.push({
|
||||
node: canonicalizeMindmapData(child),
|
||||
depth: current.depth + 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return nodes;
|
||||
};
|
||||
|
||||
export const buildMindmapProjection = (input: {
|
||||
documentId: string;
|
||||
mindmapId: string;
|
||||
data: unknown;
|
||||
meta?: unknown;
|
||||
}): MindmapProjection => {
|
||||
const data = canonicalizeMindmapData(input.data);
|
||||
const nodes = summarizeMindmapProjectionNodes(data);
|
||||
const meta = isRecord(input.meta) ? (input.meta as MindmapRouteMeta) : null;
|
||||
const rootNodeId = nodes[0]?.uid ?? null;
|
||||
|
||||
return {
|
||||
projectionId: `mindmap_projection:${input.documentId}:${input.mindmapId}`,
|
||||
projection: "mindmap_subtree",
|
||||
documentId: input.documentId,
|
||||
mindmapId: input.mindmapId,
|
||||
rootNodeId,
|
||||
title: extractMindmapTitle(data),
|
||||
nodeCount: nodes.length,
|
||||
nodes,
|
||||
data,
|
||||
meta,
|
||||
};
|
||||
};
|
||||
@@ -44,6 +44,13 @@ type SearchDocumentsRustResult = {
|
||||
hasOcr: boolean;
|
||||
publicPath: string;
|
||||
score: number;
|
||||
nodeId?: string | null;
|
||||
subtreeRootId?: string | null;
|
||||
evidence?: Array<{
|
||||
kind: string;
|
||||
nodeId?: string | null;
|
||||
snippet: string;
|
||||
}>;
|
||||
}>;
|
||||
enqueueAssetIds?: string[];
|
||||
};
|
||||
@@ -145,6 +152,15 @@ function mapRecentRowsToResults(input: {
|
||||
hasOcr: false,
|
||||
publicPath: `/documents/${item.id}`,
|
||||
score: 0,
|
||||
nodeId: item.id,
|
||||
subtreeRootId: item.id,
|
||||
evidence: [
|
||||
{
|
||||
kind: "recent",
|
||||
nodeId: item.id,
|
||||
snippet: item.title ?? "最近访问页面",
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -161,6 +177,18 @@ function mapRustResultsToResponse(
|
||||
hasOcr: Boolean(item.hasOcr),
|
||||
publicPath: item.publicPath,
|
||||
score: item.score,
|
||||
nodeId: item.nodeId ?? item.id,
|
||||
subtreeRootId: item.subtreeRootId ?? item.id,
|
||||
evidence:
|
||||
Array.isArray(item.evidence) && item.evidence.length > 0
|
||||
? item.evidence
|
||||
: [
|
||||
{
|
||||
kind: item.matchField,
|
||||
nodeId: item.nodeId ?? item.id,
|
||||
snippet: item.snippet || item.title || "命中文档",
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,8 @@ describe("buildSidebarInitialData", () => {
|
||||
});
|
||||
|
||||
expect(payload.activeWorkspaceId).toBe("ws_1");
|
||||
expect(payload.kernelSidebarProjection?.projection).toBe("sidebar_tree");
|
||||
expect(payload.kernelSidebarTree?.map((item) => item.id)).toEqual(["doc_1"]);
|
||||
expect(payload.mindmapDocs).toEqual(["doc_1"]);
|
||||
expect(payload.mindmapAssetChildren).toEqual({
|
||||
mind_1: ["img_a", "img_b"],
|
||||
@@ -210,6 +212,24 @@ describe("buildSidebarInitialData", () => {
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
kernel_sidebar_projection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
items: [
|
||||
{
|
||||
nodeId: "doc_1",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
title: "页面 1",
|
||||
depth: 0,
|
||||
position: 1,
|
||||
childCount: 0,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
trashed_documents: [],
|
||||
media_assets: [],
|
||||
trashed_media_assets: [],
|
||||
@@ -247,6 +267,46 @@ describe("buildSidebarInitialData", () => {
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
kernelSidebarProjection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
items: [
|
||||
{
|
||||
nodeId: "doc_1",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
title: "页面 1",
|
||||
depth: 0,
|
||||
position: 1,
|
||||
childCount: 0,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
kernelSidebarTree: [
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_1",
|
||||
title: "页面 1",
|
||||
parent_id: null,
|
||||
sort_order: 1,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "2026-04-14T00:00:00Z",
|
||||
updated_at: null,
|
||||
children: [],
|
||||
kernel: {
|
||||
nodeType: "page",
|
||||
depth: 0,
|
||||
position: 1,
|
||||
childCount: 0,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
trashedDocuments: [],
|
||||
trashedMediaAssets: [],
|
||||
trashedMindmapAssets: [],
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import {
|
||||
buildKernelSidebarProjection,
|
||||
buildSidebarTreeFromKernelProjection,
|
||||
type KernelSidebarProjection,
|
||||
} from "@/lib/kernel-sidebar";
|
||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
@@ -46,6 +51,7 @@ export type SidebarDatasetListQueryResult = {
|
||||
active_workspace_id: string;
|
||||
workspaces: WorkspaceSummary[];
|
||||
documents: DocumentRecord[];
|
||||
kernel_sidebar_projection?: KernelSidebarProjection | null;
|
||||
trashed_documents: SidebarInitialData["trashedDocuments"];
|
||||
media_assets: MediaAsset[];
|
||||
trashed_media_assets: MediaAsset[];
|
||||
@@ -213,11 +219,13 @@ export function buildSidebarDatasetListQueryResult(
|
||||
input: SidebarDatasetInput,
|
||||
): SidebarDatasetListQueryResult {
|
||||
const derived = deriveSidebarDataset(input);
|
||||
const kernelSidebarProjection = buildKernelSidebarProjection(input.documents);
|
||||
|
||||
return {
|
||||
active_workspace_id: input.activeWorkspaceId,
|
||||
workspaces: [...input.workspaces],
|
||||
documents: [...input.documents],
|
||||
kernel_sidebar_projection: kernelSidebarProjection,
|
||||
trashed_documents: [...input.trashedDocuments],
|
||||
media_assets: [...(input.mediaAssets ?? [])],
|
||||
trashed_media_assets: [...(input.trashedMediaAssets ?? [])],
|
||||
@@ -233,10 +241,17 @@ 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,
|
||||
kernelSidebarTree: buildSidebarTreeFromKernelProjection({
|
||||
records: result.documents,
|
||||
projection: kernelSidebarProjection,
|
||||
}),
|
||||
trashedDocuments: [...result.trashed_documents],
|
||||
trashedMediaAssets: [...result.trashed_media_assets],
|
||||
trashedMindmapAssets: [...result.trashed_mindmap_assets],
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { DocumentRecord, DocumentNode } from "@/lib/documents";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type { SidebarSectionId, TrashRecord } from "@/components/sidebar/types";
|
||||
import type { Database } from "@/types/supabase";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { buildDocumentTree } from "@/lib/documents";
|
||||
import {
|
||||
buildKernelSidebarProjection,
|
||||
buildSidebarTreeFromKernelProjection,
|
||||
type SidebarTreeNode,
|
||||
} from "@/lib/kernel-sidebar";
|
||||
|
||||
type TypedClient = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -138,10 +142,10 @@ export async function fetchSidebarDataset(
|
||||
};
|
||||
}
|
||||
|
||||
type NodePredicate = (node: DocumentNode) => boolean;
|
||||
type NodePredicate = (node: SidebarTreeNode) => boolean;
|
||||
|
||||
function projectTree(nodes: DocumentNode[], predicate: NodePredicate): DocumentNode[] {
|
||||
const result: DocumentNode[] = [];
|
||||
function projectTree(nodes: SidebarTreeNode[], predicate: NodePredicate): SidebarTreeNode[] {
|
||||
const result: SidebarTreeNode[] = [];
|
||||
nodes.forEach((node) => {
|
||||
const projectedChildren = projectTree(node.children, predicate);
|
||||
if (predicate(node)) {
|
||||
@@ -157,12 +161,12 @@ function projectTree(nodes: DocumentNode[], predicate: NodePredicate): DocumentN
|
||||
}
|
||||
|
||||
export function flattenDocumentTree(
|
||||
nodes: DocumentNode[],
|
||||
nodes: SidebarTreeNode[],
|
||||
expanded: Set<string>,
|
||||
depth = 0,
|
||||
parentId: string | null = null,
|
||||
): Array<{ node: DocumentNode; depth: number; parentId: string | null }> {
|
||||
const list: Array<{ node: DocumentNode; depth: number; parentId: string | null }> = [];
|
||||
): Array<{ node: SidebarTreeNode; depth: number; parentId: string | null }> {
|
||||
const list: Array<{ node: SidebarTreeNode; depth: number; parentId: string | null }> = [];
|
||||
nodes.forEach((node) => {
|
||||
list.push({ node, depth, parentId });
|
||||
if (node.children.length > 0 && expanded.has(node.id)) {
|
||||
@@ -184,16 +188,19 @@ type SidebarSectionSnapshot = {
|
||||
id: SidebarSectionId;
|
||||
title: string;
|
||||
icon?: string;
|
||||
nodes: DocumentNode[];
|
||||
nodes: SidebarTreeNode[];
|
||||
};
|
||||
|
||||
export function buildSidebarSections(records: DocumentRecord[]): SidebarSectionSnapshot[] {
|
||||
const tree = buildDocumentTree(records);
|
||||
const tree = buildSidebarTreeFromKernelProjection({
|
||||
records,
|
||||
projection: buildKernelSidebarProjection(records),
|
||||
});
|
||||
return buildSidebarSectionsFromTree(tree);
|
||||
}
|
||||
|
||||
export function buildSidebarSectionsFromTree(
|
||||
tree: DocumentNode[],
|
||||
tree: SidebarTreeNode[],
|
||||
): SidebarSectionSnapshot[] {
|
||||
const sections: Array<{ id: SidebarSectionId; predicate: NodePredicate }> = [
|
||||
{ id: "starred", predicate: (node) => Boolean(node.is_starred) },
|
||||
|
||||
Reference in New Issue
Block a user