import axios from 'axios'; import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js'; import { recordUsage } from './usage.js'; import { line, error } from './log.js'; // 單次 OpenCode HTTP 請求的逾時(毫秒)。預設 120 秒,可用 OPENCODE_TIMEOUT_MS 覆寫。 // 沒有逾時時,server / 模型卡住會讓請求無限等待、整個流程停擺。 const OPENCODE_TIMEOUT_MS = Number(process.env.OPENCODE_TIMEOUT_MS) || 120000; /** * 將模型識別字串解析為 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} headers - 要附加於請求的 HTTP 標頭。 * @returns {{ headers: Record, httpsAgent: import('https').Agent, timeout: number }} axios 請求選項物件。 * @remarks 帶 `timeout`(`OPENCODE_TIMEOUT_MS`,預設 120s),避免 server/模型停滯時請求無限等待。 */ function opencodeAxiosOptions(headers) { return { headers, httpsAgent: getOpenCodeHttpsAgent(), timeout: OPENCODE_TIMEOUT_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(''); } /** * 對 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} 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 呼叫失敗(含逾時)時記錄錯誤並向外**拋出例外**, * 交由呼叫端處理——多數呼叫端(去重、誤報過濾、補行號)有 try/catch fallback, * 角色分析則在 Step5 以 `Promise.allSettled` 略過失敗的單一角色,達到優雅降級。 * * @param {string} systemPrompt - 系統提示詞。 * @param {string} userContent - 使用者輸入內容。 * @returns {Promise} 模型回應的純文字內容。 * @throws {Error} 當未設定 OpenCode server(缺少 provider)時,或 OpenCode 呼叫失敗/逾時時。 */ 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) { error(`[LLM] OpenCode 呼叫失敗: ${e.message}`); throw e; } } /** * 對 OpenCode 送出對話並將回應解析為 JSON 物件/陣列。 * * 先取得文字回應,經 {@link extractJSONText} 抽出 JSON 片段後解析。 * 解析失敗時記錄錯誤並回傳空陣列,不向外拋錯(容錯設計)。 * * @param {string} systemPrompt - 系統提示詞。 * @param {string} userContent - 使用者輸入內容。 * @returns {Promise} 解析後的 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; }