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
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockSafeGetJsonBody = vi.fn();
const mockValidateRequestBody = vi.fn();
const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockStartHermesRun = vi.fn();
const mockStreamHermesRunEvents = vi.fn();
const mockFetchHermesStructuredToolResultFromMnoteWeb = vi.fn();
vi.mock("@/lib/api-utils", async () => {
const actual = await vi.importActual<typeof import("@/lib/api-utils")>("@/lib/api-utils");
return {
...actual,
safeGetJsonBody: mockSafeGetJsonBody,
validateRequestBody: mockValidateRequestBody,
};
});
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: mockIsConvexEnabled,
}));
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: mockGetAuthedConvexClient,
}));
vi.mock("@/lib/ai-agent/hermes/bridge", () => ({
startHermesRun: mockStartHermesRun,
streamHermesRunEvents: mockStreamHermesRunEvents,
}));
vi.mock("@/lib/server/mnote-web-hermes", () => ({
fetchHermesStructuredToolResultFromMnoteWeb: mockFetchHermesStructuredToolResultFromMnoteWeb,
}));
describe("/api/ai-agent/run route", () => {
beforeEach(() => {
mockSafeGetJsonBody.mockReset();
mockValidateRequestBody.mockReset();
mockIsConvexEnabled.mockReset();
mockGetAuthedConvexClient.mockReset();
mockStartHermesRun.mockReset();
mockStreamHermesRunEvents.mockReset();
mockFetchHermesStructuredToolResultFromMnoteWeb.mockReset();
});
it("应把 Hermes slash_run 完成事件恢复成结构化 tool_result", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "把标题改成 AI 标题" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "online",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartHermesRun.mockResolvedValue({
runId: "run-1",
});
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({
event: "tool.started",
tool: "slash_run",
preview: '{"text":"/rename doc-1 AI 标题"}',
});
await onEvent({
event: "tool.completed",
tool: "slash_run",
duration: 0.12,
error: false,
});
await onEvent({
event: "run.completed",
output: "已完成",
});
});
mockFetchHermesStructuredToolResultFromMnoteWeb.mockResolvedValue({
ok: true,
parsed: {
command: "rename_doc",
params: {
documentId: "doc-1",
title: "AI 标题",
},
},
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
const text = await response.text();
expect(response.status).toBe(200);
expect(mockFetchHermesStructuredToolResultFromMnoteWeb).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
tool: "slash_run",
argsJson: {
text: "/rename doc-1 AI 标题",
},
}),
);
expect(text).toContain("event: tool_result");
expect(text).toContain('"tool":"slash_run"');
expect(text).toContain('"command":"rename_doc"');
expect(text).toContain('"title":"AI 标题"');
});
});
@@ -13,6 +13,11 @@ import {
startCodexJsonRun,
} from "@/lib/ai/codex/codexExec";
import { startHermesRun, streamHermesRunEvents, type HermesRunEvent } from "@/lib/ai-agent/hermes/bridge";
import {
readHermesToolArgsFromEvent,
readHermesToolResultFromEvent,
} from "@/lib/ai-agent/hermes/tool-result-recovery";
import { fetchHermesStructuredToolResultFromMnoteWeb } from "@/lib/server/mnote-web-hermes";
export const dynamic = "force-dynamic";
@@ -223,15 +228,76 @@ const buildHermesInput = (messages: AgentMessage[]) => {
.map((item) => ({ role: item.role, content: String(item.content ?? "") }));
};
type PendingHermesToolCall = {
preview: string;
argsJson: Record<string, unknown> | null;
};
const recoverStructuredHermesToolResult = async (input: {
request: Request;
payload: RequestPayload;
userId: string;
tool: string;
argsJson: Record<string, unknown> | null;
fallbackRequestId: string;
fallbackTraceId: string;
}): Promise<unknown | null> => {
if (!input.argsJson) {
return null;
}
if (
input.tool !== "slash_run" &&
input.tool !== "doc_insert_blocks" &&
input.tool !== "doc_replace_range"
) {
return null;
}
const documentId = String(input.payload.context?.documentId ?? "").trim() || null;
const data =
input.tool === "slash_run"
? { source: "ai-agent-route" }
: input.payload.context?.documentBlocks ?? null;
if ((input.tool === "doc_insert_blocks" || input.tool === "doc_replace_range") && data == null) {
return null;
}
return fetchHermesStructuredToolResultFromMnoteWeb({
request: input.request,
userId: input.userId,
tool: input.tool,
argsJson: input.argsJson,
data,
requestId: input.fallbackRequestId,
traceId: input.fallbackTraceId,
target: documentId
? {
pageId: documentId,
blockId:
input.tool === "doc_replace_range"
? String(input.argsJson.blockId ?? "").trim() || null
: null,
}
: null,
}).catch(() => null);
};
const streamHermesLegacyEvents = async ({
messages,
instructions,
sessionId,
request,
payload,
userId,
onEvent,
}: {
messages: AgentMessage[];
instructions: string;
sessionId: string | null;
request: Request;
payload: RequestPayload;
userId: string;
onEvent: (event: LegacyStreamEvent) => Promise<void> | void;
}) => {
const input = buildHermesInput(messages);
@@ -242,7 +308,7 @@ const streamHermesLegacyEvents = async ({
});
const pendingToolIds = new Map<string, string[]>();
const previewById = new Map<string, string>();
const pendingToolCalls = new Map<string, PendingHermesToolCall>();
let toolCount = 0;
let assistantBuffer = "";
let failureMessage = "";
@@ -256,7 +322,10 @@ const streamHermesLegacyEvents = async ({
queue.push(id);
pendingToolIds.set(tool, queue);
const preview = typeof event.preview === "string" ? event.preview : "";
previewById.set(id, preview);
pendingToolCalls.set(id, {
preview,
argsJson: readHermesToolArgsFromEvent(event, tool),
});
await onEvent({
type: "tool_call",
data: {
@@ -273,8 +342,22 @@ const streamHermesLegacyEvents = async ({
const queue = pendingToolIds.get(tool) ?? [];
const id = queue.shift() ?? `hermes_${runId}_${toolCount}`;
pendingToolIds.set(tool, queue);
const preview = previewById.get(id) ?? "";
const pendingToolCall = pendingToolCalls.get(id) ?? null;
pendingToolCalls.delete(id);
const preview = pendingToolCall?.preview ?? "";
const duration = Number(event.duration ?? 0);
const structuredResultFromEvent = !Boolean(event.error) ? readHermesToolResultFromEvent(event) : null;
const recoveredResult =
structuredResultFromEvent ??
(await recoverStructuredHermesToolResult({
request,
payload,
userId,
tool,
argsJson: pendingToolCall?.argsJson ?? null,
fallbackRequestId: makeRunId(),
fallbackTraceId: makeRunId(),
}));
await onEvent({
type: "tool_result",
data: {
@@ -282,7 +365,9 @@ const streamHermesLegacyEvents = async ({
tool,
ok: !Boolean(event.error),
ms: Number.isFinite(duration) ? Math.max(0, Math.round(duration * 1000)) : 0,
result: preview ? { preview, error: Boolean(event.error) } : { error: Boolean(event.error) },
result:
recoveredResult ??
(preview ? { preview, error: Boolean(event.error) } : { error: Boolean(event.error) }),
},
});
return;
@@ -551,6 +636,9 @@ export async function POST(request: Request) {
messages: payload.messages,
instructions,
sessionId,
request,
payload,
userId,
onEvent: (event) => {
events.push(event);
},
@@ -589,6 +677,9 @@ export async function POST(request: Request) {
messages: payload.messages,
instructions,
sessionId,
request,
payload,
userId,
onEvent: (event) => {
send(event.type, event.data ?? null);
},
@@ -5,18 +5,24 @@ import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type RuntimeManifest = {
bridgeRuntimePath: string | null;
extensionModulePaths: string[];
type IslandManifest = {
entryAssetPath: string | null;
wasmAssetPath: string | null;
assetPaths: string[];
generatedRootPath: string | null;
entryScriptPath: string | null;
wasmPath: string | null;
};
const DIST_ROOT = path.resolve(process.cwd(), "..", "rust", "spikes", "leptos-tiptap-spike", "dist");
const ENTRY_SCRIPT_PATTERN = /^mnote-leptos-tiptap-spike-.*\.js$/;
const WASM_PATTERN = /^mnote-leptos-tiptap-spike-.*_bg\.wasm$/;
const EXTENSION_MODULE_PATTERN = /^tiptap_[a-z0-9_]+\.js$/;
const GENERATED_ROOT = path.resolve(
process.cwd(),
"..",
"rust",
"spikes",
"leptos-tiptap-spike",
"generated",
"island",
);
const ENTRY_ASSET_PATTERN = /^mnote-leptos-tiptap-spike-island\.js$/;
const WASM_PATTERN = /^mnote-leptos-tiptap-spike-island_bg\.wasm$/;
function toPosixPath(value: string): string {
return value.split(path.sep).join("/");
@@ -65,29 +71,26 @@ async function walkFiles(rootDir: string): Promise<string[]> {
return output.sort((a, b) => a.localeCompare(b));
}
async function buildRuntimeManifest(): Promise<RuntimeManifest> {
const files = await walkFiles(DIST_ROOT);
const bridgeRuntimePath = files.find((item) => item.endsWith("/bridge_runtime.js") || item === "bridge_runtime.js") ?? null;
const generatedRootPath = bridgeRuntimePath ? path.posix.dirname(bridgeRuntimePath) : null;
const extensionModulePaths = generatedRootPath
? files.filter((item) => {
if (!item.startsWith(`${generatedRootPath}/`)) {
return false;
}
const basename = path.posix.basename(item);
return EXTENSION_MODULE_PATTERN.test(basename);
})
: [];
const entryScriptPath =
files.find((item) => ENTRY_SCRIPT_PATTERN.test(path.posix.basename(item))) ?? null;
const wasmPath = files.find((item) => WASM_PATTERN.test(path.posix.basename(item))) ?? null;
async function buildIslandManifest(): Promise<IslandManifest> {
const files = await walkFiles(GENERATED_ROOT);
const entryAssetPath = files.find((item) => ENTRY_ASSET_PATTERN.test(path.posix.basename(item))) ?? null;
const wasmAssetPath = files.find((item) => WASM_PATTERN.test(path.posix.basename(item))) ?? null;
const generatedRootPath = entryAssetPath ? path.posix.dirname(entryAssetPath) : null;
const assetPaths = files.filter((item) => {
const basename = path.posix.basename(item);
return (
basename.endsWith(".js") ||
basename.endsWith(".wasm") ||
basename.endsWith(".css") ||
basename.endsWith(".json")
);
});
return {
bridgeRuntimePath,
extensionModulePaths,
entryAssetPath,
wasmAssetPath,
assetPaths,
generatedRootPath,
entryScriptPath,
wasmPath,
};
}
@@ -120,14 +123,14 @@ export async function GET(
if (asset.length === 1 && asset[0] === "manifest.json") {
try {
const manifest = await buildRuntimeManifest();
const manifest = await buildIslandManifest();
return NextResponse.json(manifest, {
headers: { "Cache-Control": "no-store" },
});
} catch (error) {
return NextResponse.json(
{
error: "无法生成 leptos-tiptap runtime 清单",
error: "无法生成 leptos-tiptap island 清单",
detail: error instanceof Error ? error.message : "unknown",
},
{ status: 500 },
@@ -137,12 +140,12 @@ export async function GET(
const relativePath = sanitizeRelativePath(asset);
if (!relativePath) {
return NextResponse.json({ error: "非法 runtime 资源路径" }, { status: 400 });
return NextResponse.json({ error: "非法 island 资源路径" }, { status: 400 });
}
const absolutePath = path.resolve(DIST_ROOT, relativePath);
if (!absolutePath.startsWith(DIST_ROOT + path.sep)) {
return NextResponse.json({ error: "越界访问 runtime 资源被拒绝" }, { status: 403 });
const absolutePath = path.resolve(GENERATED_ROOT, relativePath);
if (!absolutePath.startsWith(GENERATED_ROOT + path.sep)) {
return NextResponse.json({ error: "越界访问 island 资源被拒绝" }, { status: 403 });
}
try {
@@ -156,11 +159,11 @@ export async function GET(
});
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
return NextResponse.json({ error: "runtime 资源不存在" }, { status: 404 });
return NextResponse.json({ error: "island 资源不存在" }, { status: 404 });
}
return NextResponse.json(
{
error: "读取 runtime 资源失败",
error: "读取 island 资源失败",
detail: error instanceof Error ? error.message : "unknown",
},
{ status: 500 },
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockBuildMnoteWebForwardHeaders = vi.fn();
const mockBuildMnoteWebStreamUrl = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
error: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
),
);
vi.mock("@/lib/server/mnote-web", () => ({
buildMnoteWebForwardHeaders: mockBuildMnoteWebForwardHeaders,
buildMnoteWebStreamUrl: mockBuildMnoteWebStreamUrl,
}));
vi.mock("@/lib/documents/bridge", () => ({
documentBridgeErrorResponse: mockDocumentBridgeErrorResponse,
}));
describe("/api/mnote-web/stream route", () => {
beforeEach(() => {
mockBuildMnoteWebForwardHeaders.mockReset();
mockBuildMnoteWebStreamUrl.mockReset();
mockDocumentBridgeErrorResponse.mockClear();
vi.unstubAllGlobals();
});
it("透传上游 SSE 并去掉 set-cookie", async () => {
const upstreamHeaders = new Headers({
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
"set-cookie": "secret=1",
});
const upstreamResponse = new Response("event: snapshot\ndata: {\"ok\":true}\n\n", {
status: 200,
headers: upstreamHeaders,
});
mockBuildMnoteWebForwardHeaders.mockResolvedValue(new Headers({ cookie: "a=1" }));
mockBuildMnoteWebStreamUrl.mockReturnValue(
new URL("http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1"),
);
const fetchMock = vi.fn(async () => upstreamResponse);
vi.stubGlobal("fetch", fetchMock);
const { GET } = await import("./route");
const response = await GET(
new Request("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1", {
method: "GET",
headers: { cookie: "a=1" },
}),
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1",
expect.objectContaining({
method: "GET",
cache: "no-store",
redirect: "follow",
headers: expect.any(Headers),
}),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/event-stream");
expect(response.headers.get("set-cookie")).toBeNull();
await expect(response.text()).resolves.toContain("event: snapshot");
});
});
@@ -0,0 +1,43 @@
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
import {
buildMnoteWebForwardHeaders,
buildMnoteWebStreamUrl,
} from "@/lib/server/mnote-web";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
try {
const requestUrl = new URL(request.url);
const workspaceId = String(requestUrl.searchParams.get("workspaceId") || "").trim();
const cursor = String(requestUrl.searchParams.get("cursor") || "").trim() || null;
if (!workspaceId) {
return Response.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const targetUrl = buildMnoteWebStreamUrl({ workspaceId, cursor });
const headers = await buildMnoteWebForwardHeaders(request);
headers.set("accept", "text/event-stream");
headers.set("x-mnote-workspace-id", workspaceId);
const upstream = await fetch(targetUrl.toString(), {
method: "GET",
headers,
cache: "no-store",
redirect: "follow",
});
const responseHeaders = new Headers(upstream.headers);
responseHeaders.delete("set-cookie");
responseHeaders.set("cache-control", "no-store");
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}