feat(editor): save leptos island and page aggregate alignment progress

- switch main document flow toward leptos tiptap island host and generated runtime assets

- align page aggregate loading, page head single-source updates, and AI tool result recovery

- add tests and smoke scripts for title sync, AI route recovery, and editor host cutover
This commit is contained in:
lix-2026
2026-04-22 05:57:06 +08:00
parent 5d1c94eb9e
commit 8353aea2f9
105 changed files with 14768 additions and 4186 deletions
+14 -3
View File
@@ -69,9 +69,20 @@ export function buildWorkspaceTreeStreamUrl(
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");
const shouldUseSameOriginProxy =
normalizedBaseUrl.length > 0 &&
typeof window !== "undefined" &&
(() => {
try {
const runtimeUrl = new URL(`${normalizedBaseUrl}/`);
return runtimeUrl.origin !== window.location.origin;
} catch {
return false;
}
})();
const url = shouldUseSameOriginProxy
? new URL("/api/mnote-web/stream", window.location.origin)
: new URL("/api/mnote-web/stream", `${normalizedBaseUrl}/`);
url.searchParams.set("workspaceId", workspaceId.trim());
if (typeof cursor === "string" && cursor.trim()) {
url.searchParams.set("cursor", cursor.trim());
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildWorkspaceTreeStreamUrl,
normalizeTreeStreamSnapshot,
@@ -6,14 +6,38 @@ import {
} from "./protocol";
describe("tree-stream/protocol", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("构造 workspace sidebar stream url", () => {
vi.stubGlobal("window", {
...window,
location: {
...window.location,
origin: "http://127.0.0.1:3000",
},
});
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",
"http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1&cursor=evt_9",
);
});
it("同源 runtime baseUrl 下仍走同源 stream route", () => {
vi.stubGlobal("window", {
...window,
location: {
...window.location,
origin: "http://127.0.0.1:3000",
},
});
expect(
buildWorkspaceTreeStreamUrl("http://127.0.0.1:3000/", "ws_1", null),
).toBe("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1");
});
it("解析 snapshot / delta / resync 协议消息", () => {
expect(
parseTreeStreamMessage({
@@ -0,0 +1,184 @@
import { act, useEffect } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { useSidebarTreeStream } from "./use-sidebar-tree-stream";
import type { SidebarInitialData } from "@/components/sidebar/types";
const mockRuntimeConfig = vi.hoisted(() => ({
getMnoteRuntimeConfig: vi.fn(),
}));
vi.mock("@/lib/runtime-config", () => mockRuntimeConfig);
vi.mock("@/lib/mnote-web-auth", () => ({
ensureMnoteWebAuthCookie: vi.fn(async () => undefined),
}));
type MockEventListener = (event: MessageEvent<string>) => void;
class MockEventSource {
static instances: MockEventSource[] = [];
readonly url: string;
readonly withCredentials: boolean;
readonly listeners = new Map<string, Set<MockEventListener>>();
onmessage: MockEventListener | null = null;
onerror: (() => void) | null = null;
closed = false;
constructor(url: string, options?: EventSourceInit) {
this.url = url;
this.withCredentials = Boolean(options?.withCredentials);
MockEventSource.instances.push(this);
}
addEventListener(type: string, listener: EventListener) {
const typedListener = listener as unknown as MockEventListener;
const next = this.listeners.get(type) ?? new Set<MockEventListener>();
next.add(typedListener);
this.listeners.set(type, next);
}
removeEventListener(type: string, listener: EventListener) {
const typedListener = listener as unknown as MockEventListener;
this.listeners.get(type)?.delete(typedListener);
}
close() {
this.closed = true;
}
emit(type: string, payload: unknown) {
const event = {
type,
data: JSON.stringify(payload),
} as MessageEvent<string>;
this.listeners.get(type)?.forEach((listener) => listener(event));
if (type === "message" && this.onmessage) {
this.onmessage(event);
}
}
}
declare global {
// eslint-disable-next-line no-var
var EventSource: typeof MockEventSource;
}
function flush() {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
function buildInitialData(): SidebarInitialData {
return {
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: [],
};
}
function Harness({ onState }: { onState: (state: ReturnType<typeof useSidebarTreeStream>) => void }) {
const state = useSidebarTreeStream(buildInitialData());
useEffect(() => {
onState(state);
}, [onState, state]);
return null;
}
describe("useSidebarTreeStream", () => {
let container: HTMLDivElement;
let root: Root;
const onState = vi.fn();
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
mockRuntimeConfig.getMnoteRuntimeConfig.mockReturnValue({
mnoteWebBaseUrl: "http://127.0.0.1:3104",
});
MockEventSource.instances = [];
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
onState.mockClear();
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
it("收到带 cursor 的 snapshot 后不应重建 EventSource", async () => {
await act(async () => {
root.render(<Harness onState={onState} />);
await flush();
await flush();
});
expect(MockEventSource.instances).toHaveLength(1);
await act(async () => {
MockEventSource.instances[0]?.emit("snapshot", {
stream: "workspace",
workspaceId: "ws_1",
cursor: "evt_2",
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: {},
},
});
await flush();
await flush();
});
expect(MockEventSource.instances).toHaveLength(1);
expect(onState).toHaveBeenLastCalledWith(
expect.objectContaining({
cursor: "evt_2",
status: "live",
}),
);
});
});
@@ -41,6 +41,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
const workspaceId = initialData.activeWorkspaceId;
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const streamEnabled = Boolean(baseUrl && workspaceId);
const cursorRef = useRef<string | null>(null);
const [state, setState] = useState<SidebarTreeStreamState>({
data: null,
@@ -50,8 +51,13 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
});
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
cursorRef.current = state.cursor;
}, [state.cursor]);
useEffect(() => {
if (!streamEnabled) {
cursorRef.current = null;
setState({
data: null,
status: "idle",
@@ -134,7 +140,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
return;
}
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, state.cursor);
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, cursorRef.current);
eventSource = new EventSource(url, { withCredentials: true });
eventSourceRef.current = eventSource;
eventSource.addEventListener("snapshot", handleMessage as EventListener);
@@ -164,7 +170,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
eventSourceRef.current = null;
}
};
}, [baseUrl, state.cursor, streamEnabled, workspaceId]);
}, [baseUrl, streamEnabled, workspaceId]);
return state;
}