1209 lines
48 KiB
TypeScript
1209 lines
48 KiB
TypeScript
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<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 });
|
||
}
|
||
}
|
||
|
||
// 非 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<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 });
|
||
}
|