272 lines
9.9 KiB
JavaScript
272 lines
9.9 KiB
JavaScript
import axios from 'axios';
|
||
import { getLLMConfig, getOpenCodeHttpsAgent } 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 識別碼。
|
||
*/
|
||
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(),
|
||
};
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise(resolve => setTimeout(resolve, ms));
|
||
}
|
||
|
||
/**
|
||
* 從 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);
|
||
try {
|
||
return JSON.stringify(data).slice(0, 500);
|
||
} catch {
|
||
return String(data).slice(0, 500);
|
||
}
|
||
}
|
||
|
||
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 後送出訊息並回傳結果。
|
||
*
|
||
* 先 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 呼叫失敗時會記錄錯誤並向外拋出,讓呼叫端
|
||
* 決定是否降級、略過單一角色或終止整體流程。
|
||
*
|
||
* @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 chatOpenCodeWithRetry(baseURL, model, systemPrompt, userContent, headers);
|
||
recordUsage(data);
|
||
return content;
|
||
} catch (e) {
|
||
const message = formatOpenCodeError(e);
|
||
line(`[LLM] OpenCode 呼叫失敗: ${message}`);
|
||
throw new Error(message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 對 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`。
|
||
*/
|
||
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;
|
||
}
|