Files
mnote/wolai-frontend/src/lib/ai/codex/codexExec.ts
T

247 lines
6.8 KiB
TypeScript
Raw Normal View History

2026-04-13 19:21:42 +08:00
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) };
};