Files
ai-code-review/app/llm.js
T

229 lines
8.4 KiB
JavaScript
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 axios from 'axios';
import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js';
import { recordUsage } from './usage.js';
import { line, error } 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 識別碼。
*/
function opencodeModelConfig(model) {
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model];
return { providerID, modelID };
}
/**
* 建立傳給 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(),
};
}
/**
* 從 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('');
}
/**
* 對 OpenCode server 執行一次完整對話:建立 session 後送出訊息並回傳結果。
*
* 先 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 };
}
/**
* 對 OpenCode server 送出一次對話請求並回傳模型純文字回應。
*
* 從設定取得 provider/baseURL/model;未設定 provider 時拋錯。成功時記錄
* usage 並回傳內容。OpenCode 呼叫失敗時會記錄錯誤並以 `process.exit(1)`
* 終止整個行程(不會回傳)。
*
* @param {string} systemPrompt - 系統提示詞。
* @param {string} userContent - 使用者輸入內容。
* @returns {Promise<string>} 模型回應的純文字內容。
* @throws {Error} 當未設定 OpenCode server(缺少 provider)時。
*/
export async function chat(systemPrompt, userContent) {
const { provider, baseURL, model } = getLLMConfig();
if (!provider) throw new Error('未設定 OpenCode server,請設定 OPENCODE_BASE_URL');
line(`[LLM] provider=${provider} model=${model}`);
const headers = { 'Content-Type': 'application/json' };
try {
const { content, data } = await chatOpenCode(baseURL, model, systemPrompt, userContent, headers);
recordUsage(data);
return content;
} catch (e) {
line(`[LLM] OpenCode 呼叫失敗: ${e.message}`);
}
error('[LLM] OpenCode 呼叫失敗,終止流程');
process.exit(1);
}
/**
* 對 OpenCode 送出對話並將回應解析為 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`。
*/
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 後的原文。
*/
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;
}