236 lines
8.2 KiB
JavaScript
236 lines
8.2 KiB
JavaScript
import * as childProcess from 'child_process';
|
||
import { mkdtemp, writeFile, rm } from 'fs/promises';
|
||
import { tmpdir } from 'os';
|
||
import { join } from 'path';
|
||
import { getLLMConfig } from './config.js';
|
||
import { recordUsage } from './usage.js';
|
||
import { line } from './log.js';
|
||
|
||
/**
|
||
* 將既有 system/user prompt 合併成一次 CLI 呼叫用的輸入。
|
||
*/
|
||
function buildPrompt(systemPrompt, userContent) {
|
||
return [
|
||
'請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。',
|
||
'',
|
||
'<system>',
|
||
systemPrompt,
|
||
'</system>',
|
||
'',
|
||
'<user>',
|
||
userContent,
|
||
'</user>',
|
||
].join('\n');
|
||
}
|
||
|
||
function cliArgs({ provider, model, promptFile = null, prompt = null }) {
|
||
if (provider === 'codex') {
|
||
return ['exec', '--model', model, '--sandbox', 'read-only', '--skip-git-repo-check', '-'];
|
||
}
|
||
if (provider === 'claude') {
|
||
return ['--print', '--model', model, '--permission-mode', 'dontAsk', '--no-session-persistence'];
|
||
}
|
||
if (provider === 'antigravity') {
|
||
return ['-p', prompt, '--model', model];
|
||
}
|
||
if (provider === 'opencode') {
|
||
return ['run', '--model', model, '--format', 'default', '--file', promptFile, '請依附件 prompt.md 的完整內容執行,並只輸出要求的最終結果。'];
|
||
}
|
||
throw new Error(`不支援的 AI 助理 CLI: ${provider}`);
|
||
}
|
||
|
||
function summarizeCliError(e) {
|
||
const stderr = String(e.stderr || '').trim();
|
||
const stdout = String(e.stdout || '').trim();
|
||
return (stderr || stdout || e.message || String(e)).slice(0, 1000);
|
||
}
|
||
|
||
async function runAssistantCLI({ provider, command, model }, prompt) {
|
||
let tempDir = null;
|
||
let promptFile = null;
|
||
if (provider === 'opencode') {
|
||
tempDir = await mkdtemp(join(tmpdir(), 'ai-review-prompt-'));
|
||
promptFile = join(tempDir, 'prompt.md');
|
||
await writeFile(promptFile, prompt);
|
||
}
|
||
const args = cliArgs({ provider, model, promptFile, prompt });
|
||
const maxBuffer = Number(process.env.AI_ASSISTANT_MAX_BUFFER || 20 * 1024 * 1024);
|
||
const timeout = Number(process.env.AI_ASSISTANT_TIMEOUT_MS || 15 * 60 * 1000);
|
||
try {
|
||
return await new Promise((resolve, reject) => {
|
||
const child = childProcess.spawn(command, args, { env: process.env, stdio: ['pipe', 'pipe', 'pipe'] });
|
||
let stdout = '';
|
||
let stderr = '';
|
||
let settled = false;
|
||
const timer = setTimeout(() => {
|
||
settled = true;
|
||
child.kill('SIGTERM');
|
||
reject(new Error(`${provider} CLI 逾時 (${timeout}ms)`));
|
||
}, timeout);
|
||
|
||
const append = (kind, chunk) => {
|
||
if (kind === 'stdout') stdout += chunk;
|
||
else stderr += chunk;
|
||
if (stdout.length + stderr.length > maxBuffer) {
|
||
settled = true;
|
||
child.kill('SIGTERM');
|
||
reject(new Error(`${provider} CLI 輸出超過 ${maxBuffer} bytes`));
|
||
}
|
||
};
|
||
|
||
child.stdout.setEncoding('utf8');
|
||
child.stderr.setEncoding('utf8');
|
||
child.stdout.on('data', chunk => append('stdout', chunk));
|
||
child.stderr.on('data', chunk => append('stderr', chunk));
|
||
child.on('error', reject);
|
||
child.on('close', (code, signal) => {
|
||
clearTimeout(timer);
|
||
if (settled) return;
|
||
if (code === 0) resolve(stdout.trim());
|
||
else reject(Object.assign(new Error(`${provider} CLI exited with ${code ?? signal}`), { stdout, stderr }));
|
||
});
|
||
child.stdin.end(provider === 'opencode' || provider === 'antigravity' ? '' : prompt);
|
||
});
|
||
} finally {
|
||
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 對目前環境可用的 AI 助理 CLI 送出一次對話請求並回傳純文字回應。
|
||
*
|
||
* 從設定取得 provider/command/model;未偵測到 CLI 時拋錯。成功時記錄一次
|
||
* usage 呼叫(CLI 通常不回傳 token 明細,因此 token 可能為 0)並回傳內容。
|
||
*
|
||
* @param {string} systemPrompt - 系統提示詞。
|
||
* @param {string} userContent - 使用者輸入內容。
|
||
* @returns {Promise<string>} 模型回應的純文字內容。
|
||
* @throws {Error} 當未偵測到可用 AI 助理 CLI,或 CLI 呼叫失敗時。
|
||
*/
|
||
export async function chat(systemPrompt, userContent) {
|
||
const cfg = getLLMConfig();
|
||
const { provider, command, model } = cfg;
|
||
if (!provider || !command) throw new Error('未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode');
|
||
|
||
line(`[LLM] provider=${provider} command=${command} model=${model}`);
|
||
|
||
try {
|
||
const content = await runAssistantCLI(cfg, buildPrompt(systemPrompt, userContent));
|
||
recordUsage(null);
|
||
return content;
|
||
} catch (e) {
|
||
const message = summarizeCliError(e);
|
||
line(`[LLM] ${provider} CLI 呼叫失敗: ${message}`);
|
||
throw new Error(message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 對 AI 助理 CLI 送出對話並將回應解析為 JSON 物件/陣列。
|
||
*
|
||
* 先取得文字回應,經 {@link extractJSONText} 抽出 JSON 片段後解析。
|
||
* 解析失敗時記錄錯誤並回傳空陣列,不向外拋錯(容錯設計)。
|
||
*
|
||
* @param {string} systemPrompt - 系統提示詞。
|
||
* @param {string} userContent - 使用者輸入內容。
|
||
* @returns {Promise<any>} 解析後的 JSON 值;解析失敗時回傳空陣列 `[]`。
|
||
*/
|
||
export async function chatJSON(systemPrompt, userContent) {
|
||
const text = await chat(systemPrompt, userContent);
|
||
try {
|
||
return JSON.parse(extractJSONText(text));
|
||
} catch (e) {
|
||
line(`[LLM] JSON 解析失敗: ${e.message}`);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 去除文字外層的 Markdown code fence(```),用於清理被 code block 包裹的輸出。
|
||
*
|
||
* 會 trim、移除開頭 fence(含可選語言標籤與換行)與結尾 fence,再 trim。
|
||
* 對非字串輸入會先以 `String()` 轉換;無 fence 時回傳 trim 後原文。
|
||
*
|
||
* @param {*} text - 待清理的內容(會被轉為字串)。
|
||
* @returns {string} 去除外層 fence 並 trim 後的字串。
|
||
*/
|
||
function stripOuterFence(text) {
|
||
return String(text)
|
||
.trim()
|
||
.replace(/^```[a-zA-Z0-9_-]*\n?/, '')
|
||
.replace(/```$/, '')
|
||
.trim();
|
||
}
|
||
|
||
/**
|
||
* 從指定索引起,以括號平衡方式擷取一段完整配對的 JSON 子字串。
|
||
*
|
||
* 依起始字元判定為物件(`{}`)或陣列(`[]`),逐字元計數巢狀深度,
|
||
* 並正確略過字串字面值與其中的跳脫字元,深度歸零時回傳完整片段。
|
||
*
|
||
* @param {*} text - 來源內容(會被轉為字串)。
|
||
* @param {number} startIndex - 起始掃描索引,應指向 `{` 或 `[`。
|
||
* @returns {string|null} 配對完整的 JSON 子字串;找不到配對時回傳 `null`。
|
||
*/
|
||
export function extractBalancedJSON(text, startIndex) {
|
||
const source = String(text);
|
||
const open = source[startIndex];
|
||
const close = open === '{' ? '}' : ']';
|
||
let depth = 0;
|
||
let inString = false;
|
||
let escaped = false;
|
||
|
||
for (let i = startIndex; i < source.length; i++) {
|
||
const ch = source[i];
|
||
if (inString) {
|
||
if (escaped) {
|
||
escaped = false;
|
||
} else if (ch === '\\') {
|
||
escaped = true;
|
||
} else if (ch === '"') {
|
||
inString = false;
|
||
}
|
||
continue;
|
||
}
|
||
if (ch === '"') {
|
||
inString = true;
|
||
continue;
|
||
}
|
||
if (ch === open) depth += 1;
|
||
else if (ch === close) {
|
||
depth -= 1;
|
||
if (depth === 0) return source.slice(startIndex, i + 1);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 從可能夾雜雜訊或被 code fence 包裹的文字中,盡力抽出可被 JSON.parse 解析的片段。
|
||
*
|
||
* 先去除外層 fence;若整段即為合法 JSON 直接回傳;否則由左至右尋找每個
|
||
* `{`/`[` 起點,以括號平衡擷取候選片段並試解析,回傳第一個成功者;
|
||
* 全數失敗則回傳去 fence 後的原文(仍可能非合法 JSON,交由呼叫端再處理)。
|
||
*
|
||
* @param {*} text - 可能含有 JSON 的原始內容(會被轉為字串)。
|
||
* @returns {string} 最可能為合法 JSON 的字串片段,或去 fence 後的原文。
|
||
*/
|
||
export function extractJSONText(text) {
|
||
const stripped = stripOuterFence(text);
|
||
try {
|
||
JSON.parse(stripped);
|
||
return stripped;
|
||
} catch {}
|
||
|
||
for (let i = 0; i < stripped.length; i++) {
|
||
if (stripped[i] !== '[' && stripped[i] !== '{') continue;
|
||
const candidate = extractBalancedJSON(stripped, i);
|
||
if (!candidate) continue;
|
||
try {
|
||
JSON.parse(candidate);
|
||
return candidate;
|
||
} catch {}
|
||
}
|
||
return stripped;
|
||
}
|