Files
agentPCapp/src/main/api.js
T
2026-07-21 09:01:46 +08:00

188 lines
6.9 KiB
JavaScript

const https = require('https');
const http = require('http');
const { URL } = require('url');
const logger = require('./logger');
function normalizeBaseURL(baseURL) {
let url = (baseURL || '').trim().replace(/\/+$/, '');
if (/\/chat\/completions$/.test(url)) {
url = url.replace(/\/chat\/completions$/, '');
}
return url;
}
function buildChatURL(baseURL) {
return normalizeBaseURL(baseURL) + '/chat/completions';
}
function buildModelsURL(baseURL) {
return normalizeBaseURL(baseURL) + '/models';
}
async function fetchModels({ baseURL, apiKey }) {
const url = buildModelsURL(baseURL);
logger.log('FETCH_MODELS_START', { url });
const res = await fetch(url, {
method: 'GET',
headers: { Authorization: 'Bearer ' + apiKey, 'Content-Type': 'application/json' },
});
if (!res.ok) {
const text = await res.text().catch(() => '');
logger.log('FETCH_MODELS_FAIL', { status: res.status, body: text.slice(0, 300) });
throw new Error('获取模型列表失败 (HTTP ' + res.status + '): ' + text.slice(0, 300));
}
const json = await res.json();
const data = json.data || json.models || [];
const models = data.map((m) => (typeof m === 'string' ? m : m.id || m.name)).filter(Boolean);
logger.log('FETCH_MODELS_OK', { count: models.length });
return models;
}
function doRequest(url, { method, headers, bodyStr, signal }) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const lib = u.protocol === 'https:' ? https : http;
const opts = {
hostname: u.hostname,
port: u.port || (u.protocol === 'https:' ? 443 : 80),
path: u.pathname + u.search,
method,
headers: Object.assign({}, headers, { 'Content-Length': Buffer.byteLength(bodyStr) }),
};
const req = lib.request(opts, (res) => resolve(res));
if (signal) {
if (signal.aborted) {
req.destroy();
reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
return;
}
signal.addEventListener('abort', () => {
try { req.destroy(); } catch { /* ignore */ }
});
}
req.on('error', (err) => {
if (signal && signal.aborted) reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
else reject(err);
});
req.write(bodyStr);
req.end();
});
}
function readAll(stream) {
return new Promise((resolve) => {
const chunks = [];
stream.on('data', (c) => chunks.push(c));
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
stream.on('error', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
}
async function streamChat({ baseURL, apiKey, model, messages, enableThinking, reasoningEffort, maxTokens, signal, onChunk, onUsage }) {
const url = buildChatURL(baseURL);
let currentMessages = messages;
let accumulated = '';
const MAX_ITER = 12;
const CONTINUE_PROMPT = '继续,请从刚才中断的地方直接接着输出,不要重复已经输出的任何内容。';
for (let iter = 0; iter < MAX_ITER; iter++) {
const body = { model, messages: currentMessages, stream: true };
if (enableThinking) {
body.thinking = { type: 'enabled' };
if (reasoningEffort) body.reasoning_effort = reasoningEffort;
}
if (maxTokens && maxTokens > 0) body.max_tokens = maxTokens;
const bodyStr = JSON.stringify(body);
logger.log('REQUEST', { iter, model, msgCount: currentMessages.length, thinking: !!enableThinking, effort: reasoningEffort || null, maxTokens: maxTokens || null, url });
let res;
try {
res = await doRequest(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
bodyStr,
signal,
});
} catch (e) {
if (e && e.name === 'AbortError') { logger.log('ABORTED_AT_REQUEST', { iter }); throw e; }
logger.log('REQUEST_ERROR', { iter, error: e.message, code: e.code });
throw e;
}
logger.log('RESPONSE_STATUS', { iter, status: res.statusCode });
if (res.statusCode !== 200) {
const text = await readAll(res);
logger.log('HTTP_ERROR', { iter, status: res.statusCode, body: text.slice(0, 500) });
throw new Error('请求失败 (HTTP ' + res.statusCode + '): ' + text.slice(0, 500));
}
const decoder = new TextDecoder('utf-8');
let buffer = '';
let finishReason = null;
let sawDone = false;
let chunkCount = 0;
let gotContentThisIter = false;
try {
for await (const chunk of res) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const raw of lines) {
const line = raw.trim();
if (!line || !line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (data === '[DONE]') { sawDone = true; break; }
try {
const json = JSON.parse(data);
chunkCount++;
if (json.usage && onUsage) onUsage(json.usage);
const choice = (json.choices && json.choices[0]) || {};
const delta = choice.delta || {};
const content = delta.content || '';
const reasoning = delta.reasoning_content || delta.reasoning || '';
if (content) { accumulated += content; gotContentThisIter = true; onChunk({ content, reasoning: '' }); }
if (reasoning) { onChunk({ content: '', reasoning }); }
if (choice.finish_reason) finishReason = choice.finish_reason;
} catch {
/* ignore malformed lines */
}
}
if (sawDone) break;
}
} catch (e) {
if (e && e.name === 'AbortError') { logger.log('ABORTED_IN_STREAM', { iter }); throw e; }
logger.log('STREAM_ERROR', { iter, error: e.message, code: e.code, stack: e.stack });
throw e;
}
try { res.destroy(); } catch { /* ignore */ }
logger.log('SEGMENT_END', { iter, finishReason: finishReason || null, sawDone, chunkCount, accumulatedLen: accumulated.length, gotContent: gotContentThisIter });
if (signal && signal.aborted) { logger.log('SIGNAL_ABORTED_AFTER', { iter }); throw Object.assign(new Error('Aborted'), { name: 'AbortError' }); }
const truncated = finishReason === 'length' || (!finishReason && !sawDone && gotContentThisIter);
if (truncated && iter < MAX_ITER - 1 && accumulated.length > 0) {
logger.log('AUTO_CONTINUE', { iter, reason: finishReason === 'length' ? 'length' : 'abrupt', accumulatedLen: accumulated.length });
currentMessages = messages.concat([
{ role: 'assistant', content: accumulated },
{ role: 'user', content: CONTINUE_PROMPT },
]);
continue;
}
if (iter === MAX_ITER - 1 && truncated) {
logger.log('MAX_ITER_REACHED', { accumulatedLen: accumulated.length });
}
break;
}
logger.log('STREAM_COMPLETE', { totalAccumulatedLen: accumulated.length });
}
module.exports = { fetchModels, streamChat, buildChatURL };