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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user