332 lines
14 KiB
JavaScript
332 lines
14 KiB
JavaScript
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<R>} fn - 對每個項目執行的 async 函式。
|
||
* @returns {Promise<R[]>} 與 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<void>} 無回傳值。
|
||
*/
|
||
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 是以 `<system>` 標籤形式內嵌在
|
||
* user 內容中,而非透過 API 的 system role 傳遞。
|
||
*
|
||
* @param {string} systemPrompt - 系統提示詞內容,會被包在 `<system>...</system>`
|
||
* 標籤內;`null`/`undefined` 會被視為空字串。
|
||
* @param {string} userContent - 使用者輸入內容,會被包在 `<user>...</user>`
|
||
* 標籤內;`null`/`undefined` 會被視為空字串。
|
||
* @returns {string} 合併後、以換行分隔的完整 prompt 文字。
|
||
*/
|
||
function buildPrompt(systemPrompt, userContent) {
|
||
return [
|
||
'請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。',
|
||
'',
|
||
'<system>',
|
||
systemPrompt,
|
||
'</system>',
|
||
'',
|
||
'<user>',
|
||
userContent,
|
||
'</user>',
|
||
].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> <訊息>"`
|
||
* (無 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 內可能內嵌的 `<system>` 內容無關。
|
||
* @returns {Promise<any>} 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<string>} 模型回應的純文字內容。
|
||
* @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<any>} 解析後的 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;
|
||
}
|