feat: 收口 tree-first graph 主链与前端测试修复
This commit is contained in:
@@ -36,7 +36,7 @@ describe("AiAgentPanel", () => {
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("全局 AI");
|
||||
expect(container.textContent).toContain("自动工具编排");
|
||||
expect(container.textContent).toContain("前端已经退为桥接层");
|
||||
expect(container.textContent).toContain("联网检索");
|
||||
expect(container.textContent).toContain("LightRAG");
|
||||
expect(container.textContent).toContain("跨页面文档");
|
||||
@@ -50,12 +50,13 @@ describe("AiAgentPanel", () => {
|
||||
|
||||
const toggleButton = container.querySelector('button[aria-label="切换工具活动面板"]');
|
||||
expect(toggleButton).not.toBeNull();
|
||||
expect(container.textContent).toContain("本轮活动");
|
||||
expect(container.textContent).toContain("活动轨迹");
|
||||
expect(container.textContent).toContain("暂无工具活动");
|
||||
|
||||
act(() => {
|
||||
toggleButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("本轮活动");
|
||||
expect(container.textContent).not.toContain("暂无工具活动");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,12 @@ import { Button } from "@/components/ui/button";
|
||||
import { DocumentToc } from "@/components/editor/document-toc";
|
||||
import { DocumentReadView } from "@/components/editor/document-read-view";
|
||||
import { buildPageSubtreeProjection, extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||
import { moveDocumentCommand, renameDocumentCommand } from "@/lib/documents/tree-command-client";
|
||||
import {
|
||||
deleteDocumentCommand,
|
||||
embedDocumentCommand,
|
||||
moveDocumentCommand,
|
||||
renameDocumentCommand,
|
||||
} from "@/lib/documents/tree-command-client";
|
||||
|
||||
const BlockNoteEditor = dynamic(
|
||||
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
|
||||
@@ -602,19 +607,16 @@ export function DocumentContent({
|
||||
if (readOnly) return;
|
||||
const ok = window.confirm("确定删除该页面吗?删除后会进入垃圾桶。");
|
||||
if (!ok) return;
|
||||
const resp = await fetch("/api/documents/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除失败");
|
||||
try {
|
||||
await deleteDocumentCommand({ documentId, workspaceId });
|
||||
} catch (error) {
|
||||
const payload = error instanceof Error ? error.message : null;
|
||||
window.alert(payload ?? "删除失败");
|
||||
return;
|
||||
}
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}, [documentId, readOnly, router]);
|
||||
}, [documentId, readOnly, router, workspaceId]);
|
||||
|
||||
const handleOpenMoveEmbed = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
@@ -642,16 +644,22 @@ export function DocumentContent({
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch("/api/documents/embed", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceId: documentId, targetId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "嵌入失败");
|
||||
if (!targetId) {
|
||||
window.alert("请选择目标页面");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await embedDocumentCommand({
|
||||
sourceId: documentId,
|
||||
targetId,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "嵌入失败";
|
||||
window.alert(message);
|
||||
return;
|
||||
}
|
||||
|
||||
window.alert("已嵌入到目标页面");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ import { deleteOnlineTable } from "@/lib/online-table";
|
||||
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
|
||||
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
|
||||
import { useCommentsUiStore } from "@/store/comments-ui";
|
||||
import { createChildDocumentCommand } from "@/lib/documents/tree-command-client";
|
||||
import { createChildDocumentCommand, deleteDocumentCommand } from "@/lib/documents/tree-command-client";
|
||||
|
||||
type InlineNode = { text?: unknown };
|
||||
type TableMenuBlock = Parameters<
|
||||
@@ -119,10 +119,9 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
|
||||
if (block.type === "pageReference") {
|
||||
const pageId = block.props.pageId;
|
||||
if (pageId) {
|
||||
await fetch("/api/documents/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId: pageId }),
|
||||
await deleteDocumentCommand({
|
||||
documentId: pageId,
|
||||
workspaceId,
|
||||
});
|
||||
if (typeof window !== "undefined") {
|
||||
emitDocumentsChanged(pageId);
|
||||
@@ -131,7 +130,7 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
|
||||
}
|
||||
editor.removeBlocks([block.id]);
|
||||
router.refresh();
|
||||
}, [block, editor, router]);
|
||||
}, [block, editor, router, workspaceId]);
|
||||
|
||||
const handleDeleteBlock = useCallback(async () => {
|
||||
if (block.type === "pageReference") {
|
||||
|
||||
@@ -67,13 +67,19 @@ import type { MediaAsset } from "@/types/media";
|
||||
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
|
||||
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { useSidebarTreeStream } from "@/lib/tree-stream/use-sidebar-tree-stream";
|
||||
import { DocumentShareDialog } from "@/components/sharing/document-share-dialog";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { GroupManagerDialog } from "@/components/groups/group-manager-dialog";
|
||||
import {
|
||||
copyTreeCommand,
|
||||
createDocumentCommand,
|
||||
deleteDocumentCommand,
|
||||
embedDocumentCommand,
|
||||
moveDocumentCommand,
|
||||
renameDocumentCommand,
|
||||
restoreDocumentCommand,
|
||||
purgeDocumentCommand,
|
||||
} from "@/lib/documents/tree-command-client";
|
||||
|
||||
const TOP_BUTTONS = [
|
||||
@@ -149,16 +155,18 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
// Convex 模式专用组件 - 只调用 Convex hooks
|
||||
function SidebarConvex({ initialData }: SidebarProps) {
|
||||
const sidebarData = useSidebarData(initialData);
|
||||
return <SidebarContent initialData={initialData} sidebarQuery={sidebarData} />;
|
||||
const treeStream = useSidebarTreeStream(initialData);
|
||||
return <SidebarContent initialData={initialData} sidebarQuery={sidebarData} treeStream={treeStream} />;
|
||||
}
|
||||
|
||||
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
|
||||
interface SidebarContentProps {
|
||||
initialData: SidebarInitialData;
|
||||
sidebarQuery: SidebarDataResult;
|
||||
treeStream: ReturnType<typeof useSidebarTreeStream>;
|
||||
}
|
||||
|
||||
function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarContentProps) {
|
||||
const convex = useConvex();
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const currentUser = useQuery(api.users.currentUser, isAuthenticated ? {} : "skip");
|
||||
@@ -174,8 +182,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
|
||||
// 处理数据
|
||||
const sidebarData = useMemo(() => {
|
||||
return sidebarQuery.data ?? initialData;
|
||||
}, [sidebarQuery, initialData]);
|
||||
return treeStream.data ?? sidebarQuery.data ?? initialData;
|
||||
}, [treeStream.data, sidebarQuery, initialData]);
|
||||
|
||||
// isLoading 判断
|
||||
const isLoading = sidebarQuery.isLoading;
|
||||
@@ -986,20 +994,17 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
});
|
||||
|
||||
if (docItemsMap.size > 0) {
|
||||
const resp = await fetch("/api/documents/copy-tree", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
try {
|
||||
await copyTreeCommand({
|
||||
items: Array.from(docItemsMap.entries()).map(([documentId, recursive]) => ({
|
||||
documentId,
|
||||
recursive,
|
||||
})),
|
||||
targetParentId: targetDocId,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
setTimeout(() => window.alert(data?.error ?? "粘贴页面失败"), 0);
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "粘贴页面失败";
|
||||
setTimeout(() => window.alert(message), 0);
|
||||
return;
|
||||
}
|
||||
await sidebarQuery.refetch();
|
||||
@@ -1345,12 +1350,15 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
if (docIds.length > 0) {
|
||||
const results = await Promise.all(
|
||||
docIds.map(async (documentId) => {
|
||||
const resp = await fetch("/api/documents/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId }),
|
||||
});
|
||||
return { documentId, ok: resp.ok };
|
||||
try {
|
||||
await deleteDocumentCommand({
|
||||
documentId,
|
||||
workspaceId: sidebarData.activeWorkspaceId ?? null,
|
||||
});
|
||||
return { documentId, ok: true };
|
||||
} catch {
|
||||
return { documentId, ok: false };
|
||||
}
|
||||
}),
|
||||
);
|
||||
const failed = results.filter((item) => !item.ok).map((item) => item.documentId);
|
||||
@@ -1666,17 +1674,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
|
||||
if (args.copy) {
|
||||
if (docIds.length > 0) {
|
||||
const resp = await fetch("/api/documents/copy-tree", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
try {
|
||||
await copyTreeCommand({
|
||||
items: docIds.map((documentId) => ({ documentId, recursive: true })),
|
||||
targetParentId: targetDocId,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
setTimeout(() => window.alert(payload?.error ?? "复制页面失败"), 0);
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "复制页面失败";
|
||||
setTimeout(() => window.alert(message), 0);
|
||||
return;
|
||||
}
|
||||
await sidebarQuery.refetch();
|
||||
@@ -1782,10 +1787,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (documentId: string) => {
|
||||
await fetch("/api/documents/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId }),
|
||||
await deleteDocumentCommand({
|
||||
documentId,
|
||||
workspaceId: sidebarData.activeWorkspaceId ?? null,
|
||||
});
|
||||
await refreshTree();
|
||||
emitDocumentsChanged(documentId);
|
||||
@@ -1793,7 +1797,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
router.push("/");
|
||||
}
|
||||
},
|
||||
[activeId, refreshTree, router],
|
||||
[activeId, refreshTree, router, sidebarData.activeWorkspaceId],
|
||||
);
|
||||
|
||||
const handleDeleteFromContextMenuNode = useCallback(
|
||||
@@ -1879,14 +1883,13 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
if (!confirmTrashAction("确认恢复该页面吗?")) {
|
||||
return;
|
||||
}
|
||||
await fetch("/api/documents/restore", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId }),
|
||||
await restoreDocumentCommand({
|
||||
documentId,
|
||||
workspaceId: sidebarData.activeWorkspaceId ?? null,
|
||||
});
|
||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||
},
|
||||
[confirmTrashAction, refreshTree, sidebarQuery],
|
||||
[confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery],
|
||||
);
|
||||
|
||||
const handlePurgeFromTrash = useCallback(
|
||||
@@ -1894,11 +1897,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
if (!confirmTrashAction("彻底删除后将无法找回,是否继续?")) {
|
||||
return;
|
||||
}
|
||||
await fetch("/api/documents/purge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId }),
|
||||
});
|
||||
await purgeDocumentCommand({ documentId });
|
||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||
},
|
||||
[confirmTrashAction, refreshTree, sidebarQuery],
|
||||
@@ -2817,14 +2816,15 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
window.alert("不能嵌入到自身页面");
|
||||
return;
|
||||
}
|
||||
const response = await fetch("/api/documents/embed", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceId: source.id, targetId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
try {
|
||||
await embedDocumentCommand({
|
||||
sourceId: source.id,
|
||||
targetId,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "嵌入失败,请检查目标页面";
|
||||
if (typeof window !== "undefined") {
|
||||
window.alert("嵌入失败,请检查目标页面");
|
||||
window.alert(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -243,6 +243,61 @@ describe("documents bridge helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("buildDocumentBridgeMutationRequest builds documents.embed runtime request", () => {
|
||||
const payload = {
|
||||
...buildDocumentSavePayload({
|
||||
documentId: "doc_2",
|
||||
workspaceId: "ws_1",
|
||||
revision: 3,
|
||||
content: [{ id: "block_2", type: "pageReference" }],
|
||||
conflictDetectionKey: "conflict_3",
|
||||
blockCount: 1,
|
||||
}),
|
||||
sourceDocumentId: "doc_1",
|
||||
targetDocumentId: "doc_2",
|
||||
anchorBlockId: "anchor_1",
|
||||
};
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.embed",
|
||||
payload,
|
||||
context: mockContext,
|
||||
target: { workspaceId: "ws_1", pageId: "doc_2" },
|
||||
});
|
||||
|
||||
const request = buildDocumentBridgeMutationRequest({
|
||||
context: mockContext,
|
||||
envelope,
|
||||
mapConvexArgs: (nextPayload) => ({
|
||||
id: nextPayload.documentId,
|
||||
content: nextPayload.content,
|
||||
expectedRevision: nextPayload.revision,
|
||||
conflictDetectionKey: nextPayload.conflictDetectionKey,
|
||||
sourceDocumentId: nextPayload.sourceDocumentId,
|
||||
targetDocumentId: nextPayload.targetDocumentId,
|
||||
anchorBlockId: nextPayload.anchorBlockId,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(request.functionName).toBe("documents:updateContent");
|
||||
expect(request.workspaceId).toBe("ws_1");
|
||||
expect(request.args).toEqual({
|
||||
id: "doc_2",
|
||||
content: [{ id: "block_2", type: "pageReference" }],
|
||||
expectedRevision: 3,
|
||||
conflictDetectionKey: "conflict_3",
|
||||
sourceDocumentId: "doc_1",
|
||||
targetDocumentId: "doc_2",
|
||||
anchorBlockId: "anchor_1",
|
||||
});
|
||||
expect(JSON.parse(request.payloadJson)).toMatchObject({
|
||||
kind: "command",
|
||||
name: "documents.embed",
|
||||
workspace_id: "ws_1",
|
||||
request_id: "req_1",
|
||||
trace_id: "trace_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("buildDocumentBridgeMutationRequest builds media asset writeback runtime request", () => {
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "media.assets.replace_storage",
|
||||
|
||||
@@ -126,6 +126,7 @@ const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
|
||||
"documents.template": "documents:setTemplate",
|
||||
"documents.emptyTrashByWorkspace": "documents:emptyTrashByWorkspace",
|
||||
"documents.purge": "documents:purge",
|
||||
"documents.embed": "documents:updateContent",
|
||||
"blocks.patch": "documents:updateContent",
|
||||
"blocks.move": "documents:updateContent",
|
||||
"blocks.embed": "documents:updateContent",
|
||||
|
||||
@@ -20,7 +20,6 @@ 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";
|
||||
|
||||
@@ -79,6 +78,19 @@ export type DocumentPurgePayload = {
|
||||
documentId: string;
|
||||
};
|
||||
|
||||
export type DocumentEmbedPayload = {
|
||||
documentId: string;
|
||||
workspaceId: string | null;
|
||||
revision: number | null;
|
||||
content: Json;
|
||||
conflictDetectionKey: string | null;
|
||||
snapshotCapturedAt: string | null;
|
||||
blockCount: number | null;
|
||||
sourceDocumentId: string;
|
||||
targetDocumentId: string;
|
||||
anchorBlockId: string | null;
|
||||
};
|
||||
|
||||
export type PageCommandExecutionResult<TResult> = {
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
@@ -287,23 +299,28 @@ 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 embedPayload: DocumentEmbedPayload = {
|
||||
...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,
|
||||
}),
|
||||
sourceDocumentId: normalizedSourceId,
|
||||
targetDocumentId: normalizedTargetId,
|
||||
anchorBlockId: anchorId,
|
||||
};
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.save",
|
||||
payload: savePayload,
|
||||
name: "documents.embed",
|
||||
payload: embedPayload,
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
@@ -311,9 +328,13 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeSaveBridgeCommand({
|
||||
const result = await executePageLifecycleBridgeCommand<DocumentEmbedPayload, {
|
||||
revision?: number | null;
|
||||
conflict_detection_key?: string | null;
|
||||
}>({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildParentById } from "@/lib/file-tree/dnd";
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
json: (body: unknown, init?: { status?: number }) => ({
|
||||
body,
|
||||
status: init?.status ?? 200,
|
||||
}),
|
||||
},
|
||||
}), { virtual: true });
|
||||
|
||||
vi.mock("@/lib/convex/api", () => ({
|
||||
api: {
|
||||
documents: { getMeta: "documents:getMeta" },
|
||||
workspaces: { ensureDefaultWorkspace: "workspaces:ensureDefaultWorkspace" },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/convex/route", () => ({
|
||||
getAuthedConvexClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge", () => ({
|
||||
buildDocumentBridgeContextWithActor: vi.fn(),
|
||||
buildDocumentCommandEnvelope: vi.fn(),
|
||||
documentBridgeErrorResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||
recordBridgeCommandArtifacts: vi.fn(),
|
||||
recordBridgeCommandFailureArtifacts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
executeRustBridgeMutationTransport: vi.fn(),
|
||||
resolveRustBridgeCommandPlan: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server/local-paths", () => ({
|
||||
getDocumentsBaseDir: () => "/tmp/mnote-vitest-documents",
|
||||
}));
|
||||
|
||||
const {
|
||||
normalizeDocumentCopyTreePayload,
|
||||
normalizeDocumentMovePayload,
|
||||
resolveSubtreeMoveLegality,
|
||||
} = await import("./page-lifecycle-command-adapter");
|
||||
|
||||
describe("page-lifecycle-command-adapter", () => {
|
||||
it("归一化 move payload 的 targetParentId 与 sortOrder", () => {
|
||||
expect(
|
||||
normalizeDocumentMovePayload({
|
||||
documentId: " doc_1 ",
|
||||
parentId: " parent_1 ",
|
||||
position: 2.8,
|
||||
}),
|
||||
).toEqual({
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 2,
|
||||
});
|
||||
|
||||
expect(
|
||||
normalizeDocumentMovePayload({
|
||||
documentId: "doc_1",
|
||||
parentId: " ",
|
||||
position: Number.NaN,
|
||||
}),
|
||||
).toEqual({
|
||||
documentId: "doc_1",
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("归一化 copy_tree payload 并去重 source ids", () => {
|
||||
expect(
|
||||
normalizeDocumentCopyTreePayload({
|
||||
targetParentId: " parent_1 ",
|
||||
items: [
|
||||
{ documentId: " doc_a ", recursive: true },
|
||||
{ documentId: "doc_b", recursive: false },
|
||||
{ documentId: "doc_a", recursive: false },
|
||||
{ documentId: " ", recursive: true },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
targetParentId: "parent_1",
|
||||
items: [
|
||||
{ documentId: "doc_a", recursive: true },
|
||||
{ documentId: "doc_b", recursive: false },
|
||||
{ documentId: "doc_a", recursive: false },
|
||||
],
|
||||
sourceIds: ["doc_a", "doc_b"],
|
||||
});
|
||||
});
|
||||
|
||||
it("在 subtree move legality 中先收敛顶层 source,再拦截自拖拽/拖入后代", () => {
|
||||
const parentById = buildParentById([
|
||||
{ id: "a", parentId: null },
|
||||
{ id: "b", parentId: "a" },
|
||||
{ id: "c", parentId: "b" },
|
||||
{ id: "d", parentId: null },
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveSubtreeMoveLegality({
|
||||
sourceDocIds: ["a", "b", "c", "d"],
|
||||
targetParentId: "b",
|
||||
parentById,
|
||||
}),
|
||||
).toEqual({
|
||||
sourceDocIds: ["a", "d"],
|
||||
targetParentId: "b",
|
||||
isInvalid: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveSubtreeMoveLegality({
|
||||
sourceDocIds: ["b", "c"],
|
||||
targetParentId: "a",
|
||||
parentById,
|
||||
}),
|
||||
).toEqual({
|
||||
sourceDocIds: ["b"],
|
||||
targetParentId: "a",
|
||||
isInvalid: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import "server-only";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import { executeRustBridgeMutationTransport, resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
import { filterTopLevelDocIds, isInvalidDocDrop } from "@/lib/file-tree/dnd";
|
||||
|
||||
type CreatePayload = {
|
||||
parentId?: string | null;
|
||||
@@ -52,8 +51,34 @@ type CopyTreePayload = {
|
||||
targetParentId?: string | null;
|
||||
};
|
||||
|
||||
export type NormalizedDocumentMovePayload = {
|
||||
documentId: string | null;
|
||||
parentId: string | null;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type NormalizedCopyTreeItem = {
|
||||
documentId: string;
|
||||
recursive: boolean;
|
||||
};
|
||||
|
||||
export type NormalizedDocumentCopyTreePayload = {
|
||||
targetParentId: string | null;
|
||||
items: NormalizedCopyTreeItem[];
|
||||
sourceIds: string[];
|
||||
};
|
||||
|
||||
const documentsBaseDir = getDocumentsBaseDir();
|
||||
|
||||
function assertServerEnvironment() {
|
||||
if (typeof process !== "undefined" && process.env.VITEST === "true") {
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
throw new Error("page-lifecycle-command-adapter 仅允许在服务端执行");
|
||||
}
|
||||
}
|
||||
|
||||
function trimOrNull(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
@@ -65,6 +90,52 @@ function normalizeTitle(title: string | null): string {
|
||||
return safe && safe.length > 0 ? safe : "无标题";
|
||||
}
|
||||
|
||||
export function normalizeDocumentMovePayload(payload: MovePayload): NormalizedDocumentMovePayload {
|
||||
return {
|
||||
documentId: trimOrNull(payload.documentId),
|
||||
parentId: trimOrNull(payload.parentId),
|
||||
sortOrder: Number.isFinite(payload.position ?? Number.NaN) ? Math.floor(payload.position ?? 0) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDocumentCopyTreePayload(
|
||||
payload: CopyTreePayload,
|
||||
): NormalizedDocumentCopyTreePayload {
|
||||
const items = (payload.items ?? [])
|
||||
.map((item) => ({
|
||||
documentId: trimOrNull(item?.documentId),
|
||||
recursive: Boolean(item?.recursive),
|
||||
}))
|
||||
.filter((item): item is NormalizedCopyTreeItem => Boolean(item.documentId))
|
||||
.map((item) => ({
|
||||
documentId: item.documentId,
|
||||
recursive: item.recursive,
|
||||
}));
|
||||
|
||||
return {
|
||||
targetParentId: trimOrNull(payload.targetParentId),
|
||||
items,
|
||||
sourceIds: Array.from(new Set(items.map((item) => item.documentId))),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSubtreeMoveLegality(input: {
|
||||
sourceDocIds: string[];
|
||||
targetParentId: string | null;
|
||||
parentById: Map<string, string | null>;
|
||||
}) {
|
||||
const topLevelSourceDocIds = filterTopLevelDocIds(input.sourceDocIds, input.parentById);
|
||||
return {
|
||||
sourceDocIds: topLevelSourceDocIds,
|
||||
targetParentId: input.targetParentId,
|
||||
isInvalid: isInvalidDocDrop({
|
||||
sourceDocIds: topLevelSourceDocIds,
|
||||
targetParentId: input.targetParentId,
|
||||
parentById: input.parentById,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function safeRandomId() {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
@@ -138,6 +209,7 @@ async function handleLifecycleError(error: unknown) {
|
||||
}
|
||||
|
||||
export async function handleDocumentCreateRequest(request: Request): Promise<NextResponse> {
|
||||
assertServerEnvironment();
|
||||
try {
|
||||
const payload = (await request.json()) as CreatePayload;
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
@@ -185,7 +257,7 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
|
||||
pageId: documentId,
|
||||
},
|
||||
});
|
||||
const { context, plan } = await resolveCommandPlan({
|
||||
const { context: runtimeContext, plan } = await resolveCommandPlan({
|
||||
request,
|
||||
workspaceId,
|
||||
authUserId: auth.userId,
|
||||
@@ -213,7 +285,7 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
|
||||
}
|
||||
|
||||
await recordBridgeCommandArtifacts({
|
||||
context,
|
||||
context: runtimeContext,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
@@ -255,9 +327,11 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
|
||||
}
|
||||
|
||||
export async function handleDocumentMoveRequest(request: Request): Promise<NextResponse> {
|
||||
assertServerEnvironment();
|
||||
try {
|
||||
const payload = (await request.json()) as MovePayload;
|
||||
const documentId = trimOrNull(payload.documentId);
|
||||
const normalizedMove = normalizeDocumentMovePayload(payload);
|
||||
const documentId = normalizedMove.documentId;
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
@@ -273,8 +347,8 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
|
||||
name: "documents.move",
|
||||
payload: {
|
||||
documentId,
|
||||
parentId: trimOrNull(payload.parentId),
|
||||
sortOrder: Number.isFinite(payload.position ?? Number.NaN) ? Math.floor(payload.position ?? 0) : 0,
|
||||
parentId: normalizedMove.parentId,
|
||||
sortOrder: normalizedMove.sortOrder,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
@@ -329,6 +403,7 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
|
||||
}
|
||||
|
||||
export async function handleDocumentDeleteRequest(request: Request): Promise<NextResponse> {
|
||||
assertServerEnvironment();
|
||||
try {
|
||||
const payload = (await request.json()) as DeletePayload;
|
||||
const documentId = trimOrNull(payload.documentId);
|
||||
@@ -399,6 +474,7 @@ export async function handleDocumentDeleteRequest(request: Request): Promise<Nex
|
||||
}
|
||||
|
||||
export async function handleDocumentRestoreRequest(request: Request): Promise<NextResponse> {
|
||||
assertServerEnvironment();
|
||||
try {
|
||||
const payload = (await request.json()) as RestorePayload;
|
||||
const documentId = trimOrNull(payload.documentId);
|
||||
@@ -473,6 +549,7 @@ export async function handleDocumentRestoreRequest(request: Request): Promise<Ne
|
||||
}
|
||||
|
||||
export async function handleDocumentDuplicateRequest(request: Request): Promise<NextResponse> {
|
||||
assertServerEnvironment();
|
||||
try {
|
||||
const payload = (await request.json()) as DuplicatePayload;
|
||||
const documentId = trimOrNull(payload.documentId);
|
||||
@@ -569,16 +646,15 @@ export async function handleDocumentDuplicateRequest(request: Request): Promise<
|
||||
}
|
||||
|
||||
export async function handleDocumentCopyTreeRequest(request: Request): Promise<NextResponse> {
|
||||
assertServerEnvironment();
|
||||
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) {
|
||||
const normalizedCopyTree = normalizeDocumentCopyTreePayload(payload);
|
||||
if (normalizedCopyTree.items.length === 0) {
|
||||
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const targetParentId = trimOrNull(payload.targetParentId);
|
||||
const targetParentId = normalizedCopyTree.targetParentId;
|
||||
let workspaceId: string | null = null;
|
||||
|
||||
if (targetParentId) {
|
||||
@@ -589,7 +665,7 @@ export async function handleDocumentCopyTreeRequest(request: Request): Promise<N
|
||||
workspaceId = targetDoc.workspace_id;
|
||||
}
|
||||
|
||||
const sourceIds = Array.from(new Set(normalizedItems.map((it) => trimOrNull(it.documentId) as string)));
|
||||
const sourceIds = normalizedCopyTree.sourceIds;
|
||||
const firstMeta = await client.query(api.documents.getMeta, { id: sourceIds[0] });
|
||||
if (!firstMeta) {
|
||||
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
@@ -607,10 +683,7 @@ export async function handleDocumentCopyTreeRequest(request: Request): Promise<N
|
||||
const outerEnvelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.copy_tree",
|
||||
payload: {
|
||||
items: normalizedItems.map((item) => ({
|
||||
documentId: trimOrNull(item.documentId) as string,
|
||||
recursive: Boolean(item.recursive),
|
||||
})),
|
||||
items: normalizedCopyTree.items,
|
||||
targetParentId,
|
||||
},
|
||||
context,
|
||||
|
||||
@@ -89,6 +89,7 @@ export type PageSubtreeProjection = {
|
||||
};
|
||||
|
||||
const SNIPPET_MAX_LENGTH = 220;
|
||||
const PAGE_SUBTREE_PROJECTION_ID = "kernel_projection:page_tree:document_subtree";
|
||||
|
||||
const pickFirstText = (...values: unknown[]) => {
|
||||
for (const value of values) {
|
||||
@@ -367,7 +368,7 @@ export function buildPageSubtreeProjection(input: {
|
||||
}
|
||||
|
||||
return {
|
||||
projectionId: `page_subtree:${rootNodeId}`,
|
||||
projectionId: PAGE_SUBTREE_PROJECTION_ID,
|
||||
projection: "page_tree",
|
||||
rootNodeId,
|
||||
rootNode,
|
||||
|
||||
@@ -43,6 +43,33 @@ type MoveDocumentInput = {
|
||||
workspaceId?: string | null;
|
||||
};
|
||||
|
||||
type DeleteDocumentInput = {
|
||||
documentId: string;
|
||||
workspaceId?: string | null;
|
||||
};
|
||||
|
||||
type RestoreDocumentInput = {
|
||||
documentId: string;
|
||||
workspaceId?: string | null;
|
||||
};
|
||||
|
||||
type PurgeDocumentInput = {
|
||||
documentId: string;
|
||||
};
|
||||
|
||||
type EmbedDocumentInput = {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
};
|
||||
|
||||
type CopyTreeCommandInput = {
|
||||
targetParentId: string | null;
|
||||
items: Array<{
|
||||
documentId: string;
|
||||
recursive: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
type CreateChildDocumentInput = {
|
||||
parentId: string | null;
|
||||
title: string;
|
||||
@@ -106,3 +133,73 @@ export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ o
|
||||
"移动失败,请稍后再试",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteDocumentCommand(
|
||||
input: DeleteDocumentInput,
|
||||
): Promise<{ success: true; meta?: DocumentCommandMeta }> {
|
||||
return postDocumentCommand<{ success: true; meta?: DocumentCommandMeta }>(
|
||||
"/api/documents/delete",
|
||||
{
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
},
|
||||
"删除失败,请稍后再试",
|
||||
);
|
||||
}
|
||||
|
||||
export async function restoreDocumentCommand(
|
||||
input: RestoreDocumentInput,
|
||||
): Promise<{ success: true; meta?: DocumentCommandMeta }> {
|
||||
return postDocumentCommand<{ success: true; meta?: DocumentCommandMeta }>(
|
||||
"/api/documents/restore",
|
||||
{
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
},
|
||||
"恢复失败,请稍后再试",
|
||||
);
|
||||
}
|
||||
|
||||
export async function purgeDocumentCommand(
|
||||
input: PurgeDocumentInput,
|
||||
): Promise<{ success: true; purged?: boolean; meta?: DocumentCommandMeta }> {
|
||||
return postDocumentCommand<{ success: true; purged?: boolean; meta?: DocumentCommandMeta }>(
|
||||
"/api/documents/purge",
|
||||
{
|
||||
documentId: input.documentId,
|
||||
},
|
||||
"彻底删除失败,请稍后再试",
|
||||
);
|
||||
}
|
||||
|
||||
export async function embedDocumentCommand(
|
||||
input: EmbedDocumentInput,
|
||||
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
|
||||
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
|
||||
"/api/documents/embed",
|
||||
{
|
||||
sourceId: input.sourceId,
|
||||
targetId: input.targetId,
|
||||
},
|
||||
"嵌入失败,请稍后再试",
|
||||
);
|
||||
}
|
||||
|
||||
export async function copyTreeCommand(
|
||||
input: CopyTreeCommandInput,
|
||||
): Promise<{
|
||||
items: Array<{ oldId: string; newId: string }>;
|
||||
meta?: DocumentCommandMeta;
|
||||
}> {
|
||||
return postDocumentCommand<{
|
||||
items: Array<{ oldId: string; newId: string }>;
|
||||
meta?: DocumentCommandMeta;
|
||||
}>(
|
||||
"/api/documents/copy-tree",
|
||||
{
|
||||
targetParentId: input.targetParentId,
|
||||
items: input.items,
|
||||
},
|
||||
"复制页面失败,请稍后再试",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -318,4 +318,48 @@ describe("buildSidebarInitialData", () => {
|
||||
mediaAssets: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("缺少 kernel projection 时不再本地补真相,而是返回空契约 projection", () => {
|
||||
expect(
|
||||
mapSidebarDatasetListQueryResultToInitialData({
|
||||
active_workspace_id: "ws_1",
|
||||
workspaces: [],
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_1",
|
||||
title: "页面 1",
|
||||
parent_id: null,
|
||||
sort_order: 1,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-14T00:00:00Z",
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
trashed_documents: [],
|
||||
media_assets: [],
|
||||
trashed_media_assets: [],
|
||||
mindmap_assets: [],
|
||||
trashed_mindmap_assets: [],
|
||||
table_assets: [],
|
||||
trashed_table_assets: [],
|
||||
mindmap_docs: [],
|
||||
mindmap_asset_children: {},
|
||||
}),
|
||||
).toMatchObject({
|
||||
activeWorkspaceId: "ws_1",
|
||||
kernelSidebarProjection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:missing",
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
kernelSidebarTree: [
|
||||
{
|
||||
id: "doc_1",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import {
|
||||
buildKernelSidebarProjection,
|
||||
buildKernelSidebarProjection as buildProjectionContract,
|
||||
buildSidebarTreeFromKernelProjection,
|
||||
type KernelSidebarProjection,
|
||||
} from "@/lib/kernel-sidebar";
|
||||
@@ -64,6 +64,41 @@ export type SidebarDatasetListQueryResult = {
|
||||
mindmap_asset_children: Record<string, string[]>;
|
||||
};
|
||||
|
||||
const EMPTY_KERNEL_SIDEBAR_PROJECTION: KernelSidebarProjection = {
|
||||
projectionId: "kernel_projection:sidebar_tree:missing",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
function isKernelSidebarProjection(value: unknown): value is KernelSidebarProjection {
|
||||
return (
|
||||
Boolean(value) &&
|
||||
typeof value === "object" &&
|
||||
Array.isArray((value as KernelSidebarProjection).items) &&
|
||||
Array.isArray((value as KernelSidebarProjection).edges)
|
||||
);
|
||||
}
|
||||
|
||||
function readKernelSidebarProjection(
|
||||
result: SidebarDatasetListQueryResult,
|
||||
): KernelSidebarProjection {
|
||||
const snakeCaseProjection =
|
||||
"kernel_sidebar_projection" in result ? result.kernel_sidebar_projection : undefined;
|
||||
if (isKernelSidebarProjection(snakeCaseProjection)) {
|
||||
return snakeCaseProjection;
|
||||
}
|
||||
|
||||
const camelCaseProjection =
|
||||
"kernelSidebarProjection" in result ? result.kernelSidebarProjection : undefined;
|
||||
if (isKernelSidebarProjection(camelCaseProjection)) {
|
||||
return camelCaseProjection;
|
||||
}
|
||||
|
||||
return EMPTY_KERNEL_SIDEBAR_PROJECTION;
|
||||
}
|
||||
|
||||
function normalizeStringArray(values: Iterable<string>): string[] {
|
||||
return Array.from(new Set(values)).filter((value) => value.trim().length > 0);
|
||||
}
|
||||
@@ -220,7 +255,7 @@ export function buildSidebarDatasetListQueryResult(
|
||||
input: SidebarDatasetInput,
|
||||
): SidebarDatasetListQueryResult {
|
||||
const derived = deriveSidebarDataset(input);
|
||||
const kernelSidebarProjection = buildKernelSidebarProjection(input.documents);
|
||||
const kernelSidebarProjection = buildProjectionContract(input.documents);
|
||||
|
||||
return {
|
||||
active_workspace_id: input.activeWorkspaceId,
|
||||
@@ -242,20 +277,7 @@ export function buildSidebarDatasetListQueryResult(
|
||||
export function mapSidebarDatasetListQueryResultToInitialData(
|
||||
result: SidebarDatasetListQueryResult,
|
||||
): SidebarInitialData {
|
||||
// 说明:兼容旧的 Convex `sidebar.dataset.list` 返回体。
|
||||
// 若上游暂未附带 `kernel_sidebar_projection`,这里按 documents 现算一份,
|
||||
// 避免 SSR 因契约未完全切齐而直接崩掉。
|
||||
const candidateProjection =
|
||||
result.kernel_sidebar_projection ??
|
||||
result.kernelSidebarProjection ??
|
||||
null;
|
||||
const kernelSidebarProjection =
|
||||
candidateProjection &&
|
||||
typeof candidateProjection === "object" &&
|
||||
Array.isArray(candidateProjection.items) &&
|
||||
Array.isArray(candidateProjection.edges)
|
||||
? candidateProjection
|
||||
: buildKernelSidebarProjection(result.documents ?? []);
|
||||
const kernelSidebarProjection = readKernelSidebarProjection(result);
|
||||
|
||||
return {
|
||||
activeWorkspaceId: result.active_workspace_id,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import {
|
||||
mapSidebarDatasetListQueryResultToInitialData,
|
||||
type SidebarDatasetListQueryResult,
|
||||
} from "@/lib/sidebar-data";
|
||||
|
||||
export type TreeStreamKind = "snapshot" | "delta" | "resync";
|
||||
|
||||
export interface TreeStreamEnvelope {
|
||||
stream: string;
|
||||
workspaceId: string | null;
|
||||
rootNodeId: string | null;
|
||||
cursor: string | null;
|
||||
kind: TreeStreamKind;
|
||||
projection: string | null;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
const TREE_STREAM_KINDS = new Set<TreeStreamKind>(["snapshot", "delta", "resync"]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readKind(value: unknown): TreeStreamKind | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return TREE_STREAM_KINDS.has(normalized as TreeStreamKind)
|
||||
? (normalized as TreeStreamKind)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readNestedPayload(record: Record<string, unknown>): unknown {
|
||||
for (const key of ["data", "payload", "snapshot", "dataset", "sidebar"]) {
|
||||
if (key in record) {
|
||||
return record[key];
|
||||
}
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function looksLikeSidebarInitialData(value: unknown): value is SidebarInitialData {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.activeWorkspaceId === "string" &&
|
||||
Array.isArray(value.documents)
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeSidebarDatasetListQueryResult(
|
||||
value: unknown,
|
||||
): value is SidebarDatasetListQueryResult {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.active_workspace_id === "string" &&
|
||||
Array.isArray(value.documents)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildWorkspaceTreeStreamUrl(
|
||||
baseUrl: string,
|
||||
workspaceId: string,
|
||||
cursor?: string | null,
|
||||
): string {
|
||||
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
|
||||
const url = new URL("/api/stream/events", `${normalizedBaseUrl}/`);
|
||||
url.searchParams.set("stream", "workspace");
|
||||
url.searchParams.set("projection", "sidebar_tree");
|
||||
url.searchParams.set("workspaceId", workspaceId.trim());
|
||||
if (typeof cursor === "string" && cursor.trim()) {
|
||||
url.searchParams.set("cursor", cursor.trim());
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function parseTreeStreamMessage(input: {
|
||||
rawData: string;
|
||||
eventType?: string | null;
|
||||
}): TreeStreamEnvelope | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(input.rawData);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kind = readKind(parsed.kind) ?? readKind(input.eventType) ?? readKind(parsed.event);
|
||||
if (!kind) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
stream: readString(parsed.stream) ?? "workspace",
|
||||
workspaceId: readString(parsed.workspaceId) ?? readString(parsed.workspace_id),
|
||||
rootNodeId: readString(parsed.rootNodeId) ?? readString(parsed.root_node_id),
|
||||
cursor: readString(parsed.cursor),
|
||||
kind,
|
||||
projection: readString(parsed.projection),
|
||||
data: readNestedPayload(parsed),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTreeStreamSnapshot(data: unknown): SidebarInitialData | null {
|
||||
if (looksLikeSidebarInitialData(data)) {
|
||||
return data;
|
||||
}
|
||||
if (looksLikeSidebarDatasetListQueryResult(data)) {
|
||||
return mapSidebarDatasetListQueryResultToInitialData(data);
|
||||
}
|
||||
if (!isRecord(data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const key of ["snapshot", "dataset", "sidebar", "payload", "data"]) {
|
||||
if (!(key in data)) {
|
||||
continue;
|
||||
}
|
||||
const nested = normalizeTreeStreamSnapshot(data[key]);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { applyTreeStreamDelta } from "./tree-delta";
|
||||
|
||||
const baseSidebarData: SidebarInitialData = {
|
||||
activeWorkspaceId: "ws_1",
|
||||
workspaces: [],
|
||||
documents: [
|
||||
{
|
||||
id: "root",
|
||||
workspace_id: "ws_1",
|
||||
title: "Root",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-18T00:00:00Z",
|
||||
updated_at: null,
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
workspace_id: "ws_1",
|
||||
title: "Child",
|
||||
parent_id: "root",
|
||||
sort_order: 1,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-18T00:00:00Z",
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
kernelSidebarProjection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
items: [
|
||||
{
|
||||
nodeId: "root",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
title: "Root",
|
||||
depth: 0,
|
||||
position: 0,
|
||||
childCount: 1,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
{
|
||||
nodeId: "child",
|
||||
parentNodeId: "root",
|
||||
nodeType: "page",
|
||||
title: "Child",
|
||||
depth: 1,
|
||||
position: 1,
|
||||
childCount: 0,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "edge:root:child:parent_of",
|
||||
edgeType: "parent_of",
|
||||
workspaceId: "ws_1",
|
||||
fromNodeId: "root",
|
||||
toNodeId: "child",
|
||||
},
|
||||
],
|
||||
},
|
||||
kernelSidebarTree: [],
|
||||
trashedDocuments: [],
|
||||
trashedMediaAssets: [],
|
||||
trashedMindmapAssets: [],
|
||||
trashedTableAssets: [],
|
||||
tableAssets: [],
|
||||
mindmapDocs: [],
|
||||
mindmapAssets: [],
|
||||
mindmapAssetChildren: {},
|
||||
mediaAssets: [],
|
||||
};
|
||||
|
||||
describe("tree-stream/tree-delta", () => {
|
||||
it("支持 upsert_document 重建 sidebar projection", () => {
|
||||
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||
op: "upsert_document",
|
||||
document: {
|
||||
id: "leaf",
|
||||
workspace_id: "ws_1",
|
||||
title: "Leaf",
|
||||
parent_id: "child",
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-18T00:00:00Z",
|
||||
updated_at: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(next.documents.map((item) => item.id)).toEqual(["root", "child", "leaf"]);
|
||||
expect(next.kernelSidebarProjection.items.map((item) => item.nodeId)).toEqual([
|
||||
"root",
|
||||
"child",
|
||||
"leaf",
|
||||
]);
|
||||
});
|
||||
|
||||
it("支持 remove_document 级联移除子树", () => {
|
||||
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||
op: "remove_document",
|
||||
documentId: "root",
|
||||
});
|
||||
|
||||
expect(next.documents).toEqual([]);
|
||||
expect(next.kernelSidebarProjection.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("支持 replace_sidebar 直接切换到 resync snapshot", () => {
|
||||
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||
op: "replace_sidebar",
|
||||
sidebar: {
|
||||
activeWorkspaceId: "ws_1",
|
||||
workspaces: [],
|
||||
documents: [],
|
||||
kernelSidebarProjection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
kernelSidebarTree: [],
|
||||
trashedDocuments: [],
|
||||
trashedMediaAssets: [],
|
||||
trashedMindmapAssets: [],
|
||||
trashedTableAssets: [],
|
||||
tableAssets: [],
|
||||
mindmapDocs: [],
|
||||
mindmapAssets: [],
|
||||
mindmapAssetChildren: {},
|
||||
mediaAssets: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(next.documents).toEqual([]);
|
||||
expect(next.kernelSidebarProjection.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
|
||||
import {
|
||||
buildSidebarDatasetListQueryResult,
|
||||
mapSidebarDatasetListQueryResultToInitialData,
|
||||
} from "@/lib/sidebar-data";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
export type TreeStreamDeltaOp =
|
||||
| "upsert_document"
|
||||
| "remove_document"
|
||||
| "replace_documents"
|
||||
| "replace_sidebar";
|
||||
|
||||
export type TreeStreamDeltaEvent = {
|
||||
op: TreeStreamDeltaOp;
|
||||
node?: DocumentRecord | null;
|
||||
document?: DocumentRecord | null;
|
||||
documentId?: string | null;
|
||||
documents?: DocumentRecord[] | null;
|
||||
sidebar?: SidebarDatasetListQueryResult | SidebarInitialData | null;
|
||||
};
|
||||
|
||||
function cloneSidebarData(data: SidebarInitialData): SidebarInitialData {
|
||||
return {
|
||||
...data,
|
||||
workspaces: [...data.workspaces],
|
||||
documents: [...data.documents],
|
||||
kernelSidebarProjection: {
|
||||
...data.kernelSidebarProjection,
|
||||
items: [...data.kernelSidebarProjection.items],
|
||||
edges: [...data.kernelSidebarProjection.edges],
|
||||
},
|
||||
kernelSidebarTree: [...data.kernelSidebarTree],
|
||||
trashedDocuments: [...data.trashedDocuments],
|
||||
trashedMediaAssets: [...(data.trashedMediaAssets ?? [])],
|
||||
trashedMindmapAssets: [...(data.trashedMindmapAssets ?? [])],
|
||||
trashedTableAssets: [...(data.trashedTableAssets ?? [])],
|
||||
mindmapDocs: [...(data.mindmapDocs ?? [])],
|
||||
mindmapAssets: [...(data.mindmapAssets ?? [])],
|
||||
mindmapAssetChildren: { ...(data.mindmapAssetChildren ?? {}) },
|
||||
tableAssets: [...(data.tableAssets ?? [])],
|
||||
mediaAssets: [...(data.mediaAssets ?? [])],
|
||||
};
|
||||
}
|
||||
|
||||
function buildSidebarFromDocuments(input: {
|
||||
base: SidebarInitialData;
|
||||
documents: DocumentRecord[];
|
||||
}): SidebarInitialData {
|
||||
const queryResult = buildSidebarDatasetListQueryResult({
|
||||
activeWorkspaceId: input.base.activeWorkspaceId,
|
||||
workspaces: input.base.workspaces as WorkspaceSummary[],
|
||||
documents: input.documents,
|
||||
trashedDocuments: input.base.trashedDocuments,
|
||||
mindmaps: (input.base.mindmapAssets ?? []).map((asset) => ({
|
||||
mindmap_id: asset.id,
|
||||
workspace_id: asset.workspace_id,
|
||||
document_id: asset.document_id,
|
||||
created_at: asset.created_at,
|
||||
updated_at: asset.updated_at,
|
||||
deleted_at: asset.deleted_at ?? null,
|
||||
deleted_by: asset.deleted_by ?? null,
|
||||
})),
|
||||
mediaAssets: input.base.mediaAssets as MediaAsset[] | null,
|
||||
trashedMediaAssets: input.base.trashedMediaAssets as MediaAsset[] | null,
|
||||
tables: (input.base.tableAssets ?? []).map((asset) => ({
|
||||
id: asset.id,
|
||||
workspace_id: asset.workspace_id,
|
||||
document_id: asset.document_id,
|
||||
title: asset.file_name ?? null,
|
||||
created_at: asset.created_at,
|
||||
updated_at: asset.updated_at,
|
||||
deleted_at: asset.deleted_at ?? null,
|
||||
deleted_by: asset.deleted_by ?? null,
|
||||
purged_at: asset.purged_at ?? null,
|
||||
is_archived: Boolean(asset.deleted_at),
|
||||
})),
|
||||
});
|
||||
|
||||
return mapSidebarDatasetListQueryResultToInitialData(queryResult);
|
||||
}
|
||||
|
||||
function normalizeUpsertDocument(event: TreeStreamDeltaEvent): DocumentRecord | null {
|
||||
const candidate = event.node ?? event.document ?? null;
|
||||
return candidate && typeof candidate === "object" ? candidate : null;
|
||||
}
|
||||
|
||||
function normalizeDocumentId(event: TreeStreamDeltaEvent): string | null {
|
||||
const candidate = typeof event.documentId === "string" ? event.documentId.trim() : "";
|
||||
return candidate || null;
|
||||
}
|
||||
|
||||
export function applyTreeStreamDelta(
|
||||
base: SidebarInitialData,
|
||||
event: TreeStreamDeltaEvent,
|
||||
): SidebarInitialData {
|
||||
if (event.op === "replace_sidebar" && event.sidebar) {
|
||||
if ("activeWorkspaceId" in event.sidebar) {
|
||||
return cloneSidebarData(event.sidebar as SidebarInitialData);
|
||||
}
|
||||
return mapSidebarDatasetListQueryResultToInitialData(event.sidebar as SidebarDatasetListQueryResult);
|
||||
}
|
||||
|
||||
if (event.op === "replace_documents" && Array.isArray(event.documents)) {
|
||||
return buildSidebarFromDocuments({
|
||||
base,
|
||||
documents: [...event.documents],
|
||||
});
|
||||
}
|
||||
|
||||
if (event.op === "upsert_document") {
|
||||
const nextDocument = normalizeUpsertDocument(event);
|
||||
if (!nextDocument) {
|
||||
return base;
|
||||
}
|
||||
const nextDocuments = [...base.documents];
|
||||
const existingIndex = nextDocuments.findIndex((item) => item.id === nextDocument.id);
|
||||
if (existingIndex >= 0) {
|
||||
nextDocuments[existingIndex] = nextDocument;
|
||||
} else {
|
||||
nextDocuments.push(nextDocument);
|
||||
}
|
||||
return buildSidebarFromDocuments({
|
||||
base,
|
||||
documents: nextDocuments,
|
||||
});
|
||||
}
|
||||
|
||||
if (event.op === "remove_document") {
|
||||
const documentId = normalizeDocumentId(event);
|
||||
if (!documentId) {
|
||||
return base;
|
||||
}
|
||||
const removedIds = new Set<string>([documentId]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const document of base.documents) {
|
||||
if (document.parent_id && removedIds.has(document.parent_id) && !removedIds.has(document.id)) {
|
||||
removedIds.add(document.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return buildSidebarFromDocuments({
|
||||
base,
|
||||
documents: base.documents.filter((item) => !removedIds.has(item.id)),
|
||||
});
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWorkspaceTreeStreamUrl,
|
||||
normalizeTreeStreamSnapshot,
|
||||
parseTreeStreamMessage,
|
||||
} from "./protocol";
|
||||
|
||||
describe("tree-stream/protocol", () => {
|
||||
it("构造 workspace sidebar stream url", () => {
|
||||
expect(
|
||||
buildWorkspaceTreeStreamUrl("http://127.0.0.1:3104/", " ws_1 ", "evt_9"),
|
||||
).toBe(
|
||||
"http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1&cursor=evt_9",
|
||||
);
|
||||
});
|
||||
|
||||
it("解析 snapshot / delta / resync 协议消息", () => {
|
||||
expect(
|
||||
parseTreeStreamMessage({
|
||||
eventType: "snapshot",
|
||||
rawData: JSON.stringify({
|
||||
stream: "workspace",
|
||||
workspaceId: "ws_1",
|
||||
cursor: "evt_1",
|
||||
projection: "sidebar_tree",
|
||||
data: {
|
||||
active_workspace_id: "ws_1",
|
||||
workspaces: [],
|
||||
documents: [],
|
||||
kernel_sidebar_projection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
trashed_documents: [],
|
||||
media_assets: [],
|
||||
trashed_media_assets: [],
|
||||
mindmap_assets: [],
|
||||
trashed_mindmap_assets: [],
|
||||
table_assets: [],
|
||||
trashed_table_assets: [],
|
||||
mindmap_docs: [],
|
||||
mindmap_asset_children: {},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: "snapshot",
|
||||
stream: "workspace",
|
||||
workspaceId: "ws_1",
|
||||
cursor: "evt_1",
|
||||
projection: "sidebar_tree",
|
||||
});
|
||||
|
||||
expect(
|
||||
parseTreeStreamMessage({
|
||||
eventType: "delta",
|
||||
rawData: JSON.stringify({
|
||||
kind: "delta",
|
||||
workspace_id: "ws_1",
|
||||
cursor: "evt_2",
|
||||
payload: { op: "remove_document", documentId: "doc_1" },
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: "delta",
|
||||
workspaceId: "ws_1",
|
||||
cursor: "evt_2",
|
||||
data: { op: "remove_document", documentId: "doc_1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("把 sidebar.dataset.list snapshot 归一化成 SidebarInitialData", () => {
|
||||
const snapshot = normalizeTreeStreamSnapshot({
|
||||
active_workspace_id: "ws_1",
|
||||
workspaces: [],
|
||||
documents: [],
|
||||
kernel_sidebar_projection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
trashed_documents: [],
|
||||
media_assets: [],
|
||||
trashed_media_assets: [],
|
||||
mindmap_assets: [],
|
||||
trashed_mindmap_assets: [],
|
||||
table_assets: [],
|
||||
trashed_table_assets: [],
|
||||
mindmap_docs: [],
|
||||
mindmap_asset_children: {},
|
||||
});
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
activeWorkspaceId: "ws_1",
|
||||
kernelSidebarProjection: {
|
||||
projection: "sidebar_tree",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import {
|
||||
buildWorkspaceTreeStreamUrl,
|
||||
normalizeTreeStreamSnapshot,
|
||||
parseTreeStreamMessage,
|
||||
} from "@/lib/tree-stream/protocol";
|
||||
import { applyTreeStreamDelta, type TreeStreamDeltaEvent } from "@/lib/tree-stream/tree-delta";
|
||||
|
||||
export interface SidebarTreeStreamState {
|
||||
data: SidebarInitialData | null;
|
||||
status: "idle" | "connecting" | "live" | "fallback";
|
||||
cursor: string | null;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeDeltaEvent(input: unknown): TreeStreamDeltaEvent | null {
|
||||
if (!isRecord(input) || typeof input.op !== "string") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
op: input.op as TreeStreamDeltaEvent["op"],
|
||||
node: isRecord(input.node) ? (input.node as TreeStreamDeltaEvent["node"]) : null,
|
||||
document: isRecord(input.document) ? (input.document as TreeStreamDeltaEvent["document"]) : null,
|
||||
documentId: typeof input.documentId === "string" ? input.documentId : null,
|
||||
documents: Array.isArray(input.documents) ? (input.documents as TreeStreamDeltaEvent["documents"]) : null,
|
||||
sidebar: isRecord(input.sidebar) ? input.sidebar : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTreeStreamState {
|
||||
const runtime = useMemo(() => getMnoteRuntimeConfig(), []);
|
||||
const workspaceId = initialData.activeWorkspaceId;
|
||||
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
|
||||
const streamEnabled = Boolean(baseUrl && workspaceId);
|
||||
|
||||
const [state, setState] = useState<SidebarTreeStreamState>({
|
||||
data: null,
|
||||
status: streamEnabled ? "connecting" : "idle",
|
||||
cursor: null,
|
||||
error: null,
|
||||
});
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!streamEnabled) {
|
||||
setState({
|
||||
data: null,
|
||||
status: "idle",
|
||||
cursor: null,
|
||||
error: null,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, state.cursor);
|
||||
const eventSource = new EventSource(url, { withCredentials: true });
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
const handleMessage = (event: MessageEvent<string>) => {
|
||||
const envelope = parseTreeStreamMessage({
|
||||
rawData: event.data,
|
||||
eventType: event.type,
|
||||
});
|
||||
if (!envelope) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((previous) => {
|
||||
const nextCursor = envelope.cursor ?? previous.cursor;
|
||||
|
||||
if (envelope.kind === "snapshot" || envelope.kind === "resync") {
|
||||
const snapshot = normalizeTreeStreamSnapshot(envelope.data);
|
||||
if (!snapshot) {
|
||||
return {
|
||||
...previous,
|
||||
cursor: nextCursor,
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: snapshot,
|
||||
status: "live",
|
||||
cursor: nextCursor,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (envelope.kind === "delta" && previous.data) {
|
||||
const deltaEvent = normalizeDeltaEvent(envelope.data);
|
||||
if (!deltaEvent) {
|
||||
return {
|
||||
...previous,
|
||||
cursor: nextCursor,
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: applyTreeStreamDelta(previous.data, deltaEvent),
|
||||
status: "live",
|
||||
cursor: nextCursor,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
cursor: nextCursor,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setState((previous) => ({
|
||||
...previous,
|
||||
status: previous.data ? "live" : "fallback",
|
||||
error: previous.error ?? new Error("tree stream 连接失败"),
|
||||
}));
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
};
|
||||
|
||||
eventSource.addEventListener("snapshot", handleMessage as EventListener);
|
||||
eventSource.addEventListener("delta", handleMessage as EventListener);
|
||||
eventSource.addEventListener("resync", handleMessage as EventListener);
|
||||
eventSource.onmessage = handleMessage;
|
||||
eventSource.onerror = handleError;
|
||||
|
||||
return () => {
|
||||
eventSource.removeEventListener("snapshot", handleMessage as EventListener);
|
||||
eventSource.removeEventListener("delta", handleMessage as EventListener);
|
||||
eventSource.removeEventListener("resync", handleMessage as EventListener);
|
||||
eventSource.close();
|
||||
if (eventSourceRef.current === eventSource) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [baseUrl, state.cursor, streamEnabled, workspaceId]);
|
||||
|
||||
return state;
|
||||
}
|
||||
Reference in New Issue
Block a user