0.5 缩减重构
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -33,6 +34,8 @@ 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;
|
||||
@@ -48,7 +51,7 @@ type RequestPayload = {
|
||||
// v1:BlockNote 文档快照(前端可选传入,避免覆盖未落盘编辑)
|
||||
documentBlocks?: unknown;
|
||||
};
|
||||
options?: { searxng?: boolean; ai?: { provider?: "online" | "local"; model?: string } };
|
||||
options?: { searxng?: boolean; ai?: { provider?: AiProvider; model?: string; sessionId?: string } };
|
||||
};
|
||||
|
||||
/** 按作用域分组的工具集 ID 映射 */
|
||||
@@ -116,6 +119,38 @@ const toSseFrame = (event: string, data: unknown) => {
|
||||
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) {
|
||||
@@ -144,14 +179,228 @@ export async function POST(request: Request) {
|
||||
return errorResponses.unauthorized();
|
||||
}
|
||||
|
||||
const provider = payload.options?.ai?.provider === "local" ? "local" : "online";
|
||||
const provider = normalizeProvider(payload.options?.ai?.provider);
|
||||
const modelOverride = String(payload.options?.ai?.model ?? "").trim() || null;
|
||||
const cfg =
|
||||
provider === "local"
|
||||
? await loadLocalAiConfig().catch(() => null)
|
||||
: await loadOnlineAiConfig().catch(() => 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) {
|
||||
return errorResponses.aiConfigError(provider);
|
||||
// 仅 online/local 需要配置文件/环境变量
|
||||
return errorResponses.aiConfigError(provider === "local" ? "local" : "online");
|
||||
}
|
||||
|
||||
const registry = createToolRegistry({ tools: builtinTools, toolSets: builtinToolSets });
|
||||
@@ -547,9 +796,53 @@ export async function POST(request: Request) {
|
||||
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: payload.messages.slice(0, 50),
|
||||
userMessages: effectiveMessages,
|
||||
cfg: { ...cfg, model: modelOverride ?? cfg.model },
|
||||
...(chatForAgent ? { chat: chatForAgent } : {}),
|
||||
allowedToolIds,
|
||||
runTool,
|
||||
maxSteps,
|
||||
@@ -562,6 +855,16 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -573,6 +876,9 @@ export async function POST(request: Request) {
|
||||
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)) {
|
||||
@@ -603,10 +909,60 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
const onAbort = () => {
|
||||
stopActiveCodexRun();
|
||||
};
|
||||
try {
|
||||
request.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const result = await runAiAgent({
|
||||
userMessages: payload.messages.slice(0, 50),
|
||||
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,
|
||||
@@ -648,9 +1004,18 @@ export async function POST(request: Request) {
|
||||
})
|
||||
.finally(() => {
|
||||
clearInterval(ping);
|
||||
try {
|
||||
request.signal?.removeEventListener("abort", onAbort);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
stopActiveCodexRun();
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
stopActiveCodexRun();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(body, { headers: sseHeaders });
|
||||
|
||||
Reference in New Issue
Block a user