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 { 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"; 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 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; node?: unknown; subtree?: unknown; outline?: unknown; evidence?: unknown; }; options?: { searxng?: boolean; ai?: { provider?: RawAiProvider; model?: string; sessionId?: string; }; }; }; type LegacyStreamEvent = | { type: "assistant_message"; data: { text: string } } | { type: "tool_call"; data: { id: string; tool: string; args: Record } } | { 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): RuntimeProvider => { const value = String(raw ?? "").trim().toLowerCase(); return value === "codex" ? "codex" : "hermes"; }; 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 (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 ?? "") })); }; const streamHermesLegacyEvents = async ({ messages, instructions, sessionId, onEvent, }: { messages: AgentMessage[]; instructions: string; sessionId: string | null; onEvent: (event: LegacyStreamEvent) => Promise | void; }) => { const input = buildHermesInput(messages); const { runId } = await startHermesRun({ input, instructions, ...(sessionId ? { session_id: sessionId } : {}), }); const pendingToolIds = new Map(); const previewById = new Map(); 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 : ""; previewById.set(id, preview); 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 preview = previewById.get(id) ?? ""; const duration = Number(event.duration ?? 0); 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: 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({ 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(); 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(request); if (!payload) { return errorResponses.badRequest("请求体不能为空"); } const validationError = validateRequestBody(payload, ["messages"] as const); if (validationError) return validationError; if (!Array.isArray(payload.messages) || payload.messages.length === 0) { return errorResponses.badRequest("缺少 messages"); } let userId = ""; if (isConvexEnabled()) { const { auth } = await getAuthedConvexClient(); userId = auth.userId ?? ""; } if (!userId) { return errorResponses.unauthorized(); } const provider = normalizeProvider(payload.options?.ai?.provider); const stream = payload.stream !== false; if (provider === "codex") { return await runCodexBridge({ payload, request, stream }); } const scope = normalizeScope(payload); 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, 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 | undefined; const assistant = [...events].reverse().find((event) => event.type === "assistant_message") as Extract | 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({ 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, 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 new Response(body, { headers: sseHeaders }); }