import axios from 'axios'; import { getLLMConfig, getInsecureHttpsAgent } from './config.js'; import { recordUsage, recordRateLimit } from './usage.js'; import { line } from './log.js'; // 每個 LLM proxy 呼叫(角色分析、補行號等)都是一個獨立 HTTP 請求。預設「不限制」併發(全部同時跑); // 若機器資源不足或撞到提供者限流,可用 AI_ASSISTANT_CONCURRENCY 設一個正整數當上限。 // 0 / 未設定 / 非正整數 → 不限制。 export const LLM_CONCURRENCY = Number(process.env.AI_ASSISTANT_CONCURRENCY) || 0; /** * 對 items 並行執行 async fn(保序回傳),加速多個獨立的 LLM 子行程呼叫。 * * limit 為同時執行上限;`limit <= 0`、非數字或大於項目數時「不限制」(全部並行)。 * fn 需自行處理例外(內部 try/catch);若 fn 未處理而 reject,本函式會立即向外 * 拋出該錯誤(Promise.all fail-fast),但其他已啟動、尚在執行中的併發工作並不會 * 被取消,仍會在背景繼續處理剩餘項目,只是其結果會被捨棄。 * * @template T, R * @param {T[]} items - 要處理的項目;非陣列(含 null/undefined)會被視為空陣列,不拋錯。 * @param {number} limit - 同時執行的上限;<=0/非數字表示不限制。 * @param {(item: T, index: number) => Promise} fn - 對每個項目執行的 async 函式。 * @returns {Promise} 與 items 對應(同索引)的結果陣列。 * @throws 若任一次 fn 呼叫 reject 且未在內部處理,該錯誤會直接向外傳播。 */ export async function mapWithConcurrency(items, limit, fn) { const list = Array.isArray(items) ? items : []; const results = new Array(list.length); if (list.length === 0) return results; const n = Number(limit); const workers = (!Number.isFinite(n) || n <= 0) ? list.length : Math.min(n, list.length); let cursor = 0; /** * 內部 worker:從共用游標依序搶下一個索引,呼叫 `fn` 後把結果寫回對應位置。 * * @returns {Promise} 無回傳值。 */ async function run() { while (cursor < list.length) { const i = cursor++; results[i] = await fn(list[i], i); } } await Promise.all(Array.from({ length: workers }, run)); return results; } /** * 將 system prompt 與 user content 合併成單一文字,作為送往 CLIProxyAPI 的 * 「使用者訊息」內容。 * * 注意:此函式回傳的合併文字,會被 chat() 整段放入 HTTP request 的 user role * 內容;實際送出的 HTTP system role 訊息是固定的通用指示(見 runProxyAPI), * 並非這裡傳入的 systemPrompt——systemPrompt 是以 `` 標籤形式內嵌在 * user 內容中,而非透過 API 的 system role 傳遞。 * * @param {string} systemPrompt - 系統提示詞內容,會被包在 `...` * 標籤內;`null`/`undefined` 會被視為空字串。 * @param {string} userContent - 使用者輸入內容,會被包在 `...` * 標籤內;`null`/`undefined` 會被視為空字串。 * @returns {string} 合併後、以換行分隔的完整 prompt 文字。 */ function buildPrompt(systemPrompt, userContent) { return [ '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。', '', '', systemPrompt, '', '', '', userContent, '', ].join('\n'); } /** * 從 proxy API 錯誤輸出中抽出「真正有意義的錯誤」。 * * 直接取前段很容易被 HTML / JSON 包裝或回顯雜訊洗掉,因此改為:先抽出看起來像 * 錯誤的行;抽不到再退取尾段。 * * @param {string} raw - HTTP 錯誤原始內容(response body、stderr 或 stdout)。 * @param {number} [limit=1000] - 回傳字串長度上限。 * @returns {string} 最能說明失敗原因的片段。 */ export function extractMeaningfulError(raw, limit = 1000) { const text = String(raw || '').trim(); const errorLines = text .split('\n') .filter(l => /\bERROR\b|error:|unauthorized|invalidated|revoked|forbidden|\b40[13]\b|\b429\b|rate.?limit|quota|insufficient|temporarily unavailable/i.test(l)); const picked = (errorLines.length ? errorLines.join('\n') : text).trim(); return picked.length > limit ? picked.slice(-limit) : picked; } /** * 將 HTTP 例外整理成較精簡的錯誤摘要,格式為 `"HTTP <訊息>"` * (無 status 時只有訊息)。 * * 依序嘗試:`response.data`(字串或物件的 error.message/message/error 欄位)→ * `stderr` → `stdout` → `e.message` → `String(e)`,取第一個非空來源後交給 * extractMeaningfulError 濃縮成精簡訊息,再與 HTTP 狀態碼(若有)合併。 * * @param {*} e - 被拋出的錯誤物件,預期含 `response.data`/`response.status`/ * `stderr`/`stdout`/`message` 其中之一或多個。 * @returns {string} 精簡後的錯誤訊息;兩者皆空則回傳空字串。 * @remarks 適合在 log 與錯誤重新拋出前先整理訊息。 * @remarks 容錯僅涵蓋「e 是物件但欄位缺失或型態不符」的情況;若 e 本身為 * `null`/`undefined`,存取 `e.stderr`/`e.stdout`/`e.message` 會直接拋出 * TypeError,並非完全的保守容錯(需人工確認是否要補上 optional chaining 修正)。 */ function summarizeApiError(e) { const responseData = e?.response?.data; const responseText = typeof responseData === 'string' ? responseData : responseData?.error?.message || responseData?.message || responseData?.error || ''; const stderr = String(e?.stderr || '').trim(); const stdout = String(e?.stdout || '').trim(); const status = e?.response?.status ? `HTTP ${e.response.status}` : ''; const message = extractMeaningfulError(responseText || stderr || stdout || e?.message || String(e)); return [status, message].filter(Boolean).join(' ').trim(); } /** * 透過 CLIProxyAPI 執行一次對話 HTTP 請求,回傳 API 的原始回應資料(物件), * 並記錄回應 header 中的速率配額資訊。 * * 僅送出一次請求,不含任何重試邏輯——失敗(逾時、網路錯誤、非 2xx 狀態碼)時 * 由 axios 直接拋出例外,交由呼叫端(chat())攔截並摘要。僅使用 * `apiKeys` 陣列的第一個元素,不會輪替其他金鑰。 * * @param {{provider: string, baseURL: string, apiKeys: string[], model?: string|null}} cfg - 連線設定; * 僅使用 `apiKeys[0]`;`model` 可省略,省略時交由 CLIProxyAPI 自動選擇。 * @param {string} prompt - 送給 API 的完整 prompt 內容,會作為 user 訊息內容; * HTTP 層的 system 訊息為固定的通用指示,與 prompt 內可能內嵌的 `` 內容無關。 * @returns {Promise} API 回應的原始資料物件(`resp.data`),並非純文字; * 純文字需由呼叫端自行從 `data.choices[0].message.content` 等欄位擷取。 * @throws 當 HTTP 請求失敗(逾時、網路錯誤、非 2xx 狀態碼)時,`axios` 拋出的 * 例外會原樣向外傳播,本函式不攔截、不重試。 * @remarks 逾時與輸出上限由環境變數 `AI_ASSISTANT_TIMEOUT_MS`/`AI_ASSISTANT_MAX_BUFFER` * 控制,預設值為 15 分鐘/20 MB。 * @remarks 使用 `getInsecureHttpsAgent()`(停用 TLS 憑證驗證),適用內部自簽憑證環境。 */ async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) { const timeout = Number(process.env.AI_ASSISTANT_TIMEOUT_MS || 15 * 60 * 1000); const maxBuffer = Number(process.env.AI_ASSISTANT_MAX_BUFFER || 20 * 1024 * 1024); const root = String(baseURL || '').trim().replace(/\/$/, ''); const apiKey = Array.isArray(apiKeys) ? apiKeys[0] : ''; const resp = await axios.post( `${root}/v1/chat/completions`, { messages: [ { role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' }, { role: 'user', content: prompt }, ], temperature: 0, stream: false, ...(model ? { model } : {}), }, { timeout, maxBodyLength: maxBuffer, maxContentLength: maxBuffer, headers: { 'Content-Type': 'application/json', ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), }, httpsAgent: getInsecureHttpsAgent(), }, ); recordRateLimit(resp.headers || {}); return resp.data; } /** * 對目前環境可用的 CLIProxyAPI 送出一次對話請求並回傳純文字回應。 * * 從設定取得 provider/baseURL/model;未偵測到 proxy 時拋錯。成功時記錄一次 * usage 呼叫並回傳內容。**不含任何重試邏輯**——無論是設定缺失、底層 HTTP 請求 * 失敗,或回應內容為空,都是失敗一次即向外拋出(重新包裝為新的 Error,只保留 * 摘要後訊息),不會自動重試或切換金鑰/provider。呼叫前後皆會透過 line() 記錄 * 一行 log(成功記啟動資訊,失敗記錯誤摘要)。 * * @param {string} systemPrompt - 系統提示詞。 * @param {string} userContent - 使用者輸入內容。 * @returns {Promise} 模型回應的純文字內容。 * @throws {Error} 當未偵測到可用 CLIProxyAPI 設定、底層 API 呼叫失敗,或回應 * 缺少可用文字內容時。 */ export async function chat(systemPrompt, userContent) { const cfg = getLLMConfig(); const { provider, baseURL, model, modelError } = cfg; if (!provider || !baseURL) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API'); if (modelError) throw new Error(modelError); line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model || 'auto'}`); try { const data = await runProxyAPI(cfg, buildPrompt(systemPrompt, userContent)); recordUsage(data); const content = data?.choices?.[0]?.message?.content ?? data?.choices?.[0]?.text ?? data?.output_text ?? data?.content ?? ''; const text = String(content).trim(); if (!text) throw new Error('CLIProxyAPI 回應缺少文字內容'); return text; } catch (e) { const message = summarizeApiError(e); line(`[LLM] ${provider} API 呼叫失敗: ${message}`); throw new Error(message); } } /** * 對 CLIProxyAPI 送出對話並將回應解析為 JSON 物件/陣列。 * * 先呼叫 {@link chat} 取得文字回應,再經 {@link extractJSONText} 抽出 JSON 片段後 * 以 JSON.parse 解析。**僅 JSON 解析失敗時容錯**(記錄錯誤並回傳空陣列 `[]`,不 * 向外拋錯);若 `chat()` 本身失敗(例如未偵測到可用 CLIProxyAPI 設定、API 呼叫 * 失敗,或回應缺少文字內容),該例外不會被本函式攔截,會直接向外拋出。 * * @param {string} systemPrompt - 系統提示詞。 * @param {string} userContent - 使用者輸入內容。 * @returns {Promise} 解析後的 JSON 值;僅當 JSON 解析失敗時回傳空陣列 `[]`。 * @throws {Error} 當底層 {@link chat} 呼叫失敗時(設定缺失、API 錯誤、回應無文字 * 內容等),例外會原樣向外傳播。 */ 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; }