收口 Rust Web 入口与 AI 写入链
- 将 3000 主入口继续收口到 mnote-web,补齐 /favicon.ico、/api/auth、session alias、AI run 等 Rust Web 路由边界。 - 更新登录页与 Convex Auth 代理,支持测试账号快速登录写入真实 Convex Auth cookie。 - 推进页面设置、Wolai 对齐、Phase 7 AI kernel/CLI-first 设计文档与相关 smoke 脚本。 - 更新 leptos-tiptap 生成资产、mnote-cli/bridge-runtime、前端依赖和 dev/prod 启动脚本。
This commit is contained in:
@@ -11,10 +11,10 @@ import { getUserFacingErrorMessage } from "@/lib/auth/errors";
|
||||
type AuthStep = "signIn" | "signUp";
|
||||
|
||||
// 测试账号凭据常量
|
||||
const TEST_CREDENTIALS = {
|
||||
email: "test@example.com",
|
||||
password: "Test123456",
|
||||
} as const;
|
||||
const TEST_CREDENTIALS = {
|
||||
email: "mnote.e2e@example.com",
|
||||
password: "MnoteE2E123!",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Convex Auth 登录/注册页面
|
||||
|
||||
@@ -4,11 +4,23 @@ 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 mockBuildDocumentBridgeContext = vi.fn();
|
||||
const mockExecuteRustBridgeTool = vi.fn();
|
||||
const mockStartDocumentAiOrchestratorRun = vi.fn();
|
||||
const mockStartMnoteCliAgentHostRun = vi.fn();
|
||||
const mockRequireAuthContext = vi.fn();
|
||||
const mockGetConvexAuthedHttpClient = vi.fn();
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: class NextResponse extends Response {
|
||||
static json(body: unknown, init?: ResponseInit) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-utils", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api-utils")>("@/lib/api-utils");
|
||||
@@ -27,21 +39,16 @@ vi.mock("@/lib/convex/route", () => ({
|
||||
getAuthedConvexClient: mockGetAuthedConvexClient,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai-agent/hermes/bridge", () => ({
|
||||
startHermesRun: mockStartHermesRun,
|
||||
streamHermesRunEvents: mockStreamHermesRunEvents,
|
||||
vi.mock("@/lib/auth/authContext", () => ({
|
||||
requireAuthContext: mockRequireAuthContext,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge", () => ({
|
||||
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
|
||||
vi.mock("@/lib/convex/server", () => ({
|
||||
getConvexAuthedHttpClient: mockGetConvexAuthedHttpClient,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
executeRustBridgeTool: mockExecuteRustBridgeTool,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server/document-ai-orchestrator", () => ({
|
||||
startDocumentAiOrchestratorRun: mockStartDocumentAiOrchestratorRun,
|
||||
vi.mock("@/lib/server/mnote-cli-agent-host", () => ({
|
||||
startMnoteCliAgentHostRun: mockStartMnoteCliAgentHostRun,
|
||||
}));
|
||||
|
||||
describe("/api/ai-agent/run route", () => {
|
||||
@@ -50,241 +57,23 @@ describe("/api/ai-agent/run route", () => {
|
||||
mockValidateRequestBody.mockReset();
|
||||
mockIsConvexEnabled.mockReset();
|
||||
mockGetAuthedConvexClient.mockReset();
|
||||
mockStartHermesRun.mockReset();
|
||||
mockStreamHermesRunEvents.mockReset();
|
||||
mockBuildDocumentBridgeContext.mockReset();
|
||||
mockExecuteRustBridgeTool.mockReset();
|
||||
mockStartDocumentAiOrchestratorRun.mockReset();
|
||||
mockStartMnoteCliAgentHostRun.mockReset();
|
||||
mockRequireAuthContext.mockReset();
|
||||
mockGetConvexAuthedHttpClient.mockReset();
|
||||
});
|
||||
|
||||
it("应把 Hermes slash_run 完成事件恢复成结构化 tool_result", async () => {
|
||||
it("文档页在线请求应只进入 mnote-cli host", async () => {
|
||||
mockSafeGetJsonBody.mockResolvedValue({
|
||||
stream: true,
|
||||
scope: "document",
|
||||
messages: [{ role: "user", content: "把标题改成 AI 标题" }],
|
||||
context: {
|
||||
documentId: "doc-1",
|
||||
},
|
||||
options: {
|
||||
ai: {
|
||||
provider: "hermes",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockValidateRequestBody.mockReturnValue(null);
|
||||
mockIsConvexEnabled.mockReturnValue(true);
|
||||
mockGetAuthedConvexClient.mockResolvedValue({
|
||||
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",
|
||||
});
|
||||
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: "已完成",
|
||||
});
|
||||
});
|
||||
mockExecuteRustBridgeTool.mockResolvedValue({
|
||||
plan: {
|
||||
kind: "tool",
|
||||
toolName: "slash_run",
|
||||
},
|
||||
result: {
|
||||
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(mockExecuteRustBridgeTool).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolName: "slash_run",
|
||||
args: {
|
||||
text: "/rename doc-1 AI 标题",
|
||||
},
|
||||
data: {
|
||||
source: "ai-agent-route",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(text).toContain("event: tool_result");
|
||||
expect(text).toContain('"tool":"slash_run"');
|
||||
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: "根据当前页面设置调整内容" }],
|
||||
messages: [{ role: "user", content: "总结当前页面" }],
|
||||
context: {
|
||||
documentId: "doc-1",
|
||||
documentBlocks: [],
|
||||
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",
|
||||
@@ -301,73 +90,19 @@ describe("/api/ai-agent/run route", () => {
|
||||
userId: "user-1",
|
||||
},
|
||||
});
|
||||
mockStartDocumentAiOrchestratorRun.mockResolvedValue(
|
||||
new Response(
|
||||
'event: assistant_message\ndata: {"text":"来自 orchestrator"}\n\n',
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
},
|
||||
mockRequireAuthContext.mockResolvedValue({
|
||||
userId: "user-1",
|
||||
});
|
||||
mockGetConvexAuthedHttpClient.mockResolvedValue({});
|
||||
mockStartMnoteCliAgentHostRun.mockResolvedValue(
|
||||
new Response('event: completion\ndata: {"ok":true,"text":"mnote-cli"}\n\n', {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"x-mnote-ai-execution-owner": "mnote-cli",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
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", {
|
||||
@@ -377,8 +112,112 @@ describe("/api/ai-agent/run route", () => {
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledTimes(1);
|
||||
expect(mockStartHermesRun).toHaveBeenCalledTimes(1);
|
||||
expect(text).toContain("Hermes fallback");
|
||||
expect(mockStartMnoteCliAgentHostRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
payload: expect.objectContaining({
|
||||
scope: "document",
|
||||
context: expect.objectContaining({
|
||||
documentId: "doc-1",
|
||||
pageOptions: expect.objectContaining({
|
||||
wideLayout: true,
|
||||
}),
|
||||
}),
|
||||
options: expect.objectContaining({
|
||||
ai: expect.objectContaining({
|
||||
provider: "online",
|
||||
sessionId: "doc_ai:doc-1:session-1",
|
||||
modelKey: "gpt-5.3-codex",
|
||||
profileId: "page_writer_polish",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(text).toContain("mnote-cli");
|
||||
});
|
||||
|
||||
it.each(["codex", "hermes", "local", "ollama"] as const)(
|
||||
"provider=%s 也必须统一进入 mnote-cli host",
|
||||
async (provider) => {
|
||||
mockSafeGetJsonBody.mockResolvedValue({
|
||||
stream: true,
|
||||
scope: "document",
|
||||
messages: [{ role: "user", content: "处理当前页面" }],
|
||||
context: {
|
||||
documentId: "doc-1",
|
||||
},
|
||||
options: {
|
||||
ai: {
|
||||
provider,
|
||||
},
|
||||
},
|
||||
});
|
||||
mockValidateRequestBody.mockReturnValue(null);
|
||||
mockIsConvexEnabled.mockReturnValue(true);
|
||||
mockGetAuthedConvexClient.mockResolvedValue({
|
||||
auth: {
|
||||
userId: "user-1",
|
||||
},
|
||||
});
|
||||
mockRequireAuthContext.mockResolvedValue({
|
||||
userId: "user-1",
|
||||
});
|
||||
mockGetConvexAuthedHttpClient.mockResolvedValue({});
|
||||
mockStartMnoteCliAgentHostRun.mockResolvedValue(
|
||||
new Response('event: completion\ndata: {"ok":true,"text":"mnote-cli"}\n\n', {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"x-mnote-ai-execution-owner": "mnote-cli",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
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(mockStartMnoteCliAgentHostRun).toHaveBeenCalledTimes(1);
|
||||
expect(mockStartMnoteCliAgentHostRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
ai: expect.objectContaining({
|
||||
provider,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("未登录时不应启动 mnote-cli host", async () => {
|
||||
mockSafeGetJsonBody.mockResolvedValue({
|
||||
stream: true,
|
||||
messages: [{ role: "user", content: "总结当前页面" }],
|
||||
});
|
||||
mockValidateRequestBody.mockReturnValue(null);
|
||||
mockIsConvexEnabled.mockReturnValue(true);
|
||||
mockGetAuthedConvexClient.mockResolvedValue({
|
||||
auth: {
|
||||
userId: "",
|
||||
},
|
||||
});
|
||||
mockRequireAuthContext.mockRejectedValue(new Error("未登录,请先登录"));
|
||||
|
||||
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(401);
|
||||
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,684 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { safeGetJsonBody, errorResponses, validateRequestBody } from "@/lib/api-utils";
|
||||
import {
|
||||
DEFAULT_AGENT_MAX_STEPS,
|
||||
MAX_AGENT_STEPS,
|
||||
MIN_AGENT_STEPS,
|
||||
} from "@/lib/constants";
|
||||
import { errorResponses, safeGetJsonBody, validateRequestBody } from "@/lib/api-utils";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
codexMessagesToPrompt,
|
||||
findWorkspaceRoot,
|
||||
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 { 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";
|
||||
startMnoteCliAgentHostRun,
|
||||
type MnoteCliAgentRunPayload,
|
||||
} from "@/lib/server/mnote-cli-agent-host";
|
||||
|
||||
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 = "agents" | "hermes" | "codex";
|
||||
type CodexMode = "chat" | "test" | "dev";
|
||||
|
||||
type RequestPayload = {
|
||||
stream?: boolean;
|
||||
maxSteps?: number;
|
||||
scope?: AgentScope;
|
||||
messages: AgentMessage[];
|
||||
attachments?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
fileUrl: string;
|
||||
mimeType?: string | null;
|
||||
}>;
|
||||
toolChoice?: {
|
||||
mode: "auto" | "manual";
|
||||
toolSets?: string[];
|
||||
tools?: string[];
|
||||
};
|
||||
context?: {
|
||||
documentId?: string;
|
||||
mindmapId?: string;
|
||||
selectedUids?: string[];
|
||||
documentBlocks?: unknown;
|
||||
pageOptions?: PageOptionsState;
|
||||
node?: unknown;
|
||||
subtree?: unknown;
|
||||
outline?: unknown;
|
||||
evidence?: unknown;
|
||||
};
|
||||
options?: {
|
||||
searxng?: boolean;
|
||||
ai?: {
|
||||
provider?: RawAiProvider;
|
||||
model?: string;
|
||||
sessionId?: string;
|
||||
modelKey?: string;
|
||||
profileId?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type LegacyStreamEvent =
|
||||
| { type: "assistant_message"; data: { text: string } }
|
||||
| { type: "tool_call"; data: { id: string; tool: string; args: Record<string, unknown> } }
|
||||
| { type: "tool_result"; data: { id: string; tool: string; ok: boolean; ms: number; result: unknown } }
|
||||
| { type: "completion"; data: { ok: true; text: string; steps: number } }
|
||||
| { type: "error"; data: { ok: false; message: string } }
|
||||
| { type: "codex_session"; data: { sessionId: string } };
|
||||
|
||||
const sseHeaders = {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
} as const;
|
||||
|
||||
const toSseFrame = (event: string, data: unknown) => {
|
||||
const json = JSON.stringify(data ?? null);
|
||||
return `event: ${event}\ndata: ${json}\n\n`;
|
||||
};
|
||||
|
||||
const makeRunId = () => {
|
||||
try {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return `run_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
|
||||
};
|
||||
|
||||
const serializeContextSnapshot = (label: string, value: unknown, limit: number) => {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const text = JSON.stringify(value);
|
||||
return `${label}=${text.slice(0, limit)}`;
|
||||
} catch {
|
||||
return `${label}=provided`;
|
||||
}
|
||||
};
|
||||
|
||||
const clampSteps = (raw: unknown) => {
|
||||
const parsed = Number(raw ?? DEFAULT_AGENT_MAX_STEPS);
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_AGENT_MAX_STEPS;
|
||||
return Math.max(MIN_AGENT_STEPS, Math.min(MAX_AGENT_STEPS, Math.floor(parsed)));
|
||||
};
|
||||
|
||||
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") {
|
||||
return raw;
|
||||
}
|
||||
return payload.context?.mindmapId ? "mindmap" : payload.context?.documentId ? "document" : "global";
|
||||
};
|
||||
|
||||
const stripCodexModePrefix = (text: string): { mode: CodexMode | null; text: string } => {
|
||||
const value = String(text ?? "");
|
||||
const match = value.match(/^\s*#(chat|test|dev)\b[\s::\-–—]*/i);
|
||||
if (!match) return { mode: null, text: value };
|
||||
const mode = String(match[1] ?? "").toLowerCase() as CodexMode;
|
||||
return { mode, text: value.slice(match[0].length).trimStart() };
|
||||
};
|
||||
|
||||
const extractCodexModeFromMessages = (messages: AgentMessage[]) => {
|
||||
const lastUser = [...messages].reverse().find((item) => item.role === "user")?.content ?? "";
|
||||
const picked = stripCodexModePrefix(lastUser);
|
||||
const cleanedMessages = messages.map((item) => {
|
||||
if (item.role !== "user") return item;
|
||||
const cleaned = stripCodexModePrefix(item.content);
|
||||
return { ...item, content: cleaned.text };
|
||||
});
|
||||
return {
|
||||
mode: picked.mode ?? "chat",
|
||||
cleanedMessages,
|
||||
};
|
||||
};
|
||||
|
||||
const buildHermesInstructions = (
|
||||
payload: RequestPayload,
|
||||
userId: string,
|
||||
scope: AgentScope,
|
||||
maxSteps: number,
|
||||
) => {
|
||||
const attachments = Array.isArray(payload.attachments) ? payload.attachments.slice(0, 12) : [];
|
||||
const selectedUids = Array.isArray(payload.context?.selectedUids)
|
||||
? payload.context?.selectedUids.map((item) => String(item)).filter(Boolean).slice(0, 12)
|
||||
: [];
|
||||
|
||||
const lines: string[] = [
|
||||
"你当前运行在 mnote Web 前端的 Hermes bridge 后面。请始终使用简体中文。",
|
||||
"当前前端已经收口为轻桥接层,不要再假设存在旧的前端 builtin registry、toolset 静态映射或 create*ServerTools 编排逻辑。",
|
||||
"除非工具结果明确表明已完成写入,否则不要声称已经修改页面、思维导图或 OnlyOffice 文档。",
|
||||
"不要把本机终端、文件系统或其他 Hermes 默认工具当成 mnote 业务真执行面。mnote 业务写入应视为独立桥能力。",
|
||||
`当前调用用户:${userId}`,
|
||||
`当前 scope:${scope}`,
|
||||
`本轮最大步数提示:${maxSteps}`,
|
||||
];
|
||||
|
||||
if (payload.context?.documentId) {
|
||||
lines.push(`documentId=${String(payload.context.documentId).trim()}`);
|
||||
}
|
||||
if (payload.context?.mindmapId) {
|
||||
lines.push(`mindmapId=${String(payload.context.mindmapId).trim()}`);
|
||||
}
|
||||
if (selectedUids.length > 0) {
|
||||
lines.push(`selectedUids=${selectedUids.join(",")}`);
|
||||
}
|
||||
if (payload.context?.documentBlocks !== undefined) {
|
||||
lines.push(serializeContextSnapshot("documentBlocksSnapshot", payload.context.documentBlocks, 4000) ?? "documentBlocksSnapshot=provided");
|
||||
}
|
||||
if (payload.context?.node !== undefined) {
|
||||
lines.push(serializeContextSnapshot("kernelNode", payload.context.node, 1800) ?? "kernelNode=provided");
|
||||
}
|
||||
if (payload.context?.subtree !== undefined) {
|
||||
lines.push(serializeContextSnapshot("kernelSubtree", payload.context.subtree, 5000) ?? "kernelSubtree=provided");
|
||||
}
|
||||
if (payload.context?.outline !== undefined) {
|
||||
lines.push(serializeContextSnapshot("kernelOutline", payload.context.outline, 2500) ?? "kernelOutline=provided");
|
||||
}
|
||||
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(
|
||||
[
|
||||
"attachments:",
|
||||
...attachments.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. id=${String(item.id)} title=${String(item.title)} mime=${String(item.mimeType ?? "")} url=${String(item.fileUrl)}`,
|
||||
),
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.toolChoice?.mode === "manual") {
|
||||
const tools = Array.isArray(payload.toolChoice.tools) ? payload.toolChoice.tools.filter(Boolean) : [];
|
||||
const toolSets = Array.isArray(payload.toolChoice.toolSets) ? payload.toolChoice.toolSets.filter(Boolean) : [];
|
||||
if (tools.length > 0 || toolSets.length > 0) {
|
||||
lines.push(
|
||||
`前端兼容提示:manual toolChoice tools=[${tools.join(", ")}] toolSets=[${toolSets.join(", ")}]. 这些只是旧前端兼容字段,不代表当前 Hermes 一定具备同名工具。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (scope === "onlyoffice") {
|
||||
lines.push("OnlyOffice 浏览器专属 client capability 仍在单独桥接;若当前后端没有明确写入结果,请直接说明限制,不要伪造选区修改。\n");
|
||||
}
|
||||
|
||||
return lines.join("\n\n").trim();
|
||||
};
|
||||
|
||||
const buildHermesInput = (messages: AgentMessage[]) => {
|
||||
return messages
|
||||
.slice(-50)
|
||||
.filter((item) => item.role === "user" || item.role === "assistant")
|
||||
.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;
|
||||
client: ReturnType<typeof getAuthedConvexClient> extends Promise<infer T>
|
||||
? T["client"]
|
||||
: never;
|
||||
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 buildDocumentBridgeContext({
|
||||
request: input.request,
|
||||
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 ({
|
||||
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);
|
||||
const { runId } = await startHermesRun({
|
||||
input,
|
||||
instructions,
|
||||
...(sessionId ? { session_id: sessionId } : {}),
|
||||
});
|
||||
|
||||
const pendingToolIds = new Map<string, string[]>();
|
||||
const pendingToolCalls = new Map<string, PendingHermesToolCall>();
|
||||
let toolCount = 0;
|
||||
let assistantBuffer = "";
|
||||
let failureMessage = "";
|
||||
let completed = false;
|
||||
|
||||
await streamHermesRunEvents(runId, async (event: HermesRunEvent) => {
|
||||
if (event.event === "tool.started") {
|
||||
const tool = String(event.tool ?? "").trim() || "unknown_tool";
|
||||
const id = `hermes_${runId}_${++toolCount}`;
|
||||
const queue = pendingToolIds.get(tool) ?? [];
|
||||
queue.push(id);
|
||||
pendingToolIds.set(tool, queue);
|
||||
const preview = typeof event.preview === "string" ? event.preview : "";
|
||||
pendingToolCalls.set(id, {
|
||||
preview,
|
||||
argsJson: readHermesToolArgsFromEvent(event, tool),
|
||||
});
|
||||
await onEvent({
|
||||
type: "tool_call",
|
||||
data: {
|
||||
id,
|
||||
tool,
|
||||
args: preview ? { preview } : {},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.event === "tool.completed") {
|
||||
const tool = String(event.tool ?? "").trim() || "unknown_tool";
|
||||
const queue = pendingToolIds.get(tool) ?? [];
|
||||
const id = queue.shift() ?? `hermes_${runId}_${toolCount}`;
|
||||
pendingToolIds.set(tool, queue);
|
||||
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: {
|
||||
id,
|
||||
tool,
|
||||
ok: !Boolean(event.error),
|
||||
ms: Number.isFinite(duration) ? Math.max(0, Math.round(duration * 1000)) : 0,
|
||||
result:
|
||||
recoveredResult ??
|
||||
(preview ? { preview, error: Boolean(event.error) } : { error: Boolean(event.error) }),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.event === "message.delta") {
|
||||
assistantBuffer += typeof event.delta === "string" ? event.delta : "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.event === "run.failed") {
|
||||
failureMessage = String(event.error ?? "Hermes run 失败");
|
||||
await onEvent({ type: "error", data: { ok: false, message: failureMessage } });
|
||||
completed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.event === "run.completed") {
|
||||
const fallbackOutput = typeof event.output === "string" ? event.output : "";
|
||||
const finalText = (assistantBuffer || fallbackOutput || "(无输出)").trim();
|
||||
if (finalText) {
|
||||
await onEvent({ type: "assistant_message", data: { text: finalText } });
|
||||
}
|
||||
await onEvent({
|
||||
type: "completion",
|
||||
data: { ok: true, text: finalText, steps: Math.max(1, toolCount || 1) },
|
||||
});
|
||||
completed = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!completed && !failureMessage) {
|
||||
const finalText = (assistantBuffer || "(无输出)").trim();
|
||||
if (finalText) {
|
||||
await onEvent({ type: "assistant_message", data: { text: finalText } });
|
||||
}
|
||||
await onEvent({
|
||||
type: "completion",
|
||||
data: { ok: true, text: finalText, steps: Math.max(1, toolCount || 1) },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const runCodexBridge = async ({
|
||||
payload,
|
||||
request,
|
||||
stream,
|
||||
}: {
|
||||
payload: RequestPayload;
|
||||
request: Request;
|
||||
stream: boolean;
|
||||
}) => {
|
||||
const { mode, cleanedMessages } = extractCodexModeFromMessages(payload.messages.slice(0, 50));
|
||||
const workspaceRoot = await findWorkspaceRoot(process.cwd());
|
||||
const sessionIdRaw = String(payload.options?.ai?.sessionId ?? "").trim();
|
||||
const requestMode = mode;
|
||||
const sys =
|
||||
requestMode === "dev"
|
||||
? "你当前处于 #dev 模式:可在工作区内读取/修改文件并执行命令,但只能影响当前工作区。请用简体中文输出。"
|
||||
: requestMode === "test"
|
||||
? "你当前处于 #test 模式:只做分析与回答,不要执行命令,不要修改文件,不要伪造工具执行。请用简体中文输出。"
|
||||
: "你当前处于 #chat 模式:只聊天,不要执行命令,不要修改文件,不要输出 diff。请用简体中文输出。";
|
||||
|
||||
const buildPrompt = () => {
|
||||
if (!sessionIdRaw) {
|
||||
return codexMessagesToPrompt([{ role: "system", content: sys }, ...cleanedMessages]);
|
||||
}
|
||||
const lastUser = [...cleanedMessages].reverse().find((item) => item.role === "user")?.content ?? "";
|
||||
const nextUserText = String(lastUser || "").trim();
|
||||
if (!nextUserText) {
|
||||
return codexMessagesToPrompt([{ role: "system", content: sys }, ...cleanedMessages]);
|
||||
}
|
||||
return codexMessagesToPrompt([
|
||||
{ role: "system", content: sys },
|
||||
{ role: "user", content: nextUserText },
|
||||
]);
|
||||
};
|
||||
|
||||
if (!stream) {
|
||||
const run = startCodexJsonRun({
|
||||
cwd: workspaceRoot,
|
||||
sandbox: "workspace-write",
|
||||
prompt: buildPrompt(),
|
||||
model: null,
|
||||
sessionId: sessionIdRaw || null,
|
||||
});
|
||||
const result = await run.done;
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ error: result.error }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({
|
||||
text: result.text,
|
||||
steps: 1,
|
||||
events: [],
|
||||
sessionId: result.threadId || sessionIdRaw || null,
|
||||
});
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let killActiveRun: (() => void) | null = null;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const send = (event: string, data: unknown) => {
|
||||
controller.enqueue(encoder.encode(toSseFrame(event, data)));
|
||||
};
|
||||
|
||||
const requestId = makeRunId();
|
||||
send("ready", { ok: true, requestId });
|
||||
|
||||
let sessionSent = false;
|
||||
let assistantSent = false;
|
||||
const toolStartAt = new Map<string, number>();
|
||||
|
||||
const stopRun = () => {
|
||||
try {
|
||||
killActiveRun?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
stopRun();
|
||||
};
|
||||
try {
|
||||
request.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const run = startCodexJsonRun({
|
||||
cwd: workspaceRoot,
|
||||
sandbox: "workspace-write",
|
||||
prompt: buildPrompt(),
|
||||
model: null,
|
||||
sessionId: sessionIdRaw || null,
|
||||
onJsonLine: (line) => {
|
||||
if (line.type === "thread.started") {
|
||||
const sid = String((line as { thread_id?: string }).thread_id ?? "").trim();
|
||||
if (sid && !sessionSent) {
|
||||
sessionSent = true;
|
||||
send("codex_session", { sessionId: sid });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.type === "item.started" && (line as { item?: { type?: string; id?: string; command?: string } }).item?.type === "command_execution") {
|
||||
const item = (line as { item?: { type?: string; id?: string; command?: string } }).item;
|
||||
const id = String(item?.id ?? "").trim();
|
||||
const command = String(item?.command ?? "");
|
||||
if (!id) return;
|
||||
toolStartAt.set(id, Date.now());
|
||||
send("tool_call", { id, tool: "codex_command", args: { command } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.type === "item.completed" && (line as { item?: { type?: string; id?: string; exit_code?: number; aggregated_output?: string } }).item?.type === "command_execution") {
|
||||
const item = (line as { item?: { type?: string; id?: string; exit_code?: number; aggregated_output?: string } }).item;
|
||||
const id = String(item?.id ?? "").trim();
|
||||
if (!id) return;
|
||||
const startedAt = toolStartAt.get(id) ?? Date.now();
|
||||
const ms = Math.max(0, Date.now() - startedAt);
|
||||
const exitCode = Number(item?.exit_code ?? 0);
|
||||
send("tool_result", {
|
||||
id,
|
||||
tool: "codex_command",
|
||||
ok: exitCode === 0,
|
||||
ms,
|
||||
result: {
|
||||
exitCode,
|
||||
output: String(item?.aggregated_output ?? ""),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.type === "item.completed" && (line as { item?: { type?: string; text?: string } }).item?.type === "agent_message") {
|
||||
const item = (line as { item?: { type?: string; text?: string } }).item;
|
||||
const text = String(item?.text ?? "").trim();
|
||||
if (text) {
|
||||
assistantSent = true;
|
||||
send("assistant_message", { text });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
killActiveRun = run.kill;
|
||||
|
||||
const result = await run.done;
|
||||
if (!result.ok) {
|
||||
send("error", { ok: false, message: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.threadId && !sessionSent) {
|
||||
sessionSent = true;
|
||||
send("codex_session", { sessionId: result.threadId });
|
||||
}
|
||||
if (!assistantSent && result.text) {
|
||||
assistantSent = true;
|
||||
send("assistant_message", { text: result.text });
|
||||
}
|
||||
send("completion", { ok: true, text: result.text, steps: 1 });
|
||||
})()
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
send("error", { ok: false, message });
|
||||
})
|
||||
.finally(() => {
|
||||
try {
|
||||
request.signal?.removeEventListener("abort", onAbort);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
stopRun();
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
try {
|
||||
killActiveRun?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(body, { headers: sseHeaders });
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const payload = await safeGetJsonBody<RequestPayload>(request);
|
||||
const payload = await safeGetJsonBody<MnoteCliAgentRunPayload>(request);
|
||||
if (!payload) {
|
||||
return errorResponses.badRequest("请求体不能为空");
|
||||
}
|
||||
@@ -689,106 +20,23 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
let userId = "";
|
||||
let userEmail: string | undefined;
|
||||
let userName: string | undefined;
|
||||
if (isConvexEnabled()) {
|
||||
const { auth } = await getAuthedConvexClient();
|
||||
userId = auth.userId ?? "";
|
||||
userEmail = auth.email;
|
||||
userName = auth.name;
|
||||
}
|
||||
if (!userId) {
|
||||
return errorResponses.unauthorized();
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!stream) {
|
||||
const events: LegacyStreamEvent[] = [];
|
||||
await streamHermesLegacyEvents({
|
||||
messages: payload.messages,
|
||||
instructions,
|
||||
sessionId,
|
||||
request,
|
||||
payload,
|
||||
userId,
|
||||
onEvent: (event) => {
|
||||
events.push(event);
|
||||
},
|
||||
}).catch((error) => {
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
});
|
||||
|
||||
const completion = [...events].reverse().find((event) => event.type === "completion") as Extract<LegacyStreamEvent, { type: "completion" }> | undefined;
|
||||
const assistant = [...events].reverse().find((event) => event.type === "assistant_message") as Extract<LegacyStreamEvent, { type: "assistant_message" }> | undefined;
|
||||
return NextResponse.json({
|
||||
text: completion?.data.text ?? assistant?.data.text ?? "",
|
||||
steps: completion?.data.steps ?? Math.max(1, events.filter((event) => event.type === "tool_call").length || 1),
|
||||
events,
|
||||
});
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const send = (event: string, data: unknown) => {
|
||||
controller.enqueue(encoder.encode(toSseFrame(event, data)));
|
||||
};
|
||||
|
||||
send("ready", { ok: true, requestId: makeRunId() });
|
||||
|
||||
const ping = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: ping ${Date.now()}\n\n`));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
(async () => {
|
||||
await streamHermesLegacyEvents({
|
||||
messages: payload.messages,
|
||||
instructions,
|
||||
sessionId,
|
||||
request,
|
||||
payload,
|
||||
userId,
|
||||
onEvent: (event) => {
|
||||
send(event.type, event.data ?? null);
|
||||
},
|
||||
});
|
||||
})()
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
send("error", { ok: false, message });
|
||||
})
|
||||
.finally(() => {
|
||||
clearInterval(ping);
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
return startMnoteCliAgentHostRun({
|
||||
request,
|
||||
userId,
|
||||
userEmail,
|
||||
userName,
|
||||
payload,
|
||||
});
|
||||
|
||||
return new Response(body, { headers: sseHeaders });
|
||||
}
|
||||
|
||||
@@ -264,13 +264,13 @@ describe("extractCurrentPageTitleFromSlashToolResult", () => {
|
||||
|
||||
|
||||
describe("DocumentAiAgentPanel.runtime island contract", () => {
|
||||
it("AI bridge runtime 固定为 Rust Web/Hermes owned island", () => {
|
||||
it("AI bridge runtime 固定为 mnote-cli host/client 主路径", () => {
|
||||
expect(DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT).toMatchObject({
|
||||
shellOwner: "mnote-web",
|
||||
bridgeOwner: "rust-web-hermes",
|
||||
runtimeRole: "react_interaction_island",
|
||||
runEndpoint: "/api/hermes/bridge",
|
||||
legacyCompatEndpoint: "/api/ai-agent/run",
|
||||
bridgeOwner: "mnote-cli",
|
||||
runtimeRole: "mnote_cli_host_client",
|
||||
runEndpoint: "/api/ai-agent/run",
|
||||
legacyCompatEndpoint: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,10 +29,10 @@ const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
|
||||
|
||||
export const DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT = {
|
||||
shellOwner: "mnote-web",
|
||||
bridgeOwner: "rust-web-hermes",
|
||||
runtimeRole: "react_interaction_island",
|
||||
runEndpoint: "/api/hermes/bridge",
|
||||
legacyCompatEndpoint: "/api/ai-agent/run",
|
||||
bridgeOwner: "mnote-cli",
|
||||
runtimeRole: "mnote_cli_host_client",
|
||||
runEndpoint: "/api/ai-agent/run",
|
||||
legacyCompatEndpoint: null,
|
||||
} as const;
|
||||
|
||||
const extractCodexMode = (text: string): CodexMode => {
|
||||
|
||||
@@ -149,10 +149,10 @@ export function buildTreeShellDomFiletreeSelection(activeDocumentId?: string | n
|
||||
focusedRowId: null as string | null,
|
||||
};
|
||||
}
|
||||
const documentRowId = `doc:${documentId}`;
|
||||
const indexRowId = `index:${documentId}`;
|
||||
return {
|
||||
selectedRowIds: [documentRowId, `index:${documentId}`],
|
||||
anchorRowId: documentRowId,
|
||||
focusedRowId: documentRowId,
|
||||
selectedRowIds: [indexRowId],
|
||||
anchorRowId: indexRowId,
|
||||
focusedRowId: indexRowId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -512,7 +512,8 @@ describe("tree-shell-iframe-host", () => {
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"expandedIds":["doc_a"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("const rendererExpandedIds = normalizeStringArray(rendererInput.expandedIds);");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"filetreeSelection"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"selectedRowIds":["doc:doc_a","index:doc_a"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"selectedRowIds":["index:doc_a"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"focusedRowId":"index:doc_a"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_filetree_selection_reducer_v1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"family":"rust_family"');
|
||||
|
||||
@@ -849,10 +849,10 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
let selectedFileTreeRowIds = new Set(
|
||||
rendererSelectedFileTreeRowIds.length > 0
|
||||
? rendererSelectedFileTreeRowIds
|
||||
: currentActiveDocumentId ? [\`doc:\${currentActiveDocumentId}\`, \`index:\${currentActiveDocumentId}\`] : [],
|
||||
: currentActiveDocumentId ? [\`index:\${currentActiveDocumentId}\`] : [],
|
||||
);
|
||||
let fileTreeAnchorRowId = rendererAnchorRowId || (currentActiveDocumentId ? \`doc:\${currentActiveDocumentId}\` : null);
|
||||
let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? \`doc:\${currentActiveDocumentId}\` : null);
|
||||
let fileTreeAnchorRowId = rendererAnchorRowId || (currentActiveDocumentId ? \`index:\${currentActiveDocumentId}\` : null);
|
||||
let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? \`index:\${currentActiveDocumentId}\` : null);
|
||||
let visibleFileTreeRowIds = [];
|
||||
|
||||
const focusRowElement = (nodeId) => {
|
||||
|
||||
@@ -75,4 +75,33 @@ describe("page subtree response helpers", () => {
|
||||
expect(normalized.conflictDetectionKey).toBe("doc_1:3");
|
||||
expect(normalized.pageSubtree).toBeNull();
|
||||
});
|
||||
|
||||
it("兼容 mnote-web transport 返回的 result 包装", () => {
|
||||
const normalized = normalizeDocumentContentResponse({
|
||||
documentId: "doc_1",
|
||||
payload: {
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
id: "paragraph_1",
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "CLI 写入后应可编辑" }],
|
||||
},
|
||||
],
|
||||
revision: 4,
|
||||
conflictDetectionKey: "doc_1:4",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalized.content).toEqual([
|
||||
{
|
||||
id: "paragraph_1",
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "CLI 写入后应可编辑" }],
|
||||
},
|
||||
]);
|
||||
expect(normalized.revision).toBe(4);
|
||||
expect(normalized.conflictDetectionKey).toBe("doc_1:4");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ export type DocumentContentResponseLike = {
|
||||
page_subtree?: PageSubtreeProjection | null;
|
||||
pageSubtree?: PageSubtreeProjection | null;
|
||||
title?: string | null;
|
||||
result?: DocumentContentResponseLike | null;
|
||||
};
|
||||
|
||||
export type NormalizedDocumentContentResponse = {
|
||||
@@ -24,7 +25,8 @@ export function normalizeDocumentContentResponse(input: {
|
||||
title?: string | null;
|
||||
payload?: DocumentContentResponseLike | null;
|
||||
}): NormalizedDocumentContentResponse {
|
||||
const payload = input.payload ?? null;
|
||||
const rawPayload = input.payload ?? null;
|
||||
const payload = rawPayload?.result ?? rawPayload;
|
||||
const content = payload?.content ?? null;
|
||||
const revision =
|
||||
typeof payload?.revision === "number" && Number.isInteger(payload.revision)
|
||||
|
||||
@@ -23,6 +23,27 @@ describe("tiptap-content-converter", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("把 BlockNote inline text 数组转换成 TipTap 可编辑文本节点", () => {
|
||||
expect(
|
||||
tiptapDocFromBlocks([
|
||||
{
|
||||
id: "cli_visible_b_b2",
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "第二个 CLI 页面,可直接编辑。" }],
|
||||
},
|
||||
] as never),
|
||||
).toEqual({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { blockId: "cli_visible_b_b2" },
|
||||
content: [{ type: "text", text: "第二个 CLI 页面,可直接编辑。" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips p0.5 block families through editor block document", () => {
|
||||
const document = editorBlockDocumentFromContent([
|
||||
{ id: "p1", type: "paragraph", content: "段落" },
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockBuildForwardHeaders } = vi.hoisted(() => ({
|
||||
mockBuildForwardHeaders: vi.fn(async () => new Headers({ "x-forwarded-for": "127.0.0.1" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/runtime-config", () => ({
|
||||
getMnoteRuntimeConfig: () => ({ backendUrl: "http://backend.test" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server/forward-headers", () => ({
|
||||
buildForwardHeaders: mockBuildForwardHeaders,
|
||||
}));
|
||||
|
||||
import { startDocumentAiOrchestratorRun } from "./document-ai-orchestrator";
|
||||
|
||||
describe("startDocumentAiOrchestratorRun", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockBuildForwardHeaders.mockClear();
|
||||
delete process.env.BACKEND_URL;
|
||||
delete process.env.BACKEND_INTERNAL_URL;
|
||||
delete process.env.MNOTE_AI_ORCHESTRATOR_API_KEY;
|
||||
});
|
||||
|
||||
it("应把 leptos-tiptap 的 block 级 AI 上下文透传给 sidecar", async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response('event: ready\ndata: {"ok":true}\n\n', {
|
||||
headers: { "Content-Type": "text/event-stream; charset=utf-8" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await startDocumentAiOrchestratorRun({
|
||||
userId: "user-1",
|
||||
payload: {
|
||||
maxSteps: 8,
|
||||
messages: [{ role: "user", content: "改写当前块" }],
|
||||
context: {
|
||||
source: "leptos-tiptap-island",
|
||||
action: "ask_ai",
|
||||
documentId: "doc-1",
|
||||
workspaceId: "ws-1",
|
||||
selectedBlockId: "block-1",
|
||||
selectedUids: ["block-1"],
|
||||
selectedText: "旧段落",
|
||||
selection: { currentBlockId: "block-1", state: { from: 1, to: 4 } },
|
||||
tiptapDocument: { type: "doc", content: [{ type: "paragraph" }] },
|
||||
documentBlocks: { type: "doc", content: [{ type: "paragraph" }] },
|
||||
},
|
||||
options: {
|
||||
ai: {
|
||||
modelKey: "gpt-5.3-codex",
|
||||
profileId: "page_writer_polish",
|
||||
sessionId: "doc_ai:doc-1:session-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://backend.test/api/v1/ai-agent/document/run",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
|
||||
expect(body.context).toMatchObject({
|
||||
source: "leptos-tiptap-island",
|
||||
action: "ask_ai",
|
||||
documentId: "doc-1",
|
||||
workspaceId: "ws-1",
|
||||
selectedBlockId: "block-1",
|
||||
selectedUids: ["block-1"],
|
||||
selectedText: "旧段落",
|
||||
selection: { currentBlockId: "block-1", state: { from: 1, to: 4 } },
|
||||
tiptapDocument: { type: "doc", content: [{ type: "paragraph" }] },
|
||||
documentBlocks: { type: "doc", content: [{ type: "paragraph" }] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,16 @@ type RequestPayload = {
|
||||
maxSteps?: number;
|
||||
messages: AgentMessage[];
|
||||
context?: {
|
||||
source?: string;
|
||||
action?: string;
|
||||
documentId?: string;
|
||||
workspaceId?: string;
|
||||
selectedBlockId?: string;
|
||||
selectedBlockIndex?: number;
|
||||
selectedUids?: string[];
|
||||
selectedText?: string;
|
||||
selection?: unknown;
|
||||
tiptapDocument?: unknown;
|
||||
documentBlocks?: unknown;
|
||||
pageOptions?: PageOptionsState;
|
||||
node?: unknown;
|
||||
@@ -77,7 +86,16 @@ export async function startDocumentAiOrchestratorRun(input: {
|
||||
maxSteps: input.payload.maxSteps,
|
||||
messages: input.payload.messages,
|
||||
context: {
|
||||
source: input.payload.context?.source ?? null,
|
||||
action: input.payload.context?.action ?? null,
|
||||
documentId: input.payload.context?.documentId ?? null,
|
||||
workspaceId: input.payload.context?.workspaceId ?? null,
|
||||
selectedBlockId: input.payload.context?.selectedBlockId ?? null,
|
||||
selectedBlockIndex: input.payload.context?.selectedBlockIndex ?? null,
|
||||
selectedUids: input.payload.context?.selectedUids ?? null,
|
||||
selectedText: input.payload.context?.selectedText ?? null,
|
||||
selection: input.payload.context?.selection ?? null,
|
||||
tiptapDocument: input.payload.context?.tiptapDocument ?? null,
|
||||
documentBlocks: input.payload.context?.documentBlocks ?? null,
|
||||
node: input.payload.context?.node ?? null,
|
||||
subtree: input.payload.context?.subtree ?? null,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockSpawn } = vi.hoisted(() => ({
|
||||
mockSpawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
default: {
|
||||
spawn: mockSpawn,
|
||||
},
|
||||
spawn: mockSpawn,
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: class NextResponse extends Response {
|
||||
static json(body: unknown, init?: ResponseInit) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { startMnoteCliAgentHostRun } from "./mnote-cli-agent-host";
|
||||
|
||||
function createMockChild(stdoutText: string) {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = vi.fn();
|
||||
setTimeout(() => {
|
||||
child.stdout.end(stdoutText);
|
||||
child.stderr.end("");
|
||||
child.emit("close", 0);
|
||||
}, 0);
|
||||
return child;
|
||||
}
|
||||
|
||||
describe("startMnoteCliAgentHostRun", () => {
|
||||
beforeEach(() => {
|
||||
mockSpawn.mockReset();
|
||||
delete process.env.DEV_USER_ID;
|
||||
delete process.env.DEV_USER_EMAIL;
|
||||
delete process.env.DEV_USER_NAME;
|
||||
});
|
||||
|
||||
it("启动 mnote-cli 时应把当前 Web 用户作为默认 CLI 写入身份下发", async () => {
|
||||
mockSpawn.mockImplementation(() => createMockChild('{"ok":true}\n'));
|
||||
|
||||
const response = await startMnoteCliAgentHostRun({
|
||||
request: new Request("http://127.0.0.1:3000/api/ai-agent/run"),
|
||||
userId: "a4b72c17-49e3-46d3-8456-24a0a7044d64",
|
||||
userEmail: "dev@mnote.local",
|
||||
userName: "开发用户",
|
||||
payload: {
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "写入测试页" }],
|
||||
context: {
|
||||
documentId: "doc-1",
|
||||
workspaceId: "ws_req_1778035004501_15",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"cargo",
|
||||
expect.arrayContaining([
|
||||
"--args-json",
|
||||
expect.stringContaining('"workspaceId":"ws_req_1778035004501_15"'),
|
||||
]),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
DEV_USER_ID: "a4b72c17-49e3-46d3-8456-24a0a7044d64",
|
||||
DEV_USER_EMAIL: "dev@mnote.local",
|
||||
DEV_USER_NAME: "开发用户",
|
||||
MNOTE_CLI_ALLOW_CREATE_PAGE: "1",
|
||||
MNOTE_CLI_ALLOW_EDIT: "1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("应把当前页面 workspaceId 传入 CLI argsJson,避免 agent 写入错误工作空间", async () => {
|
||||
mockSpawn.mockImplementation(() => createMockChild('{"ok":true}\n'));
|
||||
|
||||
await startMnoteCliAgentHostRun({
|
||||
request: new Request("http://127.0.0.1:3000/api/ai-agent/run"),
|
||||
userId: "a4b72c17-49e3-46d3-8456-24a0a7044d64",
|
||||
userEmail: "dev@mnote.local",
|
||||
userName: "开发用户",
|
||||
payload: {
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "在当前空间写测试页" }],
|
||||
context: {
|
||||
documentId: "tree_1778036320856_1",
|
||||
workspaceId: "ws_req_1778035004501_15",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const args = mockSpawn.mock.calls[0]?.[1] as string[];
|
||||
const argsJson = args[args.indexOf("--args-json") + 1];
|
||||
expect(JSON.parse(argsJson)).toMatchObject({
|
||||
documentId: "tree_1778036320856_1",
|
||||
workspaceId: "ws_req_1778035004501_15",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { access } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { PageOptionsState } from "@/types/page-options";
|
||||
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
|
||||
export type MnoteCliAgentRunPayload = {
|
||||
stream?: boolean;
|
||||
maxSteps?: number;
|
||||
scope?: string;
|
||||
messages: AgentMessage[];
|
||||
context?: {
|
||||
documentId?: string;
|
||||
workspaceId?: string;
|
||||
documentBlocks?: unknown;
|
||||
pageOptions?: PageOptionsState;
|
||||
node?: unknown;
|
||||
subtree?: unknown;
|
||||
outline?: unknown;
|
||||
evidence?: unknown;
|
||||
selectedUids?: string[];
|
||||
};
|
||||
options?: {
|
||||
ai?: {
|
||||
provider?: string;
|
||||
sessionId?: string;
|
||||
modelKey?: string;
|
||||
profileId?: string;
|
||||
model?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type CliRunResult = {
|
||||
code: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
const CLI_HOST_TIMEOUT_MS = 30_000;
|
||||
|
||||
const toSseFrame = (event: string, data: unknown) => {
|
||||
const json = JSON.stringify(data ?? null);
|
||||
return `event: ${event}\ndata: ${json}\n\n`;
|
||||
};
|
||||
|
||||
async function pathExists(targetPath: string) {
|
||||
try {
|
||||
await access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRepoRoot() {
|
||||
let dir = path.resolve(process.cwd());
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
if (await pathExists(path.join(dir, "rust", "Cargo.toml"))) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return path.resolve(process.cwd(), "..");
|
||||
}
|
||||
|
||||
function buildCliArgs(input: { repoRoot: string; userId: string; payload: MnoteCliAgentRunPayload }) {
|
||||
const sessionId = String(input.payload.options?.ai?.sessionId ?? "").trim() || `ai-${Date.now()}`;
|
||||
const documentId = String(input.payload.context?.documentId ?? "").trim() || "current";
|
||||
const workspaceId = String(input.payload.context?.workspaceId ?? "").trim() || null;
|
||||
const argsJson = JSON.stringify({
|
||||
pageId: documentId,
|
||||
documentId,
|
||||
workspaceId,
|
||||
provider: input.payload.options?.ai?.provider ?? null,
|
||||
modelKey: input.payload.options?.ai?.modelKey ?? null,
|
||||
profileId: input.payload.options?.ai?.profileId ?? null,
|
||||
selectedUids: input.payload.context?.selectedUids ?? null,
|
||||
pageOptions: input.payload.context?.pageOptions ?? null,
|
||||
editorRuntimePageOptions: input.payload.context?.pageOptions
|
||||
? pickLeptosTiptapRuntimePageOptions(input.payload.context.pageOptions)
|
||||
: null,
|
||||
});
|
||||
|
||||
return [
|
||||
"run",
|
||||
"--quiet",
|
||||
"--manifest-path",
|
||||
path.join(input.repoRoot, "rust", "Cargo.toml"),
|
||||
"-p",
|
||||
"mnote-cli",
|
||||
"--",
|
||||
"--json",
|
||||
"--validate-only",
|
||||
"--dry-run",
|
||||
"--actor-id",
|
||||
input.userId,
|
||||
"--actor-type",
|
||||
"user",
|
||||
"--session-id",
|
||||
sessionId,
|
||||
"--reason",
|
||||
"ai-agent-run:mnote-cli-host",
|
||||
"tool",
|
||||
"run",
|
||||
"--tool-name",
|
||||
"doc_get",
|
||||
"--kind",
|
||||
"query",
|
||||
"--mode",
|
||||
"explain-plan",
|
||||
"--args-json",
|
||||
argsJson,
|
||||
];
|
||||
}
|
||||
|
||||
async function runMnoteCli(input: {
|
||||
userId: string;
|
||||
userEmail?: string;
|
||||
userName?: string;
|
||||
payload: MnoteCliAgentRunPayload;
|
||||
}): Promise<CliRunResult> {
|
||||
const repoRoot = await resolveRepoRoot();
|
||||
const args = buildCliArgs({ repoRoot, userId: input.userId, payload: input.payload });
|
||||
|
||||
return new Promise<CliRunResult>((resolve, reject) => {
|
||||
const child = spawn("cargo", args, {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
CARGO_TERM_COLOR: "never",
|
||||
RUSTUP_TOOLCHAIN: process.env.RUSTUP_TOOLCHAIN?.trim() || "1.89.0",
|
||||
DEV_USER_ID: input.userId,
|
||||
DEV_USER_EMAIL: input.userEmail?.trim() || process.env.DEV_USER_EMAIL || "dev@mnote.local",
|
||||
DEV_USER_NAME: input.userName?.trim() || process.env.DEV_USER_NAME || "开发用户",
|
||||
MNOTE_CLI_ALLOW_CREATE_PAGE: process.env.MNOTE_CLI_ALLOW_CREATE_PAGE || "1",
|
||||
MNOTE_CLI_ALLOW_EDIT: process.env.MNOTE_CLI_ALLOW_EDIT || "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGKILL");
|
||||
}, CLI_HOST_TIMEOUT_MS);
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (timedOut) {
|
||||
reject(new Error("mnote-cli host 执行超时"));
|
||||
return;
|
||||
}
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function startMnoteCliAgentHostRun(input: {
|
||||
request: Request;
|
||||
userId: string;
|
||||
userEmail?: string;
|
||||
userName?: string;
|
||||
payload: MnoteCliAgentRunPayload;
|
||||
}): Promise<Response> {
|
||||
void input.request;
|
||||
const run = runMnoteCli({
|
||||
userId: input.userId,
|
||||
userEmail: input.userEmail,
|
||||
userName: input.userName,
|
||||
payload: input.payload,
|
||||
});
|
||||
|
||||
if (!input.payload.stream) {
|
||||
const result = await run;
|
||||
if (result.code !== 0) {
|
||||
return NextResponse.json({ error: result.stderr || "mnote-cli 执行失败" }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
bridgeOwner: "mnote-cli",
|
||||
text: result.stdout.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
controller.enqueue(encoder.encode(toSseFrame("ready", { ok: true, bridgeOwner: "mnote-cli" })));
|
||||
try {
|
||||
const result = await run;
|
||||
if (result.code !== 0) {
|
||||
controller.enqueue(
|
||||
encoder.encode(toSseFrame("error", { ok: false, message: result.stderr || "mnote-cli 执行失败" })),
|
||||
);
|
||||
} else {
|
||||
const text = result.stdout.trim() || "mnote-cli 无输出";
|
||||
controller.enqueue(encoder.encode(toSseFrame("assistant_message", { text })));
|
||||
controller.enqueue(encoder.encode(toSseFrame("completion", { ok: true, text, steps: 1 })));
|
||||
}
|
||||
} catch (error) {
|
||||
controller.enqueue(
|
||||
encoder.encode(toSseFrame("error", { ok: false, message: error instanceof Error ? error.message : String(error) })),
|
||||
);
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
"x-mnote-ai-execution-owner": "mnote-cli",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -31,11 +31,11 @@ const isPublicRoute = createRouteMatcher([
|
||||
]);
|
||||
|
||||
export default convexAuthNextjsMiddleware(async (request, ctx) => {
|
||||
// 公共路由不做拦截(但 middleware 仍会处理 token 刷新/代理等)。
|
||||
// 公共路由不做拦截(但 proxy 仍会处理 token 刷新/代理等)。
|
||||
if (isPublicRoute(request)) return;
|
||||
|
||||
// 只在启用 Convex 模式时做鉴权拦截;否则走 Supabase 逻辑(页面内部自行处理)。
|
||||
// 注意:middleware 运行在 Edge/Node 环境中,读取到的是运行期环境变量。
|
||||
// 注意:proxy 运行在 Edge/Node 环境中,读取到的是运行期环境变量。
|
||||
if (process.env.NEXT_PUBLIC_USE_CONVEX !== "1") return;
|
||||
|
||||
// 开发用户模式下允许“免登录”访问(主要用于迁移/联调与 E2E 回归)。
|
||||
@@ -49,6 +49,6 @@ export default convexAuthNextjsMiddleware(async (request, ctx) => {
|
||||
});
|
||||
|
||||
export const config = {
|
||||
// 说明:排除静态资源,避免无意义的中间件开销。
|
||||
// 说明:排除静态资源,避免无意义的 proxy 开销。
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
Reference in New Issue
Block a user