feat: land page aggregate and phase7 document ai mainline

- 收口 page aggregate 读取、本地状态与命令客户端\n- 接入 phase7 document ai sidecar 与前端编排入口\n- 更新 architecture 与 design 状态迁移
This commit is contained in:
lix-2026
2026-04-23 07:38:34 +08:00
parent 8353aea2f9
commit 41e958769e
93 changed files with 8778 additions and 2222 deletions
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockFetchDocumentAiOrchestratorConfig = vi.fn();
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: mockIsConvexEnabled,
}));
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: mockGetAuthedConvexClient,
}));
vi.mock("@/lib/server/document-ai-orchestrator", () => ({
fetchDocumentAiOrchestratorConfig: mockFetchDocumentAiOrchestratorConfig,
}));
describe("/api/ai-agent/document/config route", () => {
beforeEach(() => {
mockIsConvexEnabled.mockReset();
mockGetAuthedConvexClient.mockReset();
mockFetchDocumentAiOrchestratorConfig.mockReset();
});
it("应返回 orchestrator config 能力面", async () => {
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockFetchDocumentAiOrchestratorConfig.mockResolvedValue({
provider: "online",
defaultModelKey: "gpt-5.4",
defaultProfileId: "page_writer_ai_first",
sessionEnabled: true,
models: [{ key: "gpt-5.4", title: "GPT-5.4" }],
profiles: [{ id: "page_writer_ai_first", title: "AI 主写" }],
tools: [{ name: "doc_get", description: "读取当前页摘要" }],
});
const { GET } = await import("./route");
const response = await GET(new Request("http://127.0.0.1:3000/api/ai-agent/document/config"));
const payload = await response.json();
expect(response.status).toBe(200);
expect(mockFetchDocumentAiOrchestratorConfig).toHaveBeenCalledTimes(1);
expect(payload.defaultModelKey).toBe("gpt-5.4");
expect(payload.defaultProfileId).toBe("page_writer_ai_first");
expect(payload.tools[0]?.name).toBe("doc_get");
});
});
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { fetchDocumentAiOrchestratorConfig } from "@/lib/server/document-ai-orchestrator";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
let userId = "";
if (isConvexEnabled()) {
const { auth } = await getAuthedConvexClient();
userId = auth.userId ?? "";
}
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const payload = await fetchDocumentAiOrchestratorConfig({ request });
return NextResponse.json(payload, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -6,7 +6,9 @@ const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockStartHermesRun = vi.fn();
const mockStreamHermesRunEvents = vi.fn();
const mockFetchHermesStructuredToolResultFromMnoteWeb = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockExecuteRustBridgeTool = vi.fn();
const mockStartDocumentAiOrchestratorRun = vi.fn();
vi.mock("@/lib/api-utils", async () => {
const actual = await vi.importActual<typeof import("@/lib/api-utils")>("@/lib/api-utils");
@@ -30,8 +32,16 @@ vi.mock("@/lib/ai-agent/hermes/bridge", () => ({
streamHermesRunEvents: mockStreamHermesRunEvents,
}));
vi.mock("@/lib/server/mnote-web-hermes", () => ({
fetchHermesStructuredToolResultFromMnoteWeb: mockFetchHermesStructuredToolResultFromMnoteWeb,
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
executeRustBridgeTool: mockExecuteRustBridgeTool,
}));
vi.mock("@/lib/server/document-ai-orchestrator", () => ({
startDocumentAiOrchestratorRun: mockStartDocumentAiOrchestratorRun,
}));
describe("/api/ai-agent/run route", () => {
@@ -42,7 +52,9 @@ describe("/api/ai-agent/run route", () => {
mockGetAuthedConvexClient.mockReset();
mockStartHermesRun.mockReset();
mockStreamHermesRunEvents.mockReset();
mockFetchHermesStructuredToolResultFromMnoteWeb.mockReset();
mockBuildDocumentBridgeContext.mockReset();
mockExecuteRustBridgeTool.mockReset();
mockStartDocumentAiOrchestratorRun.mockReset();
});
it("应把 Hermes slash_run 完成事件恢复成结构化 tool_result", async () => {
@@ -55,7 +67,7 @@ describe("/api/ai-agent/run route", () => {
},
options: {
ai: {
provider: "online",
provider: "hermes",
},
},
});
@@ -65,6 +77,28 @@ describe("/api/ai-agent/run route", () => {
auth: {
userId: "user-1",
},
client: { query: vi.fn(), mutation: vi.fn() },
});
mockBuildDocumentBridgeContext.mockResolvedValue({
deploymentId: null,
projectId: null,
workspaceId: null,
requestId: "req-1",
traceId: "trace-1",
actor: {
actorType: "user",
actorId: "user-1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
mockStartHermesRun.mockResolvedValue({
runId: "run-1",
@@ -86,13 +120,19 @@ describe("/api/ai-agent/run route", () => {
output: "已完成",
});
});
mockFetchHermesStructuredToolResultFromMnoteWeb.mockResolvedValue({
ok: true,
parsed: {
command: "rename_doc",
params: {
documentId: "doc-1",
title: "AI 标题",
mockExecuteRustBridgeTool.mockResolvedValue({
plan: {
kind: "tool",
toolName: "slash_run",
},
result: {
ok: true,
parsed: {
command: "rename_doc",
params: {
documentId: "doc-1",
title: "AI 标题",
},
},
},
});
@@ -106,13 +146,15 @@ describe("/api/ai-agent/run route", () => {
const text = await response.text();
expect(response.status).toBe(200);
expect(mockFetchHermesStructuredToolResultFromMnoteWeb).toHaveBeenCalledWith(
expect(mockExecuteRustBridgeTool).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
tool: "slash_run",
argsJson: {
toolName: "slash_run",
args: {
text: "/rename doc-1 AI 标题",
},
data: {
source: "ai-agent-route",
},
}),
);
expect(text).toContain("event: tool_result");
@@ -120,4 +162,223 @@ describe("/api/ai-agent/run route", () => {
expect(text).toContain('"command":"rename_doc"');
expect(text).toContain('"title":"AI 标题"');
});
it("文档页 AI 请求应把 pageOptions 带入 Hermes instructions", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "根据当前页面设置调整内容" }],
context: {
documentId: "doc-1",
pageOptions: {
wideLayout: true,
smallText: true,
layoutDensity: "compact",
},
},
options: {
ai: {
provider: "hermes",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartHermesRun.mockResolvedValue({
runId: "run-2",
});
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({
event: "run.completed",
output: "已完成",
});
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining("pageOptions="),
}),
);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining('"wideLayout":true'),
}),
);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining("editorRuntimePageOptions="),
}),
);
});
it("文档页在线流式请求应优先走 orchestrator", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
documentBlocks: [],
},
options: {
ai: {
provider: "online",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockResolvedValue(
new Response(
'event: assistant_message\ndata: {"text":"来自 orchestrator"}\n\n',
{
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
},
},
),
);
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(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
}),
);
expect(mockStartHermesRun).not.toHaveBeenCalled();
expect(text).toContain("event: ready");
expect(text).toContain("来自 orchestrator");
});
it("文档页在线请求应把 modelKey、profileId 和 sessionId 透传到 orchestrator", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
documentBlocks: [],
},
options: {
ai: {
provider: "online",
sessionId: "doc_ai:doc-1:session-1",
modelKey: "gpt-5.3-codex",
profileId: "page_writer_polish",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockResolvedValue(
new Response(
'event: assistant_message\ndata: {"text":"来自 orchestrator"}\n\n',
{
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
},
},
),
);
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
payload: expect.objectContaining({
options: expect.objectContaining({
ai: expect.objectContaining({
sessionId: "doc_ai:doc-1:session-1",
modelKey: "gpt-5.3-codex",
profileId: "page_writer_polish",
}),
}),
}),
}),
);
});
it("orchestrator 失败时应自动回退 Hermes", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "把标题改成回退标题" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "online",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockRejectedValue(new Error("sidecar down"));
mockStartHermesRun.mockResolvedValue({
runId: "run-fallback",
});
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({
event: "run.completed",
output: "Hermes fallback",
});
});
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(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledTimes(1);
expect(mockStartHermesRun).toHaveBeenCalledTimes(1);
expect(text).toContain("Hermes fallback");
});
});
+116 -22
View File
@@ -17,14 +17,18 @@ import {
readHermesToolArgsFromEvent,
readHermesToolResultFromEvent,
} from "@/lib/ai-agent/hermes/tool-result-recovery";
import { fetchHermesStructuredToolResultFromMnoteWeb } from "@/lib/server/mnote-web-hermes";
import { startDocumentAiOrchestratorRun } from "@/lib/server/document-ai-orchestrator";
import { buildDocumentBridgeContext } from "@/lib/documents/bridge";
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
import type { PageOptionsState } from "@/types/page-options";
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
export const dynamic = "force-dynamic";
type AgentMessage = { role: "user" | "assistant"; content: string };
type AgentScope = "global" | "mindmap" | "document" | "onlyoffice";
type RawAiProvider = "online" | "local" | "ollama" | "codex" | "hermes";
type RuntimeProvider = "hermes" | "codex";
type RuntimeProvider = "agents" | "hermes" | "codex";
type CodexMode = "chat" | "test" | "dev";
type RequestPayload = {
@@ -48,6 +52,7 @@ type RequestPayload = {
mindmapId?: string;
selectedUids?: string[];
documentBlocks?: unknown;
pageOptions?: PageOptionsState;
node?: unknown;
subtree?: unknown;
outline?: unknown;
@@ -59,6 +64,8 @@ type RequestPayload = {
provider?: RawAiProvider;
model?: string;
sessionId?: string;
modelKey?: string;
profileId?: string;
};
};
};
@@ -112,11 +119,46 @@ const clampSteps = (raw: unknown) => {
return Math.max(MIN_AGENT_STEPS, Math.min(MAX_AGENT_STEPS, Math.floor(parsed)));
};
const normalizeProvider = (raw: unknown): RuntimeProvider => {
const normalizeProvider = (
raw: unknown,
scope: AgentScope,
stream: boolean,
): RuntimeProvider => {
const value = String(raw ?? "").trim().toLowerCase();
if (value === "online" && scope === "document" && stream) {
return "agents";
}
return value === "codex" ? "codex" : "hermes";
};
const proxySseResponse = (upstream: Response) => {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode(toSseFrame("ready", { ok: true, requestId: makeRunId() })));
const reader = upstream.body?.getReader();
if (!reader) {
controller.close();
return;
}
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) {
controller.enqueue(value);
}
}
} finally {
controller.close();
}
},
});
return new Response(body, { headers: sseHeaders });
};
const normalizeScope = (payload: RequestPayload): AgentScope => {
const raw = String(payload.scope ?? "").trim();
if (raw === "global" || raw === "mindmap" || raw === "document" || raw === "onlyoffice") {
@@ -192,6 +234,22 @@ const buildHermesInstructions = (
if (payload.context?.evidence !== undefined) {
lines.push(serializeContextSnapshot("kernelEvidence", payload.context.evidence, 2500) ?? "kernelEvidence=provided");
}
if (payload.context?.pageOptions !== undefined) {
lines.push(
serializeContextSnapshot(
"pageOptions",
payload.context.pageOptions,
2000,
) ?? "pageOptions=provided",
);
lines.push(
serializeContextSnapshot(
"editorRuntimePageOptions",
pickLeptosTiptapRuntimePageOptions(payload.context.pageOptions),
1200,
) ?? "editorRuntimePageOptions=provided",
);
}
if (attachments.length > 0) {
lines.push(
[
@@ -237,6 +295,9 @@ const recoverStructuredHermesToolResult = async (input: {
request: Request;
payload: RequestPayload;
userId: string;
client: ReturnType<typeof getAuthedConvexClient> extends Promise<infer T>
? T["client"]
: never;
tool: string;
argsJson: Record<string, unknown> | null;
fallbackRequestId: string;
@@ -263,24 +324,41 @@ const recoverStructuredHermesToolResult = async (input: {
return null;
}
return fetchHermesStructuredToolResultFromMnoteWeb({
return buildDocumentBridgeContext({
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);
workspaceId: null,
})
.then((context) =>
executeRustBridgeTool({
context: {
...context,
requestId: context.requestId || input.fallbackRequestId,
traceId: context.traceId || input.fallbackTraceId,
actor: {
...context.actor,
actorId: input.userId,
},
},
toolName: input.tool,
invocationKind: "command",
args: input.argsJson,
data: (data && typeof data === "object" && !Array.isArray(data) ? data : { data }) as Record<
string,
unknown
>,
target: documentId
? {
pageId: documentId,
blockId:
input.tool === "doc_replace_range"
? String(input.argsJson.blockId ?? "").trim() || null
: null,
}
: null,
}),
)
.then((result) => result.result)
.catch(() => null);
};
const streamHermesLegacyEvents = async ({
@@ -619,13 +697,29 @@ export async function POST(request: Request) {
return errorResponses.unauthorized();
}
const provider = normalizeProvider(payload.options?.ai?.provider);
const stream = payload.stream !== false;
const scope = normalizeScope(payload);
const provider = normalizeProvider(payload.options?.ai?.provider, scope, stream);
if (provider === "codex") {
return await runCodexBridge({ payload, request, stream });
}
const scope = normalizeScope(payload);
if (provider === "agents") {
try {
const upstream = await startDocumentAiOrchestratorRun({
request,
userId,
payload,
});
return proxySseResponse(upstream);
} catch (error) {
console.warn(
"document ai orchestrator 不可用,回退 Hermes",
error instanceof Error ? error.message : String(error),
);
}
}
const maxSteps = clampSteps(payload.maxSteps);
const instructions = buildHermesInstructions(payload, userId, scope, maxSteps);
const sessionId = String(payload.options?.ai?.sessionId ?? "").trim() || null;