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:
@@ -9,7 +9,14 @@ import { buildDocumentBridgeContext, buildDocumentQueryEnvelope } from "@/lib/do
|
||||
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
|
||||
import { executeRustBridgeQueryTransport, resolveRustBridgeQueryPlan } from "@/lib/documents/rust-runtime";
|
||||
import { normalizeEditorHostKind, type EditorHostKind } from "@/components/editor/editor-host-config";
|
||||
import {
|
||||
DEFAULT_EDITOR_HOST_KIND,
|
||||
normalizeEditorHostKind,
|
||||
resolveEditorHostKind,
|
||||
type EditorHostKind,
|
||||
} from "@/components/editor/editor-host-config";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { buildPageAggregate } from "@/lib/documents/page-aggregate";
|
||||
|
||||
interface DocumentPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -128,9 +135,36 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
const resolvedSearch = (await searchParams) ?? {};
|
||||
const openTableIdRaw = resolvedSearch?.openTableId;
|
||||
const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null;
|
||||
const editorHostRaw = resolvedSearch?.editorHost;
|
||||
const editorHostKind: EditorHostKind =
|
||||
typeof editorHostRaw === "string" ? normalizeEditorHostKind(editorHostRaw) : "blocknote";
|
||||
const runtimeConfig = getMnoteRuntimeConfig();
|
||||
const queryEditorHostRaw = resolvedSearch?.editorHost;
|
||||
const queryHostAliasRaw = resolvedSearch?.host;
|
||||
const queryHostRaw =
|
||||
typeof queryEditorHostRaw === "string"
|
||||
? queryEditorHostRaw
|
||||
: typeof queryHostAliasRaw === "string"
|
||||
? queryHostAliasRaw
|
||||
: undefined;
|
||||
const runtimeHostRaw = runtimeConfig.documentEditorHost;
|
||||
const runtimeBlocknoteKillSwitch =
|
||||
runtimeConfig.documentEditorBlocknoteKillSwitch === true;
|
||||
|
||||
// 说明:优先级保持稳定且可预期:
|
||||
// 1) query(兼容 editorHost,并支持 host 别名);
|
||||
// 2) 运行时配置 documentEditorHost;
|
||||
// 3) kill switch(runtime/env)回退到 blocknote;
|
||||
// 4) 默认正式主链 leptos_tiptap_island。
|
||||
const editorHostKind: EditorHostKind = (() => {
|
||||
if (typeof queryHostRaw === "string") {
|
||||
return resolveEditorHostKind({
|
||||
override: queryHostRaw,
|
||||
runtimeDefault: runtimeHostRaw,
|
||||
});
|
||||
}
|
||||
if (runtimeBlocknoteKillSwitch) {
|
||||
return "blocknote";
|
||||
}
|
||||
return normalizeEditorHostKind(runtimeHostRaw, DEFAULT_EDITOR_HOST_KIND);
|
||||
})();
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const workspaceIdRaw = resolvedSearch?.workspaceId;
|
||||
@@ -176,25 +210,29 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
title: doc.title ?? "无标题",
|
||||
});
|
||||
|
||||
const page = buildPageAggregate({
|
||||
documentId: doc.id,
|
||||
workspaceId: doc.workspace_id,
|
||||
title: doc.title ?? "无标题",
|
||||
updatedAt: doc.updated_at,
|
||||
readOnly,
|
||||
disableDownload,
|
||||
disableCopy,
|
||||
pageOptions: initialOptions,
|
||||
content: initialDocumentContent.content,
|
||||
revision: initialDocumentContent.revision,
|
||||
conflictDetectionKey: initialDocumentContent.conflictDetectionKey,
|
||||
pageSubtree: initialDocumentContent.pageSubtree,
|
||||
stats: initialStats,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="min-h-0 flex-1">
|
||||
<DocumentShell
|
||||
documentId={doc.id}
|
||||
workspaceId={doc.workspace_id}
|
||||
title={doc.title ?? "无标题"}
|
||||
updatedAt={doc.updated_at}
|
||||
initialContent={initialDocumentContent.content}
|
||||
initialContentRevision={initialDocumentContent.revision}
|
||||
initialConflictDetectionKey={initialDocumentContent.conflictDetectionKey}
|
||||
initialPageSubtree={initialDocumentContent.pageSubtree}
|
||||
initialOptions={initialOptions}
|
||||
initialStats={initialStats}
|
||||
page={page}
|
||||
openTableId={openTableId}
|
||||
editorHostKind={editorHostKind}
|
||||
readOnly={readOnly}
|
||||
disableDownload={disableDownload}
|
||||
disableCopy={disableCopy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||
import { Breadcrumb } from "@/components/breadcrumb";
|
||||
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
|
||||
import { SearchPalette } from "@/components/search/search-palette";
|
||||
import { AppLayoutShell } from "@/components/app-layout-shell";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
|
||||
@@ -12,25 +9,26 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const {
|
||||
documents,
|
||||
sidebarInitialData,
|
||||
} = await loadSidebarDataFromConvex({
|
||||
client,
|
||||
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-wolai-bg text-wolai-text-primary">
|
||||
{sidebarInitialData && <Sidebar initialData={sidebarInitialData} />}
|
||||
<div className="flex min-w-0 flex-1 flex-col h-full bg-white relative overflow-hidden">
|
||||
<header className="h-[44px] w-full flex items-center px-4 text-wolai-text-secondary text-sm bg-white/80 backdrop-blur-sm sticky top-0 z-50">
|
||||
<MobileSidebarTrigger />
|
||||
<Breadcrumb documents={documents} />
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden bg-white">{children}</main>
|
||||
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
|
||||
if (!sidebarInitialData) {
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-wolai-bg text-wolai-text-primary">
|
||||
<div className="relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-white">
|
||||
<main className="flex-1 overflow-hidden bg-white">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayoutShell initialData={sidebarInitialData}>
|
||||
{children}
|
||||
</AppLayoutShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -130,12 +130,24 @@ body {
|
||||
}
|
||||
|
||||
/* 调整编辑器内容宽度 */
|
||||
.bn-editor {
|
||||
padding-left: 5rem;
|
||||
padding-right: 5rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.bn-editor {
|
||||
padding-left: 5rem;
|
||||
padding-right: 5rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 说明:leptos-tiptap / ProseMirror 需要最基础的 white-space 与换行样式,
|
||||
否则浏览器输入与光标行为会不稳定,并触发 ProseMirror 警告。 */
|
||||
.ProseMirror {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { QueryProvider } from "@/components/providers/query-provider";
|
||||
import { ConvexClientProvider } from "@/components/providers/convex-provider";
|
||||
@@ -8,11 +7,6 @@ import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Wolai Clone",
|
||||
description: "Convex 自部署模式",
|
||||
@@ -40,7 +34,7 @@ export default async function RootLayout({
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className={`${inter.variable} antialiased`}>
|
||||
<body className="antialiased">
|
||||
<ConvexAuthNextjsServerProvider>
|
||||
<ConvexClientProvider>
|
||||
<AppPreferencesHydrator />
|
||||
|
||||
Reference in New Issue
Block a user