mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
初次提交
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
data/
|
||||
client.js
|
||||
@@ -0,0 +1,375 @@
|
||||
# LMArenaImagenAutomator - 使用文档
|
||||
|
||||
## 📝 项目简介
|
||||
|
||||
LMArenaImagenAutomator 是一个基于 Puppeteer 的自动化图像生成工具,通过模拟人类操作与 LMArena 网站交互,提供图像生成服务。项目支持两种运行模式:
|
||||
- **OpenAI 兼容模式**:提供标准的 OpenAI API 接口,便于集成到现有应用
|
||||
- **Queue 队列模式**:使用 Server-Sent Events (SSE) 实时推送生成状态
|
||||
|
||||
### ✨ 主要特性
|
||||
|
||||
- 🎭 **拟人化操作**:使用贝塞尔曲线模拟真实鼠标移动轨迹
|
||||
- 🤖 **智能输入**:模拟人类打字速度和错误纠正行为
|
||||
- 🖼️ **多图支持**:最多支持同时上传 5 张参考图片
|
||||
- 🔐 **安全认证**:基于 Bearer Token 的 API 鉴权
|
||||
- 📊 **队列管理**:智能任务队列,防止请求过载
|
||||
- 🌐 **代理支持**:支持 HTTP 和 SOCKS5 代理配置
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 系统要求
|
||||
|
||||
- **Node.js**: 16.0 或更高版本
|
||||
- **操作系统**: Windows、Linux 或 macOS
|
||||
- **浏览器**: Google Chrome 或 Chromium (可选,Puppeteer 会自动下载)
|
||||
|
||||
### 安装步骤
|
||||
|
||||
1. **克隆项目**(如果从仓库获取)或解压项目文件
|
||||
|
||||
2. **安装依赖**
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. **生成 API 密钥**
|
||||
```bash
|
||||
npm run genkey
|
||||
```
|
||||
此命令会生成一个安全的随机密钥,请保存并配置到 `config.yaml` 中。
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
### config.yaml 配置文件
|
||||
|
||||
配置文件位于项目根目录下的 `config.yaml`,包含以下主要配置项:
|
||||
|
||||
#### 服务器配置
|
||||
```yaml
|
||||
server:
|
||||
# 运行模式: 'openai' (OpenAI 兼容) 或 'queue' (SSE 队列)
|
||||
type: queue
|
||||
# 监听端口
|
||||
port: 3000
|
||||
# API 鉴权密钥 (使用 npm run genkey 生成)
|
||||
auth: sk-change-me-to-your-secure-key
|
||||
```
|
||||
|
||||
#### 浏览器配置
|
||||
```yaml
|
||||
chrome:
|
||||
# Chrome 可执行文件路径 (留空使用 Puppeteer 内置版本)
|
||||
# Windows 示例: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
|
||||
# Linux 示例: "/usr/bin/chromium"
|
||||
path: ""
|
||||
|
||||
# 是否启用无头模式 (true = 后台运行,false = 显示浏览器)
|
||||
headless: false
|
||||
|
||||
# 是否启用 GPU 加速 (无显卡服务器设为 false)
|
||||
gpu: false
|
||||
```
|
||||
|
||||
#### 代理配置
|
||||
```yaml
|
||||
chrome:
|
||||
proxy:
|
||||
# 是否启用代理
|
||||
enable: false
|
||||
# 代理类型: 'http' 或 'socks5'
|
||||
type: http
|
||||
# 代理服务器地址
|
||||
host: 127.0.0.1
|
||||
# 代理端口
|
||||
port: 7890
|
||||
# 代理认证 (可选)
|
||||
# user: username
|
||||
# passwd: password
|
||||
```
|
||||
|
||||
### 重要配置建议
|
||||
|
||||
| 配置项 | 建议值 | 说明 |
|
||||
|-------|--------|------|
|
||||
| `server.type` | `queue` | 使用队列模式可获得实时状态反馈 |
|
||||
| `server.auth` | 强密钥 | 务必修改默认值,使用 `npm run genkey` 生成 |
|
||||
| `chrome.headless` | `false` / `true` | 建议保持非无头模式(true已映射为new模式) |
|
||||
| `chrome.gpu` | `false` / `true` | 无显卡环境强烈建议关闭 |
|
||||
|
||||
---
|
||||
|
||||
## 📖 使用方法
|
||||
|
||||
### 【重要】务必进行的步骤
|
||||
- 第一次启动程序时必须关闭无头模式启动(**Linux无界面请看文档结尾**)
|
||||
- 等待网页加载完毕后登录账号(否则会在使用几次后弹出登录界面阻止操作)
|
||||
- 点击输入框输入任意内容点发送,等待弹出服务条款和CloudFlare Turnstile验证码
|
||||
- 点击验证码并同意条款后再次点击发送,此时可能会弹出reCAPTCHA验证码,若出现也将其通过
|
||||
- 后续可改为无头模式运行,但建议使用非无头模式避免频繁触发人机验证码
|
||||
|
||||
|
||||
### 方式一:使用 HTTP API
|
||||
|
||||
**启动服务器**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
#### OpenAI 兼容模式
|
||||
> [!WARNING]
|
||||
> 由于模拟真实用户的浏览器操作,一次只能进行一个任务,剩下的将会放入队列中等待,为防止客户端超时影响体验,该模式如果已经有3个任务时后来的任务将会直接返回错误代码,推荐使用Queue队列模式,服务器会向客户端发送心跳包以确保连接存活。
|
||||
|
||||
**配置文件设置**
|
||||
```yaml
|
||||
server:
|
||||
type: openai
|
||||
port: 3000
|
||||
auth: your-secret-key
|
||||
```
|
||||
|
||||
**API 请求示例**
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3000/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-secret-key" \
|
||||
-d '{
|
||||
"messages": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "generate a cat"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
**响应格式**
|
||||
```json
|
||||
{
|
||||
"id": "chatcmpl-1732374740123",
|
||||
"object": "chat.completion",
|
||||
"created": 1732374740,
|
||||
"model": "lmarena-image",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": ""
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
#### Queue 队列模式(SSE)(推荐)
|
||||
|
||||
**配置文件设置**
|
||||
```yaml
|
||||
server:
|
||||
type: queue
|
||||
```
|
||||
|
||||
**请求端点**
|
||||
```
|
||||
POST http://127.0.0.1:3000/v1/queue/join
|
||||
```
|
||||
|
||||
**SSE 事件类型**
|
||||
|
||||
| 事件类型 | 数据格式 | 说明 |
|
||||
|---------|---------|------|
|
||||
| `status` | `{status: "queued", position: 1}` | 任务已入队 |
|
||||
| `status` | `{status: "processing"}` | 开始处理 |
|
||||
| `result` | `{status: "completed", image: "base64..."}` | 生成成功 |
|
||||
| `result` | `{status: "error", msg: "错误信息"}` | 生成失败 |
|
||||
| `heartbeat` | 时间戳 | 保持连接 |
|
||||
| `done` | `"[DONE]"` | 流结束 |
|
||||
|
||||
**Node.js 示例代码**
|
||||
```javascript
|
||||
import http from 'http';
|
||||
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: 3000,
|
||||
path: '/v1/queue/join',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer your-secret-key'
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
res.on('data', (chunk) => {
|
||||
const lines = chunk.toString().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
const event = line.substring(7).trim();
|
||||
console.log('事件类型:', event);
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const data = JSON.parse(line.substring(6));
|
||||
console.log('数据:', data);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.write(JSON.stringify({
|
||||
messages: [{ role: "user", content: "a cute cat" }]
|
||||
}));
|
||||
req.end();
|
||||
```
|
||||
|
||||
#### 带图片的请求
|
||||
|
||||
**支持格式**:PNG、JPEG、GIF、WebP
|
||||
**最大数量**:5 张图片
|
||||
**数据格式**:Base64 编码
|
||||
|
||||
**请求示例**
|
||||
```json
|
||||
{
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "make it more colorful"
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAA..."
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### 方式二:使用CLI客户端脚本
|
||||
|
||||
**启动CLI工具**
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
根据指引填写图片路径和提示词即可
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
lmarena/
|
||||
├── server.js # HTTP 服务器 (主入口)
|
||||
├── config.yaml # 配置文件
|
||||
├── package.json # 项目依赖
|
||||
├── lib/
|
||||
│ ├── lmarena.js # 核心生图逻辑 (Puppeteer 操作)
|
||||
│ ├── config.js # 配置加载器
|
||||
│ ├── genApiKey.js # API 密钥生成工具
|
||||
│ └── test.js # 功能测试脚本
|
||||
└── data/
|
||||
├── chromeUserData/ # Chrome 用户数据 (自动创建)
|
||||
└── temp/ # 临时图片存储
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 常见问题
|
||||
|
||||
### 浏览器启动失败
|
||||
|
||||
**问题**: `Error: Failed to launch the browser process`
|
||||
|
||||
**解决方案**:
|
||||
- 确保已安装 Chrome 或 Chromium
|
||||
- 检查 `config.yaml` 中的 `chrome.path` 是否正确
|
||||
- 尝试删除 `data/chromeUserData` 目录后重新运行
|
||||
|
||||
### GPU 相关错误
|
||||
|
||||
**问题**: 无显卡服务器运行时出现 GPU 错误
|
||||
|
||||
**解决方案**:
|
||||
- 该报错并不会影响程序运行,但是强烈建议在无显卡的设备上关闭GPU加速
|
||||
```yaml
|
||||
chrome:
|
||||
gpu: false # 禁用 GPU 加速
|
||||
```
|
||||
|
||||
### 请求被拒绝 (429 Too Many Requests)
|
||||
|
||||
**问题**: 并发请求过多
|
||||
|
||||
**解决方案**:
|
||||
- 该问题仅存在于OpenAI兼容模式
|
||||
- 当前限制:1 个并发 + 2 个排队 (总计 3 个)
|
||||
- 修改 `server.js` 中的 `MAX_CONCURRENT` 和 `MAX_QUEUE_SIZE` (不建议,应为大多数客户端HTTP请求是有超时时间的)
|
||||
- 等待当前任务完成后再提交新任务
|
||||
|
||||
### reCAPTCHA 验证失败
|
||||
|
||||
**问题**: 返回 `recaptcha validation failed`
|
||||
|
||||
**解决方案**:
|
||||
- 这是 LMArena 的人机验证机制
|
||||
- 建议:
|
||||
- 降低请求频率
|
||||
- 首次使用时手动完成一次验证 (关闭 headless 模式)
|
||||
- 使用稳定和纯净的 IP 地址 (可使用 ping0.cc 查询IP地址纯净度)
|
||||
|
||||
### 图像生成超时
|
||||
|
||||
**问题**: 任务超过 120 秒未完成
|
||||
|
||||
**解决方案**:
|
||||
- 检查网络连接是否稳定
|
||||
- 某些复杂提示词可能需要更长时间
|
||||
|
||||
### Linux下关闭无头模式运行
|
||||
|
||||
**问题**: 在Linux多用户模式下无界面运行浏览器
|
||||
|
||||
**解决方案**:
|
||||
|
||||
方法一:X11转发(适用于后续无头模式运行)
|
||||
- 推荐使用WindTerm开启右上角X-Server
|
||||
- 在会话设置中的X11栏目中改为 “内部X11显示”
|
||||
|
||||
方法二:Xvfb+X11VNC(推荐)
|
||||
- 使用xvfb创建虚拟显示器运行该程序,并且将虚拟显示器映射到VNC中便于后续管理(因为在后续使用中可能还会弹出reCAPTCHA验证码需要手动通过)
|
||||
- 创建虚拟显示器并运行程序 (99为屏幕号,冲突可行更改)
|
||||
```
|
||||
xvfb-run --server-num=99 --server-args="-ac -screen 0 1280x720x16" npm start
|
||||
```
|
||||
- 将虚拟显示器映射至VNC
|
||||
```
|
||||
x11vnc -display :99 -localhost -nopw -once -noxdamage -ncache 10
|
||||
```
|
||||
- 后续可用RealVNC等程序通过5900端口连接(推荐使用SSH隧道转发不将VNC直接暴露在公网,然后VNC连接127.0.0.1)
|
||||
```
|
||||
ssh -L 5900:127.0.0.1:5900 root@服务器IP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 配置建议
|
||||
| 资源 | 最低配置 | 推荐配置 |
|
||||
|------|---------|---------|
|
||||
| CPU | 1核 | 2核及以上 |
|
||||
| 内存 | 1GB | 2GB 及以上 |
|
||||
|
||||
参考:经测试可以在Oracle的1G1C免费机Debian环境下运行
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目仅供学习和研究使用,请遵守 LMArena.ai 的使用条款。
|
||||
|
||||
---
|
||||
|
||||
**感谢使用 LMArena 图像生成服务!** 🎉
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
server:
|
||||
# 服务器模式: 'openai' (标准兼容) 或 'queue' (流式队列)
|
||||
type: queue
|
||||
# 监听端口
|
||||
port: 3000
|
||||
# 鉴权密钥 (Bearer Token),请使用 genapikey.js 生成
|
||||
auth: sk-change-me-to-your-secure-key
|
||||
|
||||
chrome:
|
||||
# 浏览器可执行文件路径 (留空则使用Puppeteer默认)
|
||||
# Windows系统示例 "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
|
||||
# Linux系统示例 "/usr/bin/chromium"
|
||||
path: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
|
||||
# 是否启用无头模式
|
||||
headless: false
|
||||
# 是否启用 GPU (无GPU设备运行请使用false)
|
||||
gpu: false
|
||||
# 代理设置
|
||||
proxy:
|
||||
# 是否启用代理
|
||||
enable: false
|
||||
# 代理类型: 'http' 或 'socks5'
|
||||
type: http
|
||||
# 代理服务器地址
|
||||
host: 127.0.0.1
|
||||
# 代理端口
|
||||
port: 7890
|
||||
# 代理认证 (可选)
|
||||
# user: username
|
||||
# passwd: password
|
||||
@@ -0,0 +1,81 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const CONFIG_PATH = path.join(process.cwd(), 'config.yaml');
|
||||
|
||||
/**
|
||||
* 生成随机 API Key
|
||||
*/
|
||||
function generateApiKey() {
|
||||
return 'sk-' + crypto.randomBytes(24).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认配置模板
|
||||
*/
|
||||
function getDefaultConfig() {
|
||||
return `# LMArena 配置文件
|
||||
# 自动生成于 ${new Date().toLocaleString()}
|
||||
|
||||
server:
|
||||
# 服务器模式: 'openai' (标准兼容) 或 'queue' (流式队列)
|
||||
type: queue
|
||||
# 监听端口
|
||||
port: 3000
|
||||
# 鉴权 Token (Bearer Token)
|
||||
auth: ${generateApiKey()}
|
||||
|
||||
chrome:
|
||||
# 浏览器可执行文件路径 (留空则使用Puppeteer默认)
|
||||
# Windows系统示例 "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
|
||||
# Linux系统示例 "/usr/bin/chromium"
|
||||
# path: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
|
||||
|
||||
# 是否启用无头模式 (true: 后台运行, false: 显示界面)
|
||||
headless: false
|
||||
|
||||
# 是否启用 GPU (无GPU设备运行请使用false)
|
||||
gpu: false
|
||||
|
||||
# 代理设置
|
||||
proxy:
|
||||
# 是否启用代理
|
||||
enable: false
|
||||
# 代理类型: http 或 socks5
|
||||
type: http
|
||||
# 代理主机
|
||||
host: 127.0.0.1
|
||||
# 代理端口
|
||||
port: 7890
|
||||
# 代理认证 (可选)
|
||||
# user: username
|
||||
# passwd: password
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载配置,如果不存在则自动创建
|
||||
*/
|
||||
function loadConfig() {
|
||||
try {
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
console.log('>>> [Config] 配置文件不存在,正在生成默认配置...');
|
||||
const defaultConfig = getDefaultConfig();
|
||||
fs.writeFileSync(CONFIG_PATH, defaultConfig, 'utf8');
|
||||
console.log(`>>> [Config] 已生成默认配置文件: ${CONFIG_PATH}`);
|
||||
console.log('>>> [Config] 请注意查看生成的随机 API Key');
|
||||
}
|
||||
|
||||
const configFile = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const config = yaml.load(configFile);
|
||||
console.log('>>> [Config] 已加载 config.yaml');
|
||||
return config;
|
||||
} catch (e) {
|
||||
console.error('>>> [Error] 无法加载或生成配置文件:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export default loadConfig();
|
||||
@@ -0,0 +1,18 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* 生成随机 API Key
|
||||
* 格式: sk- + 32位十六进制字符串
|
||||
*/
|
||||
function generateApiKey() {
|
||||
const buffer = crypto.randomBytes(16);
|
||||
const hex = buffer.toString('hex');
|
||||
return `sk-${hex}`;
|
||||
}
|
||||
|
||||
const key = generateApiKey();
|
||||
console.log('\n=== API Key 生成器 ===');
|
||||
console.log('您的新 API Key 是:');
|
||||
console.log('\x1b[32m%s\x1b[0m', key); // 绿色高亮
|
||||
console.log('\n请将其复制到 config.yaml 的 server.auth 字段中。');
|
||||
console.log('======================\n');
|
||||
+465
@@ -0,0 +1,465 @@
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// --- 配置常量 ---
|
||||
const CHROME_PATH = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
|
||||
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'chromeUserData');
|
||||
const TARGET_URL = 'https://lmarena.ai/c/new?mode=direct&chat-modality=image';
|
||||
const TEMP_DIR = path.join(process.cwd(), 'data', 'temp');
|
||||
|
||||
// 确保临时目录存在
|
||||
if (!fs.existsSync(TEMP_DIR)) {
|
||||
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// --- 辅助工具 ---
|
||||
|
||||
/**
|
||||
* 生成指定范围内的随机数
|
||||
* @param {number} min 最小值
|
||||
* @param {number} max 最大值
|
||||
* @returns {number} 随机数
|
||||
*/
|
||||
const random = (min, max) => Math.random() * (max - min) + min;
|
||||
|
||||
/**
|
||||
* 随机休眠一段时间
|
||||
* @param {number} min 最小毫秒数
|
||||
* @param {number} max 最大毫秒数
|
||||
*/
|
||||
const sleep = (min, max) => new Promise(r => setTimeout(r, Math.floor(random(min, max))));
|
||||
|
||||
/**
|
||||
* 根据文件扩展名获取 MIME 类型
|
||||
* @param {string} filePath 文件路径
|
||||
* @returns {string} MIME 类型
|
||||
*/
|
||||
function getMimeType(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const map = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
|
||||
return map[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
// --- 核心拟人化算法 (贝塞尔曲线 + 物理模拟) ---
|
||||
|
||||
/**
|
||||
* 三次贝塞尔曲线计算
|
||||
*/
|
||||
function cubicBezier(t, p0, p1, p2, p3) {
|
||||
const k = 1 - t;
|
||||
return k * k * k * p0 + 3 * k * k * t * p1 + 3 * k * t * t * p2 + t * t * t * p3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟人类鼠标移动轨迹
|
||||
* @param {object} page Puppeteer 页面对象
|
||||
* @param {number} startX 起始 X 坐标
|
||||
* @param {number} startY 起始 Y 坐标
|
||||
* @param {number} targetX 目标 X 坐标
|
||||
* @param {number} targetY 目标 Y 坐标
|
||||
*/
|
||||
async function humanMove(page, startX, startY, targetX, targetY) {
|
||||
const distance = Math.sqrt(Math.pow(targetX - startX, 2) + Math.pow(targetY - startY, 2));
|
||||
const steps = Math.floor(Math.max(distance / 8, 15));
|
||||
|
||||
const offset = distance * 0.4;
|
||||
// 生成两个随机控制点,使轨迹弯曲
|
||||
const cp1X = startX + (targetX - startX) / 3 + random(-offset, offset);
|
||||
const cp1Y = startY + (targetY - startY) / 3 + random(-offset, offset);
|
||||
const cp2X = startX + 2 * (targetX - startX) / 3 + random(-offset, offset);
|
||||
const cp2Y = startY + 2 * (targetY - startY) / 3 + random(-offset, offset);
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
// 缓动函数:起步快,结尾慢,模拟人类肌肉运动
|
||||
const easeT = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
||||
|
||||
let x = cubicBezier(easeT, startX, cp1X, cp2X, targetX);
|
||||
let y = cubicBezier(easeT, startY, cp1Y, cp2Y, targetY);
|
||||
|
||||
// 添加微小的随机抖动
|
||||
if (i % 3 === 0) { x += random(-1, 1); y += random(-1, 1); }
|
||||
await page.mouse.move(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全点击元素(包含拟人化移动和点击)
|
||||
* @param {object} page Puppeteer 页面对象
|
||||
* @param {string} selector CSS 选择器
|
||||
*/
|
||||
async function safeClick(page, selector) {
|
||||
try {
|
||||
const el = await page.$(selector);
|
||||
if (!el) throw new Error(`未找到: ${selector}`);
|
||||
const box = await el.boundingBox();
|
||||
if (!box) throw new Error(`不可见: ${selector}`);
|
||||
|
||||
// 先稍微移动一下当前位置 (增加真实感)
|
||||
await page.mouse.move(box.x - random(50, 100), box.y - random(50, 100), { steps: 2 });
|
||||
|
||||
// 目标点击位置在元素内部随机区域
|
||||
const targetX = box.x + box.width * random(0.3, 0.7);
|
||||
const targetY = box.y + box.height * random(0.3, 0.7);
|
||||
|
||||
// 移动鼠标到目标位置
|
||||
await humanMove(page, box.x - 50, box.y - 50, targetX, targetY);
|
||||
|
||||
// 模拟点击过程:按下 -> 停顿 -> 抬起
|
||||
await sleep(100, 300);
|
||||
await page.mouse.down();
|
||||
await sleep(60, 120);
|
||||
await page.mouse.up();
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟人类键盘输入
|
||||
* @param {object} page Puppeteer 页面对象
|
||||
* @param {string} selector 输入框选择器
|
||||
* @param {string} text 要输入的文本
|
||||
*/
|
||||
async function humanType(page, selector, text) {
|
||||
const el = await page.$(selector);
|
||||
if (!el) throw new Error(`Element not found: ${selector}`);
|
||||
|
||||
// 智能输入策略
|
||||
if (text.length < 50) {
|
||||
// 短文本:保持拟人化逐字输入
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
// 模拟错字 (5% 概率)
|
||||
if (Math.random() < 0.05) {
|
||||
await el.type('x', { delay: random(50, 150) });
|
||||
await sleep(100, 300);
|
||||
await page.keyboard.press('Backspace', { delay: random(50, 100) });
|
||||
}
|
||||
await el.type(char);
|
||||
// 随机击键间隔
|
||||
await sleep(30, 100);
|
||||
}
|
||||
} else {
|
||||
// 长文本:假装打字 -> 停顿 -> 粘贴
|
||||
const fakeCount = Math.floor(random(3, 8));
|
||||
const fakeText = text.substring(0, fakeCount);
|
||||
|
||||
// 1. 假装打字几个字符
|
||||
for (let i = 0; i < fakeText.length; i++) {
|
||||
await el.type(fakeText[i], { delay: random(30, 100) });
|
||||
}
|
||||
|
||||
// 2. 停顿思考 (0.5 - 1秒)
|
||||
await sleep(500, 1000);
|
||||
|
||||
// 3. 全选删除 (模拟 Ctrl+A -> Backspace)
|
||||
await page.keyboard.down('Control');
|
||||
await page.keyboard.press('A');
|
||||
await page.keyboard.up('Control');
|
||||
await sleep(100, 300);
|
||||
await page.keyboard.press('Backspace');
|
||||
await sleep(100, 300);
|
||||
|
||||
// 4. 瞬间粘贴全部文本 (模拟 Ctrl+V)
|
||||
await page.evaluate((sel, content) => {
|
||||
const input = document.querySelector(sel);
|
||||
input.focus();
|
||||
document.execCommand('insertText', false, content);
|
||||
}, selector, text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 粘贴图片到输入框
|
||||
* @param {object} page Puppeteer 页面对象
|
||||
* @param {string} selector 输入框选择器
|
||||
* @param {string[]} filePaths 图片文件路径数组
|
||||
*/
|
||||
async function pasteImages(page, selector, filePaths) {
|
||||
if (!filePaths || filePaths.length === 0) return;
|
||||
console.log(`>>> [粘贴] 上传 ${filePaths.length} 张图片...`);
|
||||
|
||||
// 读取图片文件并转换为 Base64
|
||||
const filesData = filePaths.map(p => {
|
||||
const clean = p.replace(/['"]/g, '').trim();
|
||||
if (!fs.existsSync(clean)) return null;
|
||||
return {
|
||||
base64: fs.readFileSync(clean).toString('base64'),
|
||||
mime: getMimeType(clean),
|
||||
filename: path.basename(clean)
|
||||
};
|
||||
}).filter(f => f);
|
||||
|
||||
if (filesData.length === 0) return;
|
||||
|
||||
// 点击输入框以获取焦点
|
||||
await safeClick(page, selector);
|
||||
await sleep(500, 800);
|
||||
|
||||
// 使用 Clipboard API 模拟粘贴事件
|
||||
await page.evaluate(async (sel, files) => {
|
||||
const target = document.querySelector(sel);
|
||||
const dt = new DataTransfer();
|
||||
for (const f of files) {
|
||||
const bin = atob(f.base64);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||
dt.items.add(new File([arr], f.filename, { type: f.mime }));
|
||||
}
|
||||
target.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, clipboardData: dt }));
|
||||
}, selector, filesData);
|
||||
|
||||
console.log('>>> [粘贴] 完成,等待缩略图...');
|
||||
// 等待图片上传和缩略图生成
|
||||
await sleep(2500, 4000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从响应文本中提取图片 URL
|
||||
* @param {string} text 响应文本
|
||||
* @returns {string|null} 图片 URL 或 null
|
||||
*/
|
||||
function extractImage(text) {
|
||||
if (!text) return null;
|
||||
const lines = text.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('a2:')) {
|
||||
try {
|
||||
const data = JSON.parse(line.substring(3));
|
||||
if (data?.[0]?.image) return data[0].image;
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* 初始化浏览器
|
||||
* @param {object} config 配置对象 (包含 chrome 配置)
|
||||
* @returns {Promise<{browser: object, page: object, client: object, width: number, height: number}>}
|
||||
*/
|
||||
async function initBrowser(config) {
|
||||
console.log('>>> [Browser] 开始初始化浏览器');
|
||||
|
||||
const chromeConfig = config?.chrome || {};
|
||||
const width = Math.floor(random(900, 1100));
|
||||
const height = Math.floor(random(500, 700));
|
||||
|
||||
// 1. 基础参数
|
||||
const args = [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
`--window-size=${width},${height}`,
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-infobars',
|
||||
'--test-type',
|
||||
'--no-zygote',
|
||||
'--disable-dev-shm-usage'
|
||||
];
|
||||
|
||||
// 2. Headless 模式配置
|
||||
let headlessMode = false;
|
||||
if (chromeConfig.headless) {
|
||||
headlessMode = 'new';
|
||||
args.push('--disable-gl-drawing-for-tests');
|
||||
console.log('>>> [Browser] Headless 模式: 启用');
|
||||
} else {
|
||||
console.log('>>> [Browser] Headless 模式: 禁用');
|
||||
}
|
||||
|
||||
// 3. GPU 配置
|
||||
if (chromeConfig.gpu === false) {
|
||||
args.push(
|
||||
'--disable-gpu',
|
||||
'--use-gl=swiftshader',
|
||||
'--disable-accelerated-2d-canvas'
|
||||
);
|
||||
console.log('>>> [Browser] GPU 加速: 禁用 (优化兼容性)');
|
||||
} else {
|
||||
console.log('>>> [Browser] GPU 加速: 启用');
|
||||
}
|
||||
|
||||
// 4. 代理配置
|
||||
if (chromeConfig.proxy && chromeConfig.proxy.enable) {
|
||||
const { type, host, port } = chromeConfig.proxy;
|
||||
const proxyUrl = type === 'socks5' ? `socks5://${host}:${port}` : `${host}:${port}`;
|
||||
args.push(`--proxy-server=${proxyUrl}`);
|
||||
// 禁用 QUIC (HTTP3) 以确保代理兼容性
|
||||
args.push('--disable-quic');
|
||||
console.log(`>>> [Browser] 代理配置: ${type}://${host}:${port}`);
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: headlessMode,
|
||||
executablePath: chromeConfig.path || undefined,
|
||||
userDataDir: USER_DATA_DIR,
|
||||
defaultViewport: null,
|
||||
ignoreDefaultArgs: ['--enable-automation'],
|
||||
args: args
|
||||
});
|
||||
|
||||
// 重用第一个标签页
|
||||
const pages = await browser.pages();
|
||||
const page = pages[0];
|
||||
|
||||
// 5. 代理认证
|
||||
if (chromeConfig.proxy && chromeConfig.proxy.enable && chromeConfig.proxy.user) {
|
||||
await page.authenticate({
|
||||
username: chromeConfig.proxy.user,
|
||||
password: chromeConfig.proxy.passwd
|
||||
});
|
||||
console.log('>>> [Browser] 代理认证: 已设置');
|
||||
}
|
||||
|
||||
// 隐藏 WebDriver 特征
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
});
|
||||
|
||||
// 创建 CDP 会话以监听网络请求
|
||||
const client = await page.target().createCDPSession();
|
||||
await client.send('Network.enable');
|
||||
|
||||
// --- [行为预热] 建立人机检测信任 ---
|
||||
console.log('>>> [Browser] 正在连接 LMArena...');
|
||||
await page.goto(TARGET_URL, { waitUntil: 'networkidle2' });
|
||||
|
||||
console.log('>>> [Warmup] 正在随机浏览页面以建立信任...');
|
||||
|
||||
// 计算屏幕中心点
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
// 第一次移动:从左上角移动到中心附近
|
||||
await humanMove(page, 0, 0, centerX + random(-200, 200), centerY + random(-200, 200));
|
||||
await sleep(500, 1000);
|
||||
|
||||
// 模拟滚动行为
|
||||
try {
|
||||
await page.mouse.wheel({ deltaY: random(100, 300) });
|
||||
await sleep(800, 1500);
|
||||
await page.mouse.wheel({ deltaY: -random(50, 100) });
|
||||
} catch (e) { }
|
||||
|
||||
// 等待输入框出现
|
||||
const textareaSelector = 'textarea';
|
||||
await page.waitForSelector(textareaSelector, { timeout: 60000 });
|
||||
|
||||
// 移动鼠标到输入框
|
||||
const box = await (await page.$(textareaSelector)).boundingBox();
|
||||
if (box) {
|
||||
await humanMove(page, centerX, centerY, box.x + box.width / 2, box.y + box.height / 2);
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
|
||||
console.log('>>> [Browser] 浏览器初始化完成,系统就绪');
|
||||
|
||||
return { browser, page, client, width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行生图任务
|
||||
* @param {object} context 浏览器上下文 {page, client, width, height}
|
||||
* @param {string} prompt 提示词
|
||||
* @param {string[]} imgPaths 图片路径数组
|
||||
* @returns {Promise<{image?: string, text?: string, error?: string}>}
|
||||
*/
|
||||
async function generateImage(context, prompt, imgPaths) {
|
||||
const { page, client, width, height } = context;
|
||||
const textareaSelector = 'textarea';
|
||||
|
||||
try {
|
||||
// 1. 强制开启新会话 (通过URL跳转)
|
||||
console.log('>>> [Task] 开启新会话...');
|
||||
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 等待输入框出现
|
||||
await page.waitForSelector(textareaSelector, { timeout: 30000 });
|
||||
await sleep(1500, 2500); // 等页面稳一点
|
||||
|
||||
// 2. 粘贴图片
|
||||
if (imgPaths && imgPaths.length > 0) {
|
||||
await pasteImages(page, textareaSelector, imgPaths);
|
||||
} else {
|
||||
// 如果没有图片,也点击一下输入框获取焦点
|
||||
await safeClick(page, textareaSelector);
|
||||
}
|
||||
|
||||
// 3. 输入 Prompt
|
||||
console.log('>>> [Input] 正在输入提示词...');
|
||||
await humanType(page, textareaSelector, prompt);
|
||||
await sleep(800, 1500);
|
||||
|
||||
// 4. 发送
|
||||
const btnSelector = 'button[type="submit"]';
|
||||
await safeClick(page, btnSelector);
|
||||
|
||||
console.log('>>> [Wait] 等待生成中...');
|
||||
|
||||
// 5. 监听网络响应
|
||||
let targetRequestId = null;
|
||||
const result = await new Promise((resolve) => {
|
||||
const cleanup = () => {
|
||||
client.off('Network.responseReceived', onRes);
|
||||
client.off('Network.loadingFinished', onLoad);
|
||||
};
|
||||
const onRes = (e) => {
|
||||
// 监听流式响应接口
|
||||
if (e.response.url.includes('/nextjs-api/stream/')) targetRequestId = e.requestId;
|
||||
};
|
||||
const onLoad = async (e) => {
|
||||
if (e.requestId === targetRequestId) {
|
||||
try {
|
||||
const { body, base64Encoded } = await client.send('Network.getResponseBody', { requestId: targetRequestId });
|
||||
const content = base64Encoded ? Buffer.from(body, 'base64').toString('utf8') : body;
|
||||
|
||||
// 检查是否包含 reCAPTCHA 错误
|
||||
if (content.includes('recaptcha validation failed')) {
|
||||
cleanup();
|
||||
resolve({ error: 'recaptcha validation failed' });
|
||||
return;
|
||||
}
|
||||
|
||||
const img = extractImage(content);
|
||||
if (img) {
|
||||
console.log('>>> [Success] 生图成功');
|
||||
cleanup();
|
||||
resolve({ image: img });
|
||||
} else {
|
||||
console.log('>>> [Task] AI 返回文本回复:', content.substring(0, 150) + '...');
|
||||
cleanup();
|
||||
resolve({ text: content });
|
||||
}
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
resolve({ error: err.message });
|
||||
}
|
||||
}
|
||||
};
|
||||
client.on('Network.responseReceived', onRes);
|
||||
client.on('Network.loadingFinished', onLoad);
|
||||
|
||||
// 超时保护 (120秒)
|
||||
setTimeout(() => {
|
||||
cleanup();
|
||||
resolve({ error: 'Timeout' });
|
||||
}, 120000);
|
||||
});
|
||||
|
||||
// 任务结束,像人一样把鼠标移开,防止遮挡或误触
|
||||
await humanMove(page, width / 2, height / 2, width - 100, height / 2);
|
||||
|
||||
return result;
|
||||
|
||||
} catch (err) {
|
||||
console.error('>>> [Error] 生成任务失败:', err.message);
|
||||
return { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
export { initBrowser, generateImage, TEMP_DIR };
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import readline from 'readline';
|
||||
import config from './config.js';
|
||||
import { initBrowser, generateImage } from './lmarena.js';
|
||||
|
||||
/**
|
||||
* 创建命令行交互接口
|
||||
*/
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
/**
|
||||
* 封装 readline 为 Promise
|
||||
* @param {string} query 提示问题
|
||||
* @returns {Promise<string>} 用户输入
|
||||
*/
|
||||
const ask = (query) => new Promise((resolve) => rl.question(query, resolve));
|
||||
|
||||
async function main() {
|
||||
console.log('>>> [CLI] LMArena CLI 测试工具');
|
||||
console.log('>>> [CLI] 正在启动浏览器...');
|
||||
|
||||
let browserContext;
|
||||
try {
|
||||
// 传入配置对象
|
||||
browserContext = await initBrowser(config);
|
||||
console.log('>>> [CLI] 浏览器已就绪。');
|
||||
} catch (err) {
|
||||
console.error('>>> [Error] 浏览器启动失败:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
console.log('-----------------------------');
|
||||
|
||||
// 1. 获取图片路径
|
||||
const imgInput = await ask('>>> [CLI] 请输入图片路径 (多张用逗号隔开,回车跳过): ');
|
||||
const imagePaths = imgInput.trim()
|
||||
? imgInput.split(',').map(p => p.trim()).filter(p => p)
|
||||
: [];
|
||||
|
||||
// 2. 获取提示词
|
||||
const prompt = await ask('>>> [CLI] 请输入提示词: ');
|
||||
if (!prompt.trim()) {
|
||||
console.log('>>> [Error] 提示词不能为空,请重试。');
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`>>> [CLI] 开始任务: Prompt="${prompt}", Images=${imagePaths.length}`);
|
||||
|
||||
// 3. 调用生图逻辑
|
||||
const result = await generateImage(browserContext, prompt, imagePaths);
|
||||
|
||||
// 4. 显示结果
|
||||
if (result.error) {
|
||||
console.error('>>> [Error]', result.error);
|
||||
} else if (result.image) {
|
||||
console.log('>>> [Success] 图片 URL:', result.image);
|
||||
} else {
|
||||
console.log('>>> [CLI] AI 使用文本回复:', result.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "node lib/test.js",
|
||||
"genkey": "node lib/genApiKey.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"got-scraping": "^4.1.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"puppeteer": "^24.31.0",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
}
|
||||
Generated
+1505
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import sharp from 'sharp';
|
||||
import { gotScraping } from 'got-scraping';
|
||||
import config from './lib/config.js';
|
||||
import { initBrowser, generateImage, TEMP_DIR } from './lib/lmarena.js';
|
||||
|
||||
const PORT = config.server.port || 3000;
|
||||
const AUTH_TOKEN = config.server.auth;
|
||||
const SERVER_MODE = config.server.type || 'openai'; // 'openai' 或 'queue'
|
||||
|
||||
// --- 全局状态 ---
|
||||
let browserContext = null; // 浏览器上下文 {browser, page, client, width, height}
|
||||
const queue = []; // 请求队列
|
||||
let processingCount = 0; // 当前正在处理的任务数
|
||||
const MAX_CONCURRENT = 1; // 同时处理的任务数 (Puppeteer 只能单线程操作)
|
||||
const MAX_QUEUE_SIZE = 2; // 最大排队数 (总容量 = MAX_CONCURRENT + MAX_QUEUE_SIZE = 3)
|
||||
|
||||
/**
|
||||
* 处理队列中的任务
|
||||
*/
|
||||
async function processQueue() {
|
||||
// 如果正在处理的任务已满,或队列为空,则停止
|
||||
if (processingCount >= MAX_CONCURRENT || queue.length === 0) return;
|
||||
|
||||
// 取出下一个任务
|
||||
const task = queue.shift();
|
||||
processingCount++;
|
||||
|
||||
// 如果是 Queue 模式,通知客户端状态变更
|
||||
if (SERVER_MODE === 'queue' && task.sse) {
|
||||
task.sse.send('status', { status: 'processing' });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`>>> [Queue] 开始处理任务。剩余排队: ${queue.length}`);
|
||||
|
||||
// 确保浏览器已初始化
|
||||
if (!browserContext) {
|
||||
browserContext = await initBrowser(config);
|
||||
}
|
||||
|
||||
const { req, res, prompt, imagePaths } = task;
|
||||
|
||||
// 调用核心生图逻辑
|
||||
const result = await generateImage(browserContext, prompt, imagePaths);
|
||||
|
||||
// 清理临时图片
|
||||
for (const p of imagePaths) {
|
||||
try { fs.unlinkSync(p); } catch (e) { }
|
||||
}
|
||||
|
||||
// 处理结果
|
||||
let finalContent = '';
|
||||
let queueResult = {};
|
||||
|
||||
if (result.error) {
|
||||
// 特殊错误处理:reCAPTCHA
|
||||
if (result.error === 'recaptcha validation failed') {
|
||||
if (SERVER_MODE === 'openai') {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'recaptcha validation failed' }));
|
||||
} else {
|
||||
task.sse.send('result', { status: 'error', image: null, msg: 'recaptcha validation failed' });
|
||||
task.sse.send('done', '[DONE]');
|
||||
task.sse.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
finalContent = `[生成错误] ${result.error}`;
|
||||
queueResult = { status: 'error', image: null, msg: result.error };
|
||||
} else if (result.image) {
|
||||
try {
|
||||
console.log('>>> [Download] 正在下载生成结果...');
|
||||
const response = await gotScraping({
|
||||
url: result.image,
|
||||
responseType: 'buffer',
|
||||
http2: true,
|
||||
headerGeneratorOptions: {
|
||||
browsers: [{ name: 'chrome', minVersion: 110 }],
|
||||
devices: ['desktop'],
|
||||
locales: ['en-US'],
|
||||
operatingSystems: ['windows'],
|
||||
}
|
||||
});
|
||||
const imgBuffer = response.body;
|
||||
|
||||
// 检测图片格式并转 Base64
|
||||
const metadata = await sharp(imgBuffer).metadata();
|
||||
const mimeType = metadata.format === 'png' ? 'image/png' : 'image/jpeg';
|
||||
const base64 = imgBuffer.toString('base64');
|
||||
|
||||
finalContent = ``;
|
||||
queueResult = { status: 'completed', image: base64, msg: '' };
|
||||
console.log('>>> [Response] 图片已转换为 Base64');
|
||||
} catch (e) {
|
||||
console.error('>>> [Error] 图片下载失败:', e.message);
|
||||
finalContent = `[图片下载失败] ${result.image}`;
|
||||
queueResult = { status: 'error', image: null, msg: `Download failed: ${e.message}` };
|
||||
}
|
||||
} else {
|
||||
finalContent = result.text || '生成失败';
|
||||
queueResult = { status: 'completed', image: null, msg: result.text };
|
||||
}
|
||||
|
||||
// 发送响应
|
||||
if (SERVER_MODE === 'openai') {
|
||||
const response = {
|
||||
id: 'chatcmpl-' + Date.now(),
|
||||
object: 'chat.completion',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: 'lmarena-image',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: finalContent
|
||||
},
|
||||
finish_reason: 'stop'
|
||||
}]
|
||||
};
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(response));
|
||||
} else {
|
||||
// Queue Mode
|
||||
task.sse.send('result', queueResult);
|
||||
task.sse.send('done', '[DONE]');
|
||||
task.sse.end();
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('>>> [Error] 任务处理失败:', err);
|
||||
if (SERVER_MODE === 'openai') {
|
||||
if (!task.res.writableEnded) {
|
||||
task.res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
task.res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
} else {
|
||||
task.sse.send('result', { status: 'error', image: null, msg: err.message });
|
||||
task.sse.send('done', '[DONE]');
|
||||
task.sse.end();
|
||||
}
|
||||
} finally {
|
||||
processingCount--;
|
||||
// 递归处理下一个任务
|
||||
processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 HTTP 服务器
|
||||
*/
|
||||
async function startServer() {
|
||||
// 预先启动浏览器
|
||||
try {
|
||||
browserContext = await initBrowser(config);
|
||||
} catch (err) {
|
||||
console.error('>>> [Error] 浏览器初始化失败:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
// --- 鉴权中间件 ---
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || authHeader !== `Bearer ${AUTH_TOKEN}`) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 路由分发 ---
|
||||
const isQueueMode = SERVER_MODE === 'queue';
|
||||
const targetPath = isQueueMode ? '/v1/queue/join' : '/v1/chat/completions';
|
||||
|
||||
if (req.method === 'POST' && req.url.startsWith(targetPath)) {
|
||||
// --- SSE 设置 (仅 Queue 模式) ---
|
||||
let sseHelper = null;
|
||||
if (isQueueMode) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
});
|
||||
|
||||
sseHelper = {
|
||||
send: (event, data) => {
|
||||
res.write(`event: ${event}\n`);
|
||||
res.write(`data: ${typeof data === 'object' ? JSON.stringify(data) : data}\n\n`);
|
||||
},
|
||||
end: () => res.end()
|
||||
};
|
||||
|
||||
// 启动心跳
|
||||
const heartbeat = setInterval(() => {
|
||||
if (res.writableEnded) {
|
||||
clearInterval(heartbeat);
|
||||
return;
|
||||
}
|
||||
sseHelper.send('heartbeat', Date.now());
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
req.on('data', chunk => chunks.push(chunk));
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
// --- 限流检查 (仅 OpenAI 模式) ---
|
||||
if (!isQueueMode && processingCount + queue.length >= MAX_CONCURRENT + MAX_QUEUE_SIZE) {
|
||||
console.warn('>>> [Server] 请求过多,已拒绝(限流)');
|
||||
if (isQueueMode) {
|
||||
sseHelper.send('error', { msg: 'Too Many Requests' });
|
||||
sseHelper.end();
|
||||
} else {
|
||||
res.writeHead(429, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Too Many Requests. Server is busy.' }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const body = Buffer.concat(chunks).toString();
|
||||
const data = JSON.parse(body);
|
||||
const messages = data.messages;
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
if (isQueueMode) { sseHelper.send('error', { msg: 'No messages' }); sseHelper.end(); }
|
||||
else { res.writeHead(400); res.end(JSON.stringify({ error: 'No messages' })); }
|
||||
return;
|
||||
}
|
||||
|
||||
// 筛选用户消息
|
||||
const userMessages = messages.filter(m => m.role === 'user');
|
||||
if (userMessages.length === 0) {
|
||||
if (isQueueMode) { sseHelper.send('error', { msg: 'No user messages' }); sseHelper.end(); }
|
||||
else { res.writeHead(400); res.end(JSON.stringify({ error: 'No user messages' })); }
|
||||
return;
|
||||
}
|
||||
const lastMessage = userMessages[userMessages.length - 1];
|
||||
|
||||
let prompt = '';
|
||||
const imagePaths = [];
|
||||
let imageCount = 0;
|
||||
|
||||
// 解析内容 (拼接文本 + 处理图片)
|
||||
if (Array.isArray(lastMessage.content)) {
|
||||
for (const item of lastMessage.content) {
|
||||
if (item.type === 'text') {
|
||||
prompt += item.text + ' ';
|
||||
} else if (item.type === 'image_url' && item.image_url && item.image_url.url) {
|
||||
imageCount++;
|
||||
if (imageCount > 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = item.image_url.url;
|
||||
if (url.startsWith('data:image')) {
|
||||
const matches = url.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/);
|
||||
if (matches && matches.length === 3) {
|
||||
const buffer = Buffer.from(matches[2], 'base64');
|
||||
// 压缩图片
|
||||
const processedBuffer = await sharp(buffer)
|
||||
.jpeg({ quality: 90 })
|
||||
.toBuffer();
|
||||
|
||||
const filename = `img_${Date.now()}_${Math.random().toString(36).substring(7)}.jpg`;
|
||||
const filePath = path.join(TEMP_DIR, filename);
|
||||
fs.writeFileSync(filePath, processedBuffer);
|
||||
imagePaths.push(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
prompt = lastMessage.content; // 回落保留
|
||||
}
|
||||
|
||||
prompt = prompt.trim();
|
||||
console.log(`>>> [Queue] 请求入队 - Prompt: ${prompt}, Images: ${imagePaths.length}`);
|
||||
|
||||
if (isQueueMode) {
|
||||
sseHelper.send('status', { status: 'queued', position: queue.length + 1 });
|
||||
}
|
||||
|
||||
// 将任务加入队列
|
||||
queue.push({ req, res, prompt, imagePaths, sse: sseHelper });
|
||||
|
||||
// 触发队列处理
|
||||
processQueue();
|
||||
|
||||
} catch (err) {
|
||||
console.error('>>> [Error] 服务器处理失败:', err);
|
||||
if (isQueueMode && sseHelper) {
|
||||
sseHelper.send('error', { msg: err.message });
|
||||
sseHelper.end();
|
||||
} else if (!res.writableEnded) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`>>> [Server] HTTP 服务器启动成功,监听端口 ${PORT}`);
|
||||
console.log(`>>> [Server] 运行模式: ${SERVER_MODE === 'openai' ? 'OpenAI 兼容模式' : 'Queue 队列模式'}`);
|
||||
if (SERVER_MODE === 'openai') {
|
||||
console.log(`>>> [Server] 最大并发: ${MAX_CONCURRENT}, 最大排队: ${MAX_QUEUE_SIZE}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startServer();
|
||||
Reference in New Issue
Block a user