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

1209 lines
48 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 { 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[];
// v1BlockNote 文档快照(前端可选传入,避免覆盖未落盘编辑)
documentBlocks?: unknown;
};
options?: { searxng?: boolean; ai?: { provider?: AiProvider; model?: string; sessionId?: string } };
};
/** 按作用域分组的工具集 ID 映射 */
const SCOPE_TOOLSET_MAP: Record<AgentScope, string[]> = {
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<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");
}
// 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<Uint8Array>({
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<string, number>();
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 });
}
}
// 非 Codexonline/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<string>();
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<string, unknown>) => {
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<unknown>({
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<typeof transport>({
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<string, unknown>;
});
}
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<Uint8Array>({
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<string, unknown> } | 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<string, unknown>) => {
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<string, unknown>)
: ({} as Record<string, unknown>);
const id = String(obj.id ?? "").trim();
const tool = String(obj.tool ?? "").trim();
const args =
typeof obj.args === "object" && obj.args
? (obj.args as Record<string, unknown>)
: ({} as Record<string, unknown>);
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 });
}