feat(ai-code-review): 改用 AI 助理 CLI 執行審查
This commit is contained in:
+93
-132
@@ -1,168 +1,129 @@
|
||||
import axios from 'axios';
|
||||
import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 將模型識別字串解析為 OpenCode API 所需的 provider 與 model 識別碼。
|
||||
*
|
||||
* 當字串含有 `/` 時視為 `providerID/modelID` 形式並拆解;否則 provider
|
||||
* 取環境變數 `OPENCODE_PROVIDER`(預設 `google`),model 則為整個字串。
|
||||
*
|
||||
* @param {string} model - 模型識別字串,例如 `"google/gemini-2.0"` 或 `"gemini-2.0"`。
|
||||
* @returns {{ providerID: string, modelID: string }} 拆解後的 provider 與 model 識別碼。
|
||||
* 將既有 system/user prompt 合併成一次 CLI 呼叫用的輸入。
|
||||
*/
|
||||
function opencodeModelConfig(model) {
|
||||
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model];
|
||||
return { providerID, modelID };
|
||||
function buildPrompt(systemPrompt, userContent) {
|
||||
return [
|
||||
'請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。',
|
||||
'',
|
||||
'<system>',
|
||||
systemPrompt,
|
||||
'</system>',
|
||||
'',
|
||||
'<user>',
|
||||
userContent,
|
||||
'</user>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立傳給 axios 的共用請求選項,統一注入 headers 與 OpenCode 專用的 HTTPS agent。
|
||||
*
|
||||
* 供本模組所有 OpenCode HTTP 呼叫共用,集中管理連線設定。
|
||||
*
|
||||
* @param {Record<string, string>} headers - 要附加於請求的 HTTP 標頭。
|
||||
* @returns {{ headers: Record<string, string>, httpsAgent: import('https').Agent }} axios 請求選項物件。
|
||||
*/
|
||||
function opencodeAxiosOptions(headers) {
|
||||
return {
|
||||
headers,
|
||||
httpsAgent: getOpenCodeHttpsAgent(),
|
||||
};
|
||||
function cliArgs(provider, model, promptFile = null) {
|
||||
if (provider === 'codex') {
|
||||
return ['exec', '--model', model, '--sandbox', 'read-only', '--ask-for-approval', 'never', '--skip-git-repo-check', '-'];
|
||||
}
|
||||
if (provider === 'claude') {
|
||||
return ['--print', '--model', model, '--permission-mode', 'dontAsk', '--no-session-persistence'];
|
||||
}
|
||||
if (provider === 'opencode') {
|
||||
return ['run', '--model', model, '--format', 'default', '--file', promptFile, '請依附件 prompt.md 的完整內容執行,並只輸出要求的最終結果。'];
|
||||
}
|
||||
throw new Error(`不支援的 AI 助理 CLI: ${provider}`);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 從 OpenCode 訊息回應中抽取並串接所有文字片段。
|
||||
*
|
||||
* 以多重 fallback 相容不同包裹層級的回應結構(`parts` / `data.parts` /
|
||||
* `info.content` / `data.info.content`),逐片段取 `text` 或 `content` 後串接。
|
||||
*
|
||||
* @param {object} data - OpenCode `/session/{id}/message` 的回應資料物件。
|
||||
* @returns {string} 串接後的純文字內容;無可用片段時回傳空字串。
|
||||
*/
|
||||
function extractOpenCodeContent(data) {
|
||||
const parts = data.parts || data.data?.parts || data.info?.content || data.data?.info?.content || [];
|
||||
return parts
|
||||
.map(part => part.text || part.content || '')
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function summarizeErrorResponse(data) {
|
||||
if (data == null) return '';
|
||||
if (typeof data === 'string') return data.slice(0, 500);
|
||||
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);
|
||||
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 JSON.stringify(data).slice(0, 500);
|
||||
} catch {
|
||||
return String(data).slice(0, 500);
|
||||
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' ? '' : prompt);
|
||||
});
|
||||
} finally {
|
||||
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function formatOpenCodeError(e) {
|
||||
const status = e.response?.status;
|
||||
const response = summarizeErrorResponse(e.response?.data);
|
||||
const statusText = status ? `HTTP ${status}` : e.message;
|
||||
return response ? `${statusText}: ${response}` : statusText;
|
||||
}
|
||||
|
||||
function isTransientOpenCodeError(e) {
|
||||
const status = e.response?.status;
|
||||
return status === 500 || status === 502 || status === 503 || status === 504 || status === 429;
|
||||
}
|
||||
|
||||
/**
|
||||
* 對 OpenCode server 執行一次完整對話:建立 session 後送出訊息並回傳結果。
|
||||
* 對目前環境可用的 AI 助理 CLI 送出一次對話請求並回傳純文字回應。
|
||||
*
|
||||
* 先 POST `/session` 取得 session id(缺少則拋錯),再 POST
|
||||
* `/session/{id}/message` 送出 system prompt 與使用者內容,最後抽取回應文字。
|
||||
* 會發出兩次 HTTP 請求;網路或 API 錯誤會向外拋出,交由呼叫端處理。
|
||||
*
|
||||
* @param {string} baseURL - OpenCode server 基底 URL(尾端斜線會被去除)。
|
||||
* @param {string} model - 模型識別字串,將交由 {@link opencodeModelConfig} 解析。
|
||||
* @param {string} systemPrompt - 系統提示詞。
|
||||
* @param {string} userContent - 使用者輸入內容。
|
||||
* @param {Record<string, string>} headers - 附加於請求的 HTTP 標頭。
|
||||
* @returns {Promise<{ content: string, data: object }>} 抽取後的文字內容與原始回應資料。
|
||||
* @throws {Error} 當回應中無 session id,或任一 HTTP 請求失敗時。
|
||||
*/
|
||||
async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) {
|
||||
const base = baseURL.replace(/\/$/, '');
|
||||
const { providerID, modelID } = opencodeModelConfig(model);
|
||||
const session = await axios.post(
|
||||
`${base}/session`,
|
||||
{ title: 'AI Code Review', model: { providerID, id: modelID } },
|
||||
opencodeAxiosOptions(headers)
|
||||
);
|
||||
const sessionID = session.data.id || session.data.data?.id;
|
||||
if (!sessionID) throw new Error('OpenCode session 建立失敗:回應中沒有 session id');
|
||||
|
||||
const resp = await axios.post(
|
||||
`${base}/session/${sessionID}/message`,
|
||||
{
|
||||
model: { providerID, modelID },
|
||||
system: systemPrompt,
|
||||
parts: [{ type: 'text', text: userContent }],
|
||||
},
|
||||
opencodeAxiosOptions(headers)
|
||||
);
|
||||
return { content: extractOpenCodeContent(resp.data), data: resp.data };
|
||||
}
|
||||
|
||||
async function chatOpenCodeWithRetry(baseURL, model, systemPrompt, userContent, headers) {
|
||||
const maxAttempts = Number(process.env.OPENCODE_RETRY_ATTEMPTS || 3);
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await chatOpenCode(baseURL, model, systemPrompt, userContent, headers);
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
if (!isTransientOpenCodeError(e) || attempt === maxAttempts) throw e;
|
||||
const delay = Math.min(1000 * 2 ** (attempt - 1), 8000);
|
||||
line(`[LLM] OpenCode 暫時性錯誤,${delay}ms 後重試 (${attempt}/${maxAttempts}): ${formatOpenCodeError(e)}`);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* 對 OpenCode server 送出一次對話請求並回傳模型純文字回應。
|
||||
*
|
||||
* 從設定取得 provider/baseURL/model;未設定 provider 時拋錯。成功時記錄
|
||||
* usage 並回傳內容。OpenCode 呼叫失敗時會記錄錯誤並向外拋出,讓呼叫端
|
||||
* 決定是否降級、略過單一角色或終止整體流程。
|
||||
* 從設定取得 provider/command/model;未偵測到 CLI 時拋錯。成功時記錄一次
|
||||
* usage 呼叫(CLI 通常不回傳 token 明細,因此 token 可能為 0)並回傳內容。
|
||||
*
|
||||
* @param {string} systemPrompt - 系統提示詞。
|
||||
* @param {string} userContent - 使用者輸入內容。
|
||||
* @returns {Promise<string>} 模型回應的純文字內容。
|
||||
* @throws {Error} 當未設定 OpenCode server(缺少 provider)時。
|
||||
* @throws {Error} 當未偵測到可用 AI 助理 CLI,或 CLI 呼叫失敗時。
|
||||
*/
|
||||
export async function chat(systemPrompt, userContent) {
|
||||
const { provider, baseURL, model } = getLLMConfig();
|
||||
if (!provider) throw new Error('未設定 OpenCode server,請設定 OPENCODE_BASE_URL');
|
||||
const cfg = getLLMConfig();
|
||||
const { provider, command, model } = cfg;
|
||||
if (!provider || !command) throw new Error('未偵測到可用 AI 助理 CLI,請安裝 codex、claude 或 opencode');
|
||||
|
||||
line(`[LLM] provider=${provider} model=${model}`);
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
line(`[LLM] provider=${provider} command=${command} model=${model}`);
|
||||
|
||||
try {
|
||||
const { content, data } = await chatOpenCodeWithRetry(baseURL, model, systemPrompt, userContent, headers);
|
||||
recordUsage(data);
|
||||
const content = await runAssistantCLI(cfg, buildPrompt(systemPrompt, userContent));
|
||||
recordUsage(null);
|
||||
return content;
|
||||
} catch (e) {
|
||||
const message = formatOpenCodeError(e);
|
||||
line(`[LLM] OpenCode 呼叫失敗: ${message}`);
|
||||
const message = summarizeCliError(e);
|
||||
line(`[LLM] ${provider} CLI 呼叫失敗: ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 對 OpenCode 送出對話並將回應解析為 JSON 物件/陣列。
|
||||
* 對 AI 助理 CLI 送出對話並將回應解析為 JSON 物件/陣列。
|
||||
*
|
||||
* 先取得文字回應,經 {@link extractJSONText} 抽出 JSON 片段後解析。
|
||||
* 解析失敗時記錄錯誤並回傳空陣列,不向外拋錯(容錯設計)。
|
||||
|
||||
Reference in New Issue
Block a user