Files
mnote/wolai-frontend/src/app/api/ai-agent/run/route.ts
T

610 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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): 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> | void;
}) => {
const input = buildHermesInput(messages);
const { runId } = await startHermesRun({
input,
instructions,
...(sessionId ? { session_id: sessionId } : {}),
});
const pendingToolIds = new Map<string, string[]>();
const previewById = new Map<string, string>();
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<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);
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<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,
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 });
}