import { NextResponse } from "next/server"; import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig"; import { loadLocalAiConfig } from "@/lib/ai/localAiConfig"; import { codexMessagesToPrompt, findWorkspaceRoot, startCodexJsonRun } from "@/lib/ai/codex/codexExec"; import { createToolRegistry, resolveAllowedToolIds } from "@/lib/ai-agent/tools/registry"; import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins"; import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent"; import { isConvexEnabled } from "@/lib/convex/enabled"; import { createMindmapServerTools, type MindmapSupabaseClient } from "@/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools"; import { createDocServerTools, type DocSupabaseClient } from "@/lib/ai-agent/tools/builtins/doc/docServerTools"; import { createRagServerTools } from "@/lib/ai-agent/tools/builtins/rag/lightragServerTools"; import { createDocsServerTools, type DocsSupabaseClient } from "@/lib/ai-agent/tools/builtins/docs/docsServerTools"; import { createMediaServerTools, type MediaSupabaseClient } from "@/lib/ai-agent/tools/builtins/media/mediaServerTools"; import { createSlashServerTools, type SlashSupabaseClient } from "@/lib/ai-agent/tools/builtins/slash/slashServerTools"; import { createOnlyOfficeServerTools, type OnlyOfficeSupabaseClient } from "@/lib/ai-agent/tools/builtins/onlyoffice/onlyofficeServerTools"; import { buildClientToolKey, registerClientToolCall } from "@/lib/ai-agent/runtime/clientToolBridge"; import { getAuthedConvexClient } from "@/lib/convex/route"; import { api } from "@/lib/convex/api"; import type { ConvexHttpClient } from "convex/browser"; import { buildDocumentBridgeContextWithActor, buildDocumentCommandEnvelope, DocumentBridgeError, } from "@/lib/documents/bridge"; import { recordBridgeCommandFailureArtifacts } from "@/lib/documents/bridge-log"; import { executeRustBridgeTool } from "@/lib/documents/rust-runtime"; import { DEFAULT_AGENT_MAX_STEPS, MAX_AGENT_STEPS, MIN_AGENT_STEPS, DEFAULT_CLIENT_TOOL_TIMEOUT_MS, MAX_MINDMAP_ATTACHMENTS, MAX_SELECTED_NODES, DEFAULT_SEARCH_COUNT, } from "@/lib/constants"; import { safeGetJsonBody, errorResponses, validateRequestBody } from "@/lib/api-utils"; import { isPlainObject, hasProperty, toRecord } from "@/lib/type-guards"; export const dynamic = "force-dynamic"; type AgentMessage = { role: "user" | "assistant"; content: string }; type AgentScope = "global" | "mindmap" | "document" | "onlyoffice"; type AiProvider = "online" | "local" | "ollama" | "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[]; // v1:BlockNote 文档快照(前端可选传入,避免覆盖未落盘编辑) documentBlocks?: unknown; }; options?: { searxng?: boolean; ai?: { provider?: AiProvider; model?: string; sessionId?: string } }; }; /** 按作用域分组的工具集 ID 映射 */ const SCOPE_TOOLSET_MAP: Record = { mindmap: [ "toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.mindmap_read", "toolset.mindmap_write", ], document: [ "toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.doc_read", "toolset.doc_write", "toolset.slash_write", ], onlyoffice: [ "toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.onlyoffice_read", "toolset.onlyoffice_write", "toolset.onlyoffice_editor", ], global: [ "toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.slash_write", ], }; /** 获取指定作用域允许的工具集 ID */ const getToolSetIdsForScope = (scope: AgentScope): string[] => { return SCOPE_TOOLSET_MAP[scope] ?? SCOPE_TOOLSET_MAP.global; }; const makeRunId = () => { try { const cryptoObj = (globalThis as unknown as { crypto?: Crypto }).crypto; if (cryptoObj?.randomUUID) return cryptoObj.randomUUID(); } catch { // ignore } return `run_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`; }; const isOnlyOfficeClientTool = (toolId: string) => toolId.startsWith("oo_"); const isDocRustTool = (toolId: string): toolId is "doc_get" | "doc_find" | "doc_insert_blocks" | "doc_replace_range" => toolId === "doc_get" || toolId === "doc_find" || toolId === "doc_insert_blocks" || toolId === "doc_replace_range"; const isMindmapWriteTool = (toolId: string) => toolId === "mindmap_put" || toolId === "mindmap_apply_ops" || toolId === "mindmap_add_child" || toolId === "mindmap_add_sibling_after" || toolId === "mindmap_update_node_text" || toolId === "mindmap_set_hyperlink" || toolId === "mindmap_append_note" || toolId === "mindmap_set_refs" || toolId === "mindmap_delete_node" || toolId === "mindmap_add_attachment_ref" || toolId === "mindmap_add_attachment_child" || toolId === "mindmap_add_image_child" || toolId === "mindmap_append_image_note" || toolId === "mindmap_expand_node"; 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 OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434/v1"; const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M"; const normalizeProvider = (raw: unknown): AiProvider => { const s = String(raw ?? "").trim(); if (s === "local" || s === "online" || s === "ollama" || s === "codex") return s; return "online"; }; const stripCodexModePrefix = (text: string): { mode: CodexMode | null; text: string } => { const s = String(text ?? ""); const m = s.match(/^\s*#(chat|test|dev)\b[\s::\-–—]*/i); if (!m) return { mode: null, text: s }; const mode = String(m[1] ?? "").toLowerCase() as CodexMode; const rest = s.slice(m[0].length); return { mode, text: rest.trimStart() }; }; const extractCodexModeFromMessages = (messages: AgentMessage[]) => { const lastUser = [...messages].reverse().find((m) => m.role === "user")?.content ?? ""; const picked = stripCodexModePrefix(lastUser); const mode: CodexMode = picked.mode ?? "chat"; const cleaned: AgentMessage[] = messages.map((m) => { if (m.role !== "user") return m; const r = stripCodexModePrefix(m.content); return { ...m, content: r.text }; }); return { mode, cleanedMessages: cleaned }; }; 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"); } // v1:鉴权(Convex 迁移阶段使用固定开发用户;非 Convex 模式仍走 Supabase session) const convexOn = isConvexEnabled(); const supabase = null as unknown; let userId = ""; let convexClient: ConvexHttpClient | null = null; if (convexOn) { const { auth, client } = await getAuthedConvexClient(); userId = auth.userId ?? ""; convexClient = client; } if (!userId) { return errorResponses.unauthorized(); } const aiBridgeContext = buildDocumentBridgeContextWithActor({ request, actor: { actorType: "user", actorId: userId, sessionId: null, }, workspaceId: null, source: { channel: "ai-agent-route", client: "wolai-frontend", }, }); const provider = normalizeProvider(payload.options?.ai?.provider); const modelOverride = String(payload.options?.ai?.model ?? "").trim() || null; // Codex:三种模式(默认 #chat) let codexMode: CodexMode = "chat"; let effectiveMessages: AgentMessage[] = payload.messages.slice(0, 50); let codexWorkspaceRoot: string | null = null; if (provider === "codex") { const { mode, cleanedMessages } = extractCodexModeFromMessages(payload.messages.slice(0, 50)); codexMode = mode; effectiveMessages = cleanedMessages; codexWorkspaceRoot = await findWorkspaceRoot(process.cwd()); // #chat / #dev:直接运行 codex exec(不走本 Agent 工具链) if (codexMode !== "test") { const stream = payload.stream !== false; const sessionIdRaw = String(payload.options?.ai?.sessionId ?? "").trim(); const encoder = new TextEncoder(); let runKillOuter: (() => 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 }); // 说明:同一对话内允许 #chat/#test/#dev 来回切换;为了保证后续随时可进入 #dev, // web 侧创建的新 session 统一用 workspace-write(是否改文件由 prompt 约束)。 const sandbox: "read-only" | "workspace-write" = "workspace-write"; const sys = codexMode === "dev" ? "你当前处于 #dev 模式:行为尽量与 Codex CLI 一致。你可以在工作区内读取/修改文件并执行命令,但只能影响当前工作区。请用简体中文输出。" : "你当前处于 #chat 模式:只聊天,不要执行命令,不要修改文件,不要输出 diff。请用简体中文输出。"; const prompt = (() => { // 新会话:把系统说明 + 对话历史一起喂给 Codex(保证一致性) if (!sessionIdRaw) return codexMessagesToPrompt([{ role: "system", content: sys }, ...effectiveMessages]); // 续聊:只发送本次用户输入(带模式前缀),同时重复一遍系统约束以对齐行为 const lastUser = [...effectiveMessages].reverse().find((m) => m.role === "user")?.content ?? ""; const userText = String(lastUser || "").trim(); if (!userText) return codexMessagesToPrompt([{ role: "system", content: sys }, ...effectiveMessages]); return codexMessagesToPrompt([{ role: "system", content: sys }, { role: "user", content: userText }]); })(); const toolStartAt = new Map(); let sessionSent = false; let assistantSent = false; let runKill: (() => void) | null = null; runKillOuter = () => { try { runKill?.(); } catch { // ignore } }; const onAbort = () => { try { runKillOuter?.(); } catch { // ignore } }; try { request.signal?.addEventListener("abort", onAbort, { once: true }); } catch { // ignore } (async () => { const run = startCodexJsonRun({ cwd: codexWorkspaceRoot!, sandbox, prompt, model: null, sessionId: sessionIdRaw || null, onJsonLine: (line) => { if (line.type === "thread.started") { const sid = String((line as any).thread_id ?? "").trim(); if (sid && !sessionSent) { sessionSent = true; send("codex_session", { sessionId: sid }); } return; } if (line.type === "item.started" && (line as any).item?.type === "command_execution") { const item = (line as any).item; const id = String(item?.id ?? "").trim(); const cmd = String(item?.command ?? ""); if (!id) return; toolStartAt.set(id, Date.now()); send("tool_call", { id, tool: "codex_command", args: { command: cmd } }); return; } if (line.type === "item.completed" && (line as any).item?.type === "command_execution") { const item = (line as any).item; const id = String(item?.id ?? "").trim(); if (!id) return; const t0 = toolStartAt.get(id) ?? Date.now(); const ms = Math.max(0, Date.now() - t0); 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 any).item?.type === "agent_message") { const item = (line as any).item; const text = String(item?.text ?? "").trim(); if (text) { assistantSent = true; send("assistant_message", { text }); } } }, }); runKill = 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((e) => { const msg = e instanceof Error ? e.message : String(e); send("error", { ok: false, message: msg }); }) .finally(() => { try { request.signal?.removeEventListener("abort", onAbort); } catch { // ignore } controller.close(); }); }, cancel() { // 前端 abort fetch 时会触发 cancel:尽量终止 codex 进程(类似按 ESC) // 说明:kill 函数在 start 的闭包里赋值;这里不做任何强假设。 try { runKillOuter?.(); } catch { // ignore } }, }); if (!stream) { const prompt = (() => { if (!sessionIdRaw) return codexMessagesToPrompt([{ role: "system", content: "请用简体中文回复。" }, ...effectiveMessages]); const lastUser = [...effectiveMessages].reverse().find((m) => m.role === "user")?.content ?? ""; const userText = String(lastUser || "").trim(); if (!userText) return codexMessagesToPrompt([{ role: "system", content: "请用简体中文回复。" }, ...effectiveMessages]); return codexMessagesToPrompt([{ role: "system", content: "请用简体中文回复。" }, { role: "user", content: userText }]); })(); const run = startCodexJsonRun({ cwd: codexWorkspaceRoot!, sandbox: "workspace-write", prompt, 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 }); } return new Response(body, { headers: sseHeaders }); } } // 非 Codex:online/local/ollama 走 OpenAI 兼容网关 const cfg = await (async () => { if (provider === "codex") { // #test:工具链可能需要 cfg(例如 mindmap_expand_node),因此这里尽量给一个可用的兜底 cfg return ( (await loadLocalAiConfig().catch(() => null)) ?? (await loadOnlineAiConfig().catch(() => null)) ?? { baseUrl: (process.env.OLLAMA_BASE_URL ?? "").trim() || OLLAMA_DEFAULT_BASE_URL, apiKey: "", model: OLLAMA_QWEN3_30B, } ); } if (provider === "ollama") { return { baseUrl: (process.env.OLLAMA_BASE_URL ?? "").trim() || OLLAMA_DEFAULT_BASE_URL, apiKey: "", model: modelOverride ?? OLLAMA_QWEN3_30B, }; } if (provider === "local") return await loadLocalAiConfig().catch(() => null); return await loadOnlineAiConfig().catch(() => null); })(); if (!cfg) { // 仅 online/local 需要配置文件/环境变量 return errorResponses.aiConfigError(provider === "local" ? "local" : "online"); } const registry = createToolRegistry({ tools: builtinTools, toolSets: builtinToolSets }); const allowedToolIds = resolveAllowedToolIds({ registry, mode: payload.toolChoice?.mode === "manual" ? "manual" : "auto", toolSetIds: payload.toolChoice?.toolSets, toolIds: payload.toolChoice?.tools, }); const documentId = String(payload.context?.documentId ?? "").trim(); const mindmapId = String(payload.context?.mindmapId ?? "").trim(); const scope: AgentScope = (() => { const raw = String(payload.scope ?? "").trim(); if (raw === "global" || raw === "mindmap" || raw === "document" || raw === "onlyoffice") return raw; // 兜底:有 mindmapId 则认为在 mindmap 场景,否则视为全局场景 return mindmapId ? "mindmap" : "global"; })(); // v1:按"使用位置"隔离工具,避免工具混淆/误调用(即使用户手动传入,也会被过滤) const allowToolSetIds = getToolSetIdsForScope(scope); const allowlist = new Set(); for (const sid of allowToolSetIds) { const s = registry.toolSetsById.get(sid); (s?.toolIds ?? []).forEach((id) => allowlist.add(id)); } for (const id of [...allowedToolIds]) { if (!allowlist.has(id)) allowedToolIds.delete(id); } // v1:允许通过 options.searxng 关闭联网检索(比如离线模型/内网) if (payload.options?.searxng === false) allowedToolIds.delete("search_web"); // v1:mindmap 工具必须在提供上下文时才允许,避免模型盲调导致误操作 const selectedUids = Array.isArray(payload.context?.selectedUids) ? payload.context!.selectedUids!.map((x) => String(x)).filter(Boolean).slice(0, MAX_SELECTED_NODES) : []; const hasMindmapContext = Boolean(documentId && mindmapId); const hasDocumentContext = Boolean(documentId); const documentBlocks = payload.context?.documentBlocks ?? null; const attachments = Array.isArray(payload.attachments) ? payload.attachments.slice(0, MAX_MINDMAP_ATTACHMENTS) : []; const attachmentLines = attachments .map((a, idx) => `${idx + 1}. id=${String(a.id)} title=${String(a.title)} mime=${String(a.mimeType ?? "")} url=${String(a.fileUrl)}`) .join("\n"); if (!hasMindmapContext) { allowedToolIds.delete("mindmap_get"); allowedToolIds.delete("mindmap_get_subtree"); allowedToolIds.delete("mindmap_apply_ops"); allowedToolIds.delete("mindmap_expand_node"); allowedToolIds.delete("mindmap_add_child"); allowedToolIds.delete("mindmap_add_sibling_after"); allowedToolIds.delete("mindmap_update_node_text"); allowedToolIds.delete("mindmap_set_hyperlink"); allowedToolIds.delete("mindmap_append_note"); allowedToolIds.delete("mindmap_set_refs"); allowedToolIds.delete("mindmap_delete_node"); allowedToolIds.delete("mindmap_add_attachment_ref"); allowedToolIds.delete("mindmap_add_attachment_child"); allowedToolIds.delete("mindmap_add_image_child"); allowedToolIds.delete("mindmap_append_image_note"); } if (!hasDocumentContext) { allowedToolIds.delete("doc_get"); allowedToolIds.delete("doc_find"); allowedToolIds.delete("doc_insert_blocks"); allowedToolIds.delete("doc_replace_range"); } // 说明:Convex 迁移阶段(M4)先确保“不会再触发 Supabase 依赖”。 // 未迁移的能力(OnlyOffice 等)在 Convex 模式下直接禁用对应工具。 if (convexOn) { for (const id of [...allowedToolIds]) { if ( id === "search_web" || id === "image_read" || id === "slash_run" || id.startsWith("rag_") || id.startsWith("mindmap_") || id.startsWith("doc_") || id.startsWith("docs_") ) { continue; } allowedToolIds.delete(id); } } const systemContextText = (() => { const lines: string[] = []; if (documentId) lines.push(`documentId=${documentId}`); if (scope === "mindmap") { if (mindmapId) lines.push(`mindmapId=${mindmapId}`); if (selectedUids.length) lines.push(`selectedUids=${selectedUids.join(",")}`); } if (scope === "document") { if (documentBlocks) lines.push("documentBlocks=provided"); } if (attachments.length) lines.push(`attachments:\n${attachmentLines}`); return lines.join("\n").trim(); })(); const normalizeBlocksForTools = (content: unknown): unknown[] => { if (Array.isArray(content)) return content; if (content && typeof content === "object" && "blocks" in (content as any)) { const blocks = (content as any).blocks; if (Array.isArray(blocks)) return blocks; } return []; }; const extractPlainTextFromBlocks = (blocks: unknown[], maxChars: number) => { const pieces: string[] = []; const walk = (list: unknown[]) => { for (const b of list) { if (!b || typeof b !== "object") continue; const content = (b as any).content; if (Array.isArray(content)) { for (const n of content) { const t = n && typeof n === "object" ? String((n as any).text ?? "") : ""; if (t) pieces.push(t); if (pieces.join("").length >= maxChars) return; } } const children = (b as any).children; if (Array.isArray(children)) { walk(children); if (pieces.join("").length >= maxChars) return; } } }; walk(blocks); const raw = pieces.join("").replace(/\s+/g, " ").trim(); return raw.length > maxChars ? `${raw.slice(0, maxChars)}…` : raw; }; const mindmapTools = hasMindmapContext ? createMindmapServerTools({ supabase: supabase as unknown as MindmapSupabaseClient, ctx: { documentId, mindmapId, userId, selectedUids, attachments }, cfg: { ...cfg, model: modelOverride ?? cfg.model }, allowedToolIds, ...(convexOn ? { loadMindmap: async () => { if (!convexClient) throw new Error("Convex 未初始化"); const [mm, meta] = await Promise.all([ convexClient.query(api.mindmaps.get, { docId: documentId, mindmapId }), convexClient.query(api.documents.getMeta, { id: documentId }), ]); const title = meta?.title ?? null; const workspaceId = meta?.workspace_id ?? (mm as any)?.meta?.workspace_id ?? null; return { doc: { id: documentId, title, workspace_id: workspaceId }, base: (mm as any)?.data ?? { data: { text: "中心主题" }, children: [] }, }; }, saveMindmap: async ({ data }) => { if (!convexClient) throw new Error("Convex 未初始化"); await convexClient.mutation(api.mindmaps.put, { docId: documentId, mindmapId, data }); }, } : {}), }) : null; const docTools = hasDocumentContext && (allowedToolIds.has("doc_get") || allowedToolIds.has("doc_find") || allowedToolIds.has("doc_insert_blocks") || allowedToolIds.has("doc_replace_range")) ? createDocServerTools({ supabase: supabase as unknown as DocSupabaseClient, ctx: { documentId, userId, baseBlocks: documentBlocks }, allowedToolIds, ...(convexOn ? { loadBlocks: async () => { const base = normalizeBlocksForTools(documentBlocks); if (base.length > 0) return { blocks: base, source: "client" }; if (!convexClient) throw new Error("Convex 未初始化"); const res = await convexClient.query(api.documents.getContent, { id: documentId }); const blocks = normalizeBlocksForTools(res?.content ?? null); return { blocks, source: "convex" }; }, } : {}), }) : null; const ragTools = allowedToolIds.has("rag_lightrag_query") ? createRagServerTools({ ctx: { userId }, allowedToolIds, }) : null; const docsTools = allowedToolIds.has("docs_search") || allowedToolIds.has("docs_read") ? createDocsServerTools({ supabase: supabase as unknown as DocsSupabaseClient, ctx: { userId }, allowedToolIds, ...(convexOn ? { searchDocs: async ({ query, limit, workspaceId, includeDeleted }) => { if (!convexClient) throw new Error("Convex 未初始化"); const wsIds = workspaceId ? [workspaceId] : ((await convexClient.query(api.workspaces.fetchWorkspaceSummaries, {}))?.workspaces ?? []).map((w: any) => String(w?.id ?? ""), ); const q = query.toLowerCase(); const results: any[] = []; for (const wid of wsIds.filter(Boolean)) { const docs = await convexClient.query(api.documents.listByWorkspace, { workspaceId: wid }); const extra = includeDeleted ? await convexClient.query(api.documents.listTrashedByWorkspace, { workspaceId: wid }).catch(() => []) : []; const all = [...(Array.isArray(docs) ? docs : []), ...(Array.isArray(extra) ? extra : [])]; for (const d of all) { const title = String((d as any)?.title ?? ""); if (!title.toLowerCase().includes(q)) continue; results.push({ id: String((d as any)?.id ?? ""), title, workspaceId: String((d as any)?.workspace_id ?? wid), parentId: (d as any)?.parent_id ? String((d as any).parent_id) : null, updatedAt: (d as any)?.updated_at ?? null, snippet: title.slice(0, 120), }); if (results.length >= limit) break; } if (results.length >= limit) break; } return results.slice(0, limit); }, readDoc: async ({ documentId: rid, maxChars, includeContent }) => { if (!convexClient) throw new Error("Convex 未初始化"); const meta = await convexClient.query(api.documents.getMeta, { id: rid }); if (!meta) throw new Error("页面不存在"); const contentRes = await convexClient.query(api.documents.getContent, { id: rid }); const blocks = normalizeBlocksForTools(contentRes?.content ?? null); const rawText = extractPlainTextFromBlocks(blocks, maxChars); return { ok: true, documentId: rid, title: String(meta.title ?? ""), workspaceId: String((meta as any).workspace_id ?? ""), parentId: (meta as any).parent_id ? String((meta as any).parent_id) : null, updatedAt: (meta as any).updated_at ?? null, rawTextLength: rawText.length, rawText, ...(includeContent ? { content: contentRes?.content ?? null } : {}), }; }, } : {}), }) : null; const mediaTools = allowedToolIds.has("image_read") ? createMediaServerTools({ supabase: supabase as unknown as MediaSupabaseClient, ctx: { userId, attachments }, allowedToolIds, ...(convexOn ? { loadById: async (id: string) => { if (!convexClient) throw new Error("Convex 未初始化"); return await convexClient.query(api.mediaAssets.getById, { userId, id }); }, loadByFileUrl: async (_fileUrl: string) => null, } : {}), }) : null; const onlyofficeTools = !convexOn && (allowedToolIds.has("asset_extract_outline") || allowedToolIds.has("asset_to_mindmap")) ? createOnlyOfficeServerTools({ supabase: supabase as unknown as OnlyOfficeSupabaseClient, ctx: { userId, documentId: documentId || undefined, attachments }, allowedToolIds, }) : null; const slashTools = allowedToolIds.has("slash_run") ? createSlashServerTools({ supabase: supabase as unknown as SlashSupabaseClient, ctx: { userId, currentDocumentId: documentId || undefined }, allowedToolIds, ...(convexOn ? { loadWorkspaceIds: async (_uid: string) => { if (!convexClient) throw new Error("Convex 未初始化"); const res = await convexClient.query(api.workspaces.fetchWorkspaceSummaries, {}); return (res?.workspaces ?? []).map((w: any) => String(w?.id ?? "")).filter(Boolean); }, inferWorkspaceIdFromDoc: async (docId: string) => { if (!convexClient) throw new Error("Convex 未初始化"); const meta = await convexClient.query(api.documents.getMeta, { id: docId }); return meta ? String((meta as any).workspace_id ?? "") || null : null; }, createDoc: async ({ workspaceId, parentId, title }) => { if (!convexClient) throw new Error("Convex 未初始化"); const id = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `doc_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; const created = await convexClient.mutation(api.documents.create, { id, workspaceId, parentId, title, accessScope: "private", content: [], }); return { id: String((created as any).id ?? id), title: String((created as any).title ?? title), workspaceId: String((created as any).workspace_id ?? workspaceId), parentId: (created as any).parent_id ? String((created as any).parent_id) : parentId, createdAt: (created as any).created_at ?? null, updatedAt: (created as any).updated_at ?? null, }; }, renameDoc: async ({ documentId: did, title }) => { if (!convexClient) throw new Error("Convex 未初始化"); await convexClient.mutation(api.documents.updateTitle, { id: did, title }); const meta = await convexClient.query(api.documents.getMeta, { id: did }); if (!meta) throw new Error("页面不存在"); return { id: String((meta as any).id ?? did), title: String((meta as any).title ?? title), workspaceId: String((meta as any).workspace_id ?? ""), parentId: (meta as any).parent_id ? String((meta as any).parent_id) : null, updatedAt: (meta as any).updated_at ?? null, }; }, } : {}), }) : null; const runTool = async (toolId: string, toolArgs: Record) => { if (isOnlyOfficeClientTool(toolId)) { throw new Error("oo_* 属于客户端工具:必须使用 stream 模式并在 OnlyOffice 页面内执行"); } if (toolId === "search_web") { const query = String(toolArgs.query ?? "").trim(); const count = Number(toolArgs.count ?? DEFAULT_SEARCH_COUNT); const rustCount = Number.isFinite(count) ? count : DEFAULT_SEARCH_COUNT; const rustResult = await executeRustBridgeTool({ context: aiBridgeContext, toolName: "search_web", invocationKind: "query", args: { query, count: rustCount, }, data: { source: "ai-agent-route", }, }); return rustResult.result; } if (toolId.startsWith("rag_")) { if (!ragTools) throw new Error(`工具未初始化:${toolId}`); return await ragTools.run(toolId, toolArgs); } if (toolId.startsWith("docs_")) { if (!docsTools) throw new Error(`工具未初始化:${toolId}`); return await docsTools.run(toolId, toolArgs); } if (toolId === "image_read") { if (!mediaTools) throw new Error(`工具未初始化:${toolId}`); const transport = await mediaTools.resolveImageReadTransport(toolArgs); const rustResult = await executeRustBridgeTool({ context: aiBridgeContext, toolName: "image_read", invocationKind: "query", args: { assetId: typeof toolArgs.assetId === "string" ? toolArgs.assetId : undefined, fileUrl: typeof toolArgs.fileUrl === "string" ? toolArgs.fileUrl : undefined, attachmentRef: typeof toolArgs.attachmentRef === "string" ? toolArgs.attachmentRef : undefined, }, data: { source: "ai-agent-route", ...transport, }, }); return rustResult.result; } if (toolId.startsWith("asset_")) { if (!onlyofficeTools) throw new Error(`工具未初始化:${toolId}`); return await onlyofficeTools.run(toolId, toolArgs); } if (toolId === "slash_run") { if (!slashTools) throw new Error(`工具未初始化:${toolId}`); const rustResult = await executeRustBridgeTool<{ ok: boolean; source?: string; parsed?: unknown; }>({ context: aiBridgeContext, toolName: "slash_run", invocationKind: "command", args: { text: typeof toolArgs.text === "string" ? toolArgs.text : undefined, command: typeof toolArgs.command === "string" ? toolArgs.command : undefined, params: isPlainObject(toolArgs.params) || Array.isArray(toolArgs.params) ? toolArgs.params : undefined, }, data: { source: "ai-agent-route", }, }); const parsed = rustResult.result?.parsed; if (!isPlainObject(parsed) || parsed.ok !== true) { throw new Error("Rust runtime 未返回有效的 slash_run 解析结果"); } return await slashTools.executeSlashTransport(parsed as { ok: true; command: "new_doc" | "rename_doc"; params: Record; }); } if (toolId.startsWith("doc_")) { if (!hasDocumentContext) throw new Error(`工具需要 document 上下文:${toolId}`); if (convexOn && convexClient && isDocRustTool(toolId)) { const base = normalizeBlocksForTools(documentBlocks); const runtimeContext = buildDocumentBridgeContextWithActor({ request, actor: { actorType: "user", actorId: userId, sessionId: null, }, workspaceId: null, source: { channel: "ai-agent-route", client: "wolai-frontend", }, }); const result = await executeRustBridgeTool({ context: runtimeContext, toolName: toolId, invocationKind: toolId === "doc_get" || toolId === "doc_find" ? "query" : "command", args: toolArgs, data: { source: base.length > 0 ? "client" : "convex", blocks: base.length > 0 ? base : normalizeBlocksForTools( (await convexClient.query(api.documents.getContent, { id: documentId }))?.content ?? null, ), }, target: { pageId: documentId, workspaceId: null, blockId: typeof toolArgs.blockId === "string" ? toolArgs.blockId : typeof toolArgs.afterBlockId === "string" ? toolArgs.afterBlockId : typeof toolArgs.beforeBlockId === "string" ? toolArgs.beforeBlockId : null, }, reason: typeof toolArgs.reason === "string" && toolArgs.reason.trim() ? toolArgs.reason.trim() : `ai-agent:${toolId}`, refs: ["task-031", "ai-agent"], }); return result.result; } if (!docTools) throw new Error(`工具需要 document 上下文:${toolId}`); return await docTools.run(toolId, toolArgs); } if (!hasMindmapContext) throw new Error(`工具需要 mindmap 上下文:${toolId}`); if (!mindmapTools) throw new Error(`工具需要 mindmap 上下文:${toolId}`); try { return await mindmapTools.run(toolId, toolArgs); } catch (error) { if (isMindmapWriteTool(toolId) && hasMindmapContext) { const envelope = buildDocumentCommandEnvelope({ name: "documents.save", payload: { documentId, workspaceId: null, reason: typeof toolArgs.reason === "string" && toolArgs.reason.trim() ? toolArgs.reason.trim() : `ai-agent:${toolId}`, toolId, toolArgs, }, context: aiBridgeContext, target: { workspaceId: null, pageId: documentId, blockId: mindmapId, }, reason: typeof toolArgs.reason === "string" && toolArgs.reason.trim() ? toolArgs.reason.trim() : `ai-agent:${toolId}`, refs: ["task-042", "ai-agent", toolId], }); await recordBridgeCommandFailureArtifacts({ context: aiBridgeContext, envelope, client: convexClient ?? undefined, error: error instanceof DocumentBridgeError ? error : new DocumentBridgeError( error instanceof Error ? error.message : String(error), 500, "TRANSPORT_ERROR", { reason: "mindmap_failed", toolId }, ), }); } throw error; } }; const maxSteps = (() => { const raw = Number(payload.maxSteps ?? DEFAULT_AGENT_MAX_STEPS); if (!Number.isFinite(raw)) return DEFAULT_AGENT_MAX_STEPS; return Math.max(MIN_AGENT_STEPS, Math.min(MAX_AGENT_STEPS, Math.floor(raw))); })(); const stream = payload.stream !== false; if (!stream) { const events: Array<{ type: string; data: unknown }> = []; const codexSessionIdRaw = String(payload.options?.ai?.sessionId ?? "").trim(); let codexSessionIdInRequest: string | null = codexSessionIdRaw || null; let codexSessionEmitted = false; const chatForAgent = provider === "codex" ? async (messages: Array<{ role: "system" | "user" | "assistant"; content: string }>) => { const prompt = codexMessagesToPrompt([ { role: "system", content: "补充约束:你当前处于 #test 模式。你必须严格遵守工具标签协议输出;不要执行任何命令;不要读写文件。", }, ...messages, ]); const run = startCodexJsonRun({ cwd: codexWorkspaceRoot ?? process.cwd(), sandbox: "workspace-write", prompt, model: null, sessionId: codexSessionIdInRequest, onJsonLine: (line) => { if (line.type !== "thread.started") return; const sid = String((line as any).thread_id ?? "").trim(); if (!sid) return; if (!codexSessionIdInRequest) codexSessionIdInRequest = sid; if (!codexSessionEmitted) { codexSessionEmitted = true; events.push({ type: "codex_session", data: { sessionId: sid } }); } }, }); const result = await run.done; if (!result.ok) throw new Error(result.error); if (result.threadId && !codexSessionIdInRequest) codexSessionIdInRequest = result.threadId; if (result.threadId && !codexSessionEmitted) { codexSessionEmitted = true; events.push({ type: "codex_session", data: { sessionId: result.threadId } }); } return { text: result.text, raw: null }; } : undefined; const result = await runAiAgent({ userMessages: effectiveMessages, cfg: { ...cfg, model: modelOverride ?? cfg.model }, ...(chatForAgent ? { chat: chatForAgent } : {}), allowedToolIds, runTool, maxSteps, systemContextText, defaultMindmapTargetUid: selectedUids[0] ?? "", onEvent: (ev) => events.push(ev), }).catch((e) => ({ ok: false as const, error: e instanceof Error ? e.message : String(e) })); if (!result.ok) return NextResponse.json({ error: result.error }, { status: 500 }); return NextResponse.json({ text: result.text, steps: result.steps, events }); } const encoder = new TextEncoder(); let activeCodexKill: (() => void) | null = null; const stopActiveCodexRun = () => { try { activeCodexKill?.(); } catch { // ignore } finally { activeCodexKill = null; } }; const body = new ReadableStream({ start(controller) { const send = (event: string, data: unknown) => { controller.enqueue(encoder.encode(toSseFrame(event, data))); }; // 先发一个 ready,方便前端快速进入“流式模式” const requestId = makeRunId(); send("ready", { ok: true, requestId }); let lastToolCall: { id: string; tool: string; args: Record } | null = null; const codexSessionIdRaw = String(payload.options?.ai?.sessionId ?? "").trim(); let codexSessionIdInRequest: string | null = codexSessionIdRaw || null; let codexSessionEmitted = false; const runToolStream = async (toolId: string, toolArgs: Record) => { if (isOnlyOfficeClientTool(toolId)) { const callId = lastToolCall?.tool === toolId ? lastToolCall.id : `call_${Date.now()}`; const key = buildClientToolKey(requestId, callId); const wait = registerClientToolCall({ key, userId, timeoutMs: DEFAULT_CLIENT_TOOL_TIMEOUT_MS, }); // 说明:客户端收到该事件后,需要执行插件 API 并回调 /api/ai-agent/client-tool-result send("client_tool_call", { requestId, callId, tool: toolId, args: toolArgs }); const result = await wait; if (!result.ok) throw new Error(result.error); return result.result; } return await runTool(toolId, toolArgs); }; const ping = setInterval(() => { // 避免某些代理/浏览器长连接超时 try { controller.enqueue(encoder.encode(`: ping ${Date.now()}\n\n`)); } catch { // ignore } }, 15_000); const onAbort = () => { stopActiveCodexRun(); }; try { request.signal?.addEventListener("abort", onAbort, { once: true }); } catch { // ignore } (async () => { const result = await runAiAgent({ userMessages: effectiveMessages, cfg: { ...cfg, model: modelOverride ?? cfg.model }, ...(provider === "codex" ? { chat: async (messages) => { const prompt = codexMessagesToPrompt([ { role: "system", content: "补充约束:你当前处于 #test 模式。你必须严格遵守工具标签协议输出;不要执行任何命令;不要读写文件。", }, ...messages, ]); const run = startCodexJsonRun({ cwd: codexWorkspaceRoot ?? process.cwd(), sandbox: "workspace-write", prompt, model: null, sessionId: codexSessionIdInRequest, onJsonLine: (line) => { if (line.type !== "thread.started") return; const sid = String((line as any).thread_id ?? "").trim(); if (!sid) return; if (!codexSessionIdInRequest) codexSessionIdInRequest = sid; if (!codexSessionEmitted) { codexSessionEmitted = true; send("codex_session", { sessionId: sid }); } }, }); activeCodexKill = run.kill; const r = await run.done; if (activeCodexKill === run.kill) activeCodexKill = null; if (!r.ok) throw new Error(r.error); if (r.threadId && !codexSessionIdInRequest) codexSessionIdInRequest = r.threadId; if (r.threadId && !codexSessionEmitted) { codexSessionEmitted = true; send("codex_session", { sessionId: r.threadId }); } return { text: r.text, raw: null }; }, } : {}), allowedToolIds, runTool: runToolStream, maxSteps, systemContextText, defaultMindmapTargetUid: selectedUids[0] ?? "", onEvent: (ev) => { if (!ev?.type) return; if (ev.type === "tool_call") { try { const d = ev.data as unknown; const obj = typeof d === "object" && d ? (d as Record) : ({} as Record); const id = String(obj.id ?? "").trim(); const tool = String(obj.tool ?? "").trim(); const args = typeof obj.args === "object" && obj.args ? (obj.args as Record) : ({} as Record); if (id && tool) lastToolCall = { id, tool, args }; } catch { // ignore } } send(ev.type, ev.data ?? null); }, }); if (!result.ok) { send("error", { ok: false, message: result.error }); return; } send("completion", { ok: true, text: result.text, steps: result.steps }); })() .catch((e) => { const msg = e instanceof Error ? e.message : String(e); send("error", { ok: false, message: msg }); }) .finally(() => { clearInterval(ping); try { request.signal?.removeEventListener("abort", onAbort); } catch { // ignore } stopActiveCodexRun(); controller.close(); }); }, cancel() { stopActiveCodexRun(); }, }); return new Response(body, { headers: sseHeaders }); }