mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 将项目迁移到Playwright+Camoufox方案
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import yaml from 'yaml';
|
||||
import crypto from 'crypto';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
const CONFIG_PATH = path.join(process.cwd(), 'config.yaml');
|
||||
|
||||
/**
|
||||
* 生成随机 API Key
|
||||
* 格式: sk-{48位十六进制字符}
|
||||
* @returns {string} API Key
|
||||
*/
|
||||
function generateApiKey() {
|
||||
return 'sk-' + crypto.randomBytes(24).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认配置模板
|
||||
*/
|
||||
function getDefaultConfig() {
|
||||
return `# 自动生成于 ${new Date().toLocaleString()}
|
||||
|
||||
# 日志等级: debug | info | warn | error
|
||||
logLevel: info
|
||||
|
||||
server:
|
||||
# 监听端口
|
||||
port: 3000
|
||||
# 鉴权 Token (Bearer Token) (可使用 npm run genkey 生成)
|
||||
auth: ${generateApiKey()}
|
||||
# 保活
|
||||
keepalive:
|
||||
# 是否启用流式保活
|
||||
# 使用OpenAI接口的标准流式接口格式,客户端请求需强制使用 stream: true
|
||||
enable: false
|
||||
|
||||
# 心跳模式
|
||||
# "comment": (推荐) 发送 :keepalive 注释。不污染数据,绝大多数 SDK 支持,不会影响接口标准
|
||||
# "content": (备用) 在 choices[0].delta.content = "" 中发送空字符串
|
||||
# 仅当你使用的客户端非常特殊,必须收到 data JSON 包才重置超时时使用
|
||||
mode: "comment"
|
||||
|
||||
backend:
|
||||
# 适配器设置
|
||||
# - lmarena (LMArena)
|
||||
# - gemini_biz (Gemini Enterprise Business)
|
||||
# - nanobananafree_ai (Nano Banana Free)
|
||||
type: lmarena
|
||||
|
||||
# Gemini Business 设置
|
||||
geminiBiz:
|
||||
# 入口链接
|
||||
# 示例: "https://business.gemini.google/home/cid/8888a888-b6e0-88be-86e1-888cf3ee8cf4"
|
||||
entryUrl: ""
|
||||
|
||||
queue:
|
||||
# 最大排队数
|
||||
# 仅对未开启流式保活模式时做出限制,非必要不建议更改
|
||||
# 因客户端可能有超时保护,队列大于2是一定会触发超时保护的
|
||||
maxQueueSize: 2
|
||||
# 图片数量上限
|
||||
# 网页最多支持10个附件,如果设置大于10则直接丢弃超出10的图片
|
||||
imageLimit: 5
|
||||
|
||||
browser:
|
||||
# 浏览器可执行文件路径 (留空则使用 Camoufox 默认下载路径)
|
||||
# Windows系统示例 "C:\\camoufox\\camoufox.exe"
|
||||
# Linux系统示例 "/opt/camoufox/camoufox"
|
||||
path: ""
|
||||
|
||||
# 是否启用无头模式
|
||||
headless: false
|
||||
|
||||
# 是否启用 GPU (Camoufox 已内置指纹伪装,无GPU设备运行请使用false)
|
||||
gpu: false
|
||||
|
||||
# 代理设置
|
||||
proxy:
|
||||
# 是否启用代理
|
||||
enable: false
|
||||
# 代理类型: http | socks5
|
||||
type: http
|
||||
# 代理主机
|
||||
host: 127.0.0.1
|
||||
# 代理端口
|
||||
port: 7890
|
||||
# 代理认证 (可选)
|
||||
# user: username
|
||||
# passwd: password
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载配置,如果不存在则自动创建
|
||||
* @returns {object} 配置对象
|
||||
*/
|
||||
export function loadConfig() {
|
||||
try {
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
logger.warn('配置器', '配置文件不存在,正在生成默认配置...');
|
||||
const defaultConfig = getDefaultConfig();
|
||||
fs.writeFileSync(CONFIG_PATH, defaultConfig, 'utf8');
|
||||
logger.info('配置器', `已生成默认配置文件: ${CONFIG_PATH}`);
|
||||
logger.warn('配置器', '请注意查看生成的随机 API Key');
|
||||
}
|
||||
|
||||
const configFile = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const config = yaml.parse(configFile);
|
||||
|
||||
// 基础配置校验
|
||||
if (!config.server || !config.server.port) {
|
||||
throw new Error('配置文件缺少必需字段: server.port');
|
||||
}
|
||||
if (!config.server.auth) {
|
||||
throw new Error('配置文件缺少必需字段: server.auth');
|
||||
}
|
||||
|
||||
// 设置队列配置默认值
|
||||
if (!config.queue) {
|
||||
config.queue = {
|
||||
maxConcurrent: 1,
|
||||
maxQueueSize: 2,
|
||||
imageLimit: 5
|
||||
};
|
||||
} else {
|
||||
// 强制 maxConcurrent 为 1
|
||||
config.queue.maxConcurrent = 1;
|
||||
if (config.queue.maxQueueSize === undefined) config.queue.maxQueueSize = 2;
|
||||
if (config.queue.imageLimit === undefined) config.queue.imageLimit = 5;
|
||||
}
|
||||
|
||||
// 设置 keepalive 配置默认值
|
||||
if (!config.server.keepalive) {
|
||||
config.server.keepalive = {
|
||||
enable: true,
|
||||
mode: 'comment'
|
||||
};
|
||||
} else {
|
||||
if (config.server.keepalive.enable === undefined) config.server.keepalive.enable = true;
|
||||
if (config.server.keepalive.mode === undefined) config.server.keepalive.mode = 'comment';
|
||||
// 验证 mode 值
|
||||
if (!['comment', 'content'].includes(config.server.keepalive.mode)) {
|
||||
logger.warn('配置器', `无效的 keepalive.mode: ${config.server.keepalive.mode},使用默认值 comment`);
|
||||
config.server.keepalive.mode = 'comment';
|
||||
}
|
||||
}
|
||||
|
||||
// 设置 backend 配置默认值
|
||||
if (!config.backend) {
|
||||
config.backend = {
|
||||
type: 'lmarena',
|
||||
geminiBiz: { entryUrl: '' }
|
||||
};
|
||||
}
|
||||
|
||||
// 校验 GeminiBiz 配置
|
||||
if (config.backend.type === 'gemini_biz') {
|
||||
if (!config.backend.geminiBiz || !config.backend.geminiBiz.entryUrl) {
|
||||
throw new Error('backend.type = gemini_biz requires backend.geminiBiz.entryUrl');
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('配置器', '已加载 config.yaml');
|
||||
logger.debug('配置器', '后端类型:', config.backend.type);
|
||||
logger.debug('配置器', '流式保活:', config.server.keepalive.enable ? '已启用' : '已禁用');
|
||||
if (config.backend.type === 'gemini_biz') {
|
||||
logger.debug('配置器', `GeminiBiz 入口: ${config.backend.geminiBiz.entryUrl}`);
|
||||
}
|
||||
|
||||
// 设置日志级别
|
||||
if (config.logLevel) {
|
||||
logger.setLevel(config.logLevel);
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (e) {
|
||||
logger.error('配置器', '无法加载或生成配置文件', { error: e.message });
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 默认导出为函数
|
||||
export default loadConfig;
|
||||
|
||||
// 生成 API Key
|
||||
if (process.argv.includes('-genkey')) {
|
||||
console.log('>>> [GenAPIKey] 生成新的 API Key:');
|
||||
console.log(generateApiKey());
|
||||
console.log('\n>>> 请将此 Key 复制到 config.yaml 文件的 server.auth 字段中。');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import fs from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { gotScraping } from 'got-scraping';
|
||||
import compressing from 'compressing';
|
||||
import yaml from 'yaml';
|
||||
import { logger } from './logger.js';
|
||||
import { getHttpProxy, getProxyConfig } from './proxy.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT_ROOT = path.join(__dirname, '..', '..');
|
||||
const TEMP_DIR = path.join(PROJECT_ROOT, 'data', 'temp');
|
||||
const CONFIG_PATH = path.join(PROJECT_ROOT, 'config.yaml');
|
||||
|
||||
// 确保临时目录存在
|
||||
if (!fs.existsSync(TEMP_DIR)) {
|
||||
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Node.js ABI 版本
|
||||
*/
|
||||
function getNodeABI() {
|
||||
return process.versions.modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台信息
|
||||
*/
|
||||
function getPlatformInfo() {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
const nodeVersion = process.version;
|
||||
const abi = getNodeABI();
|
||||
|
||||
return { platform, arch, nodeVersion, abi };
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证平台支持
|
||||
*/
|
||||
function validatePlatform(platform, arch) {
|
||||
const supported = {
|
||||
'win32': ['x64'],
|
||||
'darwin': ['x64', 'arm64'],
|
||||
'linux': ['x64', 'arm64']
|
||||
};
|
||||
|
||||
if (!supported[platform] || !supported[platform].includes(arch)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Node.js ABI 版本支持
|
||||
*/
|
||||
function validateABI(abi) {
|
||||
const supportedABIs = [115, 121, 123, 125, 127, 128, 130, 131, 132, 133, 135, 136, 137, 139, 140, 141];
|
||||
return supportedABIs.includes(parseInt(abi, 10));
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件(带进度,流式,支持重试)
|
||||
* @param {string} url - 下载地址
|
||||
* @param {string} destPath - 目标文件路径
|
||||
* @param {string|null} proxyUrl - 代理 URL
|
||||
* @param {number} maxRetries - 最大重试次数
|
||||
*/
|
||||
async function downloadFile(url, destPath, proxyUrl = null, maxRetries = 3) {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
if (attempt > 1) {
|
||||
logger.info('初始化', `第 ${attempt}/${maxRetries} 次尝试下载...`);
|
||||
// 删除之前失败的文件
|
||||
try {
|
||||
if (fs.existsSync(destPath)) {
|
||||
fs.unlinkSync(destPath);
|
||||
}
|
||||
} catch (e) { }
|
||||
} else {
|
||||
logger.info('初始化', `开始下载: ${url}`);
|
||||
}
|
||||
|
||||
await downloadFileOnce(url, destPath, proxyUrl);
|
||||
return destPath;
|
||||
} catch (error) {
|
||||
logger.error('初始化', `下载失败 (尝试 ${attempt}/${maxRetries}): ${error.message}`);
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 等待后重试(递增延迟)
|
||||
const delay = attempt * 2000;
|
||||
logger.info('初始化', `${delay / 1000} 秒后重试...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单次下载尝试(内部函数)
|
||||
*/
|
||||
async function downloadFileOnce(url, destPath, proxyUrl = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
http2: false,
|
||||
timeout: {
|
||||
request: 900000, // 总请求超时 15 分钟
|
||||
read: 180000 // 两次数据接收间隔超时 3 分钟
|
||||
},
|
||||
retry: {
|
||||
limit: 0
|
||||
},
|
||||
headerGeneratorOptions: {
|
||||
browsers: [{ name: 'firefox', minVersion: 100 }],
|
||||
devices: ['desktop'],
|
||||
locales: ['en-US'],
|
||||
operatingSystems: ['windows'],
|
||||
}
|
||||
};
|
||||
|
||||
if (proxyUrl) {
|
||||
options.proxyUrl = proxyUrl;
|
||||
}
|
||||
|
||||
const downloadStream = gotScraping.stream(url, options);
|
||||
const fileStream = fs.createWriteStream(destPath);
|
||||
|
||||
let downloadedSize = 0;
|
||||
let totalSize = 0;
|
||||
let lastLogTime = Date.now();
|
||||
|
||||
downloadStream.on('response', (response) => {
|
||||
totalSize = parseInt(response.headers['content-length'] || '0', 10);
|
||||
if (totalSize > 0) {
|
||||
logger.info('初始化', `文件大小: ${(totalSize / 1024 / 1024).toFixed(2)} MB`);
|
||||
}
|
||||
});
|
||||
|
||||
downloadStream.on('data', (chunk) => {
|
||||
downloadedSize += chunk.length;
|
||||
|
||||
// 每秒更新一次进度
|
||||
const now = Date.now();
|
||||
if (totalSize > 0 && now - lastLogTime > 1000) {
|
||||
const percent = ((downloadedSize / totalSize) * 100).toFixed(1);
|
||||
const downloadedMB = (downloadedSize / 1024 / 1024).toFixed(2);
|
||||
const totalMB = (totalSize / 1024 / 1024).toFixed(2);
|
||||
logger.info('初始化', `下载进度: ${percent}% (${downloadedMB}MB / ${totalMB}MB)`);
|
||||
lastLogTime = now;
|
||||
}
|
||||
});
|
||||
|
||||
downloadStream.on('error', (error) => {
|
||||
fileStream.close();
|
||||
try {
|
||||
fs.unlinkSync(destPath);
|
||||
} catch (e) { }
|
||||
reject(error);
|
||||
});
|
||||
|
||||
fileStream.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
fileStream.on('finish', () => {
|
||||
const finalSize = (downloadedSize / 1024 / 1024).toFixed(2);
|
||||
|
||||
// 验证下载完整性
|
||||
if (totalSize > 0 && downloadedSize !== totalSize) {
|
||||
const errorMsg = `下载不完整: 预期 ${(totalSize / 1024 / 1024).toFixed(2)} MB, 实际 ${finalSize} MB`;
|
||||
logger.error('初始化', errorMsg);
|
||||
|
||||
// 清理损坏的文件
|
||||
try {
|
||||
fs.unlinkSync(destPath);
|
||||
} catch (e) { }
|
||||
|
||||
reject(new Error(errorMsg));
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('初始化', `下载完成: ${finalSize} MB`);
|
||||
resolve(destPath);
|
||||
});
|
||||
|
||||
downloadStream.pipe(fileStream);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 better-sqlite3 下载 URL
|
||||
*/
|
||||
function getBetterSqlite3Url(platform, arch, abi) {
|
||||
const version = '12.5.0';
|
||||
const platformMap = {
|
||||
'win32': 'win32',
|
||||
'darwin': 'darwin',
|
||||
'linux': 'linux'
|
||||
};
|
||||
|
||||
const platformName = platformMap[platform];
|
||||
const archName = arch; // x64 或 arm64
|
||||
|
||||
return `https://github.com/WiseLibs/better-sqlite3/releases/download/v${version}/better-sqlite3-v${version}-node-v${abi}-${platformName}-${archName}.tar.gz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并安装 better-sqlite3
|
||||
*/
|
||||
async function installBetterSqlite3(platform, arch, abi, proxyUrl) {
|
||||
logger.info('初始化', '开始安装 better-sqlite3...');
|
||||
|
||||
const url = getBetterSqlite3Url(platform, arch, abi);
|
||||
const downloadPath = path.join(TEMP_DIR, 'better-sqlite3.tar.gz');
|
||||
|
||||
// 下载
|
||||
await downloadFile(url, downloadPath, proxyUrl);
|
||||
|
||||
// 解压 .tar.gz 文件
|
||||
logger.info('初始化', '正在解压 better-sqlite3...');
|
||||
await compressing.tgz.uncompress(downloadPath, TEMP_DIR);
|
||||
|
||||
// 查找 better_sqlite3.node
|
||||
const files = fs.readdirSync(TEMP_DIR, { recursive: true });
|
||||
const nodeFile = files.find(f => f.endsWith('better_sqlite3.node'));
|
||||
if (!nodeFile) {
|
||||
throw new Error('未找到 better_sqlite3.node 文件');
|
||||
}
|
||||
|
||||
// 复制到 node_modules
|
||||
const buildDir = path.join(PROJECT_ROOT, 'node_modules', 'better-sqlite3', 'build', 'Release');
|
||||
if (!fs.existsSync(buildDir)) {
|
||||
fs.mkdirSync(buildDir, { recursive: true });
|
||||
}
|
||||
|
||||
const sourcePath = path.join(TEMP_DIR, nodeFile);
|
||||
const destPath = path.join(buildDir, 'better_sqlite3.node');
|
||||
fs.copyFileSync(sourcePath, destPath);
|
||||
|
||||
logger.info('初始化', `better-sqlite3 安装成功: ${destPath}`);
|
||||
|
||||
// 清理
|
||||
fs.unlinkSync(downloadPath);
|
||||
// 清理解压后的所有文件
|
||||
files.forEach(f => {
|
||||
const filePath = path.join(TEMP_DIR, f);
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
fs.rmSync(filePath, { recursive: true, force: true });
|
||||
} else {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Camoufox 下载 URL
|
||||
*/
|
||||
function getCamoufoxUrl(platform, arch) {
|
||||
const version = '135.0.1-beta.24';
|
||||
const platformMap = {
|
||||
'win32': 'win',
|
||||
'darwin': 'mac',
|
||||
'linux': 'lin'
|
||||
};
|
||||
|
||||
const archMap = {
|
||||
'x64': 'x86_64',
|
||||
'arm64': 'arm64'
|
||||
};
|
||||
|
||||
const platformName = platformMap[platform];
|
||||
const archName = archMap[arch];
|
||||
|
||||
return `https://github.com/daijro/camoufox/releases/download/v${version}/camoufox-${version}-${platformName}.${archName}.zip`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并安装 Camoufox
|
||||
*/
|
||||
async function installCamoufox(platform, arch, proxyUrl) {
|
||||
logger.info('初始化', '开始安装 Camoufox 浏览器...');
|
||||
|
||||
const url = getCamoufoxUrl(platform, arch);
|
||||
const downloadPath = path.join(TEMP_DIR, 'camoufox.zip');
|
||||
|
||||
// 下载
|
||||
await downloadFile(url, downloadPath, proxyUrl);
|
||||
|
||||
// 解压 .zip 文件到 camoufox 目录
|
||||
logger.info('初始化', '正在解压 Camoufox...');
|
||||
const camoufoxDir = path.join(PROJECT_ROOT, 'camoufox');
|
||||
if (!fs.existsSync(camoufoxDir)) {
|
||||
fs.mkdirSync(camoufoxDir, { recursive: true });
|
||||
}
|
||||
|
||||
await compressing.zip.uncompress(downloadPath, camoufoxDir);
|
||||
|
||||
logger.info('初始化', `Camoufox 安装成功: ${camoufoxDir}`);
|
||||
|
||||
// 更新 config.yaml
|
||||
updateConfigPath(platform, camoufoxDir);
|
||||
|
||||
// 创建 version.json
|
||||
const versionJsonPath = path.join(camoufoxDir, 'version.json');
|
||||
const versionData = {
|
||||
version: "135.0",
|
||||
release: "beta.24"
|
||||
};
|
||||
fs.writeFileSync(versionJsonPath, JSON.stringify(versionData, null, 2), 'utf8');
|
||||
logger.info('初始化', `已生成 version.json: ${versionJsonPath}`);
|
||||
|
||||
// 清理
|
||||
fs.unlinkSync(downloadPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 config.yaml 中的 browser.path
|
||||
*/
|
||||
function updateConfigPath(platform, camoufoxDir) {
|
||||
try {
|
||||
const configContent = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
|
||||
// 解析为文档对象 (CST)
|
||||
const doc = yaml.parseDocument(configContent);
|
||||
|
||||
// 构造绝对路径
|
||||
let browserPath;
|
||||
if (platform === 'win32') {
|
||||
browserPath = path.join(camoufoxDir, 'camoufox.exe');
|
||||
} else if (platform === 'darwin') {
|
||||
browserPath = path.join(camoufoxDir, 'Camoufox.app', 'Contents', 'MacOS', 'camoufox');
|
||||
} else {
|
||||
browserPath = path.join(camoufoxDir, 'camoufox');
|
||||
}
|
||||
|
||||
// 规范化路径分隔符
|
||||
browserPath = browserPath.replace(/\\/g, '/');
|
||||
|
||||
// 安全地更新路径,如果节点不存在则创建
|
||||
if (!doc.has('browser')) {
|
||||
doc.set('browser', { path: browserPath });
|
||||
} else {
|
||||
const browserNode = doc.get('browser');
|
||||
if (browserNode && typeof browserNode.set === 'function') {
|
||||
browserNode.set('path', browserPath);
|
||||
} else {
|
||||
// 如果 browser 不是对象(理论上不应该发生),强制覆盖
|
||||
doc.set('browser', { path: browserPath });
|
||||
}
|
||||
}
|
||||
|
||||
// 转回字符串,保留注释
|
||||
const updatedYaml = doc.toString();
|
||||
fs.writeFileSync(CONFIG_PATH, updatedYaml, 'utf8');
|
||||
|
||||
logger.info('初始化', `已更新配置文件 browser.path: ${browserPath}`);
|
||||
} catch (e) {
|
||||
logger.error('初始化', '更新配置文件失败', { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主流程
|
||||
*/
|
||||
(async () => {
|
||||
try {
|
||||
logger.info('初始化', '========================================');
|
||||
logger.info('初始化', '依赖初始化脚本启动');
|
||||
logger.info('初始化', '========================================');
|
||||
|
||||
// 显示系统信息
|
||||
const { platform, arch, nodeVersion, abi } = getPlatformInfo();
|
||||
logger.info('初始化', `操作系统: ${platform}`);
|
||||
logger.info('初始化', `芯片架构: ${arch}`);
|
||||
logger.info('初始化', `Node.js 版本: ${nodeVersion}`);
|
||||
logger.info('初始化', `Node.js ABI 版本: ${abi}`);
|
||||
|
||||
// 验证平台支持
|
||||
if (!validatePlatform(platform, arch)) {
|
||||
logger.error('初始化', '不支持的平台!');
|
||||
logger.error('初始化', `因该项目使用了 Camoufox 浏览器,没有您设备可用的预编译版本`);
|
||||
logger.error('初始化', `支持的平台: Windows x64, macOS x64/arm64, Linux x64/arm64`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info('初始化', '平台支持检查通过');
|
||||
|
||||
// 验证 ABI 版本支持
|
||||
if (!validateABI(abi)) {
|
||||
logger.error('初始化', '不支持的 Node.js ABI 版本!');
|
||||
logger.error('初始化', `当前 ABI 版本: ${abi}`);
|
||||
logger.error('初始化', `支持的 ABI 版本: 115, 121, 123, 125, 127, 128, 130, 131, 132, 133, 135, 136, 137, 139, 140, 141`);
|
||||
logger.error('初始化', `建议使用 Node.js 20.10.0 或更高版本`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info('初始化', 'ABI 版本检查通过');
|
||||
|
||||
// 读取并转换代理配置
|
||||
let proxyUrl = null;
|
||||
try {
|
||||
const configContent = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const config = yaml.parse(configContent);
|
||||
const proxyConfig = getProxyConfig(config);
|
||||
if (proxyConfig) {
|
||||
proxyUrl = await getHttpProxy(proxyConfig);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('初始化', '无法读取配置文件或转换代理,不使用代理');
|
||||
}
|
||||
|
||||
// 安装 better-sqlite3
|
||||
await installBetterSqlite3(platform, arch, abi, proxyUrl);
|
||||
|
||||
// 安装 Camoufox
|
||||
await installCamoufox(platform, arch, proxyUrl);
|
||||
|
||||
// 修复 Camoufox 环境 (Linux)
|
||||
fixCamoufoxEnv();
|
||||
|
||||
logger.info('初始化', '========================================');
|
||||
logger.info('初始化', '所有依赖安装完成!');
|
||||
logger.info('初始化', '========================================');
|
||||
process.exit(0);
|
||||
|
||||
} catch (err) {
|
||||
logger.error('初始化', '初始化失败', { error: err.message });
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* 自动修复 Linux 下 Camoufox 的路径依赖
|
||||
* 目的:建立软链接,欺骗 camoufox-js 以为浏览器安装在默认目录,从而防止自动下载
|
||||
*/
|
||||
function fixCamoufoxEnv() {
|
||||
// 1. 仅在 Linux 下执行
|
||||
if (os.platform() !== 'linux') return;
|
||||
|
||||
logger.info('初始化', '正在检查 Camoufox 环境配置...');
|
||||
|
||||
// --- 路径配置 ---
|
||||
// 假设浏览器存放在项目根目录下的 camoufox 文件夹中
|
||||
// 依赖 init.js 中已定义的 PROJECT_ROOT 变量
|
||||
const customBrowserDir = path.join(PROJECT_ROOT, 'camoufox');
|
||||
|
||||
// 官方默认缓存路径: ~/.cache/camoufox
|
||||
const defaultCacheDir = path.join(os.homedir(), '.cache');
|
||||
const defaultLinkPath = path.join(defaultCacheDir, 'camoufox');
|
||||
|
||||
// 2. 预检查:确保源文件存在
|
||||
if (!fs.existsSync(customBrowserDir)) {
|
||||
logger.warn('初始化', `未找到自定义浏览器目录: ${customBrowserDir}`);
|
||||
logger.warn('初始化', `请确保已将浏览器解压至项目根目录的 camoufox 文件夹`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 检查并修复软链接
|
||||
if (fs.existsSync(defaultLinkPath)) {
|
||||
const stats = fs.lstatSync(defaultLinkPath);
|
||||
if (stats.isSymbolicLink()) {
|
||||
const currentTarget = fs.readlinkSync(defaultLinkPath);
|
||||
if (currentTarget === customBrowserDir) {
|
||||
logger.info('初始化', 'Camoufox 路径映射已就绪');
|
||||
return;
|
||||
}
|
||||
logger.info('初始化', '路径映射不一致,正在更新...');
|
||||
fs.unlinkSync(defaultLinkPath);
|
||||
} else {
|
||||
// 备份旧的实体文件夹
|
||||
logger.warn('初始化', `默认路径被占用,正在备份...`);
|
||||
fs.renameSync(defaultLinkPath, `${defaultLinkPath}_backup_${Date.now()}`);
|
||||
}
|
||||
} else {
|
||||
if (!fs.existsSync(defaultCacheDir)) {
|
||||
fs.mkdirSync(defaultCacheDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 创建软链接
|
||||
try {
|
||||
// 使用 shell 命令创建软链接,比 fs.symlinkSync 在某些 Linux 环境下更可靠
|
||||
execSync(`ln -sf "${customBrowserDir}" "${defaultLinkPath}"`);
|
||||
|
||||
// 验证链接是否有效
|
||||
if (fs.existsSync(defaultLinkPath)) {
|
||||
const linkTarget = fs.readlinkSync(defaultLinkPath);
|
||||
logger.info('初始化', `成功创建路径映射: ${defaultLinkPath} -> ${linkTarget}`);
|
||||
} else {
|
||||
logger.warn('初始化', '软链接创建后无法访问,可能存在权限问题');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('初始化', `创建软链接失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import process from 'process';
|
||||
|
||||
const LEVELS = ['debug', 'info', 'warn', 'error'];
|
||||
|
||||
// ANSI 颜色代码
|
||||
const COLORS = {
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
white: '\x1b[37m'
|
||||
};
|
||||
|
||||
// 根据日志级别获取颜色
|
||||
function getColor(level) {
|
||||
switch (level.toLowerCase()) {
|
||||
case 'error':
|
||||
return COLORS.red;
|
||||
case 'warn':
|
||||
return COLORS.yellow;
|
||||
case 'info':
|
||||
return COLORS.white;
|
||||
case 'debug':
|
||||
return COLORS.blue;
|
||||
default:
|
||||
return COLORS.reset;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(date = new Date()) {
|
||||
const pad = (n, len = 2) => n.toString().padStart(len, '0');
|
||||
const yyyy = date.getFullYear();
|
||||
const MM = pad(date.getMonth() + 1);
|
||||
const dd = pad(date.getDate());
|
||||
const HH = pad(date.getHours());
|
||||
const mm = pad(date.getMinutes());
|
||||
const ss = pad(date.getSeconds());
|
||||
const SSS = pad(date.getMilliseconds(), 3);
|
||||
return `${yyyy}-${MM}-${dd} ${HH}:${mm}:${ss}.${SSS}`;
|
||||
}
|
||||
|
||||
let currentLogLevel = (process.env.LOG_LEVEL || 'info').toLowerCase();
|
||||
|
||||
export function setLogLevel(level) {
|
||||
if (level && LEVELS.includes(level.toLowerCase())) {
|
||||
currentLogLevel = level.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function shouldLog(level) {
|
||||
const targetLevel = level.toLowerCase();
|
||||
const envIndex = LEVELS.indexOf(currentLogLevel);
|
||||
const targetIndex = LEVELS.indexOf(targetLevel);
|
||||
|
||||
// If env level is invalid, default to info (index 1)
|
||||
const effectiveEnvIndex = envIndex === -1 ? 1 : envIndex;
|
||||
|
||||
return targetIndex >= effectiveEnvIndex;
|
||||
}
|
||||
|
||||
export function log(level, mod, msg, meta = {}) {
|
||||
if (!shouldLog(level)) return;
|
||||
|
||||
const ts = formatTime();
|
||||
const levelTag = level.toUpperCase();
|
||||
const base = `${ts} [${levelTag}] [${mod}] ${msg}`;
|
||||
|
||||
const metaStr = Object.keys(meta).length
|
||||
? ' | ' + Object.entries(meta).map(([k, v]) => {
|
||||
if (v instanceof Error) {
|
||||
return `${k}=${v.message}`;
|
||||
}
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
try {
|
||||
return `${k}=${JSON.stringify(v)}`;
|
||||
} catch (e) {
|
||||
return `${k}=[Circular]`;
|
||||
}
|
||||
}
|
||||
return `${k}=${v}`;
|
||||
}).join(' ')
|
||||
: '';
|
||||
|
||||
const line = base + metaStr;
|
||||
const color = getColor(level);
|
||||
const coloredLine = `${color}${line}${COLORS.reset}`;
|
||||
|
||||
if (level === 'error') {
|
||||
console.error(coloredLine);
|
||||
} else if (level === 'warn') {
|
||||
console.warn(coloredLine);
|
||||
} else {
|
||||
console.log(coloredLine);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (mod, msg, meta) => log('debug', mod, msg, meta),
|
||||
info: (mod, msg, meta) => log('info', mod, msg, meta),
|
||||
warn: (mod, msg, meta) => log('warn', mod, msg, meta),
|
||||
error: (mod, msg, meta) => log('error', mod, msg, meta),
|
||||
setLevel: setLogLevel
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { anonymizeProxy, closeAnonymizedProxy } from 'proxy-chain';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
// 全局代理状态追踪
|
||||
const proxyState = {
|
||||
anonymizedProxyUrl: null, // 转换后的 HTTP 代理地址
|
||||
originalProxyUrl: null // 原始代理地址
|
||||
};
|
||||
|
||||
/**
|
||||
* 构建代理 URL
|
||||
* @param {object} proxyConfig - 代理配置对象
|
||||
* @param {string} proxyConfig.type - 代理类型 ('http' 或 'socks5')
|
||||
* @param {string} proxyConfig.host - 代理主机地址
|
||||
* @param {number} proxyConfig.port - 代理端口
|
||||
* @param {string} [proxyConfig.user] - 可选的用户名
|
||||
* @param {string} [proxyConfig.passwd] - 可选的密码
|
||||
* @returns {string} - 代理 URL
|
||||
*/
|
||||
export function buildProxyUrl(proxyConfig) {
|
||||
const { type, host, port, user, passwd } = proxyConfig;
|
||||
|
||||
// 构建带认证的代理 URL
|
||||
if (user && passwd) {
|
||||
return `${type}://${user}:${passwd}@${host}:${port}`;
|
||||
}
|
||||
|
||||
// 构建不带认证的代理 URL
|
||||
if (type === 'socks5') {
|
||||
return `socks5://${host}:${port}`;
|
||||
}
|
||||
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将代理转换为 HTTP 代理
|
||||
* - HTTP 代理:直接返回
|
||||
* - SOCKS5 代理:使用 proxy-chain 转换为本地 HTTP 代理
|
||||
* @param {object} proxyConfig - 代理配置对象
|
||||
* @returns {Promise<string|null>} - 转换后的 HTTP 代理 URL,如果无需代理则返回 null
|
||||
*/
|
||||
export async function getHttpProxy(proxyConfig) {
|
||||
if (!proxyConfig || !proxyConfig.enable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { type, host, port } = proxyConfig;
|
||||
const originalUrl = buildProxyUrl(proxyConfig);
|
||||
|
||||
// 如果是 HTTP 代理,直接返回
|
||||
if (type === 'http') {
|
||||
logger.debug('代理器', `使用 HTTP 代理: ${host}:${port}`);
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
// 如果是 SOCKS5 代理,需要转换为 HTTP 代理
|
||||
if (type === 'socks5') {
|
||||
try {
|
||||
logger.info('代理器', `检测到 SOCKS5 代理,正在转换为 HTTP 代理: ${host}:${port}`);
|
||||
const httpProxyUrl = await anonymizeProxy(originalUrl);
|
||||
|
||||
// 保存状态用于后续清理
|
||||
proxyState.anonymizedProxyUrl = httpProxyUrl;
|
||||
proxyState.originalProxyUrl = originalUrl;
|
||||
|
||||
logger.info('代理器', `SOCKS5 代理已转换为 HTTP 代理: ${httpProxyUrl}`);
|
||||
return httpProxyUrl;
|
||||
} catch (error) {
|
||||
logger.error('代理器', `SOCKS5 代理转换失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('代理器', `不支持的代理类型: ${type}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用于浏览器的代理配置
|
||||
* 返回 Playwright 可以使用的代理对象
|
||||
* @param {object} proxyConfig - 代理配置对象
|
||||
* @returns {Promise<object|null>} - Playwright 代理配置对象
|
||||
*/
|
||||
export async function getBrowserProxy(proxyConfig) {
|
||||
if (!proxyConfig || !proxyConfig.enable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { type, host, port, user, passwd } = proxyConfig;
|
||||
|
||||
// 对于 SOCKS5 + 认证,需要转换为 HTTP 代理
|
||||
if (type === 'socks5' && user && passwd) {
|
||||
try {
|
||||
const originalUrl = buildProxyUrl(proxyConfig);
|
||||
logger.info('代理器', `检测到需鉴权的 SOCKS5 代理,正在创建本地代理桥接: ${host}:${port}`);
|
||||
|
||||
const httpProxyUrl = await anonymizeProxy(originalUrl);
|
||||
|
||||
// 保存状态用于后续清理
|
||||
proxyState.anonymizedProxyUrl = httpProxyUrl;
|
||||
proxyState.originalProxyUrl = originalUrl;
|
||||
|
||||
logger.info('代理器', `本地代理桥接已建立: ${httpProxyUrl} -> ${host}:${port}`);
|
||||
|
||||
return {
|
||||
server: httpProxyUrl
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('代理器', `本地代理桥接创建失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 对于其他情况(HTTP 代理、不带认证的 SOCKS5)
|
||||
const proxyUrl = type === 'socks5' ? `socks5://${host}:${port}` : `${host}:${port}`;
|
||||
|
||||
const proxyObject = {
|
||||
server: proxyUrl
|
||||
};
|
||||
|
||||
// 如果有认证信息,添加到代理对象
|
||||
if (user && passwd) {
|
||||
proxyObject.username = user;
|
||||
proxyObject.password = passwd;
|
||||
}
|
||||
|
||||
logger.info('代理器', `代理配置: ${type}://${host}:${port}`);
|
||||
return proxyObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理代理资源
|
||||
* 关闭由 proxy-chain 创建的本地代理服务器
|
||||
*/
|
||||
export async function cleanupProxy() {
|
||||
if (proxyState.anonymizedProxyUrl) {
|
||||
try {
|
||||
logger.debug('代理器', '正在关闭本地代理桥接...');
|
||||
await closeAnonymizedProxy(proxyState.anonymizedProxyUrl, true);
|
||||
logger.debug('代理器', '本地代理桥接已关闭');
|
||||
|
||||
// 清理状态
|
||||
proxyState.anonymizedProxyUrl = null;
|
||||
proxyState.originalProxyUrl = null;
|
||||
} catch (error) {
|
||||
logger.error('代理器', `关闭本地代理桥接失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从配置文件读取代理配置
|
||||
* @param {object} config - 配置对象
|
||||
* @returns {object|null} - 代理配置对象或 null
|
||||
*/
|
||||
export function getProxyConfig(config) {
|
||||
if (config?.browser?.proxy?.enable) {
|
||||
return config.browser.proxy;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { getBackend } from '../backend/index.js';
|
||||
import { getModelsForBackend, resolveModelId } from '../backend/models.js';
|
||||
import { select, input } from '@inquirer/prompts';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
// 使用统一后端获取配置和函数
|
||||
const { config, name, TEMP_DIR } = getBackend();
|
||||
|
||||
logger.info('CLI/Test', `测试工具启动 (后端适配器: ${name})`);
|
||||
|
||||
/**
|
||||
* 选择模型
|
||||
*/
|
||||
async function selectModel() {
|
||||
const models = getModelsForBackend(name);
|
||||
const choices = [
|
||||
{ name: 'Skip(使用默认模型)', value: null },
|
||||
...models.data.map(m => ({ name: m.id, value: m.id }))
|
||||
];
|
||||
|
||||
const modelId = await select({
|
||||
message: '选择模型',
|
||||
choices,
|
||||
pageSize: 15
|
||||
});
|
||||
|
||||
return modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入提示词
|
||||
*/
|
||||
async function promptForInput() {
|
||||
const prompt = await input({
|
||||
message: '输入提示词 (必填)',
|
||||
validate: (val) => val.trim().length > 0 || '提示词不能为空'
|
||||
});
|
||||
return prompt.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入图片路径
|
||||
*/
|
||||
async function promptForImages() {
|
||||
const imagePaths = [];
|
||||
while (true) {
|
||||
const imgPath = await input({
|
||||
message: `输入参考图片路径 (留空跳过,已添加 ${imagePaths.length} 张)`,
|
||||
});
|
||||
|
||||
if (!imgPath.trim()) break;
|
||||
|
||||
const cleanPath = imgPath.trim().replace(/^["']|["']$/g, '');
|
||||
if (fs.existsSync(cleanPath)) {
|
||||
imagePaths.push(cleanPath);
|
||||
} else {
|
||||
logger.warn('CLI/Test', `图片不存在: ${cleanPath}`);
|
||||
}
|
||||
}
|
||||
return imagePaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 测试模式 - OpenAI 格式
|
||||
*/
|
||||
async function testViaHttpOpenAI(prompt, modelId, imagePaths) {
|
||||
const PORT = config.server.port || 3000;
|
||||
const AUTH_TOKEN = config.server.auth;
|
||||
const KEEPALIVE_ENABLED = config.server.keepalive?.enable ?? true;
|
||||
|
||||
logger.info('CLI/Test', 'HTTP 测试 - OpenAI 模式');
|
||||
if (KEEPALIVE_ENABLED) {
|
||||
logger.info('CLI/Test', '流式保活已启用,将使用 stream=true');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 构造请求体
|
||||
const messages = [];
|
||||
const lastMessage = { role: 'user', content: [] };
|
||||
|
||||
if (prompt) {
|
||||
lastMessage.content.push({ type: 'text', text: prompt });
|
||||
}
|
||||
|
||||
for (const imgPath of imagePaths) {
|
||||
if (fs.existsSync(imgPath)) {
|
||||
const buffer = fs.readFileSync(imgPath);
|
||||
const base64 = buffer.toString('base64');
|
||||
const ext = path.extname(imgPath).slice(1).toLowerCase();
|
||||
const mimeType = ext === 'jpg' ? 'jpeg' : ext;
|
||||
lastMessage.content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:image/${mimeType};base64,${base64}` }
|
||||
});
|
||||
} else {
|
||||
logger.warn('CLI/Test', `图片不存在,已跳过: ${imgPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
messages.push(lastMessage);
|
||||
|
||||
const body = {
|
||||
messages,
|
||||
stream: KEEPALIVE_ENABLED, // 如果启用 keepalive,必须使用 stream
|
||||
...(modelId && { model: modelId })
|
||||
};
|
||||
|
||||
const bodyStr = JSON.stringify(body);
|
||||
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: PORT,
|
||||
path: '/v1/chat/completions',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(bodyStr),
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
if (KEEPALIVE_ENABLED) {
|
||||
// 流式响应
|
||||
let buffer = '';
|
||||
let contentReceived = '';
|
||||
|
||||
res.on('data', chunk => {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop(); // 保留未完成的行
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
// 跳过心跳注释
|
||||
if (line.startsWith(':')) continue;
|
||||
|
||||
if (line.startsWith('data:')) {
|
||||
const data = line.slice(5).trim();
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const chunk = JSON.parse(data);
|
||||
if (chunk.choices && chunk.choices[0].delta && chunk.choices[0].delta.content) {
|
||||
contentReceived += chunk.choices[0].delta.content;
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve({ choices: [{ message: { content: contentReceived } }] });
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}`));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 非流式响应
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
const response = JSON.parse(data);
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存图片
|
||||
*/
|
||||
function saveImage(base64Data) {
|
||||
const testSaveDir = path.join(TEMP_DIR, 'testSave');
|
||||
if (!fs.existsSync(testSaveDir)) {
|
||||
fs.mkdirSync(testSaveDir, { recursive: true });
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const savePath = path.join(testSaveDir, `test_${timestamp}.png`);
|
||||
|
||||
// 移除 Data URI 前缀(如果有)
|
||||
const cleanBase64 = base64Data.replace(/^data:image\/\w+;base64,/, '');
|
||||
fs.writeFileSync(savePath, Buffer.from(cleanBase64, 'base64'));
|
||||
|
||||
logger.info('CLI/Test', `图片已保存: ${savePath}`);
|
||||
return savePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主流程
|
||||
*/
|
||||
(async () => {
|
||||
try {
|
||||
logger.info('CLI/Test', '=== HTTP 服务器测试 ===');
|
||||
logger.info('CLI/Test', '请确保服务器已启动 (npm start)');
|
||||
|
||||
// 1. 选择模型
|
||||
const modelId = await selectModel();
|
||||
if (modelId) {
|
||||
logger.info('CLI/Test', `选择模型: ${modelId}`);
|
||||
} else {
|
||||
logger.info('CLI/Test', '跳过模型选择,使用默认');
|
||||
}
|
||||
|
||||
// 2. 输入提示词
|
||||
const prompt = await promptForInput();
|
||||
logger.info('CLI/Test', `提示词: ${prompt}`);
|
||||
|
||||
// 3. 输入图片路径
|
||||
const imagePaths = await promptForImages();
|
||||
if (imagePaths.length > 0) {
|
||||
logger.info('CLI/Test', `参考图片: ${imagePaths.join(', ')}`);
|
||||
}
|
||||
|
||||
// 4. 执行测试
|
||||
logger.info('CLI/Test', '正在发送请求...');
|
||||
const result = await testViaHttpOpenAI(prompt, modelId, imagePaths);
|
||||
|
||||
// 5. 处理响应
|
||||
if (result.choices) {
|
||||
const content = result.choices[0].message.content;
|
||||
logger.info('CLI/Test', `响应内容: ${content.slice(0, 100)}...`);
|
||||
|
||||
// 提取图片(如果有)
|
||||
const match = content.match(/!\[.*?\]\((data:image\/[^)]+)\)/);
|
||||
if (match) {
|
||||
saveImage(match[1]);
|
||||
} else {
|
||||
logger.info('CLI/Test', `文本回复: ${content}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('CLI/Test', '测试完成');
|
||||
process.exit(0);
|
||||
|
||||
} catch (err) {
|
||||
logger.error('CLI/Test', '测试失败', { error: err.message });
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user