Files
mnote/wolai-frontend/src/lib/ai/codex/codexExec.ts
T
2026-04-13 19:21:42 +08:00

247 lines
6.8 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 { spawn } from "child_process";
import { promises as fs } from "fs";
import path from "path";
export type CodexSandbox = "read-only" | "workspace-write";
export type CodexExecJsonLine = {
type: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
};
const getCodexBin = () => (process.platform === "win32" ? "codex.cmd" : "codex");
const isNonEmptyString = (v: unknown): v is string => typeof v === "string" && v.trim().length > 0;
const safeKill = (child: ReturnType<typeof spawn> | null | undefined) => {
if (!child) return;
try {
if (child.exitCode !== null) return;
child.kill();
} catch {
// ignore
}
};
/**
* 从 startDir 向上查找 workspace 根目录。
* - 优先以 `AGENTS.md` 作为锚点(本仓库协作约定)
* - 若找不到,则退化为 `startDir`
*/
export const findWorkspaceRoot = async (startDir: string): Promise<string> => {
let dir = path.resolve(startDir || process.cwd());
for (let i = 0; i < 12; i += 1) {
const agents = path.join(dir, "AGENTS.md");
try {
const st = await fs.stat(agents);
if (st.isFile()) return dir;
} catch {
// ignore
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return path.resolve(startDir || process.cwd());
};
const buildCodexPromptFromMessages = (
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>,
): string => {
const lines: string[] = [];
for (const m of messages) {
const role = m.role === "system" ? "系统" : m.role === "user" ? "用户" : "助手";
lines.push(`【${role}】`);
lines.push(String(m.content ?? "").trimEnd());
lines.push("");
}
return lines.join("\n").trim();
};
export const codexExecToLastMessage = async ({
cwd,
sandbox,
prompt,
model,
}: {
cwd: string;
sandbox: CodexSandbox;
prompt: string;
model?: string | null;
}): Promise<{ text: string; rawLines: CodexExecJsonLine[] }> => {
const bin = getCodexBin();
// 用 stdin 传入 prompt,避免 Windows 命令行长度限制
const args = ["exec", "--json", "-C", cwd, "-s", sandbox, "--color", "never"];
if (isNonEmptyString(model)) args.push("-m", model.trim());
args.push("-");
const rawLines: CodexExecJsonLine[] = [];
let lastAgentText = "";
await new Promise<void>((resolve, reject) => {
const child = spawn(bin, args, {
windowsHide: true,
env: process.env,
});
try {
child.stdin?.setDefaultEncoding("utf8");
child.stdin?.write(String(prompt ?? ""));
child.stdin?.end();
} catch {
// ignore
}
let buf = "";
const onData = (chunk: Buffer) => {
buf += chunk.toString("utf8");
while (true) {
const nl = buf.indexOf("\n");
if (nl === -1) break;
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
try {
const j = JSON.parse(line) as CodexExecJsonLine;
rawLines.push(j);
if (j.type === "item.completed" && j.item && j.item.type === "agent_message") {
lastAgentText = String(j.item.text ?? "");
}
} catch {
// ignore:非 JSON 行
}
}
};
child.stdout?.on("data", onData);
child.stderr?.on("data", () => {
// ignorecodex --json 主要走 stdout
});
child.on("error", reject);
child.on("close", (code) => {
if (buf.trim()) {
try {
const j = JSON.parse(buf.trim()) as CodexExecJsonLine;
rawLines.push(j);
if (j.type === "item.completed" && j.item && j.item.type === "agent_message") {
lastAgentText = String(j.item.text ?? "");
}
} catch {
// ignore
}
}
if (code && code !== 0) {
reject(new Error(`Codex 执行失败:exit=${code}`));
return;
}
resolve();
});
});
return { text: lastAgentText.trim() ? lastAgentText.trim() : "(无输出)", rawLines };
};
export const codexMessagesToPrompt = buildCodexPromptFromMessages;
export const startCodexJsonRun = ({
cwd,
sandbox,
prompt,
model,
sessionId,
onJsonLine,
}: {
cwd: string;
sandbox: CodexSandbox;
prompt: string;
model?: string | null;
sessionId?: string | null;
onJsonLine?: (line: CodexExecJsonLine) => void;
}) => {
const bin = getCodexBin();
const args = (() => {
// 新会话:可指定 -C / -s
if (!isNonEmptyString(sessionId)) {
const a = ["exec", "--json", "-C", cwd, "-s", sandbox, "--color", "never"];
if (isNonEmptyString(model)) a.push("-m", model.trim());
a.push("-"); // stdin prompt
return a;
}
// 续聊:用 sessionId 恢复;resume 子命令不支持 -C/-s(沿用会话配置)
const a = ["exec", "resume", sessionId.trim(), "--json"];
if (isNonEmptyString(model)) a.push("-m", model.trim());
a.push("-"); // stdin prompt
return a;
})();
const child = spawn(bin, args, {
cwd,
windowsHide: true,
env: process.env,
});
try {
child.stdin?.setDefaultEncoding("utf8");
child.stdin?.write(String(prompt ?? ""));
child.stdin?.end();
} catch {
// ignore
}
let threadId = "";
let lastAgentText = "";
let buf = "";
const parseLine = (line: string) => {
const s = String(line ?? "").trim();
if (!s) return;
try {
const j = JSON.parse(s) as CodexExecJsonLine;
onJsonLine?.(j);
if (j.type === "thread.started" && isNonEmptyString(j.thread_id)) {
threadId = j.thread_id.trim();
}
if (j.type === "item.completed" && j.item && j.item.type === "agent_message") {
lastAgentText = String(j.item.text ?? "");
}
} catch {
// ignore
}
};
child.stdout?.on("data", (chunk: Buffer) => {
buf += chunk.toString("utf8");
while (true) {
const nl = buf.indexOf("\n");
if (nl === -1) break;
const line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
parseLine(line);
}
});
child.stderr?.on("data", () => {
// ignore--json 主要走 stdout
});
const done = new Promise<{ ok: true; threadId: string; text: string } | { ok: false; error: string; threadId: string; text: string }>((resolve) => {
child.on("close", (code) => {
if (buf.trim()) parseLine(buf);
const text = lastAgentText.trim() ? lastAgentText.trim() : "(无输出)";
if (code && code !== 0) {
resolve({ ok: false, error: `Codex 执行失败:exit=${code}`, threadId, text });
return;
}
resolve({ ok: true, threadId, text });
});
child.on("error", (e) => {
resolve({ ok: false, error: e instanceof Error ? e.message : String(e), threadId, text: lastAgentText.trim() || "(无输出)" });
});
});
return { child, done, kill: () => safeKill(child) };
};