feat: 收口 tree-first graph 主链与前端测试修复
This commit is contained in:
@@ -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