ZhaoShanGeng
commited on
Commit
·
0fadeb3
1
Parent(s):
5a28024
feat: 大规模性能优化与功能增强
Browse files### 新增功能
1. 自定义思考预算:支持 reasoning_effort 参数 (low:1024/medium:16000/high:32000) 和 thinking_budget 参数 (1024-32000)
2. 适配 DeepSeek 思考格式:reasoning_content 字段单独输出,避免思维链混在 content 中
3. 资格校验自动回退:无资格时自动生成随机 ProjectId,避免添加 Token 失败
4. 真 System 消息支持:开头连续多条 system 与 SystemInstruction 合并
5. 三种轮询策略:round_robin / quota_exhausted / request_count
6. 心跳机制:SSE 心跳防止 Cloudflare 等 CDN 超时断连
7. 模型缓存:自动缓存模型列表和额度,减少 API 请求频率
### 前端优化
- 重构 Web 管理界面,优化 PC 体验
- 隐私模式:默认隐藏敏感信息(Token、ProjectId 等)
- 新增设置项:思考预算、图片地址、轮询策略、内存阈值、心跳间隔、字体大小
- 采用 MiSans + Ubuntu Mono 字体,增强可读性
### 性能优化
- 内存占用:从 100MB+ 降至 50MB+,进程数从 8+ 减少为 2 个
- 对象池复用:减少 50%+ 临时对象创建,降低 GC 频率
- 预编译常量:正则表达式、格式字符串等预编译
- LineBuffer 优化:高效流式行分割
- 自动内存清理:堆内存超阈值时自动触发 GC
### Bug 修复
- Token 刷新失败时返回 400 而非 401,避免触发 JWT 登出
- 修复流式响应内存泄漏问题
- .env.example +1 -1
- API.md +265 -9
- README.md +215 -18
- config.json +14 -4
- package.json +3 -2
- public/app.js +789 -145
- public/index.html +164 -61
- public/style.css +1189 -137
- src/AntigravityRequester.js +81 -11
- src/api/client.js +261 -64
- src/auth/quota_manager.js +32 -1
- src/auth/token_manager.js +160 -7
- src/config/config.js +36 -4
- src/routes/admin.js +62 -13
- src/server/index.js +160 -36
- src/utils/configReloader.js +25 -3
- src/utils/memoryManager.js +268 -0
- src/utils/utils.js +137 -9
- test/test-memory.js +291 -0
.env.example
CHANGED
|
@@ -5,6 +5,6 @@ ADMIN_PASSWORD=admin123
|
|
| 5 |
JWT_SECRET=your-jwt-secret-key-change-this-in-production
|
| 6 |
|
| 7 |
# 可选配置
|
| 8 |
-
# PROXY=http://127.0.0.1:
|
| 9 |
SYSTEM_INSTRUCTION=你是聊天机器人,名字叫萌萌,如同名字这般,你的性格是软软糯糯萌萌哒的,专门为用户提供聊天和情绪价值,协助进行小说创作或者角色扮演
|
| 10 |
# IMAGE_BASE_URL=http://your-domain.com
|
|
|
|
| 5 |
JWT_SECRET=your-jwt-secret-key-change-this-in-production
|
| 6 |
|
| 7 |
# 可选配置
|
| 8 |
+
# PROXY=http://127.0.0.1:7890
|
| 9 |
SYSTEM_INSTRUCTION=你是聊天机器人,名字叫萌萌,如同名字这般,你的性格是软软糯糯萌萌哒的,专门为用户提供聊天和情绪价值,协助进行小说创作或者角色扮演
|
| 10 |
# IMAGE_BASE_URL=http://your-domain.com
|
API.md
CHANGED
|
@@ -12,6 +12,18 @@ Authorization: Bearer YOUR_API_KEY
|
|
| 12 |
|
| 13 |
默认服务地址:`http://localhost:8045`
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
## 获取模型列表
|
| 16 |
|
| 17 |
```bash
|
|
@@ -19,6 +31,8 @@ curl http://localhost:8045/v1/models \
|
|
| 19 |
-H "Authorization: Bearer sk-text"
|
| 20 |
```
|
| 21 |
|
|
|
|
|
|
|
| 22 |
## 聊天补全
|
| 23 |
|
| 24 |
### 流式响应
|
|
@@ -108,19 +122,35 @@ curl http://localhost:8045/v1/chat/completions \
|
|
| 108 |
|
| 109 |
## 图片生成
|
| 110 |
|
| 111 |
-
支持使用
|
| 112 |
|
| 113 |
```bash
|
| 114 |
curl http://localhost:8045/v1/chat/completions \
|
| 115 |
-H "Content-Type: application/json" \
|
| 116 |
-H "Authorization: Bearer sk-text" \
|
| 117 |
-d '{
|
| 118 |
-
"model": "
|
| 119 |
"messages": [{"role": "user", "content": "画一只可爱的猫"}],
|
| 120 |
"stream": false
|
| 121 |
}'
|
| 122 |
```
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
## 请求参数说明
|
| 125 |
|
| 126 |
| 参数 | 类型 | 必填 | 说明 |
|
|
@@ -129,9 +159,11 @@ curl http://localhost:8045/v1/chat/completions \
|
|
| 129 |
| `messages` | array | ✅ | 对话消息列表 |
|
| 130 |
| `stream` | boolean | ❌ | 是否流式响应,默认 false |
|
| 131 |
| `temperature` | number | ❌ | 温度参数,默认 1 |
|
| 132 |
-
| `top_p` | number | ❌ | Top P 参数,默认
|
| 133 |
| `top_k` | number | ❌ | Top K 参数,默认 50 |
|
| 134 |
-
| `max_tokens` | number | ❌ | 最大 token 数,默认
|
|
|
|
|
|
|
| 135 |
| `tools` | array | ❌ | 工具列表(Function Calling) |
|
| 136 |
|
| 137 |
## 响应格式
|
|
@@ -194,6 +226,227 @@ API 返回标准的 HTTP 状态码:
|
|
| 194 |
}
|
| 195 |
```
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
## 使用示例
|
| 198 |
|
| 199 |
### Python
|
|
@@ -237,8 +490,11 @@ for await (const chunk of stream) {
|
|
| 237 |
|
| 238 |
## 注意事项
|
| 239 |
|
| 240 |
-
1.
|
| 241 |
-
2.
|
| 242 |
-
3.
|
| 243 |
-
4.
|
| 244 |
-
5.
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
默认服务地址:`http://localhost:8045`
|
| 14 |
|
| 15 |
+
## 目录
|
| 16 |
+
|
| 17 |
+
- [获取模型列表](#获取模型列表)
|
| 18 |
+
- [聊天补全](#聊天补全)
|
| 19 |
+
- [工具调用](#工具调用function-calling)
|
| 20 |
+
- [图片输入](#图片输入多模态)
|
| 21 |
+
- [图片生成](#图片生成)
|
| 22 |
+
- [思维链模型](#思维链模型)
|
| 23 |
+
- [SD WebUI 兼容 API](#sd-webui-兼容-api)
|
| 24 |
+
- [管理 API](#管理-api)
|
| 25 |
+
- [使用示例](#使用示例)
|
| 26 |
+
|
| 27 |
## 获取模型列表
|
| 28 |
|
| 29 |
```bash
|
|
|
|
| 31 |
-H "Authorization: Bearer sk-text"
|
| 32 |
```
|
| 33 |
|
| 34 |
+
**说明**:模型列表会缓存 1 小时(可通过 `config.json` 的 `cache.modelListTTL` 配置),减少 API 请求。
|
| 35 |
+
|
| 36 |
## 聊天补全
|
| 37 |
|
| 38 |
### 流式响应
|
|
|
|
| 122 |
|
| 123 |
## 图片生成
|
| 124 |
|
| 125 |
+
支持使用 `gemini-3-pro-image` 模型生成图片,生成的图片会以 Markdown 格式返回:
|
| 126 |
|
| 127 |
```bash
|
| 128 |
curl http://localhost:8045/v1/chat/completions \
|
| 129 |
-H "Content-Type: application/json" \
|
| 130 |
-H "Authorization: Bearer sk-text" \
|
| 131 |
-d '{
|
| 132 |
+
"model": "gemini-3-pro-image",
|
| 133 |
"messages": [{"role": "user", "content": "画一只可爱的猫"}],
|
| 134 |
"stream": false
|
| 135 |
}'
|
| 136 |
```
|
| 137 |
|
| 138 |
+
**响应示例**:
|
| 139 |
+
```json
|
| 140 |
+
{
|
| 141 |
+
"choices": [{
|
| 142 |
+
"message": {
|
| 143 |
+
"role": "assistant",
|
| 144 |
+
"content": ""
|
| 145 |
+
}
|
| 146 |
+
}]
|
| 147 |
+
}
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
**注意**:
|
| 151 |
+
- 生成的图片会保存到 `public/images/` 目录
|
| 152 |
+
- 需要配置 `IMAGE_BASE_URL` 环境变量以返回正确的图片 URL
|
| 153 |
+
|
| 154 |
## 请求参数说明
|
| 155 |
|
| 156 |
| 参数 | 类型 | 必填 | 说明 |
|
|
|
|
| 159 |
| `messages` | array | ✅ | 对话消息列表 |
|
| 160 |
| `stream` | boolean | ❌ | 是否流式响应,默认 false |
|
| 161 |
| `temperature` | number | ❌ | 温度参数,默认 1 |
|
| 162 |
+
| `top_p` | number | ❌ | Top P 参数,默认 1 |
|
| 163 |
| `top_k` | number | ❌ | Top K 参数,默认 50 |
|
| 164 |
+
| `max_tokens` | number | ❌ | 最大 token 数,默认 32000 |
|
| 165 |
+
| `thinking_budget` | number | ❌ | 思考预算(仅对思考模型生效),范围 1024-32000,默认 16000 |
|
| 166 |
+
| `reasoning_effort` | string | ❌ | 思维链强度(OpenAI 格式),可选值:`low`(1024)、`medium`(16000)、`high`(32000) |
|
| 167 |
| `tools` | array | ❌ | 工具列表(Function Calling) |
|
| 168 |
|
| 169 |
## 响应格式
|
|
|
|
| 226 |
}
|
| 227 |
```
|
| 228 |
|
| 229 |
+
## 思维链模型
|
| 230 |
+
|
| 231 |
+
对于支持思维链的模型(如 `gemini-2.5-pro`、`claude-opus-4-5-thinking` 等),可以通过以下参数控制推理深度:
|
| 232 |
+
|
| 233 |
+
### 使用 reasoning_effort(OpenAI 兼容格式)
|
| 234 |
+
|
| 235 |
+
```bash
|
| 236 |
+
curl http://localhost:8045/v1/chat/completions \
|
| 237 |
+
-H "Content-Type: application/json" \
|
| 238 |
+
-H "Authorization: Bearer sk-text" \
|
| 239 |
+
-d '{
|
| 240 |
+
"model": "gemini-2.5-pro",
|
| 241 |
+
"messages": [{"role": "user", "content": "解释量子纠缠"}],
|
| 242 |
+
"stream": true,
|
| 243 |
+
"reasoning_effort": "high"
|
| 244 |
+
}'
|
| 245 |
+
```
|
| 246 |
+
|
| 247 |
+
| reasoning_effort | thinking_budget | 说明 |
|
| 248 |
+
|-----------------|-----------------|------|
|
| 249 |
+
| `low` | 1024 | 快速响应,适合简单问题 |
|
| 250 |
+
| `medium` | 16000 | 平衡模式(默认) |
|
| 251 |
+
| `high` | 32000 | 深度思考,适合复杂推理 |
|
| 252 |
+
|
| 253 |
+
### 使用 thinking_budget(直接数值)
|
| 254 |
+
|
| 255 |
+
```bash
|
| 256 |
+
curl http://localhost:8045/v1/chat/completions \
|
| 257 |
+
-H "Content-Type: application/json" \
|
| 258 |
+
-H "Authorization: Bearer sk-text" \
|
| 259 |
+
-d '{
|
| 260 |
+
"model": "gemini-2.5-pro",
|
| 261 |
+
"messages": [{"role": "user", "content": "证明勾股定理"}],
|
| 262 |
+
"stream": true,
|
| 263 |
+
"thinking_budget": 24000
|
| 264 |
+
}'
|
| 265 |
+
```
|
| 266 |
+
|
| 267 |
+
### 思维链响应格式
|
| 268 |
+
|
| 269 |
+
思维链内容通过 `reasoning_content` 字段输出(兼容 DeepSeek 格式):
|
| 270 |
+
|
| 271 |
+
**非流式响应**:
|
| 272 |
+
```json
|
| 273 |
+
{
|
| 274 |
+
"choices": [{
|
| 275 |
+
"message": {
|
| 276 |
+
"role": "assistant",
|
| 277 |
+
"reasoning_content": "让我思考一下这个问题...",
|
| 278 |
+
"content": "量子纠缠是..."
|
| 279 |
+
}
|
| 280 |
+
}]
|
| 281 |
+
}
|
| 282 |
+
```
|
| 283 |
+
|
| 284 |
+
**流式响应**:
|
| 285 |
+
```
|
| 286 |
+
data: {"choices":[{"delta":{"reasoning_content":"让我"}}]}
|
| 287 |
+
data: {"choices":[{"delta":{"reasoning_content":"思考..."}}]}
|
| 288 |
+
data: {"choices":[{"delta":{"content":"量子纠缠是..."}}]}
|
| 289 |
+
```
|
| 290 |
+
|
| 291 |
+
### 支持思维链的模型
|
| 292 |
+
|
| 293 |
+
- `gemini-2.5-pro`
|
| 294 |
+
- `gemini-2.5-flash-thinking`
|
| 295 |
+
- `gemini-3-pro-high`
|
| 296 |
+
- `gemini-3-pro-low`
|
| 297 |
+
- `claude-opus-4-5-thinking`
|
| 298 |
+
- `claude-sonnet-4-5-thinking`
|
| 299 |
+
- `rev19-uic3-1p`
|
| 300 |
+
- `gpt-oss-120b-medium`
|
| 301 |
+
|
| 302 |
+
## SD WebUI 兼容 API
|
| 303 |
+
|
| 304 |
+
本服务提供与 Stable Diffusion WebUI 兼容的 API 接口,可用于与支持 SD WebUI API 的客户端集成。
|
| 305 |
+
|
| 306 |
+
### 文本生成图片
|
| 307 |
+
|
| 308 |
+
```bash
|
| 309 |
+
curl http://localhost:8045/sdapi/v1/txt2img \
|
| 310 |
+
-H "Content-Type: application/json" \
|
| 311 |
+
-d '{
|
| 312 |
+
"prompt": "a cute cat, high quality, detailed",
|
| 313 |
+
"negative_prompt": "",
|
| 314 |
+
"steps": 20,
|
| 315 |
+
"width": 512,
|
| 316 |
+
"height": 512
|
| 317 |
+
}'
|
| 318 |
+
```
|
| 319 |
+
|
| 320 |
+
### 图片生成图片
|
| 321 |
+
|
| 322 |
+
```bash
|
| 323 |
+
curl http://localhost:8045/sdapi/v1/img2img \
|
| 324 |
+
-H "Content-Type: application/json" \
|
| 325 |
+
-d '{
|
| 326 |
+
"prompt": "enhance this image, high quality",
|
| 327 |
+
"init_images": ["BASE64_ENCODED_IMAGE"],
|
| 328 |
+
"steps": 20
|
| 329 |
+
}'
|
| 330 |
+
```
|
| 331 |
+
|
| 332 |
+
### 其他 SD API 端点
|
| 333 |
+
|
| 334 |
+
| 端点 | 说明 |
|
| 335 |
+
|------|------|
|
| 336 |
+
| `GET /sdapi/v1/sd-models` | 获取可用的图片生成模型 |
|
| 337 |
+
| `GET /sdapi/v1/options` | 获取当前选项 |
|
| 338 |
+
| `GET /sdapi/v1/samplers` | 获取可用的采样器 |
|
| 339 |
+
| `GET /sdapi/v1/upscalers` | 获取可用的放大器 |
|
| 340 |
+
| `GET /sdapi/v1/progress` | 获取生成进度 |
|
| 341 |
+
|
| 342 |
+
## 管理 API
|
| 343 |
+
|
| 344 |
+
管理 API 需要 JWT 认证,先通过登录接口获取 token。
|
| 345 |
+
|
| 346 |
+
### 登录
|
| 347 |
+
|
| 348 |
+
```bash
|
| 349 |
+
curl http://localhost:8045/admin/login \
|
| 350 |
+
-H "Content-Type: application/json" \
|
| 351 |
+
-d '{
|
| 352 |
+
"username": "admin",
|
| 353 |
+
"password": "admin123"
|
| 354 |
+
}'
|
| 355 |
+
```
|
| 356 |
+
|
| 357 |
+
### Token 管理
|
| 358 |
+
|
| 359 |
+
```bash
|
| 360 |
+
# 获取 Token 列表
|
| 361 |
+
curl http://localhost:8045/admin/tokens \
|
| 362 |
+
-H "Authorization: Bearer JWT_TOKEN"
|
| 363 |
+
|
| 364 |
+
# 添加 Token
|
| 365 |
+
curl http://localhost:8045/admin/tokens \
|
| 366 |
+
-H "Content-Type: application/json" \
|
| 367 |
+
-H "Authorization: Bearer JWT_TOKEN" \
|
| 368 |
+
-d '{
|
| 369 |
+
"access_token": "ya29.xxx",
|
| 370 |
+
"refresh_token": "1//xxx",
|
| 371 |
+
"expires_in": 3599
|
| 372 |
+
}'
|
| 373 |
+
|
| 374 |
+
# 删除 Token
|
| 375 |
+
curl -X DELETE http://localhost:8045/admin/tokens/REFRESH_TOKEN \
|
| 376 |
+
-H "Authorization: Bearer JWT_TOKEN"
|
| 377 |
+
```
|
| 378 |
+
|
| 379 |
+
### 查看模型额度
|
| 380 |
+
|
| 381 |
+
```bash
|
| 382 |
+
# 获取指定 Token 的模型额度
|
| 383 |
+
curl http://localhost:8045/admin/tokens/REFRESH_TOKEN/quotas \
|
| 384 |
+
-H "Authorization: Bearer JWT_TOKEN"
|
| 385 |
+
|
| 386 |
+
# 强制刷新额度数据
|
| 387 |
+
curl "http://localhost:8045/admin/tokens/REFRESH_TOKEN/quotas?refresh=true" \
|
| 388 |
+
-H "Authorization: Bearer JWT_TOKEN"
|
| 389 |
+
```
|
| 390 |
+
|
| 391 |
+
**响应示例**:
|
| 392 |
+
```json
|
| 393 |
+
{
|
| 394 |
+
"success": true,
|
| 395 |
+
"data": {
|
| 396 |
+
"lastUpdated": 1702700000000,
|
| 397 |
+
"models": {
|
| 398 |
+
"gemini-2.5-pro": {
|
| 399 |
+
"remaining": 0.85,
|
| 400 |
+
"resetTime": "12-16 20:00",
|
| 401 |
+
"resetTimeRaw": "2024-12-16T12:00:00Z"
|
| 402 |
+
}
|
| 403 |
+
}
|
| 404 |
+
}
|
| 405 |
+
}
|
| 406 |
+
```
|
| 407 |
+
|
| 408 |
+
### 轮询策略配置
|
| 409 |
+
|
| 410 |
+
```bash
|
| 411 |
+
# 获取当前轮询配置
|
| 412 |
+
curl http://localhost:8045/admin/rotation \
|
| 413 |
+
-H "Authorization: Bearer JWT_TOKEN"
|
| 414 |
+
|
| 415 |
+
# 更新轮询策略
|
| 416 |
+
curl -X PUT http://localhost:8045/admin/rotation \
|
| 417 |
+
-H "Content-Type: application/json" \
|
| 418 |
+
-H "Authorization: Bearer JWT_TOKEN" \
|
| 419 |
+
-d '{
|
| 420 |
+
"strategy": "request_count",
|
| 421 |
+
"requestCount": 20
|
| 422 |
+
}'
|
| 423 |
+
```
|
| 424 |
+
|
| 425 |
+
**可用策略**:
|
| 426 |
+
- `round_robin`:每次请求切换 Token
|
| 427 |
+
- `quota_exhausted`:额度耗尽才切换
|
| 428 |
+
- `request_count`:自定义请求次数后切换
|
| 429 |
+
|
| 430 |
+
### 配置管理
|
| 431 |
+
|
| 432 |
+
```bash
|
| 433 |
+
# 获取配置
|
| 434 |
+
curl http://localhost:8045/admin/config \
|
| 435 |
+
-H "Authorization: Bearer JWT_TOKEN"
|
| 436 |
+
|
| 437 |
+
# 更新配置
|
| 438 |
+
curl -X PUT http://localhost:8045/admin/config \
|
| 439 |
+
-H "Content-Type: application/json" \
|
| 440 |
+
-H "Authorization: Bearer JWT_TOKEN" \
|
| 441 |
+
-d '{
|
| 442 |
+
"json": {
|
| 443 |
+
"defaults": {
|
| 444 |
+
"temperature": 0.7
|
| 445 |
+
}
|
| 446 |
+
}
|
| 447 |
+
}'
|
| 448 |
+
```
|
| 449 |
+
|
| 450 |
## 使用示例
|
| 451 |
|
| 452 |
### Python
|
|
|
|
| 490 |
|
| 491 |
## 注意事项
|
| 492 |
|
| 493 |
+
1. 所有 `/v1/*` 请求必须携带有效的 API Key
|
| 494 |
+
2. 管理 API (`/admin/*`) 需要 JWT 认证
|
| 495 |
+
3. 图片输入需要使用 Base64 编码
|
| 496 |
+
4. 流式响应使用 Server-Sent Events (SSE) 格式,包含心跳机制防止超时
|
| 497 |
+
5. 工具调用需要模型支持 Function Calling
|
| 498 |
+
6. 图片生成仅支持 `gemini-3-pro-image` 模型
|
| 499 |
+
7. 模型列表会缓存 1 小时,可通过配置调整
|
| 500 |
+
8. 思维链内容通过 `reasoning_content` 字段输出(兼容 DeepSeek 格式)
|
README.md
CHANGED
|
@@ -7,13 +7,22 @@
|
|
| 7 |
- ✅ OpenAI API 兼容格式
|
| 8 |
- ✅ 流式和非流式响应
|
| 9 |
- ✅ 工具调用(Function Calling)支持
|
| 10 |
-
- ✅
|
| 11 |
- ✅ Token 自动刷新
|
| 12 |
- ✅ API Key 认证
|
| 13 |
-
- ✅ 思维链(Thinking
|
| 14 |
- ✅ 图片输入支持(Base64 编码)
|
| 15 |
-
- ✅
|
| 16 |
- ✅ Pro 账号随机 ProjectId 支持
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
## 环境要求
|
| 19 |
|
|
@@ -45,7 +54,7 @@ ADMIN_PASSWORD=admin123
|
|
| 45 |
JWT_SECRET=your-jwt-secret-key-change-this-in-production
|
| 46 |
|
| 47 |
# 可选配置
|
| 48 |
-
# PROXY=http://127.0.0.1:
|
| 49 |
# SYSTEM_INSTRUCTION=你是聊天机器人
|
| 50 |
# IMAGE_BASE_URL=http://your-domain.com
|
| 51 |
```
|
|
@@ -246,17 +255,38 @@ ghcr.io/liuw1535/antigravity2api-nodejs
|
|
| 246 |
- 使用「删除」按钮移除无效 Token
|
| 247 |
- 点击「刷新」按钮更新列表
|
| 248 |
|
| 249 |
-
4.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
- 切换到「设置」标签页
|
| 251 |
- 修改需要调整的配置项
|
| 252 |
- 点击「保存配置」按钮应用更改
|
| 253 |
- 注意:端口和监听地址修改需要重启服务
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
### 界面预览
|
| 256 |
|
| 257 |
- **Token 管理页面**:卡片式展示所有 Token,支持快速操作
|
| 258 |
- **设置页面**:分类展示所有配置项,支持在线编辑
|
| 259 |
- **响应式设计**:支持桌面和移动设备访问
|
|
|
|
| 260 |
|
| 261 |
## API 使用
|
| 262 |
|
|
@@ -314,21 +344,40 @@ curl http://localhost:8045/v1/chat/completions \
|
|
| 314 |
"server": {
|
| 315 |
"port": 8045, // 服务端口
|
| 316 |
"host": "0.0.0.0", // 监听地址
|
| 317 |
-
"maxRequestSize": "500mb"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
},
|
| 319 |
"defaults": {
|
| 320 |
"temperature": 1, // 默认温度参数
|
| 321 |
-
"topP":
|
| 322 |
"topK": 50, // 默认 top_k
|
| 323 |
-
"maxTokens":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
},
|
| 325 |
"other": {
|
| 326 |
-
"timeout":
|
| 327 |
-
"skipProjectIdFetch":
|
|
|
|
| 328 |
}
|
| 329 |
}
|
| 330 |
```
|
| 331 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
### 2. .env(敏感配置)
|
| 333 |
|
| 334 |
环境变量配置文件,包含敏感信息和可选配置:
|
|
@@ -339,7 +388,8 @@ curl http://localhost:8045/v1/chat/completions \
|
|
| 339 |
| `ADMIN_USERNAME` | 管理员用户名 | ✅ |
|
| 340 |
| `ADMIN_PASSWORD` | 管理员密码 | ✅ |
|
| 341 |
| `JWT_SECRET` | JWT 密钥 | ✅ |
|
| 342 |
-
| `PROXY` | 代理地址(如:http://127.0.0.1:
|
|
|
|
| 343 |
| `SYSTEM_INSTRUCTION` | 系统提示词 | ❌ |
|
| 344 |
| `IMAGE_BASE_URL` | 图片服务基础 URL | ❌ |
|
| 345 |
|
|
@@ -363,41 +413,56 @@ npm run login
|
|
| 363 |
```
|
| 364 |
.
|
| 365 |
├── data/
|
| 366 |
-
│
|
|
|
|
| 367 |
├── public/
|
| 368 |
│ ├── index.html # Web 管理界面
|
| 369 |
│ ├── app.js # 前端逻辑
|
| 370 |
-
│
|
|
|
|
| 371 |
├── scripts/
|
| 372 |
│ ├── oauth-server.js # OAuth 登录服务
|
| 373 |
│ └── refresh-tokens.js # Token 刷新脚本
|
| 374 |
├── src/
|
| 375 |
│ ├── api/
|
| 376 |
-
│ │ └── client.js # API
|
| 377 |
│ ├── auth/
|
| 378 |
│ │ ├── jwt.js # JWT 认证
|
| 379 |
-
│ │
|
|
|
|
| 380 |
│ ├── routes/
|
| 381 |
-
│ │
|
|
|
|
| 382 |
│ ├── bin/
|
| 383 |
│ │ ├── antigravity_requester_android_arm64 # Android ARM64 TLS 请求器
|
| 384 |
│ │ ├── antigravity_requester_linux_amd64 # Linux AMD64 TLS 请求器
|
| 385 |
│ │ └── antigravity_requester_windows_amd64.exe # Windows AMD64 TLS 请求器
|
| 386 |
│ ├── config/
|
| 387 |
-
│ │
|
|
|
|
|
|
|
|
|
|
| 388 |
│ ├── server/
|
| 389 |
-
│ │ └── index.js #
|
| 390 |
│ ├── utils/
|
|
|
|
|
|
|
|
|
|
| 391 |
│ │ ├── idGenerator.js # ID 生成器
|
|
|
|
| 392 |
│ │ ├── logger.js # 日志模块
|
| 393 |
│ │ └── utils.js # 工具函数
|
| 394 |
│ └── AntigravityRequester.js # TLS 指纹请求器封装
|
| 395 |
├── test/
|
| 396 |
│ ├── test-request.js # 请求测试
|
|
|
|
|
|
|
| 397 |
│ └── test-transform.js # 转换测试
|
| 398 |
├── .env # 环境变量配置(敏感信息)
|
| 399 |
├── .env.example # 环境变量配置示例
|
| 400 |
├── config.json # 基础配置文件
|
|
|
|
|
|
|
| 401 |
└── package.json # 项目配置
|
| 402 |
```
|
| 403 |
|
|
@@ -420,6 +485,138 @@ npm run login
|
|
| 420 |
|
| 421 |
注意:此功能仅适用于 Pro 订阅账号。官方已修复免费账号使用随机 ProjectId 的漏洞。
|
| 422 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
## 注意事项
|
| 424 |
|
| 425 |
1. 首次使用需要复制 `.env.example` 为 `.env` 并配置
|
|
|
|
| 7 |
- ✅ OpenAI API 兼容格式
|
| 8 |
- ✅ 流式和非流式响应
|
| 9 |
- ✅ 工具调用(Function Calling)支持
|
| 10 |
+
- ✅ 多账号自动轮换(支持多种轮询策略)
|
| 11 |
- ✅ Token 自动刷新
|
| 12 |
- ✅ API Key 认证
|
| 13 |
+
- ✅ 思维链(Thinking)输出,兼容 OpenAI reasoning_effort 参数和 DeepSeek reasoning_content 格式
|
| 14 |
- ✅ 图片输入支持(Base64 编码)
|
| 15 |
+
- ✅ 图片生成支持(gemini-3-pro-image 模型)
|
| 16 |
- ✅ Pro 账号随机 ProjectId 支持
|
| 17 |
+
- ✅ 模型额度查看(实时显示剩余额度和重置时间)
|
| 18 |
+
- ✅ SD WebUI API 兼容(支持 txt2img/img2img)
|
| 19 |
+
- ✅ 心跳机制(防止 Cloudflare 超时断连)
|
| 20 |
+
- ✅ 模型列表缓存(减少 API 请求)
|
| 21 |
+
- ✅ 资格校验自动回退(无资格时自动生成随机 ProjectId)
|
| 22 |
+
- ✅ 真 System 消息合并(开头连续多条 system 与 SystemInstruction 合并)
|
| 23 |
+
- ✅ 隐私模式(自动隐藏敏感信息)
|
| 24 |
+
- ✅ 内存优化(从 8+ 进程减少为 2 个进程,内存占用从 100MB+ 降为 50MB+)
|
| 25 |
+
- ✅ 对象池复用(减少 50%+ 临时对象创建,降低 GC 频率)
|
| 26 |
|
| 27 |
## 环境要求
|
| 28 |
|
|
|
|
| 54 |
JWT_SECRET=your-jwt-secret-key-change-this-in-production
|
| 55 |
|
| 56 |
# 可选配置
|
| 57 |
+
# PROXY=http://127.0.0.1:7890
|
| 58 |
# SYSTEM_INSTRUCTION=你是聊天机器人
|
| 59 |
# IMAGE_BASE_URL=http://your-domain.com
|
| 60 |
```
|
|
|
|
| 255 |
- 使用「删除」按钮移除无效 Token
|
| 256 |
- 点击「刷新」按钮更新列表
|
| 257 |
|
| 258 |
+
4. **隐私模式**
|
| 259 |
+
- 默认开启,自动隐藏 Token、Project ID 等敏感信息
|
| 260 |
+
- 点击「显示敏感信息」切换显示/隐藏状态
|
| 261 |
+
- 支持逐个查看或批量显示
|
| 262 |
+
|
| 263 |
+
5. **配置轮询策略**
|
| 264 |
+
- 支持三种轮询策略:
|
| 265 |
+
- `round_robin`:均衡负载,每次请求切换 Token
|
| 266 |
+
- `quota_exhausted`:额度耗尽才切换
|
| 267 |
+
- `request_count`:自定义请求次数后切换
|
| 268 |
+
- 可在「设置」页面配置
|
| 269 |
+
|
| 270 |
+
6. **修改配置**
|
| 271 |
- 切换到「设置」标签页
|
| 272 |
- 修改需要调整的配置项
|
| 273 |
- 点击「保存配置」按钮应用更改
|
| 274 |
- 注意:端口和监听地址修改需要重启服务
|
| 275 |
+
- 支持的设置项:
|
| 276 |
+
- 编辑 Token 信息(Access Token、Refresh Token)
|
| 277 |
+
- 思考预算(1024-32000)
|
| 278 |
+
- 图片访问地址
|
| 279 |
+
- 轮询策略
|
| 280 |
+
- 内存阈值
|
| 281 |
+
- 心跳间隔
|
| 282 |
+
- 字体大小
|
| 283 |
|
| 284 |
### 界面预览
|
| 285 |
|
| 286 |
- **Token 管理页面**:卡片式展示所有 Token,支持快速操作
|
| 287 |
- **设置页面**:分类展示所有配置项,支持在线编辑
|
| 288 |
- **响应式设计**:支持桌面和移动设备访问
|
| 289 |
+
- **字体优化**:采用 MiSans + Ubuntu Mono 字体,增强可读性
|
| 290 |
|
| 291 |
## API 使用
|
| 292 |
|
|
|
|
| 344 |
"server": {
|
| 345 |
"port": 8045, // 服务端口
|
| 346 |
"host": "0.0.0.0", // 监听地址
|
| 347 |
+
"maxRequestSize": "500mb", // 最大请求体大小
|
| 348 |
+
"heartbeatInterval": 15000,// 心跳间隔(毫秒),防止 Cloudflare 超时
|
| 349 |
+
"memoryThreshold": 100 // 内存阈值(MB),超过时触发 GC
|
| 350 |
+
},
|
| 351 |
+
"rotation": {
|
| 352 |
+
"strategy": "round_robin", // 轮询策略:round_robin/quota_exhausted/request_count
|
| 353 |
+
"requestCount": 50 // request_count 策略下每个 Token 的请求次数
|
| 354 |
},
|
| 355 |
"defaults": {
|
| 356 |
"temperature": 1, // 默认温度参数
|
| 357 |
+
"topP": 1, // 默认 top_p
|
| 358 |
"topK": 50, // 默认 top_k
|
| 359 |
+
"maxTokens": 32000, // 默认最大 token 数
|
| 360 |
+
"thinkingBudget": 16000 // 默认思考预算(仅对思考模型生效,范围 1024-32000)
|
| 361 |
+
},
|
| 362 |
+
"cache": {
|
| 363 |
+
"modelListTTL": 3600000 // 模型列表缓存时间(毫秒),默认 1 小时
|
| 364 |
},
|
| 365 |
"other": {
|
| 366 |
+
"timeout": 300000, // 请求超时时间(毫秒)
|
| 367 |
+
"skipProjectIdFetch": false,// 跳过 ProjectId 获取,直接随机生成(仅 Pro 账号有效)
|
| 368 |
+
"useNativeAxios": false // 使用原生 axios 而非 AntigravityRequester
|
| 369 |
}
|
| 370 |
}
|
| 371 |
```
|
| 372 |
|
| 373 |
+
### 轮询策略说明
|
| 374 |
+
|
| 375 |
+
| 策略 | 说明 |
|
| 376 |
+
|------|------|
|
| 377 |
+
| `round_robin` | 均衡负载:每次请求后切换到下一个 Token |
|
| 378 |
+
| `quota_exhausted` | 额度耗尽才切换:持续使用当前 Token 直到额度用完 |
|
| 379 |
+
| `request_count` | 自定义次数:每个 Token 使用指定次数后切换 |
|
| 380 |
+
|
| 381 |
### 2. .env(敏感配置)
|
| 382 |
|
| 383 |
环境变量配置文件,包含敏感信息和可选配置:
|
|
|
|
| 388 |
| `ADMIN_USERNAME` | 管理员用户名 | ✅ |
|
| 389 |
| `ADMIN_PASSWORD` | 管理员密码 | ✅ |
|
| 390 |
| `JWT_SECRET` | JWT 密钥 | ✅ |
|
| 391 |
+
| `PROXY` | 代理地址(如:http://127.0.0.1:7890),也支持系统代理环境变量
|
| 392 |
+
|`HTTP_PROXY`/`HTTPS_PROXY` | ❌ |
|
| 393 |
| `SYSTEM_INSTRUCTION` | 系统提示词 | ❌ |
|
| 394 |
| `IMAGE_BASE_URL` | 图片服务基础 URL | ❌ |
|
| 395 |
|
|
|
|
| 413 |
```
|
| 414 |
.
|
| 415 |
├── data/
|
| 416 |
+
│ ├── accounts.json # Token 存储(自动生成)
|
| 417 |
+
│ └── quotas.json # 额度缓存(自动生成)
|
| 418 |
├── public/
|
| 419 |
│ ├── index.html # Web 管理界面
|
| 420 |
│ ├── app.js # 前端逻辑
|
| 421 |
+
│ ├── style.css # 界面样式
|
| 422 |
+
│ └── images/ # 生成的图片存储目录
|
| 423 |
├── scripts/
|
| 424 |
│ ├── oauth-server.js # OAuth 登录服务
|
| 425 |
│ └── refresh-tokens.js # Token 刷新脚本
|
| 426 |
├── src/
|
| 427 |
│ ├── api/
|
| 428 |
+
│ │ └── client.js # API 调用逻辑(含模型列表缓存)
|
| 429 |
│ ├── auth/
|
| 430 |
│ │ ├── jwt.js # JWT 认证
|
| 431 |
+
│ │ ├── token_manager.js # Token 管理(含轮询策略)
|
| 432 |
+
│ │ └── quota_manager.js # 额度缓存管理
|
| 433 |
│ ├── routes/
|
| 434 |
+
│ │ ├── admin.js # 管理接口路由
|
| 435 |
+
│ │ └── sd.js # SD WebUI 兼容接口
|
| 436 |
│ ├── bin/
|
| 437 |
│ │ ├── antigravity_requester_android_arm64 # Android ARM64 TLS 请求器
|
| 438 |
│ │ ├── antigravity_requester_linux_amd64 # Linux AMD64 TLS 请求器
|
| 439 |
│ │ └── antigravity_requester_windows_amd64.exe # Windows AMD64 TLS 请求器
|
| 440 |
│ ├── config/
|
| 441 |
+
│ │ ├── config.js # 配置加载
|
| 442 |
+
│ │ └── init-env.js # 环境变量初始化
|
| 443 |
+
│ ├── constants/
|
| 444 |
+
│ │ └── oauth.js # OAuth 常量
|
| 445 |
│ ├── server/
|
| 446 |
+
│ │ └── index.js # 主服务器(含内存管理和心跳)
|
| 447 |
│ ├── utils/
|
| 448 |
+
│ │ ├── configReloader.js # 配置热重载
|
| 449 |
+
│ │ ├── deepMerge.js # 深度合并工具
|
| 450 |
+
│ │ ├── envParser.js # 环境变量解析
|
| 451 |
│ │ ├── idGenerator.js # ID 生成器
|
| 452 |
+
│ │ ├── imageStorage.js # 图片存储
|
| 453 |
│ │ ├── logger.js # 日志模块
|
| 454 |
│ │ └── utils.js # 工具函数
|
| 455 |
│ └── AntigravityRequester.js # TLS 指纹请求器封装
|
| 456 |
├── test/
|
| 457 |
│ ├── test-request.js # 请求测试
|
| 458 |
+
│ ├── test-image-generation.js # 图片生成测试
|
| 459 |
+
│ ├── test-token-rotation.js # Token 轮换测试
|
| 460 |
│ └── test-transform.js # 转换测试
|
| 461 |
├── .env # 环境变量配置(敏感信息)
|
| 462 |
├── .env.example # 环境变量配置示例
|
| 463 |
├── config.json # 基础配置文件
|
| 464 |
+
├── Dockerfile # Docker 构建文件
|
| 465 |
+
├── docker-compose.yml # Docker Compose 配置
|
| 466 |
└── package.json # 项目配置
|
| 467 |
```
|
| 468 |
|
|
|
|
| 485 |
|
| 486 |
注意:此功能仅适用于 Pro 订阅账号。官方已修复免费账号使用随机 ProjectId 的漏洞。
|
| 487 |
|
| 488 |
+
## 资格校验自动回退
|
| 489 |
+
|
| 490 |
+
当 OAuth 登录或添加 Token 时,系统会自动检测账号的订阅资格:
|
| 491 |
+
|
| 492 |
+
1. **有资格的账号**:正常使用 API 返回的 ProjectId
|
| 493 |
+
2. **无资格的账号**:自动生成随机 ProjectId,避免添加失败
|
| 494 |
+
|
| 495 |
+
这一机制确保了:
|
| 496 |
+
- 无论账号是否有 Pro 订阅,都能成功添加 Token
|
| 497 |
+
- 自动降级处理,无需手动干预
|
| 498 |
+
- 不会因为资格校验失败而阻止登录流程
|
| 499 |
+
|
| 500 |
+
## 真 System 消息合并
|
| 501 |
+
|
| 502 |
+
本服务支持将开头连续的多条 system 消息与全局 SystemInstruction 合并:
|
| 503 |
+
|
| 504 |
+
```
|
| 505 |
+
请求消息:
|
| 506 |
+
[system] 你是助手
|
| 507 |
+
[system] 请使用中文回答
|
| 508 |
+
[user] 你好
|
| 509 |
+
|
| 510 |
+
合并后:
|
| 511 |
+
SystemInstruction = 全局配置的系统提示词 + "\n\n" + "你是助手\n\n请使用中文回答"
|
| 512 |
+
messages = [{role: user, content: 你好}]
|
| 513 |
+
```
|
| 514 |
+
|
| 515 |
+
这一设计:
|
| 516 |
+
- 兼容 OpenAI 的多 system 消息格式
|
| 517 |
+
- 充分利用 Antigravity 的 SystemInstruction 功能
|
| 518 |
+
- 确保系统提示词的完整性和优先级
|
| 519 |
+
|
| 520 |
+
## 思考预算(Thinking Budget)
|
| 521 |
+
|
| 522 |
+
对于支持思考能力的模型(如 gemini-2.0-flash-thinking-exp),可以通过以下方式控制思考深度:
|
| 523 |
+
|
| 524 |
+
### 方式一:使用 reasoning_effort 参数(OpenAI 兼容)
|
| 525 |
+
|
| 526 |
+
```json
|
| 527 |
+
{
|
| 528 |
+
"model": "gemini-2.0-flash-thinking-exp",
|
| 529 |
+
"reasoning_effort": "high",
|
| 530 |
+
"messages": [...]
|
| 531 |
+
}
|
| 532 |
+
```
|
| 533 |
+
|
| 534 |
+
| 值 | 思考 Token ��算 |
|
| 535 |
+
|---|----------------|
|
| 536 |
+
| `low` | 1024 |
|
| 537 |
+
| `medium` | 16000 |
|
| 538 |
+
| `high` | 32000 |
|
| 539 |
+
|
| 540 |
+
### 方式二:使用 thinking_budget 参数(精确控制)
|
| 541 |
+
|
| 542 |
+
```json
|
| 543 |
+
{
|
| 544 |
+
"model": "gemini-2.0-flash-thinking-exp",
|
| 545 |
+
"thinking_budget": 24000,
|
| 546 |
+
"messages": [...]
|
| 547 |
+
}
|
| 548 |
+
```
|
| 549 |
+
|
| 550 |
+
- 范围:1024 - 32000
|
| 551 |
+
- 优先级:`thinking_budget` > `reasoning_effort` > 配置文件默认值
|
| 552 |
+
|
| 553 |
+
### DeepSeek 思考格式兼容
|
| 554 |
+
|
| 555 |
+
本服务自动适配 DeepSeek 的 `reasoning_content` 格式,将思维链内容单独输出,避免与正常内容混淆:
|
| 556 |
+
|
| 557 |
+
```json
|
| 558 |
+
{
|
| 559 |
+
"choices": [{
|
| 560 |
+
"message": {
|
| 561 |
+
"content": "最终答案",
|
| 562 |
+
"reasoning_content": "这是思考过程..."
|
| 563 |
+
}
|
| 564 |
+
}]
|
| 565 |
+
}
|
| 566 |
+
```
|
| 567 |
+
|
| 568 |
+
## 内存优化
|
| 569 |
+
|
| 570 |
+
本服务经过深度内存优化:
|
| 571 |
+
|
| 572 |
+
### 优化效果
|
| 573 |
+
|
| 574 |
+
| 指标 | 优化前 | 优化后 |
|
| 575 |
+
|------|--------|--------|
|
| 576 |
+
| 进程数 | 8+ | 2 |
|
| 577 |
+
| 内存占用 | 100MB+ | 50MB+ |
|
| 578 |
+
| GC 频率 | 高 | 低 |
|
| 579 |
+
|
| 580 |
+
### 优化手段
|
| 581 |
+
|
| 582 |
+
1. **对象池复用**:流式响应对象通过对象池复用,减少 50%+ 临时对象创建
|
| 583 |
+
2. **预编译常量**:正则表达式、格式字符串等预编译,避免重复创建
|
| 584 |
+
3. **LineBuffer 优化**:高效的流式行分割,避免频繁字符串操作
|
| 585 |
+
4. **自动内存清理**:堆内存超过阈值(默认 100MB)时自动触发 GC
|
| 586 |
+
5. **进程精简**:移除不必要的子进程,统一在主进程处理
|
| 587 |
+
|
| 588 |
+
### 配置
|
| 589 |
+
|
| 590 |
+
```json
|
| 591 |
+
{
|
| 592 |
+
"server": {
|
| 593 |
+
"memoryThreshold": 100
|
| 594 |
+
}
|
| 595 |
+
}
|
| 596 |
+
```
|
| 597 |
+
|
| 598 |
+
- `memoryThreshold`:触发 GC 的堆内存阈值(MB)
|
| 599 |
+
|
| 600 |
+
## 心跳机制
|
| 601 |
+
|
| 602 |
+
为防止 Cloudflare 等 CDN 因长时间无响应而断开连接,本服务实现了 SSE 心跳机制:
|
| 603 |
+
|
| 604 |
+
- 在流式响应期间,定期发送心跳包(`: heartbeat\n\n`)
|
| 605 |
+
- 默认间隔 15 秒,可配置
|
| 606 |
+
- 心跳包符合 SSE 规范,客户端会自动忽略
|
| 607 |
+
|
| 608 |
+
### 配置
|
| 609 |
+
|
| 610 |
+
```json
|
| 611 |
+
{
|
| 612 |
+
"server": {
|
| 613 |
+
"heartbeatInterval": 15000
|
| 614 |
+
}
|
| 615 |
+
}
|
| 616 |
+
```
|
| 617 |
+
|
| 618 |
+
- `heartbeatInterval`:心跳间隔(毫秒),设为 0 禁用心跳
|
| 619 |
+
|
| 620 |
## 注意事项
|
| 621 |
|
| 622 |
1. 首次使用需要复制 `.env.example` 为 `.env` 并配置
|
config.json
CHANGED
|
@@ -2,7 +2,13 @@
|
|
| 2 |
"server": {
|
| 3 |
"port": 8045,
|
| 4 |
"host": "0.0.0.0",
|
| 5 |
-
"maxRequestSize": "500mb"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
},
|
| 7 |
"api": {
|
| 8 |
"url": "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse",
|
|
@@ -13,12 +19,16 @@
|
|
| 13 |
},
|
| 14 |
"defaults": {
|
| 15 |
"temperature": 1,
|
| 16 |
-
"topP":
|
| 17 |
"topK": 50,
|
| 18 |
-
"maxTokens":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
},
|
| 20 |
"other": {
|
| 21 |
-
"timeout":
|
| 22 |
"skipProjectIdFetch": false,
|
| 23 |
"useNativeAxios": false
|
| 24 |
}
|
|
|
|
| 2 |
"server": {
|
| 3 |
"port": 8045,
|
| 4 |
"host": "0.0.0.0",
|
| 5 |
+
"maxRequestSize": "500mb",
|
| 6 |
+
"heartbeatInterval": 15000,
|
| 7 |
+
"memoryThreshold": 25
|
| 8 |
+
},
|
| 9 |
+
"rotation": {
|
| 10 |
+
"strategy": "round_robin",
|
| 11 |
+
"requestCount": 50
|
| 12 |
},
|
| 13 |
"api": {
|
| 14 |
"url": "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse",
|
|
|
|
| 19 |
},
|
| 20 |
"defaults": {
|
| 21 |
"temperature": 1,
|
| 22 |
+
"topP": 1,
|
| 23 |
"topK": 50,
|
| 24 |
+
"maxTokens": 32000,
|
| 25 |
+
"thinkingBudget": 16000
|
| 26 |
+
},
|
| 27 |
+
"cache": {
|
| 28 |
+
"modelListTTL": 3600000
|
| 29 |
},
|
| 30 |
"other": {
|
| 31 |
+
"timeout": 300000,
|
| 32 |
"skipProjectIdFetch": false,
|
| 33 |
"useNativeAxios": false
|
| 34 |
}
|
package.json
CHANGED
|
@@ -5,10 +5,11 @@
|
|
| 5 |
"type": "module",
|
| 6 |
"main": "src/server/index.js",
|
| 7 |
"scripts": {
|
| 8 |
-
"start": "node src/server/index.js",
|
|
|
|
| 9 |
"login": "node scripts/oauth-server.js",
|
| 10 |
"refresh": "node scripts/refresh-tokens.js",
|
| 11 |
-
"dev": "node --watch src/server/index.js"
|
| 12 |
},
|
| 13 |
"keywords": [
|
| 14 |
"antigravity",
|
|
|
|
| 5 |
"type": "module",
|
| 6 |
"main": "src/server/index.js",
|
| 7 |
"scripts": {
|
| 8 |
+
"start": "node --expose-gc src/server/index.js",
|
| 9 |
+
"start:no-gc": "node src/server/index.js",
|
| 10 |
"login": "node scripts/oauth-server.js",
|
| 11 |
"refresh": "node scripts/refresh-tokens.js",
|
| 12 |
+
"dev": "node --expose-gc --watch src/server/index.js"
|
| 13 |
},
|
| 14 |
"keywords": [
|
| 15 |
"antigravity",
|
public/app.js
CHANGED
|
@@ -1,5 +1,80 @@
|
|
| 1 |
let authToken = localStorage.getItem('authToken');
|
| 2 |
let oauthPort = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
| 4 |
const SCOPES = [
|
| 5 |
'https://www.googleapis.com/auth/cloud-platform',
|
|
@@ -102,9 +177,10 @@ document.getElementById('login').addEventListener('submit', async (e) => {
|
|
| 102 |
if (data.success) {
|
| 103 |
authToken = data.token;
|
| 104 |
localStorage.setItem('authToken', authToken);
|
| 105 |
-
showToast('
|
| 106 |
showMainContent();
|
| 107 |
loadTokens();
|
|
|
|
| 108 |
} else {
|
| 109 |
showToast(data.message || '用户名或密码错误', 'error');
|
| 110 |
}
|
|
@@ -118,7 +194,7 @@ document.getElementById('login').addEventListener('submit', async (e) => {
|
|
| 118 |
});
|
| 119 |
|
| 120 |
function showOAuthModal() {
|
| 121 |
-
showToast('点击后请在新窗口完成授权', 'info'
|
| 122 |
const modal = document.createElement('div');
|
| 123 |
modal.className = 'modal form-modal';
|
| 124 |
modal.innerHTML = `
|
|
@@ -130,9 +206,9 @@ function showOAuthModal() {
|
|
| 130 |
<p>2️⃣ 完成授权后,复制浏览器地址栏的完整URL</p>
|
| 131 |
<p>3️⃣ 粘贴URL到下方输入框并提交</p>
|
| 132 |
</div>
|
| 133 |
-
<div style="display: flex; gap: 8px; margin-bottom:
|
| 134 |
<button type="button" onclick="openOAuthWindow()" class="btn btn-success" style="flex: 1;">🔐 打开授权页面</button>
|
| 135 |
-
<button type="button" onclick="copyOAuthUrl()" class="btn btn-info" style="
|
| 136 |
</div>
|
| 137 |
<input type="text" id="modalCallbackUrl" placeholder="粘贴完整的回调URL (http://localhost:xxxxx/oauth-callback?code=...)">
|
| 138 |
<div class="modal-actions">
|
|
@@ -156,7 +232,7 @@ function showManualModal() {
|
|
| 156 |
<input type="text" id="modalRefreshToken" placeholder="Refresh Token (必填)">
|
| 157 |
<input type="number" id="modalExpiresIn" placeholder="过期时间(秒)" value="3599">
|
| 158 |
</div>
|
| 159 |
-
<p style="font-size: 0.
|
| 160 |
<div class="modal-actions">
|
| 161 |
<button class="btn btn-secondary" onclick="this.closest('.modal').remove()">取消</button>
|
| 162 |
<button class="btn btn-success" onclick="addTokenFromModal()">✅ 添加</button>
|
|
@@ -183,9 +259,9 @@ function openOAuthWindow() {
|
|
| 183 |
function copyOAuthUrl() {
|
| 184 |
const url = getOAuthUrl();
|
| 185 |
navigator.clipboard.writeText(url).then(() => {
|
| 186 |
-
showToast('
|
| 187 |
}).catch(() => {
|
| 188 |
-
showToast('
|
| 189 |
});
|
| 190 |
}
|
| 191 |
|
|
@@ -206,7 +282,7 @@ async function processOAuthCallbackModal() {
|
|
| 206 |
|
| 207 |
if (!code) {
|
| 208 |
hideLoading();
|
| 209 |
-
showToast('URL
|
| 210 |
return;
|
| 211 |
}
|
| 212 |
|
|
@@ -235,14 +311,14 @@ async function processOAuthCallbackModal() {
|
|
| 235 |
hideLoading();
|
| 236 |
if (addResult.success) {
|
| 237 |
modal.remove();
|
| 238 |
-
showToast('Token
|
| 239 |
loadTokens();
|
| 240 |
} else {
|
| 241 |
-
showToast('
|
| 242 |
}
|
| 243 |
} else {
|
| 244 |
hideLoading();
|
| 245 |
-
showToast('
|
| 246 |
}
|
| 247 |
} catch (error) {
|
| 248 |
hideLoading();
|
|
@@ -276,7 +352,7 @@ async function addTokenFromModal() {
|
|
| 276 |
hideLoading();
|
| 277 |
if (data.success) {
|
| 278 |
modal.remove();
|
| 279 |
-
showToast('Token
|
| 280 |
loadTokens();
|
| 281 |
} else {
|
| 282 |
showToast(data.message || '添加失败', 'error');
|
|
@@ -296,11 +372,12 @@ function switchTab(tab) {
|
|
| 296 |
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
| 297 |
event.target.classList.add('active');
|
| 298 |
|
|
|
|
|
|
|
|
|
|
| 299 |
if (tab === 'tokens') {
|
| 300 |
document.getElementById('tokensPage').classList.remove('hidden');
|
| 301 |
-
document.getElementById('settingsPage').classList.add('hidden');
|
| 302 |
} else if (tab === 'settings') {
|
| 303 |
-
document.getElementById('tokensPage').classList.add('hidden');
|
| 304 |
document.getElementById('settingsPage').classList.remove('hidden');
|
| 305 |
loadConfig();
|
| 306 |
}
|
|
@@ -339,6 +416,9 @@ async function loadTokens() {
|
|
| 339 |
}
|
| 340 |
|
| 341 |
function renderTokens(tokens) {
|
|
|
|
|
|
|
|
|
|
| 342 |
document.getElementById('totalTokens').textContent = tokens.length;
|
| 343 |
document.getElementById('enabledTokens').textContent = tokens.filter(t => t.enable).length;
|
| 344 |
document.getElementById('disabledTokens').textContent = tokens.filter(t => !t.enable).length;
|
|
@@ -349,47 +429,430 @@ function renderTokens(tokens) {
|
|
| 349 |
<div class="empty-state">
|
| 350 |
<div class="empty-state-icon">📦</div>
|
| 351 |
<div class="empty-state-text">暂无Token</div>
|
| 352 |
-
<div class="empty-state-hint"
|
| 353 |
</div>
|
| 354 |
`;
|
| 355 |
return;
|
| 356 |
}
|
| 357 |
|
| 358 |
-
tokenList.innerHTML = tokens.map(token =>
|
| 359 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
<div class="token-header">
|
| 361 |
<span class="status ${token.enable ? 'enabled' : 'disabled'}">
|
| 362 |
${token.enable ? '✅ 启用' : '❌ 禁用'}
|
| 363 |
</span>
|
| 364 |
-
<
|
|
|
|
|
|
|
|
|
|
| 365 |
</div>
|
| 366 |
<div class="token-info">
|
| 367 |
<div class="info-row">
|
| 368 |
-
<span class="info-label"
|
| 369 |
-
<span class="info-value">${token.access_token_suffix}</span>
|
| 370 |
</div>
|
| 371 |
-
<div class="info-row">
|
| 372 |
-
<span class="info-label"
|
| 373 |
-
<span class="info-value">${token.projectId || '
|
|
|
|
| 374 |
</div>
|
| 375 |
-
<div class="info-row">
|
| 376 |
-
<span class="info-label"
|
| 377 |
-
<span class="info-value">${token.email || '
|
|
|
|
| 378 |
</div>
|
| 379 |
-
<div class="info-row">
|
| 380 |
-
<span class="info-label"
|
| 381 |
-
<span class="info-value">${
|
| 382 |
</div>
|
| 383 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
<div class="token-actions">
|
| 385 |
-
<button class="btn btn-info" onclick="showQuotaModal('${token.refresh_token}')">📊
|
| 386 |
-
<button class="btn ${token.enable ? 'btn-warning' : 'btn-success'}" onclick="toggleToken('${token.refresh_token}', ${!token.enable})">
|
| 387 |
${token.enable ? '⏸️ 禁用' : '▶️ 启用'}
|
| 388 |
</button>
|
| 389 |
-
<button class="btn btn-danger" onclick="deleteToken('${token.refresh_token}')">🗑️ 删除</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
</div>
|
| 391 |
</div>
|
| 392 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
}
|
| 394 |
|
| 395 |
async function toggleToken(refreshToken, enable) {
|
|
@@ -397,7 +860,7 @@ async function toggleToken(refreshToken, enable) {
|
|
| 397 |
const confirmed = await showConfirm(`确定要${action}这个Token吗?`, `${action}确认`);
|
| 398 |
if (!confirmed) return;
|
| 399 |
|
| 400 |
-
showLoading(`正在${action}
|
| 401 |
try {
|
| 402 |
const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}`, {
|
| 403 |
method: 'PUT',
|
|
@@ -411,7 +874,7 @@ async function toggleToken(refreshToken, enable) {
|
|
| 411 |
const data = await response.json();
|
| 412 |
hideLoading();
|
| 413 |
if (data.success) {
|
| 414 |
-
showToast(
|
| 415 |
loadTokens();
|
| 416 |
} else {
|
| 417 |
showToast(data.message || '操作失败', 'error');
|
|
@@ -423,10 +886,10 @@ async function toggleToken(refreshToken, enable) {
|
|
| 423 |
}
|
| 424 |
|
| 425 |
async function deleteToken(refreshToken) {
|
| 426 |
-
const confirmed = await showConfirm('
|
| 427 |
if (!confirmed) return;
|
| 428 |
|
| 429 |
-
showLoading('
|
| 430 |
try {
|
| 431 |
const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}`, {
|
| 432 |
method: 'DELETE',
|
|
@@ -436,7 +899,7 @@ async function deleteToken(refreshToken) {
|
|
| 436 |
const data = await response.json();
|
| 437 |
hideLoading();
|
| 438 |
if (data.success) {
|
| 439 |
-
showToast('
|
| 440 |
loadTokens();
|
| 441 |
} else {
|
| 442 |
showToast(data.message || '删除失败', 'error');
|
|
@@ -447,18 +910,74 @@ async function deleteToken(refreshToken) {
|
|
| 447 |
}
|
| 448 |
}
|
| 449 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
async function showQuotaModal(refreshToken) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
const modal = document.createElement('div');
|
| 452 |
modal.className = 'modal';
|
|
|
|
| 453 |
modal.innerHTML = `
|
| 454 |
-
<div class="modal-content
|
| 455 |
-
<div class="modal-
|
| 456 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
<div class="quota-loading">加载中...</div>
|
| 458 |
</div>
|
| 459 |
<div class="modal-actions">
|
| 460 |
-
<button class="btn btn-info" onclick="refreshQuotaData(
|
| 461 |
-
<button class="btn btn-secondary" onclick="this.closest('.modal').remove()">关闭</button>
|
| 462 |
</div>
|
| 463 |
</div>
|
| 464 |
`;
|
|
@@ -466,18 +985,73 @@ async function showQuotaModal(refreshToken) {
|
|
| 466 |
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
|
| 467 |
|
| 468 |
await loadQuotaData(refreshToken);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
}
|
| 470 |
|
| 471 |
async function loadQuotaData(refreshToken, forceRefresh = false) {
|
| 472 |
const quotaContent = document.getElementById('quotaContent');
|
| 473 |
if (!quotaContent) return;
|
| 474 |
|
| 475 |
-
const refreshBtn = document.
|
| 476 |
if (refreshBtn) {
|
| 477 |
refreshBtn.disabled = true;
|
| 478 |
refreshBtn.textContent = '⏳ 加载中...';
|
| 479 |
}
|
| 480 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
quotaContent.innerHTML = '<div class="quota-loading">加载中...</div>';
|
| 482 |
|
| 483 |
try {
|
|
@@ -489,85 +1063,9 @@ async function loadQuotaData(refreshToken, forceRefresh = false) {
|
|
| 489 |
const data = await response.json();
|
| 490 |
|
| 491 |
if (data.success) {
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
if (Object.keys(models).length === 0) {
|
| 496 |
-
quotaContent.innerHTML = '<div class="quota-empty">暂无额度信息</div>';
|
| 497 |
-
return;
|
| 498 |
-
}
|
| 499 |
-
|
| 500 |
-
const lastUpdated = new Date(quotaData.lastUpdated).toLocaleString('zh-CN', {
|
| 501 |
-
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
|
| 502 |
-
});
|
| 503 |
-
|
| 504 |
-
// 按模型类型分组
|
| 505 |
-
const grouped = { claude: [], gemini: [], other: [] };
|
| 506 |
-
Object.entries(models).forEach(([modelId, quota]) => {
|
| 507 |
-
const item = { modelId, quota };
|
| 508 |
-
if (modelId.toLowerCase().includes('claude')) grouped.claude.push(item);
|
| 509 |
-
else if (modelId.toLowerCase().includes('gemini')) grouped.gemini.push(item);
|
| 510 |
-
else grouped.other.push(item);
|
| 511 |
-
});
|
| 512 |
-
|
| 513 |
-
let html = `<div class="quota-header">更新于 ${lastUpdated}</div>`;
|
| 514 |
-
|
| 515 |
-
// 渲染各组
|
| 516 |
-
if (grouped.claude.length > 0) {
|
| 517 |
-
html += '<div class="quota-group-title">🤖 Claude 模型</div>';
|
| 518 |
-
grouped.claude.forEach(({ modelId, quota }) => {
|
| 519 |
-
const percentage = (quota.remaining * 100).toFixed(1);
|
| 520 |
-
const barColor = percentage > 50 ? '#10b981' : percentage > 20 ? '#f59e0b' : '#ef4444';
|
| 521 |
-
html += `
|
| 522 |
-
<div class="quota-item">
|
| 523 |
-
<div class="quota-model-name">${modelId}</div>
|
| 524 |
-
<div class="quota-bar-container">
|
| 525 |
-
<div class="quota-bar" style="width: ${percentage}%; background: ${barColor};"></div>
|
| 526 |
-
<span class="quota-percentage">${percentage}%</span>
|
| 527 |
-
</div>
|
| 528 |
-
<div class="quota-reset">🔄 重置: ${quota.resetTime}</div>
|
| 529 |
-
</div>
|
| 530 |
-
`;
|
| 531 |
-
});
|
| 532 |
-
}
|
| 533 |
-
|
| 534 |
-
if (grouped.gemini.length > 0) {
|
| 535 |
-
html += '<div class="quota-group-title">💎 Gemini 模型</div>';
|
| 536 |
-
grouped.gemini.forEach(({ modelId, quota }) => {
|
| 537 |
-
const percentage = (quota.remaining * 100).toFixed(1);
|
| 538 |
-
const barColor = percentage > 50 ? '#10b981' : percentage > 20 ? '#f59e0b' : '#ef4444';
|
| 539 |
-
html += `
|
| 540 |
-
<div class="quota-item">
|
| 541 |
-
<div class="quota-model-name">${modelId}</div>
|
| 542 |
-
<div class="quota-bar-container">
|
| 543 |
-
<div class="quota-bar" style="width: ${percentage}%; background: ${barColor};"></div>
|
| 544 |
-
<span class="quota-percentage">${percentage}%</span>
|
| 545 |
-
</div>
|
| 546 |
-
<div class="quota-reset">🔄 重置: ${quota.resetTime}</div>
|
| 547 |
-
</div>
|
| 548 |
-
`;
|
| 549 |
-
});
|
| 550 |
-
}
|
| 551 |
-
|
| 552 |
-
if (grouped.other.length > 0) {
|
| 553 |
-
html += '<div class="quota-group-title">🔧 其他模型</div>';
|
| 554 |
-
grouped.other.forEach(({ modelId, quota }) => {
|
| 555 |
-
const percentage = (quota.remaining * 100).toFixed(1);
|
| 556 |
-
const barColor = percentage > 50 ? '#10b981' : percentage > 20 ? '#f59e0b' : '#ef4444';
|
| 557 |
-
html += `
|
| 558 |
-
<div class="quota-item">
|
| 559 |
-
<div class="quota-model-name">${modelId}</div>
|
| 560 |
-
<div class="quota-bar-container">
|
| 561 |
-
<div class="quota-bar" style="width: ${percentage}%; background: ${barColor};"></div>
|
| 562 |
-
<span class="quota-percentage">${percentage}%</span>
|
| 563 |
-
</div>
|
| 564 |
-
<div class="quota-reset">🔄 重置: ${quota.resetTime}</div>
|
| 565 |
-
</div>
|
| 566 |
-
`;
|
| 567 |
-
});
|
| 568 |
-
}
|
| 569 |
-
|
| 570 |
-
quotaContent.innerHTML = html;
|
| 571 |
} else {
|
| 572 |
quotaContent.innerHTML = `<div class="quota-error">加载失败: ${data.message}</div>`;
|
| 573 |
}
|
|
@@ -578,13 +1076,113 @@ async function loadQuotaData(refreshToken, forceRefresh = false) {
|
|
| 578 |
} finally {
|
| 579 |
if (refreshBtn) {
|
| 580 |
refreshBtn.disabled = false;
|
| 581 |
-
refreshBtn.textContent = '🔄
|
| 582 |
}
|
| 583 |
}
|
| 584 |
}
|
| 585 |
|
| 586 |
-
async function refreshQuotaData(
|
| 587 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
}
|
| 589 |
|
| 590 |
async function loadConfig() {
|
|
@@ -597,6 +1195,12 @@ async function loadConfig() {
|
|
| 597 |
const form = document.getElementById('configForm');
|
| 598 |
const { env, json } = data.data;
|
| 599 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
// 加载 .env 配置
|
| 601 |
Object.entries(env).forEach(([key, value]) => {
|
| 602 |
const input = form.elements[key];
|
|
@@ -608,19 +1212,33 @@ async function loadConfig() {
|
|
| 608 |
if (form.elements['PORT']) form.elements['PORT'].value = json.server.port || '';
|
| 609 |
if (form.elements['HOST']) form.elements['HOST'].value = json.server.host || '';
|
| 610 |
if (form.elements['MAX_REQUEST_SIZE']) form.elements['MAX_REQUEST_SIZE'].value = json.server.maxRequestSize || '';
|
|
|
|
|
|
|
| 611 |
}
|
| 612 |
if (json.defaults) {
|
| 613 |
if (form.elements['DEFAULT_TEMPERATURE']) form.elements['DEFAULT_TEMPERATURE'].value = json.defaults.temperature ?? '';
|
| 614 |
if (form.elements['DEFAULT_TOP_P']) form.elements['DEFAULT_TOP_P'].value = json.defaults.topP ?? '';
|
| 615 |
if (form.elements['DEFAULT_TOP_K']) form.elements['DEFAULT_TOP_K'].value = json.defaults.topK ?? '';
|
| 616 |
if (form.elements['DEFAULT_MAX_TOKENS']) form.elements['DEFAULT_MAX_TOKENS'].value = json.defaults.maxTokens ?? '';
|
|
|
|
| 617 |
}
|
| 618 |
if (json.other) {
|
| 619 |
if (form.elements['TIMEOUT']) form.elements['TIMEOUT'].value = json.other.timeout ?? '';
|
| 620 |
-
if (form.elements['MAX_IMAGES']) form.elements['MAX_IMAGES'].value = json.other.maxImages ?? '';
|
| 621 |
-
if (form.elements['USE_NATIVE_AXIOS']) form.elements['USE_NATIVE_AXIOS'].value = json.other.useNativeAxios ? 'true' : 'false';
|
| 622 |
if (form.elements['SKIP_PROJECT_ID_FETCH']) form.elements['SKIP_PROJECT_ID_FETCH'].value = json.other.skipProjectIdFetch ? 'true' : 'false';
|
| 623 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 624 |
}
|
| 625 |
} catch (error) {
|
| 626 |
showToast('加载配置失败: ' + error.message, 'error');
|
|
@@ -639,7 +1257,8 @@ document.getElementById('configForm').addEventListener('submit', async (e) => {
|
|
| 639 |
server: {},
|
| 640 |
api: {},
|
| 641 |
defaults: {},
|
| 642 |
-
other: {}
|
|
|
|
| 643 |
};
|
| 644 |
|
| 645 |
Object.entries(allConfig).forEach(([key, value]) => {
|
|
@@ -647,28 +1266,39 @@ document.getElementById('configForm').addEventListener('submit', async (e) => {
|
|
| 647 |
envConfig[key] = value;
|
| 648 |
} else {
|
| 649 |
// 映射到 config.json 结构
|
| 650 |
-
if (key === 'PORT') jsonConfig.server.port = parseInt(value);
|
| 651 |
-
else if (key === 'HOST') jsonConfig.server.host = value;
|
| 652 |
-
else if (key === 'MAX_REQUEST_SIZE') jsonConfig.server.maxRequestSize = value;
|
| 653 |
-
else if (key === '
|
| 654 |
-
else if (key === '
|
| 655 |
-
else if (key === '
|
| 656 |
-
else if (key === '
|
| 657 |
-
else if (key === '
|
| 658 |
-
else if (key === '
|
| 659 |
-
else if (key === '
|
| 660 |
-
else if (key === '
|
| 661 |
-
else if (key === 'DEFAULT_MAX_TOKENS') jsonConfig.defaults.maxTokens = parseInt(value);
|
| 662 |
-
else if (key === 'USE_NATIVE_AXIOS') jsonConfig.other.useNativeAxios = value !== 'false';
|
| 663 |
-
else if (key === 'TIMEOUT') jsonConfig.other.timeout = parseInt(value);
|
| 664 |
-
else if (key === 'MAX_IMAGES') jsonConfig.other.maxImages = parseInt(value);
|
| 665 |
else if (key === 'SKIP_PROJECT_ID_FETCH') jsonConfig.other.skipProjectIdFetch = value === 'true';
|
|
|
|
|
|
|
| 666 |
else envConfig[key] = value;
|
| 667 |
}
|
| 668 |
});
|
| 669 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
showLoading('正在保存配置...');
|
| 671 |
try {
|
|
|
|
| 672 |
const response = await authFetch('/admin/config', {
|
| 673 |
method: 'PUT',
|
| 674 |
headers: {
|
|
@@ -679,9 +1309,23 @@ document.getElementById('configForm').addEventListener('submit', async (e) => {
|
|
| 679 |
});
|
| 680 |
|
| 681 |
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 682 |
hideLoading();
|
| 683 |
if (data.success) {
|
| 684 |
-
showToast(
|
|
|
|
| 685 |
} else {
|
| 686 |
showToast(data.message || '保存失败', 'error');
|
| 687 |
}
|
|
|
|
| 1 |
let authToken = localStorage.getItem('authToken');
|
| 2 |
let oauthPort = null;
|
| 3 |
+
|
| 4 |
+
// 字体大小设置
|
| 5 |
+
function initFontSize() {
|
| 6 |
+
const savedSize = localStorage.getItem('fontSize') || '18';
|
| 7 |
+
document.documentElement.style.setProperty('--font-size-base', savedSize + 'px');
|
| 8 |
+
updateFontSizeInputs(savedSize);
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
function changeFontSize(size) {
|
| 12 |
+
// 限制范围
|
| 13 |
+
size = Math.max(10, Math.min(24, parseInt(size) || 14));
|
| 14 |
+
document.documentElement.style.setProperty('--font-size-base', size + 'px');
|
| 15 |
+
localStorage.setItem('fontSize', size);
|
| 16 |
+
updateFontSizeInputs(size);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function updateFontSizeInputs(size) {
|
| 20 |
+
const rangeInput = document.getElementById('fontSizeRange');
|
| 21 |
+
const numberInput = document.getElementById('fontSizeInput');
|
| 22 |
+
if (rangeInput) rangeInput.value = size;
|
| 23 |
+
if (numberInput) numberInput.value = size;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// 页面加载时初始化字体大小
|
| 27 |
+
initFontSize();
|
| 28 |
+
|
| 29 |
+
// 敏感信息隐藏功能 - 默认隐藏
|
| 30 |
+
// localStorage 存储的是字符串 'true' 或 'false'
|
| 31 |
+
// 如果没有存储过,默认为隐藏状态
|
| 32 |
+
let sensitiveInfoHidden = localStorage.getItem('sensitiveInfoHidden') !== 'false';
|
| 33 |
+
|
| 34 |
+
function initSensitiveInfo() {
|
| 35 |
+
updateSensitiveInfoDisplay();
|
| 36 |
+
updateSensitiveBtn();
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function toggleSensitiveInfo() {
|
| 40 |
+
sensitiveInfoHidden = !sensitiveInfoHidden;
|
| 41 |
+
localStorage.setItem('sensitiveInfoHidden', sensitiveInfoHidden);
|
| 42 |
+
updateSensitiveInfoDisplay();
|
| 43 |
+
updateSensitiveBtn();
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function updateSensitiveBtn() {
|
| 47 |
+
const btn = document.getElementById('toggleSensitiveBtn');
|
| 48 |
+
if (btn) {
|
| 49 |
+
if (sensitiveInfoHidden) {
|
| 50 |
+
btn.innerHTML = '🙈 隐藏';
|
| 51 |
+
btn.title = '点击显示敏感信息';
|
| 52 |
+
btn.classList.remove('btn-info');
|
| 53 |
+
btn.classList.add('btn-secondary');
|
| 54 |
+
} else {
|
| 55 |
+
btn.innerHTML = '👁️ 显示';
|
| 56 |
+
btn.title = '点击隐藏敏感信息';
|
| 57 |
+
btn.classList.remove('btn-secondary');
|
| 58 |
+
btn.classList.add('btn-info');
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function updateSensitiveInfoDisplay() {
|
| 64 |
+
document.querySelectorAll('.sensitive-info').forEach(el => {
|
| 65 |
+
if (sensitiveInfoHidden) {
|
| 66 |
+
el.dataset.original = el.textContent;
|
| 67 |
+
el.textContent = '••••••';
|
| 68 |
+
el.classList.add('blurred');
|
| 69 |
+
} else if (el.dataset.original) {
|
| 70 |
+
el.textContent = el.dataset.original;
|
| 71 |
+
el.classList.remove('blurred');
|
| 72 |
+
}
|
| 73 |
+
});
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// 页面加载时初始化敏感信息状态
|
| 77 |
+
initSensitiveInfo();
|
| 78 |
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
| 79 |
const SCOPES = [
|
| 80 |
'https://www.googleapis.com/auth/cloud-platform',
|
|
|
|
| 177 |
if (data.success) {
|
| 178 |
authToken = data.token;
|
| 179 |
localStorage.setItem('authToken', authToken);
|
| 180 |
+
showToast('登录成功', 'success');
|
| 181 |
showMainContent();
|
| 182 |
loadTokens();
|
| 183 |
+
loadConfig();
|
| 184 |
} else {
|
| 185 |
showToast(data.message || '用户名或密码错误', 'error');
|
| 186 |
}
|
|
|
|
| 194 |
});
|
| 195 |
|
| 196 |
function showOAuthModal() {
|
| 197 |
+
showToast('点击后请在新窗口完成授权', 'info');
|
| 198 |
const modal = document.createElement('div');
|
| 199 |
modal.className = 'modal form-modal';
|
| 200 |
modal.innerHTML = `
|
|
|
|
| 206 |
<p>2️⃣ 完成授权后,复制浏览器地址栏的完整URL</p>
|
| 207 |
<p>3️⃣ 粘贴URL到下方输入框并提交</p>
|
| 208 |
</div>
|
| 209 |
+
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
|
| 210 |
<button type="button" onclick="openOAuthWindow()" class="btn btn-success" style="flex: 1;">🔐 打开授权页面</button>
|
| 211 |
+
<button type="button" onclick="copyOAuthUrl()" class="btn btn-info" style="flex: 1;">📋 复制授权链接</button>
|
| 212 |
</div>
|
| 213 |
<input type="text" id="modalCallbackUrl" placeholder="粘贴完整的回调URL (http://localhost:xxxxx/oauth-callback?code=...)">
|
| 214 |
<div class="modal-actions">
|
|
|
|
| 232 |
<input type="text" id="modalRefreshToken" placeholder="Refresh Token (必填)">
|
| 233 |
<input type="number" id="modalExpiresIn" placeholder="过期时间(秒)" value="3599">
|
| 234 |
</div>
|
| 235 |
+
<p style="font-size: 0.8rem; color: var(--text-light); margin-bottom: 12px;">💡 过期时间默认3599秒(约1小时)</p>
|
| 236 |
<div class="modal-actions">
|
| 237 |
<button class="btn btn-secondary" onclick="this.closest('.modal').remove()">取消</button>
|
| 238 |
<button class="btn btn-success" onclick="addTokenFromModal()">✅ 添加</button>
|
|
|
|
| 259 |
function copyOAuthUrl() {
|
| 260 |
const url = getOAuthUrl();
|
| 261 |
navigator.clipboard.writeText(url).then(() => {
|
| 262 |
+
showToast('授权链接已复制', 'success');
|
| 263 |
}).catch(() => {
|
| 264 |
+
showToast('复制失败', 'error');
|
| 265 |
});
|
| 266 |
}
|
| 267 |
|
|
|
|
| 282 |
|
| 283 |
if (!code) {
|
| 284 |
hideLoading();
|
| 285 |
+
showToast('URL中未找到授权码', 'error');
|
| 286 |
return;
|
| 287 |
}
|
| 288 |
|
|
|
|
| 311 |
hideLoading();
|
| 312 |
if (addResult.success) {
|
| 313 |
modal.remove();
|
| 314 |
+
showToast('Token添加成功', 'success');
|
| 315 |
loadTokens();
|
| 316 |
} else {
|
| 317 |
+
showToast('添加失败: ' + addResult.message, 'error');
|
| 318 |
}
|
| 319 |
} else {
|
| 320 |
hideLoading();
|
| 321 |
+
showToast('交换失败: ' + result.message, 'error');
|
| 322 |
}
|
| 323 |
} catch (error) {
|
| 324 |
hideLoading();
|
|
|
|
| 352 |
hideLoading();
|
| 353 |
if (data.success) {
|
| 354 |
modal.remove();
|
| 355 |
+
showToast('Token添加成功', 'success');
|
| 356 |
loadTokens();
|
| 357 |
} else {
|
| 358 |
showToast(data.message || '添加失败', 'error');
|
|
|
|
| 372 |
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
| 373 |
event.target.classList.add('active');
|
| 374 |
|
| 375 |
+
document.getElementById('tokensPage').classList.add('hidden');
|
| 376 |
+
document.getElementById('settingsPage').classList.add('hidden');
|
| 377 |
+
|
| 378 |
if (tab === 'tokens') {
|
| 379 |
document.getElementById('tokensPage').classList.remove('hidden');
|
|
|
|
| 380 |
} else if (tab === 'settings') {
|
|
|
|
| 381 |
document.getElementById('settingsPage').classList.remove('hidden');
|
| 382 |
loadConfig();
|
| 383 |
}
|
|
|
|
| 416 |
}
|
| 417 |
|
| 418 |
function renderTokens(tokens) {
|
| 419 |
+
// 缓存tokens用于额度弹窗
|
| 420 |
+
cachedTokens = tokens;
|
| 421 |
+
|
| 422 |
document.getElementById('totalTokens').textContent = tokens.length;
|
| 423 |
document.getElementById('enabledTokens').textContent = tokens.filter(t => t.enable).length;
|
| 424 |
document.getElementById('disabledTokens').textContent = tokens.filter(t => !t.enable).length;
|
|
|
|
| 429 |
<div class="empty-state">
|
| 430 |
<div class="empty-state-icon">📦</div>
|
| 431 |
<div class="empty-state-text">暂无Token</div>
|
| 432 |
+
<div class="empty-state-hint">点击上方OAuth按钮添加Token</div>
|
| 433 |
</div>
|
| 434 |
`;
|
| 435 |
return;
|
| 436 |
}
|
| 437 |
|
| 438 |
+
tokenList.innerHTML = tokens.map(token => {
|
| 439 |
+
const expireTime = new Date(token.timestamp + token.expires_in * 1000);
|
| 440 |
+
const isExpired = expireTime < new Date();
|
| 441 |
+
const expireStr = expireTime.toLocaleString('zh-CN', {month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'});
|
| 442 |
+
const cardId = token.refresh_token.substring(0, 8);
|
| 443 |
+
|
| 444 |
+
return `
|
| 445 |
+
<div class="token-card ${!token.enable ? 'disabled' : ''} ${isExpired ? 'expired' : ''}">
|
| 446 |
<div class="token-header">
|
| 447 |
<span class="status ${token.enable ? 'enabled' : 'disabled'}">
|
| 448 |
${token.enable ? '✅ 启用' : '❌ 禁用'}
|
| 449 |
</span>
|
| 450 |
+
<div class="token-header-right">
|
| 451 |
+
<button class="btn-icon" onclick="showTokenDetail('${token.refresh_token}')" title="编辑全部">✏️</button>
|
| 452 |
+
<span class="token-id">#${token.refresh_token.substring(0, 6)}</span>
|
| 453 |
+
</div>
|
| 454 |
</div>
|
| 455 |
<div class="token-info">
|
| 456 |
<div class="info-row">
|
| 457 |
+
<span class="info-label">🎫</span>
|
| 458 |
+
<span class="info-value sensitive-info" title="${token.access_token_suffix}">${token.access_token_suffix}</span>
|
| 459 |
</div>
|
| 460 |
+
<div class="info-row editable" onclick="editField(event, '${token.refresh_token}', 'projectId', '${(token.projectId || '').replace(/'/g, "\\'")}')" title="点击编辑">
|
| 461 |
+
<span class="info-label">📦</span>
|
| 462 |
+
<span class="info-value sensitive-info">${token.projectId || '点击设置'}</span>
|
| 463 |
+
<span class="info-edit-icon">✏️</span>
|
| 464 |
</div>
|
| 465 |
+
<div class="info-row editable" onclick="editField(event, '${token.refresh_token}', 'email', '${(token.email || '').replace(/'/g, "\\'")}')" title="点击编辑">
|
| 466 |
+
<span class="info-label">📧</span>
|
| 467 |
+
<span class="info-value sensitive-info">${token.email || '点击设置'}</span>
|
| 468 |
+
<span class="info-edit-icon">✏️</span>
|
| 469 |
</div>
|
| 470 |
+
<div class="info-row ${isExpired ? 'expired-text' : ''}">
|
| 471 |
+
<span class="info-label">⏰</span>
|
| 472 |
+
<span class="info-value">${expireStr}${isExpired ? ' (已过期)' : ''}</span>
|
| 473 |
</div>
|
| 474 |
</div>
|
| 475 |
+
<!-- 内嵌额度显示 -->
|
| 476 |
+
<div class="token-quota-inline" id="quota-inline-${cardId}">
|
| 477 |
+
<div class="quota-inline-header" onclick="toggleQuotaExpand('${cardId}', '${token.refresh_token}')">
|
| 478 |
+
<span class="quota-inline-summary" id="quota-summary-${cardId}">📊 加载中...</span>
|
| 479 |
+
<span class="quota-inline-toggle" id="quota-toggle-${cardId}">▼</span>
|
| 480 |
+
</div>
|
| 481 |
+
<div class="quota-inline-detail hidden" id="quota-detail-${cardId}"></div>
|
| 482 |
+
</div>
|
| 483 |
<div class="token-actions">
|
| 484 |
+
<button class="btn btn-info btn-xs" onclick="showQuotaModal('${token.refresh_token}')" title="查看额度">📊 详情</button>
|
| 485 |
+
<button class="btn ${token.enable ? 'btn-warning' : 'btn-success'} btn-xs" onclick="toggleToken('${token.refresh_token}', ${!token.enable})" title="${token.enable ? '禁用' : '启用'}">
|
| 486 |
${token.enable ? '⏸️ 禁用' : '▶️ 启用'}
|
| 487 |
</button>
|
| 488 |
+
<button class="btn btn-danger btn-xs" onclick="deleteToken('${token.refresh_token}')" title="删除">🗑️ 删除</button>
|
| 489 |
+
</div>
|
| 490 |
+
</div>
|
| 491 |
+
`}).join('');
|
| 492 |
+
|
| 493 |
+
// 自动加载所有token的额度摘要
|
| 494 |
+
tokens.forEach(token => {
|
| 495 |
+
loadTokenQuotaSummary(token.refresh_token);
|
| 496 |
+
});
|
| 497 |
+
|
| 498 |
+
// 应用敏感信息隐藏状态
|
| 499 |
+
updateSensitiveInfoDisplay();
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
// 加载token额度摘要(只显示最低额度的模型)
|
| 503 |
+
async function loadTokenQuotaSummary(refreshToken) {
|
| 504 |
+
const cardId = refreshToken.substring(0, 8);
|
| 505 |
+
const summaryEl = document.getElementById(`quota-summary-${cardId}`);
|
| 506 |
+
if (!summaryEl) return;
|
| 507 |
+
|
| 508 |
+
// 先检查缓存
|
| 509 |
+
const cached = quotaCache.get(refreshToken);
|
| 510 |
+
if (cached) {
|
| 511 |
+
renderQuotaSummary(summaryEl, cached);
|
| 512 |
+
return;
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
try {
|
| 516 |
+
const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}/quotas`, {
|
| 517 |
+
headers: { 'Authorization': `Bearer ${authToken}` }
|
| 518 |
+
});
|
| 519 |
+
const data = await response.json();
|
| 520 |
+
|
| 521 |
+
if (data.success && data.data && data.data.models) {
|
| 522 |
+
// 缓存数据
|
| 523 |
+
quotaCache.set(refreshToken, data.data);
|
| 524 |
+
renderQuotaSummary(summaryEl, data.data);
|
| 525 |
+
} else {
|
| 526 |
+
const errMsg = data.message || '未知错误';
|
| 527 |
+
summaryEl.innerHTML = `<span class="quota-summary-error">📊 ${errMsg}</span>`;
|
| 528 |
+
}
|
| 529 |
+
} catch (error) {
|
| 530 |
+
if (error.message !== 'Unauthorized') {
|
| 531 |
+
console.error('加载额度摘要失败:', error);
|
| 532 |
+
summaryEl.innerHTML = `<span class="quota-summary-error">📊 加载失败</span>`;
|
| 533 |
+
}
|
| 534 |
+
}
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
// 渲染额度摘要
|
| 538 |
+
function renderQuotaSummary(summaryEl, quotaData) {
|
| 539 |
+
const models = quotaData.models;
|
| 540 |
+
const modelEntries = Object.entries(models);
|
| 541 |
+
|
| 542 |
+
if (modelEntries.length === 0) {
|
| 543 |
+
summaryEl.textContent = '📊 暂无额度';
|
| 544 |
+
return;
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
// 找到额度最低的模型
|
| 548 |
+
let minModel = modelEntries[0][0];
|
| 549 |
+
let minQuota = modelEntries[0][1];
|
| 550 |
+
modelEntries.forEach(([modelId, quota]) => {
|
| 551 |
+
if (quota.remaining < minQuota.remaining) {
|
| 552 |
+
minQuota = quota;
|
| 553 |
+
minModel = modelId;
|
| 554 |
+
}
|
| 555 |
+
});
|
| 556 |
+
|
| 557 |
+
const percentage = (minQuota.remaining * 100).toFixed(0);
|
| 558 |
+
const shortName = minModel.replace('models/', '').replace('publishers/google/', '').split('/').pop();
|
| 559 |
+
const barColor = percentage > 50 ? '#10b981' : percentage > 20 ? '#f59e0b' : '#ef4444';
|
| 560 |
+
|
| 561 |
+
// 简洁的一行显示
|
| 562 |
+
summaryEl.innerHTML = `
|
| 563 |
+
<span class="quota-summary-icon">📊</span>
|
| 564 |
+
<span class="quota-summary-model" title="${minModel}">${shortName}</span>
|
| 565 |
+
<span class="quota-summary-bar"><span style="width:${percentage}%;background:${barColor}"></span></span>
|
| 566 |
+
<span class="quota-summary-pct">${percentage}%</span>
|
| 567 |
+
`;
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
// 展开/收起额度详情
|
| 571 |
+
async function toggleQuotaExpand(cardId, refreshToken) {
|
| 572 |
+
const detailEl = document.getElementById(`quota-detail-${cardId}`);
|
| 573 |
+
const toggleEl = document.getElementById(`quota-toggle-${cardId}`);
|
| 574 |
+
if (!detailEl || !toggleEl) return;
|
| 575 |
+
|
| 576 |
+
const isHidden = detailEl.classList.contains('hidden');
|
| 577 |
+
|
| 578 |
+
if (isHidden) {
|
| 579 |
+
// 展开
|
| 580 |
+
detailEl.classList.remove('hidden');
|
| 581 |
+
toggleEl.textContent = '▲';
|
| 582 |
+
|
| 583 |
+
// 如果还没加载过详情,加载它
|
| 584 |
+
if (!detailEl.dataset.loaded) {
|
| 585 |
+
detailEl.innerHTML = '<div class="quota-loading-small">加载中...</div>';
|
| 586 |
+
await loadQuotaDetail(cardId, refreshToken);
|
| 587 |
+
detailEl.dataset.loaded = 'true';
|
| 588 |
+
}
|
| 589 |
+
} else {
|
| 590 |
+
// 收起
|
| 591 |
+
detailEl.classList.add('hidden');
|
| 592 |
+
toggleEl.textContent = '▼';
|
| 593 |
+
}
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
// 加载额度详情
|
| 597 |
+
async function loadQuotaDetail(cardId, refreshToken) {
|
| 598 |
+
const detailEl = document.getElementById(`quota-detail-${cardId}`);
|
| 599 |
+
if (!detailEl) return;
|
| 600 |
+
|
| 601 |
+
try {
|
| 602 |
+
const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}/quotas`, {
|
| 603 |
+
headers: { 'Authorization': `Bearer ${authToken}` }
|
| 604 |
+
});
|
| 605 |
+
const data = await response.json();
|
| 606 |
+
|
| 607 |
+
if (data.success && data.data && data.data.models) {
|
| 608 |
+
const models = data.data.models;
|
| 609 |
+
const modelEntries = Object.entries(models);
|
| 610 |
+
|
| 611 |
+
if (modelEntries.length === 0) {
|
| 612 |
+
detailEl.innerHTML = '<div class="quota-empty-small">暂无额度信息</div>';
|
| 613 |
+
return;
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
// 按模型类型分组
|
| 617 |
+
const grouped = { claude: [], gemini: [], other: [] };
|
| 618 |
+
modelEntries.forEach(([modelId, quota]) => {
|
| 619 |
+
const item = { modelId, quota };
|
| 620 |
+
if (modelId.toLowerCase().includes('claude')) grouped.claude.push(item);
|
| 621 |
+
else if (modelId.toLowerCase().includes('gemini')) grouped.gemini.push(item);
|
| 622 |
+
else grouped.other.push(item);
|
| 623 |
+
});
|
| 624 |
+
|
| 625 |
+
let html = '<div class="quota-detail-grid">';
|
| 626 |
+
|
| 627 |
+
const renderGroup = (items, icon) => {
|
| 628 |
+
if (items.length === 0) return '';
|
| 629 |
+
let groupHtml = '';
|
| 630 |
+
items.forEach(({ modelId, quota }) => {
|
| 631 |
+
const percentage = (quota.remaining * 100).toFixed(0);
|
| 632 |
+
const barColor = percentage > 50 ? '#10b981' : percentage > 20 ? '#f59e0b' : '#ef4444';
|
| 633 |
+
const shortName = modelId.replace('models/', '').replace('publishers/google/', '').split('/').pop();
|
| 634 |
+
// 紧凑的一行显示
|
| 635 |
+
groupHtml += `
|
| 636 |
+
<div class="quota-detail-row" title="${modelId} - 重置: ${quota.resetTime}">
|
| 637 |
+
<span class="quota-detail-icon">${icon}</span>
|
| 638 |
+
<span class="quota-detail-name">${shortName}</span>
|
| 639 |
+
<span class="quota-detail-bar"><span style="width:${percentage}%;background:${barColor}"></span></span>
|
| 640 |
+
<span class="quota-detail-pct">${percentage}%</span>
|
| 641 |
+
</div>
|
| 642 |
+
`;
|
| 643 |
+
});
|
| 644 |
+
return groupHtml;
|
| 645 |
+
};
|
| 646 |
+
|
| 647 |
+
html += renderGroup(grouped.claude, '���');
|
| 648 |
+
html += renderGroup(grouped.gemini, '💎');
|
| 649 |
+
html += renderGroup(grouped.other, '🔧');
|
| 650 |
+
html += '</div>';
|
| 651 |
+
|
| 652 |
+
// 添加刷新按钮
|
| 653 |
+
html += `<button class="btn btn-info btn-xs quota-refresh-btn" onclick="refreshInlineQuota('${cardId}', '${refreshToken}')">🔄 刷新额度</button>`;
|
| 654 |
+
|
| 655 |
+
detailEl.innerHTML = html;
|
| 656 |
+
} else {
|
| 657 |
+
const errMsg = data.message || '未知错误';
|
| 658 |
+
detailEl.innerHTML = `<div class="quota-error-small">加载失败: ${errMsg}</div>`;
|
| 659 |
+
}
|
| 660 |
+
} catch (error) {
|
| 661 |
+
if (error.message !== 'Unauthorized') {
|
| 662 |
+
detailEl.innerHTML = `<div class="quota-error-small">网络错误</div>`;
|
| 663 |
+
}
|
| 664 |
+
}
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
// 刷新内嵌额度
|
| 668 |
+
async function refreshInlineQuota(cardId, refreshToken) {
|
| 669 |
+
const detailEl = document.getElementById(`quota-detail-${cardId}`);
|
| 670 |
+
const summaryEl = document.getElementById(`quota-summary-${cardId}`);
|
| 671 |
+
|
| 672 |
+
if (detailEl) {
|
| 673 |
+
detailEl.innerHTML = '<div class="quota-loading-small">刷新中...</div>';
|
| 674 |
+
}
|
| 675 |
+
if (summaryEl) {
|
| 676 |
+
summaryEl.textContent = '📊 刷新中...';
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
// 清除缓存
|
| 680 |
+
quotaCache.clear(refreshToken);
|
| 681 |
+
|
| 682 |
+
// 强制刷新
|
| 683 |
+
try {
|
| 684 |
+
const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}/quotas?refresh=true`, {
|
| 685 |
+
headers: { 'Authorization': `Bearer ${authToken}` }
|
| 686 |
+
});
|
| 687 |
+
const data = await response.json();
|
| 688 |
+
if (data.success && data.data) {
|
| 689 |
+
quotaCache.set(refreshToken, data.data);
|
| 690 |
+
}
|
| 691 |
+
} catch (e) {}
|
| 692 |
+
|
| 693 |
+
// 重新加载摘要和详情
|
| 694 |
+
await loadTokenQuotaSummary(refreshToken);
|
| 695 |
+
await loadQuotaDetail(cardId, refreshToken);
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
// 内联编辑字段
|
| 699 |
+
function editField(event, refreshToken, field, currentValue) {
|
| 700 |
+
event.stopPropagation();
|
| 701 |
+
const row = event.currentTarget;
|
| 702 |
+
const valueSpan = row.querySelector('.info-value');
|
| 703 |
+
|
| 704 |
+
// 如果已经在编辑状态,不重复创建
|
| 705 |
+
if (row.querySelector('input')) return;
|
| 706 |
+
|
| 707 |
+
const fieldLabels = {
|
| 708 |
+
projectId: 'Project ID',
|
| 709 |
+
email: '邮箱'
|
| 710 |
+
};
|
| 711 |
+
|
| 712 |
+
// 创建输入框
|
| 713 |
+
const input = document.createElement('input');
|
| 714 |
+
input.type = field === 'email' ? 'email' : 'text';
|
| 715 |
+
input.value = currentValue;
|
| 716 |
+
input.className = 'inline-edit-input';
|
| 717 |
+
input.placeholder = `输入${fieldLabels[field]}`;
|
| 718 |
+
|
| 719 |
+
// 保存原始内容
|
| 720 |
+
const originalContent = valueSpan.textContent;
|
| 721 |
+
valueSpan.style.display = 'none';
|
| 722 |
+
row.insertBefore(input, valueSpan.nextSibling);
|
| 723 |
+
input.focus();
|
| 724 |
+
input.select();
|
| 725 |
+
|
| 726 |
+
// 保存函数
|
| 727 |
+
const save = async () => {
|
| 728 |
+
const newValue = input.value.trim();
|
| 729 |
+
input.disabled = true;
|
| 730 |
+
|
| 731 |
+
try {
|
| 732 |
+
const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}`, {
|
| 733 |
+
method: 'PUT',
|
| 734 |
+
headers: {
|
| 735 |
+
'Content-Type': 'application/json',
|
| 736 |
+
'Authorization': `Bearer ${authToken}`
|
| 737 |
+
},
|
| 738 |
+
body: JSON.stringify({ [field]: newValue })
|
| 739 |
+
});
|
| 740 |
+
|
| 741 |
+
const data = await response.json();
|
| 742 |
+
if (data.success) {
|
| 743 |
+
showToast('已保存', 'success');
|
| 744 |
+
loadTokens();
|
| 745 |
+
} else {
|
| 746 |
+
showToast(data.message || '保存失败', 'error');
|
| 747 |
+
cancel();
|
| 748 |
+
}
|
| 749 |
+
} catch (error) {
|
| 750 |
+
showToast('保存失败', 'error');
|
| 751 |
+
cancel();
|
| 752 |
+
}
|
| 753 |
+
};
|
| 754 |
+
|
| 755 |
+
// 取消函数
|
| 756 |
+
const cancel = () => {
|
| 757 |
+
input.remove();
|
| 758 |
+
valueSpan.style.display = '';
|
| 759 |
+
};
|
| 760 |
+
|
| 761 |
+
// 事件监听
|
| 762 |
+
input.addEventListener('blur', () => {
|
| 763 |
+
setTimeout(() => {
|
| 764 |
+
if (document.activeElement !== input) {
|
| 765 |
+
if (input.value.trim() !== currentValue) {
|
| 766 |
+
save();
|
| 767 |
+
} else {
|
| 768 |
+
cancel();
|
| 769 |
+
}
|
| 770 |
+
}
|
| 771 |
+
}, 100);
|
| 772 |
+
});
|
| 773 |
+
|
| 774 |
+
input.addEventListener('keydown', (e) => {
|
| 775 |
+
if (e.key === 'Enter') {
|
| 776 |
+
e.preventDefault();
|
| 777 |
+
save();
|
| 778 |
+
} else if (e.key === 'Escape') {
|
| 779 |
+
cancel();
|
| 780 |
+
}
|
| 781 |
+
});
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
// 显示Token详情编辑弹窗
|
| 785 |
+
function showTokenDetail(refreshToken) {
|
| 786 |
+
const token = cachedTokens.find(t => t.refresh_token === refreshToken);
|
| 787 |
+
if (!token) {
|
| 788 |
+
showToast('Token不存在', 'error');
|
| 789 |
+
return;
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
const modal = document.createElement('div');
|
| 793 |
+
modal.className = 'modal form-modal';
|
| 794 |
+
modal.innerHTML = `
|
| 795 |
+
<div class="modal-content">
|
| 796 |
+
<div class="modal-title">📝 Token详情</div>
|
| 797 |
+
<div class="form-group compact">
|
| 798 |
+
<label>🎫 Access Token (只读)</label>
|
| 799 |
+
<div class="token-display">${token.access_token || ''}</div>
|
| 800 |
+
</div>
|
| 801 |
+
<div class="form-group compact">
|
| 802 |
+
<label>🔄 Refresh Token (只读)</label>
|
| 803 |
+
<div class="token-display">${token.refresh_token}</div>
|
| 804 |
+
</div>
|
| 805 |
+
<div class="form-group compact">
|
| 806 |
+
<label>📦 Project ID</label>
|
| 807 |
+
<input type="text" id="editProjectId" value="${token.projectId || ''}" placeholder="项目ID">
|
| 808 |
+
</div>
|
| 809 |
+
<div class="form-group compact">
|
| 810 |
+
<label>📧 邮箱</label>
|
| 811 |
+
<input type="email" id="editEmail" value="${token.email || ''}" placeholder="账号邮箱">
|
| 812 |
+
</div>
|
| 813 |
+
<div class="form-group compact">
|
| 814 |
+
<label>⏰ 过期时间</label>
|
| 815 |
+
<input type="text" value="${new Date(token.timestamp + token.expires_in * 1000).toLocaleString('zh-CN')}" readonly style="background: var(--bg); cursor: not-allowed;">
|
| 816 |
+
</div>
|
| 817 |
+
<div class="modal-actions">
|
| 818 |
+
<button class="btn btn-secondary" onclick="this.closest('.modal').remove()">取消</button>
|
| 819 |
+
<button class="btn btn-success" onclick="saveTokenDetail('${refreshToken}')">💾 保存</button>
|
| 820 |
</div>
|
| 821 |
</div>
|
| 822 |
+
`;
|
| 823 |
+
document.body.appendChild(modal);
|
| 824 |
+
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
// 保存Token详情
|
| 828 |
+
async function saveTokenDetail(refreshToken) {
|
| 829 |
+
const projectId = document.getElementById('editProjectId').value.trim();
|
| 830 |
+
const email = document.getElementById('editEmail').value.trim();
|
| 831 |
+
|
| 832 |
+
showLoading('保存中...');
|
| 833 |
+
try {
|
| 834 |
+
const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}`, {
|
| 835 |
+
method: 'PUT',
|
| 836 |
+
headers: {
|
| 837 |
+
'Content-Type': 'application/json',
|
| 838 |
+
'Authorization': `Bearer ${authToken}`
|
| 839 |
+
},
|
| 840 |
+
body: JSON.stringify({ projectId, email })
|
| 841 |
+
});
|
| 842 |
+
|
| 843 |
+
const data = await response.json();
|
| 844 |
+
hideLoading();
|
| 845 |
+
if (data.success) {
|
| 846 |
+
document.querySelector('.form-modal').remove();
|
| 847 |
+
showToast('保存成功', 'success');
|
| 848 |
+
loadTokens();
|
| 849 |
+
} else {
|
| 850 |
+
showToast(data.message || '保存失败', 'error');
|
| 851 |
+
}
|
| 852 |
+
} catch (error) {
|
| 853 |
+
hideLoading();
|
| 854 |
+
showToast('保存失败: ' + error.message, 'error');
|
| 855 |
+
}
|
| 856 |
}
|
| 857 |
|
| 858 |
async function toggleToken(refreshToken, enable) {
|
|
|
|
| 860 |
const confirmed = await showConfirm(`确定要${action}这个Token吗?`, `${action}确认`);
|
| 861 |
if (!confirmed) return;
|
| 862 |
|
| 863 |
+
showLoading(`正在${action}...`);
|
| 864 |
try {
|
| 865 |
const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}`, {
|
| 866 |
method: 'PUT',
|
|
|
|
| 874 |
const data = await response.json();
|
| 875 |
hideLoading();
|
| 876 |
if (data.success) {
|
| 877 |
+
showToast(`已${action}`, 'success');
|
| 878 |
loadTokens();
|
| 879 |
} else {
|
| 880 |
showToast(data.message || '操作失败', 'error');
|
|
|
|
| 886 |
}
|
| 887 |
|
| 888 |
async function deleteToken(refreshToken) {
|
| 889 |
+
const confirmed = await showConfirm('删除后无法恢复,确定删除?', '⚠️ 删除确认');
|
| 890 |
if (!confirmed) return;
|
| 891 |
|
| 892 |
+
showLoading('正在删除...');
|
| 893 |
try {
|
| 894 |
const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}`, {
|
| 895 |
method: 'DELETE',
|
|
|
|
| 899 |
const data = await response.json();
|
| 900 |
hideLoading();
|
| 901 |
if (data.success) {
|
| 902 |
+
showToast('已删除', 'success');
|
| 903 |
loadTokens();
|
| 904 |
} else {
|
| 905 |
showToast(data.message || '删除失败', 'error');
|
|
|
|
| 910 |
}
|
| 911 |
}
|
| 912 |
|
| 913 |
+
// 存储token数据用于额度弹窗显示邮箱
|
| 914 |
+
let cachedTokens = [];
|
| 915 |
+
// 当前选中的token(用于额度弹窗)
|
| 916 |
+
let currentQuotaToken = null;
|
| 917 |
+
|
| 918 |
+
// 额度数据缓存 - 避免频繁请求
|
| 919 |
+
const quotaCache = {
|
| 920 |
+
data: {}, // { refreshToken: { data, timestamp } }
|
| 921 |
+
ttl: 5 * 60 * 1000, // 缓存5分钟
|
| 922 |
+
|
| 923 |
+
get(refreshToken) {
|
| 924 |
+
const cached = this.data[refreshToken];
|
| 925 |
+
if (!cached) return null;
|
| 926 |
+
if (Date.now() - cached.timestamp > this.ttl) {
|
| 927 |
+
delete this.data[refreshToken];
|
| 928 |
+
return null;
|
| 929 |
+
}
|
| 930 |
+
return cached.data;
|
| 931 |
+
},
|
| 932 |
+
|
| 933 |
+
set(refreshToken, data) {
|
| 934 |
+
this.data[refreshToken] = {
|
| 935 |
+
data,
|
| 936 |
+
timestamp: Date.now()
|
| 937 |
+
};
|
| 938 |
+
},
|
| 939 |
+
|
| 940 |
+
clear(refreshToken) {
|
| 941 |
+
if (refreshToken) {
|
| 942 |
+
delete this.data[refreshToken];
|
| 943 |
+
} else {
|
| 944 |
+
this.data = {};
|
| 945 |
+
}
|
| 946 |
+
}
|
| 947 |
+
};
|
| 948 |
+
|
| 949 |
async function showQuotaModal(refreshToken) {
|
| 950 |
+
currentQuotaToken = refreshToken;
|
| 951 |
+
|
| 952 |
+
// 找到当前token的索引
|
| 953 |
+
const activeIndex = cachedTokens.findIndex(t => t.refresh_token === refreshToken);
|
| 954 |
+
|
| 955 |
+
// 生成邮箱标签 - 使用索引来确保只有一个active
|
| 956 |
+
const emailTabs = cachedTokens.map((t, index) => {
|
| 957 |
+
const email = t.email || '未知';
|
| 958 |
+
const shortEmail = email.length > 20 ? email.substring(0, 17) + '...' : email;
|
| 959 |
+
const isActive = index === activeIndex;
|
| 960 |
+
return `<button type="button" class="quota-tab${isActive ? ' active' : ''}" data-index="${index}" onclick="switchQuotaAccountByIndex(${index})" title="${email}">${shortEmail}</button>`;
|
| 961 |
+
}).join('');
|
| 962 |
+
|
| 963 |
const modal = document.createElement('div');
|
| 964 |
modal.className = 'modal';
|
| 965 |
+
modal.id = 'quotaModal';
|
| 966 |
modal.innerHTML = `
|
| 967 |
+
<div class="modal-content modal-xl">
|
| 968 |
+
<div class="quota-modal-header">
|
| 969 |
+
<div class="modal-title">📊 模型额度</div>
|
| 970 |
+
<div class="quota-update-time" id="quotaUpdateTime"></div>
|
| 971 |
+
</div>
|
| 972 |
+
<div class="quota-tabs" id="quotaEmailList">
|
| 973 |
+
${emailTabs}
|
| 974 |
+
</div>
|
| 975 |
+
<div id="quotaContent" class="quota-container">
|
| 976 |
<div class="quota-loading">加载中...</div>
|
| 977 |
</div>
|
| 978 |
<div class="modal-actions">
|
| 979 |
+
<button class="btn btn-info btn-sm" id="quotaRefreshBtn" onclick="refreshQuotaData()">🔄 刷新</button>
|
| 980 |
+
<button class="btn btn-secondary btn-sm" onclick="this.closest('.modal').remove()">关闭</button>
|
| 981 |
</div>
|
| 982 |
</div>
|
| 983 |
`;
|
|
|
|
| 985 |
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
|
| 986 |
|
| 987 |
await loadQuotaData(refreshToken);
|
| 988 |
+
|
| 989 |
+
// 添加鼠标滚轮横向滚动支持
|
| 990 |
+
const tabsContainer = document.getElementById('quotaEmailList');
|
| 991 |
+
if (tabsContainer) {
|
| 992 |
+
tabsContainer.addEventListener('wheel', (e) => {
|
| 993 |
+
if (e.deltaY !== 0) {
|
| 994 |
+
e.preventDefault();
|
| 995 |
+
tabsContainer.scrollLeft += e.deltaY;
|
| 996 |
+
}
|
| 997 |
+
}, { passive: false });
|
| 998 |
+
}
|
| 999 |
+
}
|
| 1000 |
+
|
| 1001 |
+
// 切换账号(通过索引)
|
| 1002 |
+
async function switchQuotaAccountByIndex(index) {
|
| 1003 |
+
if (index < 0 || index >= cachedTokens.length) return;
|
| 1004 |
+
|
| 1005 |
+
const token = cachedTokens[index];
|
| 1006 |
+
currentQuotaToken = token.refresh_token;
|
| 1007 |
+
|
| 1008 |
+
// 更新标签的激活状态
|
| 1009 |
+
document.querySelectorAll('.quota-tab').forEach((tab, i) => {
|
| 1010 |
+
if (i === index) {
|
| 1011 |
+
tab.classList.add('active');
|
| 1012 |
+
} else {
|
| 1013 |
+
tab.classList.remove('active');
|
| 1014 |
+
}
|
| 1015 |
+
});
|
| 1016 |
+
|
| 1017 |
+
// 加载新账号的额度
|
| 1018 |
+
await loadQuotaData(token.refresh_token);
|
| 1019 |
+
}
|
| 1020 |
+
|
| 1021 |
+
// 保留旧函数以兼容
|
| 1022 |
+
async function switchQuotaAccount(refreshToken) {
|
| 1023 |
+
const index = cachedTokens.findIndex(t => t.refresh_token === refreshToken);
|
| 1024 |
+
if (index >= 0) {
|
| 1025 |
+
await switchQuotaAccountByIndex(index);
|
| 1026 |
+
}
|
| 1027 |
}
|
| 1028 |
|
| 1029 |
async function loadQuotaData(refreshToken, forceRefresh = false) {
|
| 1030 |
const quotaContent = document.getElementById('quotaContent');
|
| 1031 |
if (!quotaContent) return;
|
| 1032 |
|
| 1033 |
+
const refreshBtn = document.getElementById('quotaRefreshBtn');
|
| 1034 |
if (refreshBtn) {
|
| 1035 |
refreshBtn.disabled = true;
|
| 1036 |
refreshBtn.textContent = '⏳ 加载中...';
|
| 1037 |
}
|
| 1038 |
|
| 1039 |
+
// 如果不是强制刷新,先检查缓存
|
| 1040 |
+
if (!forceRefresh) {
|
| 1041 |
+
const cached = quotaCache.get(refreshToken);
|
| 1042 |
+
if (cached) {
|
| 1043 |
+
renderQuotaModal(quotaContent, cached);
|
| 1044 |
+
if (refreshBtn) {
|
| 1045 |
+
refreshBtn.disabled = false;
|
| 1046 |
+
refreshBtn.textContent = '🔄 刷新';
|
| 1047 |
+
}
|
| 1048 |
+
return;
|
| 1049 |
+
}
|
| 1050 |
+
} else {
|
| 1051 |
+
// 强制刷新时清除缓存
|
| 1052 |
+
quotaCache.clear(refreshToken);
|
| 1053 |
+
}
|
| 1054 |
+
|
| 1055 |
quotaContent.innerHTML = '<div class="quota-loading">加载中...</div>';
|
| 1056 |
|
| 1057 |
try {
|
|
|
|
| 1063 |
const data = await response.json();
|
| 1064 |
|
| 1065 |
if (data.success) {
|
| 1066 |
+
// 缓存数据
|
| 1067 |
+
quotaCache.set(refreshToken, data.data);
|
| 1068 |
+
renderQuotaModal(quotaContent, data.data);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1069 |
} else {
|
| 1070 |
quotaContent.innerHTML = `<div class="quota-error">加载失败: ${data.message}</div>`;
|
| 1071 |
}
|
|
|
|
| 1076 |
} finally {
|
| 1077 |
if (refreshBtn) {
|
| 1078 |
refreshBtn.disabled = false;
|
| 1079 |
+
refreshBtn.textContent = '🔄 刷新';
|
| 1080 |
}
|
| 1081 |
}
|
| 1082 |
}
|
| 1083 |
|
| 1084 |
+
async function refreshQuotaData() {
|
| 1085 |
+
if (currentQuotaToken) {
|
| 1086 |
+
await loadQuotaData(currentQuotaToken, true);
|
| 1087 |
+
}
|
| 1088 |
+
}
|
| 1089 |
+
|
| 1090 |
+
// 渲染额度弹窗内容
|
| 1091 |
+
function renderQuotaModal(quotaContent, quotaData) {
|
| 1092 |
+
const models = quotaData.models;
|
| 1093 |
+
|
| 1094 |
+
// 更新时间显示
|
| 1095 |
+
const updateTimeEl = document.getElementById('quotaUpdateTime');
|
| 1096 |
+
if (updateTimeEl && quotaData.lastUpdated) {
|
| 1097 |
+
const lastUpdated = new Date(quotaData.lastUpdated).toLocaleString('zh-CN', {
|
| 1098 |
+
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
|
| 1099 |
+
});
|
| 1100 |
+
updateTimeEl.textContent = `更新于 ${lastUpdated}`;
|
| 1101 |
+
}
|
| 1102 |
+
|
| 1103 |
+
if (Object.keys(models).length === 0) {
|
| 1104 |
+
quotaContent.innerHTML = '<div class="quota-empty">暂无额度信息</div>';
|
| 1105 |
+
return;
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
// 按模型类型分组
|
| 1109 |
+
const grouped = { claude: [], gemini: [], other: [] };
|
| 1110 |
+
Object.entries(models).forEach(([modelId, quota]) => {
|
| 1111 |
+
const item = { modelId, quota };
|
| 1112 |
+
if (modelId.toLowerCase().includes('claude')) grouped.claude.push(item);
|
| 1113 |
+
else if (modelId.toLowerCase().includes('gemini')) grouped.gemini.push(item);
|
| 1114 |
+
else grouped.other.push(item);
|
| 1115 |
+
});
|
| 1116 |
+
|
| 1117 |
+
let html = '';
|
| 1118 |
+
|
| 1119 |
+
const renderGroup = (items, title) => {
|
| 1120 |
+
if (items.length === 0) return '';
|
| 1121 |
+
let groupHtml = `<div class="quota-group-title">${title}</div><div class="quota-grid">`;
|
| 1122 |
+
items.forEach(({ modelId, quota }) => {
|
| 1123 |
+
const percentage = (quota.remaining * 100).toFixed(0);
|
| 1124 |
+
const barColor = percentage > 50 ? '#10b981' : percentage > 20 ? '#f59e0b' : '#ef4444';
|
| 1125 |
+
const shortName = modelId.replace('models/', '').replace('publishers/google/', '');
|
| 1126 |
+
groupHtml += `
|
| 1127 |
+
<div class="quota-item">
|
| 1128 |
+
<div class="quota-model-name" title="${modelId}">${shortName}</div>
|
| 1129 |
+
<div class="quota-bar-container">
|
| 1130 |
+
<div class="quota-bar" style="width: ${percentage}%; background: ${barColor};"></div>
|
| 1131 |
+
</div>
|
| 1132 |
+
<div class="quota-info-row">
|
| 1133 |
+
<span class="quota-reset">重置: ${quota.resetTime}</span>
|
| 1134 |
+
<span class="quota-percentage">${percentage}%</span>
|
| 1135 |
+
</div>
|
| 1136 |
+
</div>
|
| 1137 |
+
`;
|
| 1138 |
+
});
|
| 1139 |
+
groupHtml += '</div>';
|
| 1140 |
+
return groupHtml;
|
| 1141 |
+
};
|
| 1142 |
+
|
| 1143 |
+
html += renderGroup(grouped.claude, '🤖 Claude');
|
| 1144 |
+
html += renderGroup(grouped.gemini, '💎 Gemini');
|
| 1145 |
+
html += renderGroup(grouped.other, '🔧 其他');
|
| 1146 |
+
|
| 1147 |
+
quotaContent.innerHTML = html;
|
| 1148 |
+
}
|
| 1149 |
+
|
| 1150 |
+
// 切换请求次数输入框的显示
|
| 1151 |
+
function toggleRequestCountInput() {
|
| 1152 |
+
const strategy = document.getElementById('rotationStrategy').value;
|
| 1153 |
+
const requestCountGroup = document.getElementById('requestCountGroup');
|
| 1154 |
+
if (requestCountGroup) {
|
| 1155 |
+
requestCountGroup.style.display = strategy === 'request_count' ? 'block' : 'none';
|
| 1156 |
+
}
|
| 1157 |
+
}
|
| 1158 |
+
|
| 1159 |
+
// 加载轮询策略状态
|
| 1160 |
+
async function loadRotationStatus() {
|
| 1161 |
+
try {
|
| 1162 |
+
const response = await authFetch('/admin/rotation', {
|
| 1163 |
+
headers: { 'Authorization': `Bearer ${authToken}` }
|
| 1164 |
+
});
|
| 1165 |
+
const data = await response.json();
|
| 1166 |
+
if (data.success) {
|
| 1167 |
+
const { strategy, requestCount, currentIndex, tokenCounts } = data.data;
|
| 1168 |
+
const strategyNames = {
|
| 1169 |
+
'round_robin': '均衡负载',
|
| 1170 |
+
'quota_exhausted': '额度耗尽切换',
|
| 1171 |
+
'request_count': '自定义次数'
|
| 1172 |
+
};
|
| 1173 |
+
const statusEl = document.getElementById('currentRotationInfo');
|
| 1174 |
+
if (statusEl) {
|
| 1175 |
+
let statusText = `${strategyNames[strategy] || strategy}`;
|
| 1176 |
+
if (strategy === 'request_count') {
|
| 1177 |
+
statusText += ` (每${requestCount}次)`;
|
| 1178 |
+
}
|
| 1179 |
+
statusText += ` | 当前索引: ${currentIndex}`;
|
| 1180 |
+
statusEl.textContent = statusText;
|
| 1181 |
+
}
|
| 1182 |
+
}
|
| 1183 |
+
} catch (error) {
|
| 1184 |
+
console.error('加载轮询状态失败:', error);
|
| 1185 |
+
}
|
| 1186 |
}
|
| 1187 |
|
| 1188 |
async function loadConfig() {
|
|
|
|
| 1195 |
const form = document.getElementById('configForm');
|
| 1196 |
const { env, json } = data.data;
|
| 1197 |
|
| 1198 |
+
// 更新服务器信息显示
|
| 1199 |
+
const serverInfo = document.getElementById('serverInfo');
|
| 1200 |
+
if (serverInfo && json.server) {
|
| 1201 |
+
serverInfo.textContent = `${json.server.host || '0.0.0.0'}:${json.server.port || 8045}`;
|
| 1202 |
+
}
|
| 1203 |
+
|
| 1204 |
// 加载 .env 配置
|
| 1205 |
Object.entries(env).forEach(([key, value]) => {
|
| 1206 |
const input = form.elements[key];
|
|
|
|
| 1212 |
if (form.elements['PORT']) form.elements['PORT'].value = json.server.port || '';
|
| 1213 |
if (form.elements['HOST']) form.elements['HOST'].value = json.server.host || '';
|
| 1214 |
if (form.elements['MAX_REQUEST_SIZE']) form.elements['MAX_REQUEST_SIZE'].value = json.server.maxRequestSize || '';
|
| 1215 |
+
if (form.elements['HEARTBEAT_INTERVAL']) form.elements['HEARTBEAT_INTERVAL'].value = json.server.heartbeatInterval || '';
|
| 1216 |
+
if (form.elements['MEMORY_THRESHOLD']) form.elements['MEMORY_THRESHOLD'].value = json.server.memoryThreshold || '';
|
| 1217 |
}
|
| 1218 |
if (json.defaults) {
|
| 1219 |
if (form.elements['DEFAULT_TEMPERATURE']) form.elements['DEFAULT_TEMPERATURE'].value = json.defaults.temperature ?? '';
|
| 1220 |
if (form.elements['DEFAULT_TOP_P']) form.elements['DEFAULT_TOP_P'].value = json.defaults.topP ?? '';
|
| 1221 |
if (form.elements['DEFAULT_TOP_K']) form.elements['DEFAULT_TOP_K'].value = json.defaults.topK ?? '';
|
| 1222 |
if (form.elements['DEFAULT_MAX_TOKENS']) form.elements['DEFAULT_MAX_TOKENS'].value = json.defaults.maxTokens ?? '';
|
| 1223 |
+
if (form.elements['DEFAULT_THINKING_BUDGET']) form.elements['DEFAULT_THINKING_BUDGET'].value = json.defaults.thinkingBudget ?? '';
|
| 1224 |
}
|
| 1225 |
if (json.other) {
|
| 1226 |
if (form.elements['TIMEOUT']) form.elements['TIMEOUT'].value = json.other.timeout ?? '';
|
|
|
|
|
|
|
| 1227 |
if (form.elements['SKIP_PROJECT_ID_FETCH']) form.elements['SKIP_PROJECT_ID_FETCH'].value = json.other.skipProjectIdFetch ? 'true' : 'false';
|
| 1228 |
}
|
| 1229 |
+
// 加载轮询策略配置
|
| 1230 |
+
if (json.rotation) {
|
| 1231 |
+
if (form.elements['ROTATION_STRATEGY']) {
|
| 1232 |
+
form.elements['ROTATION_STRATEGY'].value = json.rotation.strategy || 'round_robin';
|
| 1233 |
+
}
|
| 1234 |
+
if (form.elements['ROTATION_REQUEST_COUNT']) {
|
| 1235 |
+
form.elements['ROTATION_REQUEST_COUNT'].value = json.rotation.requestCount || 10;
|
| 1236 |
+
}
|
| 1237 |
+
toggleRequestCountInput();
|
| 1238 |
+
}
|
| 1239 |
+
|
| 1240 |
+
// 加载轮询状态
|
| 1241 |
+
loadRotationStatus();
|
| 1242 |
}
|
| 1243 |
} catch (error) {
|
| 1244 |
showToast('加载配置失败: ' + error.message, 'error');
|
|
|
|
| 1257 |
server: {},
|
| 1258 |
api: {},
|
| 1259 |
defaults: {},
|
| 1260 |
+
other: {},
|
| 1261 |
+
rotation: {}
|
| 1262 |
};
|
| 1263 |
|
| 1264 |
Object.entries(allConfig).forEach(([key, value]) => {
|
|
|
|
| 1266 |
envConfig[key] = value;
|
| 1267 |
} else {
|
| 1268 |
// 映射到 config.json 结构
|
| 1269 |
+
if (key === 'PORT') jsonConfig.server.port = parseInt(value) || undefined;
|
| 1270 |
+
else if (key === 'HOST') jsonConfig.server.host = value || undefined;
|
| 1271 |
+
else if (key === 'MAX_REQUEST_SIZE') jsonConfig.server.maxRequestSize = value || undefined;
|
| 1272 |
+
else if (key === 'HEARTBEAT_INTERVAL') jsonConfig.server.heartbeatInterval = parseInt(value) || undefined;
|
| 1273 |
+
else if (key === 'MEMORY_THRESHOLD') jsonConfig.server.memoryThreshold = parseInt(value) || undefined;
|
| 1274 |
+
else if (key === 'DEFAULT_TEMPERATURE') jsonConfig.defaults.temperature = parseFloat(value) || undefined;
|
| 1275 |
+
else if (key === 'DEFAULT_TOP_P') jsonConfig.defaults.topP = parseFloat(value) || undefined;
|
| 1276 |
+
else if (key === 'DEFAULT_TOP_K') jsonConfig.defaults.topK = parseInt(value) || undefined;
|
| 1277 |
+
else if (key === 'DEFAULT_MAX_TOKENS') jsonConfig.defaults.maxTokens = parseInt(value) || undefined;
|
| 1278 |
+
else if (key === 'DEFAULT_THINKING_BUDGET') jsonConfig.defaults.thinkingBudget = parseInt(value) || undefined;
|
| 1279 |
+
else if (key === 'TIMEOUT') jsonConfig.other.timeout = parseInt(value) || undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1280 |
else if (key === 'SKIP_PROJECT_ID_FETCH') jsonConfig.other.skipProjectIdFetch = value === 'true';
|
| 1281 |
+
else if (key === 'ROTATION_STRATEGY') jsonConfig.rotation.strategy = value || undefined;
|
| 1282 |
+
else if (key === 'ROTATION_REQUEST_COUNT') jsonConfig.rotation.requestCount = parseInt(value) || undefined;
|
| 1283 |
else envConfig[key] = value;
|
| 1284 |
}
|
| 1285 |
});
|
| 1286 |
|
| 1287 |
+
// 清理undefined值
|
| 1288 |
+
Object.keys(jsonConfig).forEach(section => {
|
| 1289 |
+
Object.keys(jsonConfig[section]).forEach(key => {
|
| 1290 |
+
if (jsonConfig[section][key] === undefined) {
|
| 1291 |
+
delete jsonConfig[section][key];
|
| 1292 |
+
}
|
| 1293 |
+
});
|
| 1294 |
+
if (Object.keys(jsonConfig[section]).length === 0) {
|
| 1295 |
+
delete jsonConfig[section];
|
| 1296 |
+
}
|
| 1297 |
+
});
|
| 1298 |
+
|
| 1299 |
showLoading('正在保存配置...');
|
| 1300 |
try {
|
| 1301 |
+
// 先保存通用配置
|
| 1302 |
const response = await authFetch('/admin/config', {
|
| 1303 |
method: 'PUT',
|
| 1304 |
headers: {
|
|
|
|
| 1309 |
});
|
| 1310 |
|
| 1311 |
const data = await response.json();
|
| 1312 |
+
|
| 1313 |
+
// 如果有轮询配置,单独更新轮询策略(触发热更新)
|
| 1314 |
+
if (jsonConfig.rotation && Object.keys(jsonConfig.rotation).length > 0) {
|
| 1315 |
+
await authFetch('/admin/rotation', {
|
| 1316 |
+
method: 'PUT',
|
| 1317 |
+
headers: {
|
| 1318 |
+
'Content-Type': 'application/json',
|
| 1319 |
+
'Authorization': `Bearer ${authToken}`
|
| 1320 |
+
},
|
| 1321 |
+
body: JSON.stringify(jsonConfig.rotation)
|
| 1322 |
+
});
|
| 1323 |
+
}
|
| 1324 |
+
|
| 1325 |
hideLoading();
|
| 1326 |
if (data.success) {
|
| 1327 |
+
showToast('配置已保存', 'success');
|
| 1328 |
+
loadConfig(); // 重新加载以更新显示
|
| 1329 |
} else {
|
| 1330 |
showToast(data.message || '保存失败', 'error');
|
| 1331 |
}
|
public/index.html
CHANGED
|
@@ -3,14 +3,18 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
| 6 |
-
<title>Token
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
<link rel="stylesheet" href="style.css">
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div class="container">
|
| 11 |
<!-- 登录表单 -->
|
| 12 |
<div id="loginForm" class="login-form">
|
| 13 |
-
<h2
|
| 14 |
<form id="login">
|
| 15 |
<div class="form-group">
|
| 16 |
<label>👤 用户名</label>
|
|
@@ -27,80 +31,179 @@
|
|
| 27 |
<!-- 主内容 -->
|
| 28 |
<div id="mainContent" class="main-content hidden">
|
| 29 |
<div class="header">
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
</div>
|
| 34 |
-
<button onclick="logout()">🚪 退出</button>
|
| 35 |
</div>
|
| 36 |
<div class="content">
|
| 37 |
<!-- Token管理页面 -->
|
| 38 |
<div id="tokensPage">
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
</div>
|
| 53 |
-
</div>
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
</div>
|
| 63 |
-
</div>
|
| 64 |
-
|
| 65 |
-
<!-- Token网格 -->
|
| 66 |
-
<div id="tokenList" class="token-grid">
|
| 67 |
-
<div class="empty-state">
|
| 68 |
-
<div class="empty-state-icon">📦</div>
|
| 69 |
-
<div class="empty-state-text">暂无Token</div>
|
| 70 |
-
<div class="empty-state-hint">点击上方按钮添加您的第一个Token</div>
|
| 71 |
</div>
|
| 72 |
</div>
|
| 73 |
-
</div>
|
| 74 |
|
| 75 |
<!-- 设置页面 -->
|
| 76 |
<div id="settingsPage" class="hidden">
|
| 77 |
-
<h3>⚙️ 系统配置</h3>
|
| 78 |
<form id="configForm" class="config-form">
|
| 79 |
-
<div class="config-
|
| 80 |
-
|
| 81 |
-
<div class="
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
</div>
|
| 96 |
-
<div class="config-
|
| 97 |
-
<
|
| 98 |
-
<
|
| 99 |
-
<div class="form-group"><label>代理地址</label><input type="text" name="PROXY" placeholder="http://127.0.0.1:7897"></div>
|
| 100 |
-
<div class="form-group"><label>跳过ProjectId验证</label><select name="SKIP_PROJECT_ID_FETCH"><option value="false">否</option><option value="true">是</option></select></div>
|
| 101 |
-
<div class="form-group"><label>系统提示词</label><textarea name="SYSTEM_INSTRUCTION" rows="3"></textarea></div>
|
| 102 |
</div>
|
| 103 |
-
<button type="submit" class="btn btn-success">💾 保存配置</button>
|
| 104 |
</form>
|
| 105 |
</div>
|
| 106 |
</div>
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
| 6 |
+
<title>Token 管理</title>
|
| 7 |
+
<!-- 引入 MiSans 字体 -->
|
| 8 |
+
<link rel="stylesheet" href="https://font.sec.miui.com/font/css?family=MiSans:100,200,300,400,450,500,600,650,700,900:Chinese_Simplify,Latin&display=swap">
|
| 9 |
+
<!-- 引入 Ubuntu Mono 等宽字体 -->
|
| 10 |
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Ubuntu+Mono:wght@400;700&display=swap">
|
| 11 |
<link rel="stylesheet" href="style.css">
|
| 12 |
</head>
|
| 13 |
<body>
|
| 14 |
<div class="container">
|
| 15 |
<!-- 登录表单 -->
|
| 16 |
<div id="loginForm" class="login-form">
|
| 17 |
+
<h2>Token 管理</h2>
|
| 18 |
<form id="login">
|
| 19 |
<div class="form-group">
|
| 20 |
<label>👤 用户名</label>
|
|
|
|
| 31 |
<!-- 主内容 -->
|
| 32 |
<div id="mainContent" class="main-content hidden">
|
| 33 |
<div class="header">
|
| 34 |
+
<div class="tabs">
|
| 35 |
+
<button class="tab active" onclick="switchTab('tokens')">🎯 Token</button>
|
| 36 |
+
<button class="tab" onclick="switchTab('settings')">⚙️ 设置</button>
|
| 37 |
+
</div>
|
| 38 |
+
<div class="header-right">
|
| 39 |
+
<span class="server-info" id="serverInfo"></span>
|
| 40 |
+
<button onclick="logout()">🚪 退出</button>
|
| 41 |
</div>
|
|
|
|
| 42 |
</div>
|
| 43 |
<div class="content">
|
| 44 |
<!-- Token管理页面 -->
|
| 45 |
<div id="tokensPage">
|
| 46 |
+
<!-- 统计卡片 + 操作按钮 合并 -->
|
| 47 |
+
<div class="top-bar">
|
| 48 |
+
<div class="stats-inline">
|
| 49 |
+
<div class="stat-item">
|
| 50 |
+
<span class="stat-num" id="totalTokens">0</span>
|
| 51 |
+
<span class="stat-text">总数</span>
|
| 52 |
+
</div>
|
| 53 |
+
<div class="stat-item success">
|
| 54 |
+
<span class="stat-num" id="enabledTokens">0</span>
|
| 55 |
+
<span class="stat-text">启用</span>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="stat-item danger">
|
| 58 |
+
<span class="stat-num" id="disabledTokens">0</span>
|
| 59 |
+
<span class="stat-text">禁用</span>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
<div class="action-btns">
|
| 63 |
+
<button type="button" onclick="showOAuthModal()" class="btn btn-success btn-sm">🔐 OAuth</button>
|
| 64 |
+
<button type="button" onclick="showManualModal()" class="btn btn-secondary btn-sm">✏️ 手动</button>
|
| 65 |
+
<button type="button" onclick="loadTokens()" class="btn btn-warning btn-sm">🔄 刷新</button>
|
| 66 |
+
<button type="button" onclick="toggleSensitiveInfo()" class="btn btn-sm" id="toggleSensitiveBtn" title="隐藏/显示敏感信息">👁️ 显示</button>
|
| 67 |
+
</div>
|
| 68 |
</div>
|
|
|
|
| 69 |
|
| 70 |
+
<!-- Token网格 -->
|
| 71 |
+
<div id="tokenList" class="token-grid">
|
| 72 |
+
<div class="empty-state">
|
| 73 |
+
<div class="empty-state-icon">📦</div>
|
| 74 |
+
<div class="empty-state-text">暂无Token</div>
|
| 75 |
+
<div class="empty-state-hint">点击上方OAuth按钮添加Token</div>
|
| 76 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
</div>
|
| 78 |
</div>
|
|
|
|
| 79 |
|
| 80 |
<!-- 设置页面 -->
|
| 81 |
<div id="settingsPage" class="hidden">
|
|
|
|
| 82 |
<form id="configForm" class="config-form">
|
| 83 |
+
<div class="config-grid">
|
| 84 |
+
<!-- 服务器配置 -->
|
| 85 |
+
<div class="config-section">
|
| 86 |
+
<h4>🖥️ 服务器</h4>
|
| 87 |
+
<div class="form-row-inline">
|
| 88 |
+
<div class="form-group compact">
|
| 89 |
+
<label>端口</label>
|
| 90 |
+
<input type="number" name="PORT" placeholder="8045">
|
| 91 |
+
</div>
|
| 92 |
+
<div class="form-group compact">
|
| 93 |
+
<label>监听地址</label>
|
| 94 |
+
<input type="text" name="HOST" placeholder="0.0.0.0">
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
<div class="form-row-inline">
|
| 98 |
+
<div class="form-group compact">
|
| 99 |
+
<label>最大请求大小</label>
|
| 100 |
+
<input type="text" name="MAX_REQUEST_SIZE" placeholder="500mb">
|
| 101 |
+
</div>
|
| 102 |
+
<div class="form-group compact">
|
| 103 |
+
<label>API密钥</label>
|
| 104 |
+
<input type="password" name="API_KEY" placeholder="留空则不验证">
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
<div class="form-group compact">
|
| 108 |
+
<label>图片访问链接</label>
|
| 109 |
+
<input type="text" name="IMAGE_BASE_URL" placeholder="https://your-domain.zeabur.app">
|
| 110 |
+
</div>
|
| 111 |
+
<div class="form-group compact">
|
| 112 |
+
<label>代理地址</label>
|
| 113 |
+
<input type="text" name="PROXY" placeholder="http://127.0.0.1:7890">
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<!-- 模型默认参数 -->
|
| 118 |
+
<div class="config-section">
|
| 119 |
+
<h4>🎛️ 模型参数</h4>
|
| 120 |
+
<div class="form-row-inline">
|
| 121 |
+
<div class="form-group compact">
|
| 122 |
+
<label>温度</label>
|
| 123 |
+
<input type="number" step="0.1" name="DEFAULT_TEMPERATURE" placeholder="1">
|
| 124 |
+
</div>
|
| 125 |
+
<div class="form-group compact">
|
| 126 |
+
<label>Top P</label>
|
| 127 |
+
<input type="number" step="0.01" name="DEFAULT_TOP_P" placeholder="1">
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
<div class="form-row-inline">
|
| 131 |
+
<div class="form-group compact">
|
| 132 |
+
<label>Top K</label>
|
| 133 |
+
<input type="number" name="DEFAULT_TOP_K" placeholder="50">
|
| 134 |
+
</div>
|
| 135 |
+
<div class="form-group compact">
|
| 136 |
+
<label>最大Token</label>
|
| 137 |
+
<input type="number" name="DEFAULT_MAX_TOKENS" placeholder="32000">
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
<div class="form-group compact highlight">
|
| 141 |
+
<label>思考预算 <span class="help-tip" title="思考模型的思考token预算,影响推理深度">?</span></label>
|
| 142 |
+
<input type="number" name="DEFAULT_THINKING_BUDGET" placeholder="16000">
|
| 143 |
+
</div>
|
| 144 |
+
<div class="form-group compact">
|
| 145 |
+
<label>系统提示词</label>
|
| 146 |
+
<textarea name="SYSTEM_INSTRUCTION" rows="3" placeholder="可选的系统提示词"></textarea>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
<!-- 轮询策略与性能优化 -->
|
| 151 |
+
<div class="config-section">
|
| 152 |
+
<h4>🔄 轮询与性能</h4>
|
| 153 |
+
<div class="form-row-inline">
|
| 154 |
+
<div class="form-group compact">
|
| 155 |
+
<label>策略模式 <span class="help-tip" title="均衡负载:每次请求切换Token 额度耗尽:用完额度才切换 自定义次数:指定次数后切换">?</span></label>
|
| 156 |
+
<select name="ROTATION_STRATEGY" id="rotationStrategy" onchange="toggleRequestCountInput()">
|
| 157 |
+
<option value="round_robin">均衡负载</option>
|
| 158 |
+
<option value="quota_exhausted">额度耗尽切换</option>
|
| 159 |
+
<option value="request_count">自定义次数</option>
|
| 160 |
+
</select>
|
| 161 |
+
</div>
|
| 162 |
+
<div class="form-group compact" id="requestCountGroup">
|
| 163 |
+
<label>每Token请求次数 <span class="help-tip" title="自定义次数模式下,每个Token处理多少次请求后切换">?</span></label>
|
| 164 |
+
<input type="number" name="ROTATION_REQUEST_COUNT" min="1" placeholder="10">
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
<div class="rotation-status" id="rotationStatus">
|
| 168 |
+
<span class="rotation-label">当前状态:</span>
|
| 169 |
+
<span id="currentRotationInfo">加载中...</span>
|
| 170 |
+
</div>
|
| 171 |
+
<div class="form-row-inline">
|
| 172 |
+
<div class="form-group compact">
|
| 173 |
+
<label>超时(ms)</label>
|
| 174 |
+
<input type="number" name="TIMEOUT" placeholder="300000">
|
| 175 |
+
</div>
|
| 176 |
+
<div class="form-group compact">
|
| 177 |
+
<label>跳过验证</label>
|
| 178 |
+
<select name="SKIP_PROJECT_ID_FETCH">
|
| 179 |
+
<option value="false">否</option>
|
| 180 |
+
<option value="true">是</option>
|
| 181 |
+
</select>
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
<div class="form-row-inline">
|
| 185 |
+
<div class="form-group compact">
|
| 186 |
+
<label>心跳间隔(ms) <span class="help-tip" title="SSE心跳间隔,防止CF超时断连">?</span></label>
|
| 187 |
+
<input type="number" name="HEARTBEAT_INTERVAL" placeholder="15000">
|
| 188 |
+
</div>
|
| 189 |
+
<div class="form-group compact">
|
| 190 |
+
<label>内存阈值(MB) <span class="help-tip" title="超过此值触发GC清理">?</span></label>
|
| 191 |
+
<input type="number" name="MEMORY_THRESHOLD" placeholder="100">
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
<div class="form-group compact">
|
| 195 |
+
<label>🔤 界面字体大小 (px)</label>
|
| 196 |
+
<div class="font-size-control">
|
| 197 |
+
<input type="range" id="fontSizeRange" min="10" max="24" value="18" oninput="changeFontSize(this.value)">
|
| 198 |
+
<input type="number" id="fontSizeInput" min="10" max="24" value="18" onchange="changeFontSize(this.value)" style="width: 60px;">
|
| 199 |
+
</div>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
</div>
|
| 203 |
+
<div class="config-actions">
|
| 204 |
+
<button type="submit" class="btn btn-success">💾 保存配置</button>
|
| 205 |
+
<button type="button" onclick="loadConfig()" class="btn btn-secondary">🔄 重新加载</button>
|
|
|
|
|
|
|
|
|
|
| 206 |
</div>
|
|
|
|
| 207 |
</form>
|
| 208 |
</div>
|
| 209 |
</div>
|
public/style.css
CHANGED
|
@@ -1,16 +1,17 @@
|
|
| 1 |
:root {
|
| 2 |
-
--primary: #
|
| 3 |
-
--primary-dark: #
|
| 4 |
--success: #10b981;
|
| 5 |
--danger: #ef4444;
|
| 6 |
--warning: #f59e0b;
|
| 7 |
-
--info: #
|
| 8 |
--bg: #f8fafc;
|
| 9 |
--card: #ffffff;
|
| 10 |
--text: #1e293b;
|
| 11 |
--text-light: #64748b;
|
| 12 |
--border: #e2e8f0;
|
| 13 |
-
--shadow: 0
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
@media (prefers-color-scheme: dark) {
|
|
@@ -23,164 +24,1215 @@
|
|
| 23 |
}
|
| 24 |
}
|
| 25 |
|
| 26 |
-
|
| 27 |
-
.toast.success { border-left-color: var(--success); }
|
| 28 |
-
.toast.error { border-left-color: var(--danger); }
|
| 29 |
-
.toast.warning { border-left-color: var(--warning); }
|
| 30 |
-
.toast.info { border-left-color: var(--info); }
|
| 31 |
-
.toast-icon { font-size: 24px; flex-shrink: 0; }
|
| 32 |
-
.toast-content { flex: 1; }
|
| 33 |
-
.toast-title { font-weight: 600; margin-bottom: 4px; color: var(--text); }
|
| 34 |
-
.toast-message { font-size: 14px; color: var(--text-light); }
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-light); }
|
| 60 |
-
.empty-state-icon { font-size: 64px; margin-bottom: 16px; opacity: 0.5; }
|
| 61 |
-
.empty-state-text { font-size: 18px; font-weight: 500; margin-bottom: 8px; }
|
| 62 |
-
.empty-state-hint { font-size: 14px; }
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
button:disabled { opacity: 0.7; cursor: not-allowed; }
|
| 80 |
-
button.loading::after {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
@keyframes spin { to { transform: rotate(360deg); } }
|
| 83 |
|
| 84 |
-
|
| 85 |
-
.
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
.tab:hover { background: var(--bg); color: var(--text); }
|
| 91 |
-
.tab.active {
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
.
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
.
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
.btn:hover { transform: translateY(-1px); }
|
|
|
|
|
|
|
| 122 |
.btn-danger { background: var(--danger); color: white; }
|
| 123 |
.btn-warning { background: var(--warning); color: white; }
|
| 124 |
.btn-success { background: var(--success); color: white; }
|
| 125 |
.btn-secondary { background: #6b7280; color: white; }
|
|
|
|
| 126 |
|
| 127 |
-
|
| 128 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
.status.enabled { background: rgba(16,185,129,0.15); color: var(--success); }
|
| 130 |
.status.disabled { background: rgba(239,68,68,0.15); color: var(--danger); }
|
| 131 |
|
| 132 |
-
|
| 133 |
-
.
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
.
|
| 145 |
-
.
|
|
|
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
.
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
.
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
}
|
|
|
|
| 166 |
|
| 167 |
-
|
| 168 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
-
|
| 171 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
.quota-error { color: var(--danger); }
|
| 173 |
-
.quota-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
.quota-group-title { font-size: 1rem; font-weight: 700; color: var(--text); margin: 1.25rem 0 0.75rem 0; padding-bottom: 0.5rem; border-bottom: 2px solid var(--border); }
|
| 182 |
.quota-group-title:first-child { margin-top: 0; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
@media (max-width: 768px) {
|
| 185 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
}
|
|
|
|
| 1 |
:root {
|
| 2 |
+
--primary: #0891b2;
|
| 3 |
+
--primary-dark: #0e7490;
|
| 4 |
--success: #10b981;
|
| 5 |
--danger: #ef4444;
|
| 6 |
--warning: #f59e0b;
|
| 7 |
+
--info: #06b6d4;
|
| 8 |
--bg: #f8fafc;
|
| 9 |
--card: #ffffff;
|
| 10 |
--text: #1e293b;
|
| 11 |
--text-light: #64748b;
|
| 12 |
--border: #e2e8f0;
|
| 13 |
+
--shadow: 0 2px 8px rgba(0,0,0,0.08);
|
| 14 |
+
--font-size-base: 18px;
|
| 15 |
}
|
| 16 |
|
| 17 |
@media (prefers-color-scheme: dark) {
|
|
|
|
| 24 |
}
|
| 25 |
}
|
| 26 |
|
| 27 |
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
+
/* 固定背景图片 - 使用静态风景图(清晰无模糊) */
|
| 30 |
+
body::before {
|
| 31 |
+
content: '';
|
| 32 |
+
position: fixed;
|
| 33 |
+
top: 0;
|
| 34 |
+
left: 0;
|
| 35 |
+
right: 0;
|
| 36 |
+
bottom: 0;
|
| 37 |
+
background-image: url('https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1920&q=80');
|
| 38 |
+
background-size: cover;
|
| 39 |
+
background-position: center;
|
| 40 |
+
background-repeat: no-repeat;
|
| 41 |
+
z-index: -1;
|
| 42 |
+
}
|
| 43 |
|
| 44 |
+
/* iOS风格滚动条 */
|
| 45 |
+
::-webkit-scrollbar {
|
| 46 |
+
width: 6px;
|
| 47 |
+
height: 6px;
|
| 48 |
+
}
|
| 49 |
+
::-webkit-scrollbar-track {
|
| 50 |
+
background: transparent;
|
| 51 |
+
}
|
| 52 |
+
::-webkit-scrollbar-thumb {
|
| 53 |
+
background: rgba(0, 0, 0, 0.2);
|
| 54 |
+
border-radius: 10px;
|
| 55 |
+
}
|
| 56 |
+
::-webkit-scrollbar-thumb:hover {
|
| 57 |
+
background: rgba(0, 0, 0, 0.35);
|
| 58 |
+
}
|
| 59 |
+
::-webkit-scrollbar-thumb:active {
|
| 60 |
+
background: rgba(0, 0, 0, 0.5);
|
| 61 |
+
}
|
| 62 |
+
::-webkit-scrollbar-corner {
|
| 63 |
+
background: transparent;
|
| 64 |
+
}
|
| 65 |
+
/* 暗色模式下的滚动条 */
|
| 66 |
+
@media (prefers-color-scheme: dark) {
|
| 67 |
+
::-webkit-scrollbar-thumb {
|
| 68 |
+
background: rgba(255, 255, 255, 0.2);
|
| 69 |
+
}
|
| 70 |
+
::-webkit-scrollbar-thumb:hover {
|
| 71 |
+
background: rgba(255, 255, 255, 0.35);
|
| 72 |
+
}
|
| 73 |
+
::-webkit-scrollbar-thumb:active {
|
| 74 |
+
background: rgba(255, 255, 255, 0.5);
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
/* Firefox 滚动条 */
|
| 78 |
+
* {
|
| 79 |
+
scrollbar-width: thin;
|
| 80 |
+
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
| 81 |
+
}
|
| 82 |
+
@media (prefers-color-scheme: dark) {
|
| 83 |
+
* {
|
| 84 |
+
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
html {
|
| 88 |
+
font-size: var(--font-size-base);
|
| 89 |
+
}
|
| 90 |
+
body {
|
| 91 |
+
font-family: 'Ubuntu Mono', 'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 92 |
+
background: var(--bg);
|
| 93 |
+
color: var(--text);
|
| 94 |
+
line-height: 1.5;
|
| 95 |
+
min-height: 100vh;
|
| 96 |
+
font-size: 1rem;
|
| 97 |
+
/* 字体渲染优化 */
|
| 98 |
+
-webkit-font-smoothing: antialiased;
|
| 99 |
+
-moz-osx-font-smoothing: grayscale;
|
| 100 |
+
text-rendering: optimizeLegibility;
|
| 101 |
+
font-weight: 400;
|
| 102 |
+
}
|
| 103 |
|
| 104 |
+
/* 确保所有元素继承字体 */
|
| 105 |
+
*, *::before, *::after {
|
| 106 |
+
font-family: inherit;
|
| 107 |
+
}
|
| 108 |
|
| 109 |
+
/* 等宽字体用于代码/Token显示 */
|
| 110 |
+
code, pre, .mono, .token-display {
|
| 111 |
+
font-family: 'Ubuntu Mono', 'Consolas', 'Monaco', monospace !important;
|
| 112 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
+
.container {
|
| 115 |
+
max-width: 1400px;
|
| 116 |
+
margin: 0 auto;
|
| 117 |
+
padding: 0.5rem;
|
| 118 |
+
height: 100vh;
|
| 119 |
+
display: flex;
|
| 120 |
+
align-items: center;
|
| 121 |
+
justify-content: center;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/* 登录表单 */
|
| 125 |
+
.login-form {
|
| 126 |
+
background: rgba(255, 255, 255, 0.6);
|
| 127 |
+
backdrop-filter: blur(24px) saturate(180%);
|
| 128 |
+
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
| 129 |
+
border-radius: 1rem;
|
| 130 |
+
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
| 131 |
+
max-width: 400px;
|
| 132 |
+
width: 100%;
|
| 133 |
+
padding: 2rem;
|
| 134 |
+
border: 1px solid rgba(255, 255, 255, 0.5);
|
| 135 |
+
}
|
| 136 |
+
@media (prefers-color-scheme: dark) {
|
| 137 |
+
.login-form {
|
| 138 |
+
background: rgba(30, 41, 59, 0.65);
|
| 139 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
.login-form h2 {
|
| 143 |
+
text-align: center;
|
| 144 |
+
margin-bottom: 1.5rem;
|
| 145 |
+
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
|
| 146 |
+
-webkit-background-clip: text;
|
| 147 |
+
-webkit-text-fill-color: transparent;
|
| 148 |
+
background-clip: text;
|
| 149 |
+
font-size: 1.5rem;
|
| 150 |
+
font-weight: 700;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.form-group { margin-bottom: 1rem; }
|
| 154 |
+
label {
|
| 155 |
+
display: block;
|
| 156 |
+
margin-bottom: 0.5rem;
|
| 157 |
+
font-weight: 600;
|
| 158 |
+
color: var(--text);
|
| 159 |
+
font-size: 0.875rem;
|
| 160 |
+
}
|
| 161 |
+
input, select, textarea {
|
| 162 |
+
width: 100%;
|
| 163 |
+
min-height: 40px;
|
| 164 |
+
padding: 0.5rem 0.75rem;
|
| 165 |
+
border: 1.5px solid var(--border);
|
| 166 |
+
border-radius: 0.5rem;
|
| 167 |
+
font-size: 0.875rem;
|
| 168 |
+
background: var(--card);
|
| 169 |
+
color: var(--text);
|
| 170 |
+
transition: all 0.2s;
|
| 171 |
+
}
|
| 172 |
+
input:focus, select:focus, textarea:focus {
|
| 173 |
+
outline: none;
|
| 174 |
+
border-color: var(--primary);
|
| 175 |
+
box-shadow: 0 0 0 3px rgba(8,145,178,0.1);
|
| 176 |
+
}
|
| 177 |
+
input::placeholder, textarea::placeholder { color: var(--text-light); opacity: 0.6; }
|
| 178 |
+
|
| 179 |
+
button {
|
| 180 |
+
width: 100%;
|
| 181 |
+
min-height: 44px;
|
| 182 |
+
padding: 0.75rem;
|
| 183 |
+
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
|
| 184 |
+
color: white;
|
| 185 |
+
border: none;
|
| 186 |
+
border-radius: 0.5rem;
|
| 187 |
+
cursor: pointer;
|
| 188 |
+
font-size: 0.95rem;
|
| 189 |
+
font-weight: 600;
|
| 190 |
+
transition: all 0.2s;
|
| 191 |
+
box-shadow: 0 2px 8px rgba(8,145,178,0.2);
|
| 192 |
+
}
|
| 193 |
+
button:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(8,145,178,0.3); }
|
| 194 |
+
button:active:not(:disabled) { transform: translateY(0); }
|
| 195 |
button:disabled { opacity: 0.7; cursor: not-allowed; }
|
| 196 |
+
button.loading::after {
|
| 197 |
+
content: '';
|
| 198 |
+
position: absolute;
|
| 199 |
+
width: 14px;
|
| 200 |
+
height: 14px;
|
| 201 |
+
margin-left: 8px;
|
| 202 |
+
border: 2px solid rgba(255,255,255,0.3);
|
| 203 |
+
border-top-color: white;
|
| 204 |
+
border-radius: 50%;
|
| 205 |
+
animation: spin 0.6s linear infinite;
|
| 206 |
+
}
|
| 207 |
|
| 208 |
@keyframes spin { to { transform: rotate(360deg); } }
|
| 209 |
|
| 210 |
+
/* 主内容区 */
|
| 211 |
+
.main-content {
|
| 212 |
+
background: rgba(255, 255, 255, 0.6);
|
| 213 |
+
backdrop-filter: blur(24px) saturate(180%);
|
| 214 |
+
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
| 215 |
+
border-radius: 1rem;
|
| 216 |
+
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
| 217 |
+
width: 100%;
|
| 218 |
+
max-width: 1400px;
|
| 219 |
+
height: calc(100vh - 1rem);
|
| 220 |
+
display: flex;
|
| 221 |
+
flex-direction: column;
|
| 222 |
+
border: 1px solid rgba(255, 255, 255, 0.5);
|
| 223 |
+
}
|
| 224 |
+
@media (prefers-color-scheme: dark) {
|
| 225 |
+
.main-content {
|
| 226 |
+
background: rgba(30, 41, 59, 0.65);
|
| 227 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.header {
|
| 232 |
+
display: flex;
|
| 233 |
+
justify-content: space-between;
|
| 234 |
+
align-items: center;
|
| 235 |
+
padding: 0.75rem 1rem;
|
| 236 |
+
border-bottom: 1.5px solid var(--border);
|
| 237 |
+
flex-shrink: 0;
|
| 238 |
+
}
|
| 239 |
+
.header-right {
|
| 240 |
+
display: flex;
|
| 241 |
+
align-items: center;
|
| 242 |
+
gap: 0.75rem;
|
| 243 |
+
}
|
| 244 |
+
.server-info {
|
| 245 |
+
font-size: 0.75rem;
|
| 246 |
+
color: var(--text-light);
|
| 247 |
+
background: var(--bg);
|
| 248 |
+
padding: 0.25rem 0.5rem;
|
| 249 |
+
border-radius: 0.25rem;
|
| 250 |
+
}
|
| 251 |
+
.header button {
|
| 252 |
+
width: auto;
|
| 253 |
+
padding: 0.5rem 1rem;
|
| 254 |
+
font-size: 0.8rem;
|
| 255 |
+
min-height: 36px;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.tabs { display: flex; gap: 0.25rem; }
|
| 259 |
+
.tab {
|
| 260 |
+
background: transparent;
|
| 261 |
+
color: var(--text-light);
|
| 262 |
+
border: none;
|
| 263 |
+
padding: 0.5rem 1rem;
|
| 264 |
+
border-radius: 0.375rem;
|
| 265 |
+
cursor: pointer;
|
| 266 |
+
font-weight: 600;
|
| 267 |
+
font-size: 0.875rem;
|
| 268 |
+
transition: all 0.2s;
|
| 269 |
+
min-height: 36px;
|
| 270 |
+
box-shadow: none;
|
| 271 |
+
}
|
| 272 |
.tab:hover { background: var(--bg); color: var(--text); }
|
| 273 |
+
.tab.active {
|
| 274 |
+
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
|
| 275 |
+
color: white;
|
| 276 |
+
box-shadow: 0 2px 6px rgba(8,145,178,0.25);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.content {
|
| 280 |
+
padding: 1rem;
|
| 281 |
+
flex: 1;
|
| 282 |
+
overflow-y: auto;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
/* 顶部栏 - 统计+操作 */
|
| 286 |
+
.top-bar {
|
| 287 |
+
display: flex;
|
| 288 |
+
justify-content: space-between;
|
| 289 |
+
align-items: center;
|
| 290 |
+
background: rgba(255, 255, 255, 0.6);
|
| 291 |
+
padding: 0.75rem 1rem;
|
| 292 |
+
border-radius: 0.75rem;
|
| 293 |
+
margin-bottom: 1rem;
|
| 294 |
+
border: 1.5px solid var(--border);
|
| 295 |
+
}
|
| 296 |
+
@media (prefers-color-scheme: dark) {
|
| 297 |
+
.top-bar {
|
| 298 |
+
background: rgba(30, 41, 59, 0.6);
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
.stats-inline {
|
| 302 |
+
display: flex;
|
| 303 |
+
gap: 1.5rem;
|
| 304 |
+
}
|
| 305 |
+
.stat-item {
|
| 306 |
+
display: flex;
|
| 307 |
+
align-items: baseline;
|
| 308 |
+
gap: 0.25rem;
|
| 309 |
+
}
|
| 310 |
+
.stat-num {
|
| 311 |
+
font-size: 1.25rem;
|
| 312 |
+
font-weight: 700;
|
| 313 |
+
color: var(--primary);
|
| 314 |
+
line-height: 1;
|
| 315 |
+
}
|
| 316 |
+
.stat-item.success .stat-num { color: var(--success); }
|
| 317 |
+
.stat-item.danger .stat-num { color: var(--danger); }
|
| 318 |
+
.stat-text {
|
| 319 |
+
font-size: 0.85rem;
|
| 320 |
+
color: var(--text-light);
|
| 321 |
+
line-height: 1;
|
| 322 |
+
}
|
| 323 |
+
.action-btns {
|
| 324 |
+
display: flex;
|
| 325 |
+
gap: 0.5rem;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
/* 按钮样式 */
|
| 329 |
+
.btn {
|
| 330 |
+
padding: 0.5rem 0.875rem;
|
| 331 |
+
border: none;
|
| 332 |
+
border-radius: 0.375rem;
|
| 333 |
+
cursor: pointer;
|
| 334 |
+
font-size: 0.8rem;
|
| 335 |
+
font-weight: 600;
|
| 336 |
+
transition: all 0.2s;
|
| 337 |
+
white-space: nowrap;
|
| 338 |
+
text-align: center;
|
| 339 |
+
display: inline-flex;
|
| 340 |
+
align-items: center;
|
| 341 |
+
justify-content: center;
|
| 342 |
+
gap: 0.25rem;
|
| 343 |
+
min-height: 36px;
|
| 344 |
+
box-shadow: none;
|
| 345 |
+
}
|
| 346 |
.btn:hover { transform: translateY(-1px); }
|
| 347 |
+
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; min-height: 32px; }
|
| 348 |
+
.btn-xs { padding: 0.25rem 0.5rem; font-size: 0.7rem; min-height: 28px; min-width: 28px; }
|
| 349 |
.btn-danger { background: var(--danger); color: white; }
|
| 350 |
.btn-warning { background: var(--warning); color: white; }
|
| 351 |
.btn-success { background: var(--success); color: white; }
|
| 352 |
.btn-secondary { background: #6b7280; color: white; }
|
| 353 |
+
.btn-info { background: var(--info); color: white; }
|
| 354 |
|
| 355 |
+
/* Token网格 */
|
| 356 |
+
.token-grid {
|
| 357 |
+
display: grid;
|
| 358 |
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
| 359 |
+
gap: 0.75rem;
|
| 360 |
+
}
|
| 361 |
+
.token-card {
|
| 362 |
+
background: rgba(255, 255, 255, 0.6);
|
| 363 |
+
border: 1.5px solid var(--border);
|
| 364 |
+
border-radius: 0.75rem;
|
| 365 |
+
padding: 0.875rem;
|
| 366 |
+
transition: all 0.2s;
|
| 367 |
+
position: relative;
|
| 368 |
+
overflow: hidden;
|
| 369 |
+
}
|
| 370 |
+
@media (prefers-color-scheme: dark) {
|
| 371 |
+
.token-card {
|
| 372 |
+
background: rgba(30, 41, 59, 0.6);
|
| 373 |
+
}
|
| 374 |
+
}
|
| 375 |
+
.token-card:hover {
|
| 376 |
+
border-color: var(--primary);
|
| 377 |
+
box-shadow: 0 6px 16px rgba(0,0,0,0.12);
|
| 378 |
+
background: rgba(255, 255, 255, 0.85);
|
| 379 |
+
transform: scale(1.02);
|
| 380 |
+
z-index: 1;
|
| 381 |
+
position: relative;
|
| 382 |
+
}
|
| 383 |
+
@media (prefers-color-scheme: dark) {
|
| 384 |
+
.token-card:hover {
|
| 385 |
+
background: rgba(30, 41, 59, 0.85);
|
| 386 |
+
}
|
| 387 |
+
}
|
| 388 |
+
.token-card.disabled { opacity: 0.6; }
|
| 389 |
+
.token-card.expired { border-color: var(--danger); }
|
| 390 |
+
|
| 391 |
+
.token-header {
|
| 392 |
+
display: flex;
|
| 393 |
+
justify-content: space-between;
|
| 394 |
+
align-items: center;
|
| 395 |
+
margin-bottom: 0.5rem;
|
| 396 |
+
}
|
| 397 |
+
.token-id {
|
| 398 |
+
font-size: 0.7rem;
|
| 399 |
+
color: var(--text-light);
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
.token-info {
|
| 403 |
+
display: flex;
|
| 404 |
+
flex-direction: column;
|
| 405 |
+
gap: 0.375rem;
|
| 406 |
+
margin-bottom: 0.75rem;
|
| 407 |
+
}
|
| 408 |
+
.info-row {
|
| 409 |
+
display: flex;
|
| 410 |
+
align-items: center;
|
| 411 |
+
gap: 0.375rem;
|
| 412 |
+
font-size: 0.8rem;
|
| 413 |
+
}
|
| 414 |
+
.info-label {
|
| 415 |
+
flex-shrink: 0;
|
| 416 |
+
width: 20px;
|
| 417 |
+
text-align: center;
|
| 418 |
+
}
|
| 419 |
+
.info-value {
|
| 420 |
+
color: var(--text);
|
| 421 |
+
font-size: 0.75rem;
|
| 422 |
+
overflow: hidden;
|
| 423 |
+
text-overflow: ellipsis;
|
| 424 |
+
white-space: nowrap;
|
| 425 |
+
}
|
| 426 |
+
.info-row.expired-text .info-value { color: var(--danger); }
|
| 427 |
+
.info-row.editable {
|
| 428 |
+
cursor: pointer;
|
| 429 |
+
padding: 0.25rem;
|
| 430 |
+
margin: -0.25rem;
|
| 431 |
+
border-radius: 0.25rem;
|
| 432 |
+
transition: background 0.2s;
|
| 433 |
+
}
|
| 434 |
+
.info-row.editable:hover {
|
| 435 |
+
background: rgba(8,145,178,0.08);
|
| 436 |
+
}
|
| 437 |
+
.info-edit-icon {
|
| 438 |
+
font-size: 0.6rem;
|
| 439 |
+
opacity: 0;
|
| 440 |
+
transition: opacity 0.2s;
|
| 441 |
+
margin-left: auto;
|
| 442 |
+
flex-shrink: 0;
|
| 443 |
+
}
|
| 444 |
+
.info-row.editable:hover .info-edit-icon {
|
| 445 |
+
opacity: 0.5;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
/* Token完整显示样式 */
|
| 449 |
+
.token-display {
|
| 450 |
+
background: var(--bg);
|
| 451 |
+
border: 1px solid var(--border);
|
| 452 |
+
border-radius: 6px;
|
| 453 |
+
padding: 0.5rem 0.75rem;
|
| 454 |
+
font-family: 'Ubuntu Mono', 'MiSans', 'Consolas', monospace;
|
| 455 |
+
font-size: 0.75rem;
|
| 456 |
+
line-height: 1.4;
|
| 457 |
+
word-break: break-all;
|
| 458 |
+
color: var(--text-light);
|
| 459 |
+
max-height: 80px;
|
| 460 |
+
overflow-y: auto;
|
| 461 |
+
user-select: all;
|
| 462 |
+
cursor: text;
|
| 463 |
+
}
|
| 464 |
+
.inline-edit-input {
|
| 465 |
+
flex: 1;
|
| 466 |
+
min-height: 24px;
|
| 467 |
+
padding: 0.125rem 0.375rem;
|
| 468 |
+
font-size: 0.75rem;
|
| 469 |
+
border: 1px solid var(--primary);
|
| 470 |
+
border-radius: 0.25rem;
|
| 471 |
+
background: var(--card);
|
| 472 |
+
}
|
| 473 |
+
.inline-edit-input:focus {
|
| 474 |
+
outline: none;
|
| 475 |
+
box-shadow: 0 0 0 2px rgba(8,145,178,0.2);
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
/* Token卡片头部 */
|
| 479 |
+
.token-header-right {
|
| 480 |
+
display: flex;
|
| 481 |
+
align-items: center;
|
| 482 |
+
gap: 0.5rem;
|
| 483 |
+
}
|
| 484 |
+
.btn-icon {
|
| 485 |
+
width: 24px;
|
| 486 |
+
height: 24px;
|
| 487 |
+
min-height: 24px;
|
| 488 |
+
min-width: 24px;
|
| 489 |
+
padding: 0;
|
| 490 |
+
background: transparent;
|
| 491 |
+
border: 1px solid var(--border);
|
| 492 |
+
border-radius: 0.25rem;
|
| 493 |
+
cursor: pointer;
|
| 494 |
+
font-size: 0.7rem;
|
| 495 |
+
display: flex;
|
| 496 |
+
align-items: center;
|
| 497 |
+
justify-content: center;
|
| 498 |
+
transition: all 0.2s;
|
| 499 |
+
box-shadow: none;
|
| 500 |
+
}
|
| 501 |
+
.btn-icon:hover {
|
| 502 |
+
background: var(--primary);
|
| 503 |
+
border-color: var(--primary);
|
| 504 |
+
transform: none;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
.token-actions {
|
| 508 |
+
display: flex;
|
| 509 |
+
gap: 0.375rem;
|
| 510 |
+
justify-content: flex-end;
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
.status {
|
| 514 |
+
padding: 0.25rem 0.5rem;
|
| 515 |
+
border-radius: 9999px;
|
| 516 |
+
font-size: 0.7rem;
|
| 517 |
+
font-weight: 600;
|
| 518 |
+
display: inline-flex;
|
| 519 |
+
align-items: center;
|
| 520 |
+
}
|
| 521 |
.status.enabled { background: rgba(16,185,129,0.15); color: var(--success); }
|
| 522 |
.status.disabled { background: rgba(239,68,68,0.15); color: var(--danger); }
|
| 523 |
|
| 524 |
+
/* 空状态 */
|
| 525 |
+
.empty-state {
|
| 526 |
+
text-align: center;
|
| 527 |
+
padding: 3rem 1rem;
|
| 528 |
+
color: var(--text-light);
|
| 529 |
+
grid-column: 1 / -1;
|
| 530 |
+
display: flex;
|
| 531 |
+
flex-direction: column;
|
| 532 |
+
align-items: center;
|
| 533 |
+
justify-content: center;
|
| 534 |
+
min-height: 300px;
|
| 535 |
+
}
|
| 536 |
+
.empty-state-icon { font-size: 3rem; margin-bottom: 0.75rem; opacity: 0.5; }
|
| 537 |
+
.empty-state-text { font-size: 1rem; font-weight: 500; margin-bottom: 0.375rem; }
|
| 538 |
+
.empty-state-hint { font-size: 0.8rem; }
|
| 539 |
|
| 540 |
+
/* 设置页面 */
|
| 541 |
+
.config-form { max-width: 100%; }
|
| 542 |
+
.config-grid {
|
| 543 |
+
display: grid;
|
| 544 |
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
| 545 |
+
gap: 1rem;
|
| 546 |
+
margin-bottom: 1rem;
|
| 547 |
+
}
|
| 548 |
+
.config-section {
|
| 549 |
+
background: rgba(255, 255, 255, 0.6);
|
| 550 |
+
padding: 1rem;
|
| 551 |
+
border-radius: 0.75rem;
|
| 552 |
+
border: 1.5px solid var(--border);
|
| 553 |
+
}
|
| 554 |
+
@media (prefers-color-scheme: dark) {
|
| 555 |
+
.config-section {
|
| 556 |
+
background: rgba(30, 41, 59, 0.6);
|
| 557 |
+
}
|
| 558 |
+
}
|
| 559 |
+
.config-section h4 {
|
| 560 |
+
margin-bottom: 0.75rem;
|
| 561 |
+
color: var(--primary);
|
| 562 |
+
font-size: 0.95rem;
|
| 563 |
+
font-weight: 600;
|
| 564 |
+
}
|
| 565 |
+
.form-row-inline {
|
| 566 |
+
display: grid;
|
| 567 |
+
grid-template-columns: 1fr 1fr;
|
| 568 |
+
gap: 0.5rem;
|
| 569 |
+
margin-bottom: 0.5rem;
|
| 570 |
+
}
|
| 571 |
+
.form-group.compact { margin-bottom: 0.5rem; }
|
| 572 |
+
.form-group.compact label {
|
| 573 |
+
font-size: 0.75rem;
|
| 574 |
+
margin-bottom: 0.25rem;
|
| 575 |
+
color: var(--text-light);
|
| 576 |
+
}
|
| 577 |
+
.form-group.compact input,
|
| 578 |
+
.form-group.compact select {
|
| 579 |
+
min-height: 36px;
|
| 580 |
+
padding: 0.375rem 0.5rem;
|
| 581 |
+
font-size: 0.8rem;
|
| 582 |
+
}
|
| 583 |
+
.form-group.compact textarea {
|
| 584 |
+
min-height: 60px;
|
| 585 |
+
max-height: 300px;
|
| 586 |
+
padding: 0.375rem 0.5rem;
|
| 587 |
+
font-size: 0.8rem;
|
| 588 |
+
resize: vertical;
|
| 589 |
+
height: auto;
|
| 590 |
+
/* 自适应内容高度 - 纯CSS方案,零JS资源消耗 */
|
| 591 |
+
field-sizing: content;
|
| 592 |
+
}
|
| 593 |
+
.form-group.highlight {
|
| 594 |
+
background: rgba(8,145,178,0.08);
|
| 595 |
+
padding: 0.5rem;
|
| 596 |
+
border-radius: 0.5rem;
|
| 597 |
+
margin-top: 0.5rem;
|
| 598 |
+
}
|
| 599 |
+
.form-group.highlight label {
|
| 600 |
+
color: var(--primary);
|
| 601 |
+
font-weight: 600;
|
| 602 |
+
}
|
| 603 |
+
.config-actions {
|
| 604 |
+
display: flex;
|
| 605 |
+
gap: 0.5rem;
|
| 606 |
+
justify-content: flex-start;
|
| 607 |
}
|
| 608 |
+
.config-actions .btn { width: auto; }
|
| 609 |
|
| 610 |
+
/* 字体大小控制 */
|
| 611 |
+
.font-size-control {
|
| 612 |
+
display: flex;
|
| 613 |
+
align-items: center;
|
| 614 |
+
gap: 0.5rem;
|
| 615 |
+
}
|
| 616 |
+
.font-size-control input[type="range"] {
|
| 617 |
+
flex: 1;
|
| 618 |
+
min-height: auto;
|
| 619 |
+
height: 6px;
|
| 620 |
+
padding: 0;
|
| 621 |
+
cursor: pointer;
|
| 622 |
+
}
|
| 623 |
+
.font-size-control input[type="number"] {
|
| 624 |
+
width: 60px;
|
| 625 |
+
text-align: center;
|
| 626 |
+
flex-shrink: 0;
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
/* 轮询策略状态显示 */
|
| 630 |
+
.rotation-status {
|
| 631 |
+
display: flex;
|
| 632 |
+
align-items: center;
|
| 633 |
+
gap: 0.5rem;
|
| 634 |
+
padding: 0.5rem 0.75rem;
|
| 635 |
+
background: rgba(8,145,178,0.08);
|
| 636 |
+
border-radius: 0.5rem;
|
| 637 |
+
margin-top: 0.5rem;
|
| 638 |
+
font-size: 0.75rem;
|
| 639 |
+
}
|
| 640 |
+
.rotation-label {
|
| 641 |
+
color: var(--text-light);
|
| 642 |
+
font-weight: 500;
|
| 643 |
+
}
|
| 644 |
+
#currentRotationInfo {
|
| 645 |
+
color: var(--primary);
|
| 646 |
+
font-weight: 600;
|
| 647 |
+
}
|
| 648 |
|
| 649 |
+
/* Toast通知 */
|
| 650 |
+
.toast {
|
| 651 |
+
position: fixed;
|
| 652 |
+
top: 16px;
|
| 653 |
+
right: 16px;
|
| 654 |
+
background: var(--card);
|
| 655 |
+
border-radius: 0.75rem;
|
| 656 |
+
padding: 0.75rem 1rem;
|
| 657 |
+
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
|
| 658 |
+
z-index: 9999;
|
| 659 |
+
display: flex;
|
| 660 |
+
align-items: center;
|
| 661 |
+
gap: 0.75rem;
|
| 662 |
+
min-width: 240px;
|
| 663 |
+
max-width: 360px;
|
| 664 |
+
animation: slideIn 0.3s ease;
|
| 665 |
+
border-left: 3px solid var(--primary);
|
| 666 |
+
}
|
| 667 |
+
.toast.success { border-left-color: var(--success); }
|
| 668 |
+
.toast.error { border-left-color: var(--danger); }
|
| 669 |
+
.toast.warning { border-left-color: var(--warning); }
|
| 670 |
+
.toast.info { border-left-color: var(--info); }
|
| 671 |
+
.toast-icon { font-size: 1.25rem; flex-shrink: 0; }
|
| 672 |
+
.toast-content { flex: 1; }
|
| 673 |
+
.toast-title { font-weight: 600; font-size: 0.875rem; color: var(--text); }
|
| 674 |
+
.toast-message { font-size: 0.8rem; color: var(--text-light); }
|
| 675 |
+
|
| 676 |
+
@keyframes slideIn { from { transform: translateX(400px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
| 677 |
+
@keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(400px); opacity: 0; } }
|
| 678 |
+
|
| 679 |
+
/* Modal弹窗 */
|
| 680 |
+
.modal {
|
| 681 |
+
position: fixed;
|
| 682 |
+
inset: 0;
|
| 683 |
+
background: rgba(0,0,0,0.5);
|
| 684 |
+
z-index: 9998;
|
| 685 |
+
display: flex;
|
| 686 |
+
align-items: center;
|
| 687 |
+
justify-content: center;
|
| 688 |
+
padding: 1rem;
|
| 689 |
+
animation: fadeIn 0.2s;
|
| 690 |
+
}
|
| 691 |
+
.modal-content {
|
| 692 |
+
background: rgba(255, 255, 255, 0.8);
|
| 693 |
+
backdrop-filter: blur(24px) saturate(180%);
|
| 694 |
+
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
| 695 |
+
border-radius: 1rem;
|
| 696 |
+
padding: 1.25rem;
|
| 697 |
+
max-width: 400px;
|
| 698 |
+
width: 100%;
|
| 699 |
+
box-shadow: 0 16px 48px rgba(0,0,0,0.2);
|
| 700 |
+
animation: scaleIn 0.2s;
|
| 701 |
+
border: 1px solid rgba(255, 255, 255, 0.5);
|
| 702 |
+
}
|
| 703 |
+
@media (prefers-color-scheme: dark) {
|
| 704 |
+
.modal-content {
|
| 705 |
+
background: rgba(30, 41, 59, 0.8);
|
| 706 |
+
border: 1px solid rgba(255, 255, 255, 0.15);
|
| 707 |
+
}
|
| 708 |
+
}
|
| 709 |
+
.modal-content.modal-lg { max-width: 700px; }
|
| 710 |
+
.modal-content.modal-xl {
|
| 711 |
+
max-width: 900px;
|
| 712 |
+
display: flex;
|
| 713 |
+
flex-direction: column;
|
| 714 |
+
max-height: 80vh;
|
| 715 |
+
overflow: hidden;
|
| 716 |
+
}
|
| 717 |
+
.modal-content.modal-xl .quota-container {
|
| 718 |
+
flex: 1;
|
| 719 |
+
overflow-y: auto;
|
| 720 |
+
overflow-x: hidden;
|
| 721 |
+
}
|
| 722 |
+
.modal-content.modal-xl .modal-actions {
|
| 723 |
+
flex-shrink: 0;
|
| 724 |
+
border-top: 1px solid var(--border);
|
| 725 |
+
padding-top: 1rem;
|
| 726 |
+
margin-top: 0.5rem;
|
| 727 |
+
background: transparent;
|
| 728 |
+
}
|
| 729 |
+
.modal-title { font-size: 1rem; font-weight: 700; margin-bottom: 0.75rem; color: var(--text); }
|
| 730 |
+
.modal-message { color: var(--text-light); margin-bottom: 1rem; line-height: 1.5; font-size: 0.85rem; }
|
| 731 |
+
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
|
| 732 |
+
.modal-actions button { flex: none; width: auto; }
|
| 733 |
+
|
| 734 |
+
.form-modal .modal-content { max-width: 480px; }
|
| 735 |
+
.form-modal input { margin-bottom: 0.75rem; }
|
| 736 |
+
.form-modal .form-row { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1rem; }
|
| 737 |
+
.form-modal .oauth-steps {
|
| 738 |
+
background: rgba(59,130,246,0.1);
|
| 739 |
+
padding: 0.875rem;
|
| 740 |
+
border-radius: 0.5rem;
|
| 741 |
+
margin-bottom: 1rem;
|
| 742 |
+
border: 1.5px solid var(--info);
|
| 743 |
+
}
|
| 744 |
+
.form-modal .oauth-steps p { margin-bottom: 0.5rem; font-size: 0.8rem; }
|
| 745 |
+
.form-modal .oauth-steps p:last-child { margin-bottom: 0; }
|
| 746 |
+
|
| 747 |
+
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
| 748 |
+
@keyframes scaleIn { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
| 749 |
+
|
| 750 |
+
/* Loading遮罩 */
|
| 751 |
+
.loading-overlay {
|
| 752 |
+
position: fixed;
|
| 753 |
+
inset: 0;
|
| 754 |
+
background: rgba(0,0,0,0.6);
|
| 755 |
+
z-index: 9999;
|
| 756 |
+
display: flex;
|
| 757 |
+
align-items: center;
|
| 758 |
+
justify-content: center;
|
| 759 |
+
flex-direction: column;
|
| 760 |
+
gap: 1rem;
|
| 761 |
+
}
|
| 762 |
+
.spinner {
|
| 763 |
+
width: 40px;
|
| 764 |
+
height: 40px;
|
| 765 |
+
border: 3px solid rgba(255,255,255,0.2);
|
| 766 |
+
border-top-color: white;
|
| 767 |
+
border-radius: 50%;
|
| 768 |
+
animation: spin 0.7s linear infinite;
|
| 769 |
+
}
|
| 770 |
+
.loading-text { color: white; font-size: 0.875rem; font-weight: 500; }
|
| 771 |
+
|
| 772 |
+
/* 帮助提示 */
|
| 773 |
+
.help-tip {
|
| 774 |
+
display: inline-flex;
|
| 775 |
+
align-items: center;
|
| 776 |
+
justify-content: center;
|
| 777 |
+
width: 16px;
|
| 778 |
+
height: 16px;
|
| 779 |
+
border-radius: 50%;
|
| 780 |
+
background: var(--info);
|
| 781 |
+
color: white;
|
| 782 |
+
font-size: 0.65rem;
|
| 783 |
+
font-weight: 600;
|
| 784 |
+
cursor: help;
|
| 785 |
+
margin-left: 4px;
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
/* 额度弹窗头部 */
|
| 789 |
+
.quota-modal-header {
|
| 790 |
+
display: flex;
|
| 791 |
+
justify-content: space-between;
|
| 792 |
+
align-items: center;
|
| 793 |
+
margin-bottom: 0.75rem;
|
| 794 |
+
flex-wrap: wrap;
|
| 795 |
+
gap: 0.5rem;
|
| 796 |
+
}
|
| 797 |
+
.quota-update-time {
|
| 798 |
+
font-size: 0.75rem;
|
| 799 |
+
color: var(--text-light);
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
/* 额度弹窗 - 标签页样式 */
|
| 803 |
+
.quota-tabs {
|
| 804 |
+
display: flex;
|
| 805 |
+
gap: 0.625rem;
|
| 806 |
+
margin-bottom: 1rem;
|
| 807 |
+
overflow-x: auto;
|
| 808 |
+
overflow-y: hidden;
|
| 809 |
+
scrollbar-width: thin;
|
| 810 |
+
scrollbar-color: rgba(0,0,0,0.2) transparent;
|
| 811 |
+
padding: 0.375rem;
|
| 812 |
+
margin: -0.375rem;
|
| 813 |
+
margin-bottom: 0.625rem;
|
| 814 |
+
}
|
| 815 |
+
.quota-tabs::-webkit-scrollbar {
|
| 816 |
+
height: 4px;
|
| 817 |
+
}
|
| 818 |
+
.quota-tabs::-webkit-scrollbar-thumb {
|
| 819 |
+
background: rgba(0,0,0,0.2);
|
| 820 |
+
border-radius: 2px;
|
| 821 |
+
}
|
| 822 |
+
.quota-tab {
|
| 823 |
+
font-size: 0.8rem;
|
| 824 |
+
font-weight: 500;
|
| 825 |
+
color: var(--text-light) !important;
|
| 826 |
+
padding: 0.5rem 1rem;
|
| 827 |
+
background: var(--bg) !important;
|
| 828 |
+
border: 2px solid var(--border) !important;
|
| 829 |
+
border-radius: 0.5rem;
|
| 830 |
+
cursor: pointer;
|
| 831 |
+
transition: all 0.15s ease;
|
| 832 |
+
white-space: nowrap;
|
| 833 |
+
min-height: auto;
|
| 834 |
+
box-shadow: none !important;
|
| 835 |
+
width: auto;
|
| 836 |
+
line-height: 1.2;
|
| 837 |
+
display: inline-flex;
|
| 838 |
+
align-items: center;
|
| 839 |
+
justify-content: center;
|
| 840 |
+
flex-shrink: 0;
|
| 841 |
+
}
|
| 842 |
+
.quota-tab:hover {
|
| 843 |
+
color: var(--primary) !important;
|
| 844 |
+
border-color: var(--primary) !important;
|
| 845 |
+
background: rgba(8,145,178,0.05) !important;
|
| 846 |
+
transform: none;
|
| 847 |
+
}
|
| 848 |
+
.quota-tab.active {
|
| 849 |
+
color: white !important;
|
| 850 |
+
background: var(--primary) !important;
|
| 851 |
+
border-color: var(--primary) !important;
|
| 852 |
+
}
|
| 853 |
+
.quota-container {
|
| 854 |
+
max-height: 55vh;
|
| 855 |
+
overflow: auto;
|
| 856 |
+
position: relative;
|
| 857 |
+
padding: 0.25rem;
|
| 858 |
+
}
|
| 859 |
+
.quota-loading, .quota-error, .quota-empty {
|
| 860 |
+
text-align: center;
|
| 861 |
+
padding: 1.5rem;
|
| 862 |
+
color: var(--text-light);
|
| 863 |
+
font-size: 0.875rem;
|
| 864 |
+
}
|
| 865 |
.quota-error { color: var(--danger); }
|
| 866 |
+
.quota-group-title {
|
| 867 |
+
font-size: 0.9rem;
|
| 868 |
+
font-weight: 700;
|
| 869 |
+
color: var(--text);
|
| 870 |
+
margin: 1rem 0 0.5rem 0;
|
| 871 |
+
padding-bottom: 0.375rem;
|
| 872 |
+
border-bottom: 1.5px solid var(--border);
|
| 873 |
+
}
|
|
|
|
| 874 |
.quota-group-title:first-child { margin-top: 0; }
|
| 875 |
+
.quota-grid {
|
| 876 |
+
display: grid;
|
| 877 |
+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
| 878 |
+
gap: 0.75rem;
|
| 879 |
+
padding: 0.5rem;
|
| 880 |
+
}
|
| 881 |
+
.quota-item {
|
| 882 |
+
background: rgba(255,255,255,0.6);
|
| 883 |
+
padding: 0.625rem;
|
| 884 |
+
border-radius: 0.5rem;
|
| 885 |
+
border: 1.5px solid var(--border);
|
| 886 |
+
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
| 887 |
+
cursor: default;
|
| 888 |
+
}
|
| 889 |
+
.quota-item:hover {
|
| 890 |
+
transform: scale(1.02);
|
| 891 |
+
box-shadow: 0 6px 16px rgba(0,0,0,0.12);
|
| 892 |
+
border-color: var(--primary);
|
| 893 |
+
background: rgba(255,255,255,0.95);
|
| 894 |
+
z-index: 1;
|
| 895 |
+
position: relative;
|
| 896 |
+
}
|
| 897 |
+
@media (prefers-color-scheme: dark) {
|
| 898 |
+
.quota-item {
|
| 899 |
+
background: rgba(30, 41, 59, 0.6);
|
| 900 |
+
}
|
| 901 |
+
.quota-item:hover {
|
| 902 |
+
background: rgba(30, 41, 59, 0.9);
|
| 903 |
+
}
|
| 904 |
+
}
|
| 905 |
+
.quota-model-name {
|
| 906 |
+
font-size: 0.75rem;
|
| 907 |
+
color: var(--text);
|
| 908 |
+
margin-bottom: 0.375rem;
|
| 909 |
+
font-weight: 500;
|
| 910 |
+
overflow: hidden;
|
| 911 |
+
text-overflow: ellipsis;
|
| 912 |
+
white-space: nowrap;
|
| 913 |
+
}
|
| 914 |
+
.quota-bar-container {
|
| 915 |
+
position: relative;
|
| 916 |
+
height: 0.5rem;
|
| 917 |
+
background: var(--border);
|
| 918 |
+
border-radius: 0.25rem;
|
| 919 |
+
overflow: hidden;
|
| 920 |
+
margin-bottom: 0.375rem;
|
| 921 |
+
}
|
| 922 |
+
.quota-bar {
|
| 923 |
+
height: 100%;
|
| 924 |
+
border-radius: 0.25rem;
|
| 925 |
+
transition: width 0.3s ease;
|
| 926 |
+
}
|
| 927 |
+
.quota-info-row {
|
| 928 |
+
display: flex;
|
| 929 |
+
justify-content: space-between;
|
| 930 |
+
align-items: center;
|
| 931 |
+
font-size: 0.7rem;
|
| 932 |
+
}
|
| 933 |
+
.quota-percentage {
|
| 934 |
+
font-weight: 600;
|
| 935 |
+
color: var(--text);
|
| 936 |
+
}
|
| 937 |
+
.quota-reset {
|
| 938 |
+
color: var(--text-light);
|
| 939 |
+
}
|
| 940 |
|
| 941 |
+
/* Token卡片内嵌额度 */
|
| 942 |
+
.token-quota-inline {
|
| 943 |
+
background: rgba(8,145,178,0.05);
|
| 944 |
+
border: 1px solid var(--border);
|
| 945 |
+
border-radius: 0.5rem;
|
| 946 |
+
margin-bottom: 0.75rem;
|
| 947 |
+
overflow: hidden;
|
| 948 |
+
}
|
| 949 |
+
.quota-inline-header {
|
| 950 |
+
display: flex;
|
| 951 |
+
justify-content: space-between;
|
| 952 |
+
align-items: center;
|
| 953 |
+
padding: 0.375rem 0.5rem;
|
| 954 |
+
cursor: pointer;
|
| 955 |
+
transition: background 0.2s;
|
| 956 |
+
}
|
| 957 |
+
.quota-inline-header:hover {
|
| 958 |
+
background: rgba(8,145,178,0.1);
|
| 959 |
+
}
|
| 960 |
+
.quota-inline-summary {
|
| 961 |
+
display: flex;
|
| 962 |
+
align-items: center;
|
| 963 |
+
gap: 0.5rem;
|
| 964 |
+
flex: 1;
|
| 965 |
+
min-width: 0;
|
| 966 |
+
font-size: 0.75rem;
|
| 967 |
+
}
|
| 968 |
+
.quota-summary-icon {
|
| 969 |
+
flex-shrink: 0;
|
| 970 |
+
}
|
| 971 |
+
.quota-summary-model {
|
| 972 |
+
flex: 1;
|
| 973 |
+
min-width: 0;
|
| 974 |
+
overflow: hidden;
|
| 975 |
+
text-overflow: ellipsis;
|
| 976 |
+
white-space: nowrap;
|
| 977 |
+
color: var(--text);
|
| 978 |
+
font-weight: 500;
|
| 979 |
+
}
|
| 980 |
+
.quota-summary-bar {
|
| 981 |
+
flex: 0 0 60px;
|
| 982 |
+
height: 8px;
|
| 983 |
+
background: var(--border);
|
| 984 |
+
border-radius: 4px;
|
| 985 |
+
overflow: hidden;
|
| 986 |
+
}
|
| 987 |
+
.quota-summary-bar span {
|
| 988 |
+
display: block;
|
| 989 |
+
height: 100%;
|
| 990 |
+
border-radius: 4px;
|
| 991 |
+
}
|
| 992 |
+
.quota-summary-pct {
|
| 993 |
+
flex-shrink: 0;
|
| 994 |
+
font-weight: 600;
|
| 995 |
+
font-size: 0.7rem;
|
| 996 |
+
color: var(--primary);
|
| 997 |
+
min-width: 32px;
|
| 998 |
+
text-align: right;
|
| 999 |
+
}
|
| 1000 |
+
.quota-inline-toggle {
|
| 1001 |
+
font-size: 0.65rem;
|
| 1002 |
+
color: var(--text-light);
|
| 1003 |
+
flex-shrink: 0;
|
| 1004 |
+
margin-left: 0.5rem;
|
| 1005 |
+
}
|
| 1006 |
+
.quota-inline-detail {
|
| 1007 |
+
border-top: 1px solid var(--border);
|
| 1008 |
+
padding: 0.5rem;
|
| 1009 |
+
background: var(--bg);
|
| 1010 |
+
}
|
| 1011 |
+
.quota-detail-grid {
|
| 1012 |
+
display: flex;
|
| 1013 |
+
flex-direction: column;
|
| 1014 |
+
gap: 0.25rem;
|
| 1015 |
+
margin-bottom: 0.5rem;
|
| 1016 |
+
max-height: 200px;
|
| 1017 |
+
overflow-y: auto;
|
| 1018 |
+
}
|
| 1019 |
+
.quota-detail-row {
|
| 1020 |
+
display: flex;
|
| 1021 |
+
align-items: center;
|
| 1022 |
+
gap: 0.375rem;
|
| 1023 |
+
padding: 0.25rem 0;
|
| 1024 |
+
font-size: 0.7rem;
|
| 1025 |
+
border-bottom: 1px solid var(--border);
|
| 1026 |
+
}
|
| 1027 |
+
.quota-detail-row:last-child {
|
| 1028 |
+
border-bottom: none;
|
| 1029 |
+
}
|
| 1030 |
+
.quota-detail-icon {
|
| 1031 |
+
flex-shrink: 0;
|
| 1032 |
+
font-size: 0.65rem;
|
| 1033 |
+
}
|
| 1034 |
+
.quota-detail-name {
|
| 1035 |
+
flex: 1;
|
| 1036 |
+
min-width: 0;
|
| 1037 |
+
overflow: hidden;
|
| 1038 |
+
text-overflow: ellipsis;
|
| 1039 |
+
white-space: nowrap;
|
| 1040 |
+
color: var(--text);
|
| 1041 |
+
}
|
| 1042 |
+
.quota-detail-bar {
|
| 1043 |
+
flex: 0 0 50px;
|
| 1044 |
+
height: 6px;
|
| 1045 |
+
background: var(--border);
|
| 1046 |
+
border-radius: 3px;
|
| 1047 |
+
overflow: hidden;
|
| 1048 |
+
}
|
| 1049 |
+
.quota-detail-bar span {
|
| 1050 |
+
display: block;
|
| 1051 |
+
height: 100%;
|
| 1052 |
+
border-radius: 3px;
|
| 1053 |
+
}
|
| 1054 |
+
.quota-detail-pct {
|
| 1055 |
+
flex-shrink: 0;
|
| 1056 |
+
font-weight: 600;
|
| 1057 |
+
font-size: 0.65rem;
|
| 1058 |
+
color: var(--primary);
|
| 1059 |
+
min-width: 28px;
|
| 1060 |
+
text-align: right;
|
| 1061 |
+
}
|
| 1062 |
+
.quota-refresh-btn {
|
| 1063 |
+
width: 100%;
|
| 1064 |
+
margin-top: 0.5rem;
|
| 1065 |
+
}
|
| 1066 |
+
.quota-loading-small {
|
| 1067 |
+
font-size: 0.7rem;
|
| 1068 |
+
color: var(--text-light);
|
| 1069 |
+
text-align: center;
|
| 1070 |
+
padding: 0.5rem;
|
| 1071 |
+
}
|
| 1072 |
+
.quota-empty-small, .quota-error-small {
|
| 1073 |
+
font-size: 0.7rem;
|
| 1074 |
+
color: var(--text-light);
|
| 1075 |
+
text-align: center;
|
| 1076 |
+
padding: 0.5rem;
|
| 1077 |
+
}
|
| 1078 |
+
.quota-error-small { color: var(--danger); }
|
| 1079 |
+
.quota-summary-error {
|
| 1080 |
+
color: var(--danger);
|
| 1081 |
+
font-size: 0.7rem;
|
| 1082 |
+
}
|
| 1083 |
+
|
| 1084 |
+
.hidden { display: none; }
|
| 1085 |
+
|
| 1086 |
+
/* 敏感信息模糊 */
|
| 1087 |
+
.sensitive-info.blurred {
|
| 1088 |
+
filter: blur(4px);
|
| 1089 |
+
user-select: none;
|
| 1090 |
+
cursor: default;
|
| 1091 |
+
}
|
| 1092 |
+
|
| 1093 |
+
/* 响应式 */
|
| 1094 |
@media (max-width: 768px) {
|
| 1095 |
+
.container { padding: 0; height: 100vh; }
|
| 1096 |
+
.login-form { margin: 0.5rem; padding: 1.5rem; }
|
| 1097 |
+
.login-form h2 { font-size: 1.25rem; }
|
| 1098 |
+
.main-content { height: 100vh; border-radius: 0; }
|
| 1099 |
+
.header {
|
| 1100 |
+
padding: 0.5rem 0.75rem;
|
| 1101 |
+
flex-wrap: nowrap;
|
| 1102 |
+
gap: 0.5rem;
|
| 1103 |
+
}
|
| 1104 |
+
.header-right {
|
| 1105 |
+
flex-shrink: 0;
|
| 1106 |
+
}
|
| 1107 |
+
.header-right .server-info {
|
| 1108 |
+
display: none;
|
| 1109 |
+
}
|
| 1110 |
+
.header-right button {
|
| 1111 |
+
padding: 0.375rem 0.5rem;
|
| 1112 |
+
font-size: 0.7rem;
|
| 1113 |
+
min-height: 32px;
|
| 1114 |
+
}
|
| 1115 |
+
.tabs {
|
| 1116 |
+
flex: 1;
|
| 1117 |
+
min-width: 0;
|
| 1118 |
+
}
|
| 1119 |
+
.tab { flex: 1; font-size: 0.7rem; padding: 0.375rem 0.5rem; min-height: 32px; }
|
| 1120 |
+
.content { padding: 0.75rem; }
|
| 1121 |
+
|
| 1122 |
+
.top-bar { flex-direction: column; gap: 0.5rem; align-items: stretch; padding: 0.5rem 0.75rem; }
|
| 1123 |
+
.stats-inline { justify-content: space-around; }
|
| 1124 |
+
.action-btns {
|
| 1125 |
+
display: grid;
|
| 1126 |
+
grid-template-columns: repeat(4, 1fr);
|
| 1127 |
+
gap: 0.375rem;
|
| 1128 |
+
}
|
| 1129 |
+
.action-btns .btn-sm {
|
| 1130 |
+
padding: 0.375rem 0.25rem;
|
| 1131 |
+
font-size: 0.7rem;
|
| 1132 |
+
min-height: 32px;
|
| 1133 |
+
}
|
| 1134 |
+
|
| 1135 |
+
.token-grid { grid-template-columns: 1fr; }
|
| 1136 |
+
.config-grid { grid-template-columns: 1fr; }
|
| 1137 |
+
.form-row-inline { grid-template-columns: 1fr; }
|
| 1138 |
+
.toast { right: 8px; left: 8px; min-width: auto; max-width: none; }
|
| 1139 |
+
.modal { padding: 0.25rem; }
|
| 1140 |
+
.modal-content { padding: 0.75rem; }
|
| 1141 |
+
.modal-content.modal-xl {
|
| 1142 |
+
max-width: 100%;
|
| 1143 |
+
max-height: 90vh;
|
| 1144 |
+
margin: 0.25rem;
|
| 1145 |
+
}
|
| 1146 |
+
.quota-modal-header {
|
| 1147 |
+
flex-direction: column;
|
| 1148 |
+
align-items: flex-start;
|
| 1149 |
+
gap: 0.25rem;
|
| 1150 |
+
}
|
| 1151 |
+
.quota-tabs {
|
| 1152 |
+
flex-wrap: nowrap;
|
| 1153 |
+
gap: 0.375rem;
|
| 1154 |
+
overflow-x: auto;
|
| 1155 |
+
-webkit-overflow-scrolling: touch;
|
| 1156 |
+
padding-bottom: 0.25rem;
|
| 1157 |
+
}
|
| 1158 |
+
.quota-tab {
|
| 1159 |
+
font-size: 0.75rem;
|
| 1160 |
+
padding: 0.3rem 0.6rem;
|
| 1161 |
+
}
|
| 1162 |
+
.quota-grid {
|
| 1163 |
+
grid-template-columns: 1fr;
|
| 1164 |
+
gap: 0.5rem;
|
| 1165 |
+
padding: 0.25rem;
|
| 1166 |
+
}
|
| 1167 |
+
.quota-item {
|
| 1168 |
+
padding: 0.5rem;
|
| 1169 |
+
}
|
| 1170 |
+
.quota-item:hover {
|
| 1171 |
+
transform: none;
|
| 1172 |
+
}
|
| 1173 |
+
.quota-group-title {
|
| 1174 |
+
font-size: 0.85rem;
|
| 1175 |
+
margin: 0.75rem 0 0.375rem 0;
|
| 1176 |
+
}
|
| 1177 |
+
|
| 1178 |
+
/* 移动端内嵌额度优化 */
|
| 1179 |
+
.quota-summary-model {
|
| 1180 |
+
font-size: 0.7rem;
|
| 1181 |
+
}
|
| 1182 |
+
|
| 1183 |
+
/* 移动端Token卡片操作按钮 */
|
| 1184 |
+
.token-actions {
|
| 1185 |
+
display: grid;
|
| 1186 |
+
grid-template-columns: repeat(3, 1fr);
|
| 1187 |
+
gap: 0.25rem;
|
| 1188 |
+
}
|
| 1189 |
+
.token-actions .btn-xs {
|
| 1190 |
+
padding: 0.375rem 0.25rem;
|
| 1191 |
+
font-size: 0.65rem;
|
| 1192 |
+
min-height: 28px;
|
| 1193 |
+
}
|
| 1194 |
+
}
|
| 1195 |
+
|
| 1196 |
+
@media (max-width: 480px) {
|
| 1197 |
+
.stat-num { font-size: 1rem; }
|
| 1198 |
+
.stat-text { font-size: 0.75rem; }
|
| 1199 |
+
.btn-sm { padding: 0.25rem 0.375rem; font-size: 0.65rem; min-height: 30px; }
|
| 1200 |
+
.token-card { padding: 0.625rem; }
|
| 1201 |
+
.info-row { font-size: 0.7rem; }
|
| 1202 |
+
.info-label { width: 18px; }
|
| 1203 |
+
.info-value { font-size: 0.7rem; }
|
| 1204 |
+
|
| 1205 |
+
/* 超小屏幕按钮2x2布局 */
|
| 1206 |
+
.action-btns {
|
| 1207 |
+
grid-template-columns: repeat(2, 1fr);
|
| 1208 |
+
}
|
| 1209 |
+
|
| 1210 |
+
.quota-summary-model {
|
| 1211 |
+
font-size: 0.65rem;
|
| 1212 |
+
}
|
| 1213 |
+
.quota-summary-pct {
|
| 1214 |
+
font-size: 0.65rem;
|
| 1215 |
+
}
|
| 1216 |
+
.quota-summary-bar {
|
| 1217 |
+
flex: 0 0 40px;
|
| 1218 |
+
}
|
| 1219 |
+
.quota-detail-name {
|
| 1220 |
+
font-size: 0.6rem;
|
| 1221 |
+
}
|
| 1222 |
+
.quota-detail-pct {
|
| 1223 |
+
font-size: 0.6rem;
|
| 1224 |
+
}
|
| 1225 |
+
|
| 1226 |
+
/* Token卡片头部优化 */
|
| 1227 |
+
.token-header {
|
| 1228 |
+
flex-wrap: wrap;
|
| 1229 |
+
gap: 0.25rem;
|
| 1230 |
+
}
|
| 1231 |
+
.status {
|
| 1232 |
+
font-size: 0.65rem;
|
| 1233 |
+
padding: 0.2rem 0.4rem;
|
| 1234 |
+
}
|
| 1235 |
+
.token-id {
|
| 1236 |
+
font-size: 0.6rem;
|
| 1237 |
+
}
|
| 1238 |
}
|
src/AntigravityRequester.js
CHANGED
|
@@ -6,6 +6,9 @@ import fs from 'fs';
|
|
| 6 |
|
| 7 |
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
| 8 |
|
|
|
|
|
|
|
|
|
|
| 9 |
class antigravityRequester {
|
| 10 |
constructor(options = {}) {
|
| 11 |
this.binPath = options.binPath;
|
|
@@ -15,6 +18,7 @@ class antigravityRequester {
|
|
| 15 |
this.pendingRequests = new Map();
|
| 16 |
this.buffer = '';
|
| 17 |
this.writeQueue = Promise.resolve();
|
|
|
|
| 18 |
}
|
| 19 |
|
| 20 |
_getExecutablePath() {
|
|
@@ -64,15 +68,28 @@ class antigravityRequester {
|
|
| 64 |
|
| 65 |
// 使用 setImmediate 异步处理数据,避免阻塞
|
| 66 |
this.proc.stdout.on('data', (data) => {
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
// 使用 setImmediate 异步处理,避免阻塞 stdout 读取
|
| 70 |
setImmediate(() => {
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
try {
|
| 77 |
const response = JSON.parse(line);
|
| 78 |
const pending = this.pendingRequests.get(response.id);
|
|
@@ -92,9 +109,13 @@ class antigravityRequester {
|
|
| 92 |
}
|
| 93 |
}
|
| 94 |
} catch (e) {
|
| 95 |
-
|
| 96 |
}
|
| 97 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
});
|
| 99 |
});
|
| 100 |
|
|
@@ -168,9 +189,17 @@ class antigravityRequester {
|
|
| 168 |
if (canWrite) {
|
| 169 |
resolve();
|
| 170 |
} else {
|
| 171 |
-
// 等待 drain
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
}
|
| 175 |
});
|
| 176 |
}).catch(err => {
|
|
@@ -180,8 +209,49 @@ class antigravityRequester {
|
|
| 180 |
|
| 181 |
close() {
|
| 182 |
if (this.proc) {
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
this.proc = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
}
|
| 186 |
}
|
| 187 |
}
|
|
|
|
| 6 |
|
| 7 |
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
| 8 |
|
| 9 |
+
// 缓冲区大小警告阈值(不限制,只警告)
|
| 10 |
+
const BUFFER_WARNING_SIZE = 50 * 1024 * 1024; // 50MB 警告
|
| 11 |
+
|
| 12 |
class antigravityRequester {
|
| 13 |
constructor(options = {}) {
|
| 14 |
this.binPath = options.binPath;
|
|
|
|
| 18 |
this.pendingRequests = new Map();
|
| 19 |
this.buffer = '';
|
| 20 |
this.writeQueue = Promise.resolve();
|
| 21 |
+
this.bufferWarned = false;
|
| 22 |
}
|
| 23 |
|
| 24 |
_getExecutablePath() {
|
|
|
|
| 68 |
|
| 69 |
// 使用 setImmediate 异步处理数据,避免阻塞
|
| 70 |
this.proc.stdout.on('data', (data) => {
|
| 71 |
+
const chunk = data.toString();
|
| 72 |
+
|
| 73 |
+
// 缓冲区大小监控(仅警告,不限制,因为图片响应可能很大)
|
| 74 |
+
if (!this.bufferWarned && this.buffer.length > BUFFER_WARNING_SIZE) {
|
| 75 |
+
console.warn(`AntigravityRequester: 缓冲区较大 (${Math.round(this.buffer.length / 1024 / 1024)}MB),可能有大型响应`);
|
| 76 |
+
this.bufferWarned = true;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
this.buffer += chunk;
|
| 80 |
|
| 81 |
// 使用 setImmediate 异步处理,避免阻塞 stdout 读取
|
| 82 |
setImmediate(() => {
|
| 83 |
+
let start = 0;
|
| 84 |
+
let end;
|
| 85 |
+
|
| 86 |
+
// 高效的行分割(避免 split 创建大量字符串)
|
| 87 |
+
while ((end = this.buffer.indexOf('\n', start)) !== -1) {
|
| 88 |
+
const line = this.buffer.slice(start, end).trim();
|
| 89 |
+
start = end + 1;
|
| 90 |
+
|
| 91 |
+
if (!line) continue;
|
| 92 |
+
|
| 93 |
try {
|
| 94 |
const response = JSON.parse(line);
|
| 95 |
const pending = this.pendingRequests.get(response.id);
|
|
|
|
| 109 |
}
|
| 110 |
}
|
| 111 |
} catch (e) {
|
| 112 |
+
// 忽略 JSON 解析错误(可能是不完整的行)
|
| 113 |
}
|
| 114 |
}
|
| 115 |
+
|
| 116 |
+
// 保留未处理的部分
|
| 117 |
+
this.buffer = start < this.buffer.length ? this.buffer.slice(start) : '';
|
| 118 |
+
this.bufferWarned = false;
|
| 119 |
});
|
| 120 |
});
|
| 121 |
|
|
|
|
| 189 |
if (canWrite) {
|
| 190 |
resolve();
|
| 191 |
} else {
|
| 192 |
+
// 等待 drain 事件,并在任一事件触发后移除另一个监听器
|
| 193 |
+
const onDrain = () => {
|
| 194 |
+
this.proc.stdin.removeListener('error', onError);
|
| 195 |
+
resolve();
|
| 196 |
+
};
|
| 197 |
+
const onError = (err) => {
|
| 198 |
+
this.proc.stdin.removeListener('drain', onDrain);
|
| 199 |
+
reject(err);
|
| 200 |
+
};
|
| 201 |
+
this.proc.stdin.once('drain', onDrain);
|
| 202 |
+
this.proc.stdin.once('error', onError);
|
| 203 |
}
|
| 204 |
});
|
| 205 |
}).catch(err => {
|
|
|
|
| 209 |
|
| 210 |
close() {
|
| 211 |
if (this.proc) {
|
| 212 |
+
// 先拒绝所有待处理的请求
|
| 213 |
+
for (const [id, pending] of this.pendingRequests) {
|
| 214 |
+
if (pending.reject) {
|
| 215 |
+
pending.reject(new Error('Requester closed'));
|
| 216 |
+
} else if (pending.streamResponse && pending.streamResponse._onError) {
|
| 217 |
+
pending.streamResponse._onError(new Error('Requester closed'));
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
this.pendingRequests.clear();
|
| 221 |
+
|
| 222 |
+
// 清理缓冲区
|
| 223 |
+
this.buffer = '';
|
| 224 |
+
|
| 225 |
+
// 关闭输入流
|
| 226 |
+
try {
|
| 227 |
+
this.proc.stdin.end();
|
| 228 |
+
} catch (e) {
|
| 229 |
+
// 忽略关闭错误
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
// 给子进程一点时间优雅退出,否则强制终止
|
| 233 |
+
const proc = this.proc;
|
| 234 |
this.proc = null;
|
| 235 |
+
|
| 236 |
+
setTimeout(() => {
|
| 237 |
+
try {
|
| 238 |
+
if (proc && !proc.killed) {
|
| 239 |
+
proc.kill('SIGTERM');
|
| 240 |
+
// 如果 SIGTERM 无效,1秒后使用 SIGKILL
|
| 241 |
+
setTimeout(() => {
|
| 242 |
+
try {
|
| 243 |
+
if (proc && !proc.killed) {
|
| 244 |
+
proc.kill('SIGKILL');
|
| 245 |
+
}
|
| 246 |
+
} catch (e) {
|
| 247 |
+
// 忽略错误
|
| 248 |
+
}
|
| 249 |
+
}, 1000);
|
| 250 |
+
}
|
| 251 |
+
} catch (e) {
|
| 252 |
+
// 忽略错误
|
| 253 |
+
}
|
| 254 |
+
}, 500);
|
| 255 |
}
|
| 256 |
}
|
| 257 |
}
|
src/api/client.js
CHANGED
|
@@ -4,11 +4,61 @@ import config from '../config/config.js';
|
|
| 4 |
import { generateToolCallId } from '../utils/idGenerator.js';
|
| 5 |
import AntigravityRequester from '../AntigravityRequester.js';
|
| 6 |
import { saveBase64Image } from '../utils/imageStorage.js';
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
// 请求客户端:优先使用 AntigravityRequester,失败则降级到 axios
|
| 9 |
let requester = null;
|
| 10 |
let useAxios = false;
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
if (config.useNativeAxios === true) {
|
| 13 |
useAxios = true;
|
| 14 |
} else {
|
|
@@ -20,6 +70,106 @@ if (config.useNativeAxios === true) {
|
|
| 20 |
}
|
| 21 |
}
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
// ==================== 辅助函数 ====================
|
| 24 |
|
| 25 |
function buildHeaders(token) {
|
|
@@ -38,6 +188,9 @@ function buildAxiosConfig(url, headers, body = null) {
|
|
| 38 |
url,
|
| 39 |
headers,
|
| 40 |
timeout: config.timeout,
|
|
|
|
|
|
|
|
|
|
| 41 |
proxy: config.proxy ? (() => {
|
| 42 |
const proxyUrl = new URL(config.proxy);
|
| 43 |
return { protocol: proxyUrl.protocol.replace(':', ''), host: proxyUrl.hostname, port: parseInt(proxyUrl.port) };
|
|
@@ -86,19 +239,17 @@ async function handleApiError(error, token) {
|
|
| 86 |
throw new Error(`API请求失败 (${status}): ${errorBody}`);
|
| 87 |
}
|
| 88 |
|
| 89 |
-
// 转换 functionCall 为 OpenAI
|
| 90 |
function convertToToolCall(functionCall) {
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
arguments: JSON.stringify(functionCall.args)
|
| 97 |
-
}
|
| 98 |
-
};
|
| 99 |
}
|
| 100 |
|
| 101 |
// 解析并发送流式响应片段(会修改 state 并触发 callback)
|
|
|
|
| 102 |
function parseAndEmitStreamChunk(line, state, callback) {
|
| 103 |
if (!line.startsWith('data: ')) return;
|
| 104 |
|
|
@@ -110,18 +261,10 @@ function parseAndEmitStreamChunk(line, state, callback) {
|
|
| 110 |
if (parts) {
|
| 111 |
for (const part of parts) {
|
| 112 |
if (part.thought === true) {
|
| 113 |
-
// 思维链内容
|
| 114 |
-
|
| 115 |
-
callback({ type: 'thinking', content: '<think>\n' });
|
| 116 |
-
state.thinkingStarted = true;
|
| 117 |
-
}
|
| 118 |
-
callback({ type: 'thinking', content: part.text || '' });
|
| 119 |
} else if (part.text !== undefined) {
|
| 120 |
// 普通文本内容
|
| 121 |
-
if (state.thinkingStarted) {
|
| 122 |
-
callback({ type: 'thinking', content: '\n</think>\n' });
|
| 123 |
-
state.thinkingStarted = false;
|
| 124 |
-
}
|
| 125 |
callback({ type: 'text', content: part.text });
|
| 126 |
} else if (part.functionCall) {
|
| 127 |
// 工具调用
|
|
@@ -132,10 +275,6 @@ function parseAndEmitStreamChunk(line, state, callback) {
|
|
| 132 |
|
| 133 |
// 响应结束时发送工具调用和使用统计
|
| 134 |
if (data.response?.candidates?.[0]?.finishReason) {
|
| 135 |
-
if (state.thinkingStarted) {
|
| 136 |
-
callback({ type: 'thinking', content: '\n</think>\n' });
|
| 137 |
-
state.thinkingStarted = false;
|
| 138 |
-
}
|
| 139 |
if (state.toolCalls.length > 0) {
|
| 140 |
callback({ type: 'tool_calls', tool_calls: state.toolCalls });
|
| 141 |
state.toolCalls = [];
|
|
@@ -143,8 +282,8 @@ function parseAndEmitStreamChunk(line, state, callback) {
|
|
| 143 |
// 提取 token 使用统计
|
| 144 |
const usage = data.response?.usageMetadata;
|
| 145 |
if (usage) {
|
| 146 |
-
callback({
|
| 147 |
-
type: 'usage',
|
| 148 |
usage: {
|
| 149 |
prompt_tokens: usage.promptTokenCount || 0,
|
| 150 |
completion_tokens: usage.candidatesTokenCount || 0,
|
|
@@ -163,31 +302,34 @@ function parseAndEmitStreamChunk(line, state, callback) {
|
|
| 163 |
export async function generateAssistantResponse(requestBody, token, callback) {
|
| 164 |
|
| 165 |
const headers = buildHeaders(token);
|
| 166 |
-
const state = {
|
| 167 |
-
|
| 168 |
|
| 169 |
const processChunk = (chunk) => {
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
};
|
| 175 |
|
| 176 |
-
|
| 177 |
-
|
| 178 |
const axiosConfig = { ...buildAxiosConfig(config.api.url, headers, requestBody), responseType: 'stream' };
|
| 179 |
const response = await axios(axiosConfig);
|
| 180 |
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
await new Promise((resolve, reject) => {
|
| 183 |
-
response.data.on('end',
|
|
|
|
|
|
|
|
|
|
| 184 |
response.data.on('error', reject);
|
| 185 |
});
|
| 186 |
-
}
|
| 187 |
-
await handleApiError(error, token);
|
| 188 |
-
}
|
| 189 |
-
} else {
|
| 190 |
-
try {
|
| 191 |
const streamResponse = requester.antigravity_fetchStream(config.api.url, buildRequesterConfig(headers, requestBody));
|
| 192 |
let errorBody = '';
|
| 193 |
let statusCode = null;
|
|
@@ -195,19 +337,45 @@ export async function generateAssistantResponse(requestBody, token, callback) {
|
|
| 195 |
await new Promise((resolve, reject) => {
|
| 196 |
streamResponse
|
| 197 |
.onStart(({ status }) => { statusCode = status; })
|
| 198 |
-
.onData((chunk) =>
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
.onError(reject);
|
| 201 |
});
|
| 202 |
-
} catch (error) {
|
| 203 |
-
await handleApiError(error, token);
|
| 204 |
}
|
|
|
|
|
|
|
|
|
|
| 205 |
}
|
| 206 |
}
|
| 207 |
|
| 208 |
export async function getAvailableModels() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
const token = await tokenManager.getToken();
|
| 210 |
-
if (!token)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
const headers = buildHeaders(token);
|
| 213 |
|
|
@@ -224,28 +392,58 @@ export async function getAvailableModels() {
|
|
| 224 |
data = await response.json();
|
| 225 |
}
|
| 226 |
//console.log(JSON.stringify(data,null,2));
|
| 227 |
-
const
|
|
|
|
| 228 |
id,
|
| 229 |
object: 'model',
|
| 230 |
-
created
|
| 231 |
owned_by: 'google'
|
| 232 |
}));
|
| 233 |
-
modelList.push({
|
| 234 |
-
id: "claude-opus-4-5",
|
| 235 |
-
object: 'model',
|
| 236 |
-
created: Math.floor(Date.now() / 1000),
|
| 237 |
-
owned_by: 'google'
|
| 238 |
-
})
|
| 239 |
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
object: 'list',
|
| 242 |
data: modelList
|
| 243 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
} catch (error) {
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
}
|
| 247 |
}
|
| 248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
export async function getModelsWithQuotas(token) {
|
| 250 |
const headers = buildHeaders(token);
|
| 251 |
|
|
@@ -301,13 +499,14 @@ export async function generateAssistantResponseNoStream(requestBody, token) {
|
|
| 301 |
// 解析响应内容
|
| 302 |
const parts = data.response?.candidates?.[0]?.content?.parts || [];
|
| 303 |
let content = '';
|
| 304 |
-
let
|
| 305 |
const toolCalls = [];
|
| 306 |
const imageUrls = [];
|
| 307 |
|
| 308 |
for (const part of parts) {
|
| 309 |
if (part.thought === true) {
|
| 310 |
-
|
|
|
|
| 311 |
} else if (part.text !== undefined) {
|
| 312 |
content += part.text;
|
| 313 |
} else if (part.functionCall) {
|
|
@@ -319,11 +518,6 @@ export async function generateAssistantResponseNoStream(requestBody, token) {
|
|
| 319 |
}
|
| 320 |
}
|
| 321 |
|
| 322 |
-
// 拼接思维链标签
|
| 323 |
-
if (thinkingContent) {
|
| 324 |
-
content = `<think>\n${thinkingContent}\n</think>\n${content}`;
|
| 325 |
-
}
|
| 326 |
-
|
| 327 |
// 提取 token 使用统计
|
| 328 |
const usage = data.response?.usageMetadata;
|
| 329 |
const usageData = usage ? {
|
|
@@ -336,10 +530,10 @@ export async function generateAssistantResponseNoStream(requestBody, token) {
|
|
| 336 |
if (imageUrls.length > 0) {
|
| 337 |
let markdown = content ? content + '\n\n' : '';
|
| 338 |
markdown += imageUrls.map(url => ``).join('\n\n');
|
| 339 |
-
return { content: markdown, toolCalls, usage: usageData };
|
| 340 |
}
|
| 341 |
|
| 342 |
-
return { content, toolCalls, usage: usageData };
|
| 343 |
}
|
| 344 |
|
| 345 |
export async function generateImageForSD(requestBody, token) {
|
|
@@ -371,3 +565,6 @@ export async function generateImageForSD(requestBody, token) {
|
|
| 371 |
export function closeRequester() {
|
| 372 |
if (requester) requester.close();
|
| 373 |
}
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import { generateToolCallId } from '../utils/idGenerator.js';
|
| 5 |
import AntigravityRequester from '../AntigravityRequester.js';
|
| 6 |
import { saveBase64Image } from '../utils/imageStorage.js';
|
| 7 |
+
import logger from '../utils/logger.js';
|
| 8 |
+
import { httpAgent, httpsAgent } from '../utils/utils.js';
|
| 9 |
+
import memoryManager, { MemoryPressure } from '../utils/memoryManager.js';
|
| 10 |
|
| 11 |
// 请求客户端:优先使用 AntigravityRequester,失败则降级到 axios
|
| 12 |
let requester = null;
|
| 13 |
let useAxios = false;
|
| 14 |
|
| 15 |
+
// ==================== 模型列表缓存(智能管理) ====================
|
| 16 |
+
// 缓存过期时间根据内存压力动态调整
|
| 17 |
+
const getModelCacheTTL = () => {
|
| 18 |
+
const baseTTL = config.cache?.modelListTTL || 60 * 60 * 1000;
|
| 19 |
+
const pressure = memoryManager.currentPressure;
|
| 20 |
+
// 高压力时缩短缓存时间
|
| 21 |
+
if (pressure === MemoryPressure.CRITICAL) return Math.min(baseTTL, 5 * 60 * 1000);
|
| 22 |
+
if (pressure === MemoryPressure.HIGH) return Math.min(baseTTL, 15 * 60 * 1000);
|
| 23 |
+
return baseTTL;
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
let modelListCache = null;
|
| 27 |
+
let modelListCacheTime = 0;
|
| 28 |
+
|
| 29 |
+
// 默认模型列表(当 API 请求失败时使用)
|
| 30 |
+
const DEFAULT_MODELS = [
|
| 31 |
+
'claude-opus-4-5',
|
| 32 |
+
'claude-opus-4-5-thinking',
|
| 33 |
+
'claude-sonnet-4-5-thinking',
|
| 34 |
+
'claude-sonnet-4-5',
|
| 35 |
+
'gemini-3-pro-high',
|
| 36 |
+
'gemini-2.5-flash-lite',
|
| 37 |
+
'gemini-3-pro-image',
|
| 38 |
+
'gemini-2.5-flash-thinking',
|
| 39 |
+
'gemini-2.5-pro',
|
| 40 |
+
'gemini-2.5-flash',
|
| 41 |
+
'gemini-3-pro-low',
|
| 42 |
+
'chat_20706',
|
| 43 |
+
'rev19-uic3-1p',
|
| 44 |
+
'gpt-oss-120b-medium',
|
| 45 |
+
'chat_23310'
|
| 46 |
+
];
|
| 47 |
+
|
| 48 |
+
// 生成默认模型列表响应
|
| 49 |
+
function getDefaultModelList() {
|
| 50 |
+
const created = Math.floor(Date.now() / 1000);
|
| 51 |
+
return {
|
| 52 |
+
object: 'list',
|
| 53 |
+
data: DEFAULT_MODELS.map(id => ({
|
| 54 |
+
id,
|
| 55 |
+
object: 'model',
|
| 56 |
+
created,
|
| 57 |
+
owned_by: 'google'
|
| 58 |
+
}))
|
| 59 |
+
};
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
if (config.useNativeAxios === true) {
|
| 63 |
useAxios = true;
|
| 64 |
} else {
|
|
|
|
| 70 |
}
|
| 71 |
}
|
| 72 |
|
| 73 |
+
// ==================== 零拷贝优化 ====================
|
| 74 |
+
|
| 75 |
+
// 预编译的常量(避免重复创建字符串)
|
| 76 |
+
const DATA_PREFIX = 'data: ';
|
| 77 |
+
const DATA_PREFIX_LEN = DATA_PREFIX.length;
|
| 78 |
+
|
| 79 |
+
// 高效的行分割器(零拷贝,避免 split 创建新数组)
|
| 80 |
+
// 使用对象池复用 LineBuffer 实例
|
| 81 |
+
class LineBuffer {
|
| 82 |
+
constructor() {
|
| 83 |
+
this.buffer = '';
|
| 84 |
+
this.lines = [];
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// 追加数据并返回完整的行
|
| 88 |
+
append(chunk) {
|
| 89 |
+
this.buffer += chunk;
|
| 90 |
+
this.lines.length = 0; // 重用数组
|
| 91 |
+
|
| 92 |
+
let start = 0;
|
| 93 |
+
let end;
|
| 94 |
+
while ((end = this.buffer.indexOf('\n', start)) !== -1) {
|
| 95 |
+
this.lines.push(this.buffer.slice(start, end));
|
| 96 |
+
start = end + 1;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// 保留未完成的部分
|
| 100 |
+
this.buffer = start < this.buffer.length ? this.buffer.slice(start) : '';
|
| 101 |
+
return this.lines;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// 清空缓冲区(用于归还到池之前)
|
| 105 |
+
clear() {
|
| 106 |
+
this.buffer = '';
|
| 107 |
+
this.lines.length = 0;
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// LineBuffer 对象池
|
| 112 |
+
const lineBufferPool = [];
|
| 113 |
+
const getLineBuffer = () => {
|
| 114 |
+
const buffer = lineBufferPool.pop();
|
| 115 |
+
if (buffer) {
|
| 116 |
+
buffer.clear();
|
| 117 |
+
return buffer;
|
| 118 |
+
}
|
| 119 |
+
return new LineBuffer();
|
| 120 |
+
};
|
| 121 |
+
const releaseLineBuffer = (buffer) => {
|
| 122 |
+
const maxSize = memoryManager.getPoolSizes().lineBuffer;
|
| 123 |
+
if (lineBufferPool.length < maxSize) {
|
| 124 |
+
buffer.clear();
|
| 125 |
+
lineBufferPool.push(buffer);
|
| 126 |
+
}
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
// 对象池:复用 toolCall 对象
|
| 130 |
+
const toolCallPool = [];
|
| 131 |
+
const getToolCallObject = () => toolCallPool.pop() || { id: '', type: 'function', function: { name: '', arguments: '' } };
|
| 132 |
+
const releaseToolCallObject = (obj) => {
|
| 133 |
+
const maxSize = memoryManager.getPoolSizes().toolCall;
|
| 134 |
+
if (toolCallPool.length < maxSize) toolCallPool.push(obj);
|
| 135 |
+
};
|
| 136 |
+
|
| 137 |
+
// 注册内存清理回调
|
| 138 |
+
function registerMemoryCleanup() {
|
| 139 |
+
memoryManager.registerCleanup((pressure) => {
|
| 140 |
+
const poolSizes = memoryManager.getPoolSizes();
|
| 141 |
+
|
| 142 |
+
// 根据压力缩减对象池
|
| 143 |
+
while (toolCallPool.length > poolSizes.toolCall) {
|
| 144 |
+
toolCallPool.pop();
|
| 145 |
+
}
|
| 146 |
+
while (lineBufferPool.length > poolSizes.lineBuffer) {
|
| 147 |
+
lineBufferPool.pop();
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
// 高压力或紧急时清理模型缓存
|
| 151 |
+
if (pressure === MemoryPressure.HIGH || pressure === MemoryPressure.CRITICAL) {
|
| 152 |
+
const ttl = getModelCacheTTL();
|
| 153 |
+
const now = Date.now();
|
| 154 |
+
if (modelListCache && (now - modelListCacheTime) > ttl) {
|
| 155 |
+
modelListCache = null;
|
| 156 |
+
modelListCacheTime = 0;
|
| 157 |
+
logger.info('已清理过期模型列表缓存');
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// 紧急时强制清理模型缓存
|
| 162 |
+
if (pressure === MemoryPressure.CRITICAL && modelListCache) {
|
| 163 |
+
modelListCache = null;
|
| 164 |
+
modelListCacheTime = 0;
|
| 165 |
+
logger.info('紧急清理模型列表缓存');
|
| 166 |
+
}
|
| 167 |
+
});
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
// 初始化时注册清理回调
|
| 171 |
+
registerMemoryCleanup();
|
| 172 |
+
|
| 173 |
// ==================== 辅助函数 ====================
|
| 174 |
|
| 175 |
function buildHeaders(token) {
|
|
|
|
| 188 |
url,
|
| 189 |
headers,
|
| 190 |
timeout: config.timeout,
|
| 191 |
+
// 使用自定义 DNS 解析的 Agent(优先 IPv4,失败则 IPv6)
|
| 192 |
+
httpAgent,
|
| 193 |
+
httpsAgent,
|
| 194 |
proxy: config.proxy ? (() => {
|
| 195 |
const proxyUrl = new URL(config.proxy);
|
| 196 |
return { protocol: proxyUrl.protocol.replace(':', ''), host: proxyUrl.hostname, port: parseInt(proxyUrl.port) };
|
|
|
|
| 239 |
throw new Error(`API请求失败 (${status}): ${errorBody}`);
|
| 240 |
}
|
| 241 |
|
| 242 |
+
// 转换 functionCall 为 OpenAI 格式(使用对象池)
|
| 243 |
function convertToToolCall(functionCall) {
|
| 244 |
+
const toolCall = getToolCallObject();
|
| 245 |
+
toolCall.id = functionCall.id || generateToolCallId();
|
| 246 |
+
toolCall.function.name = functionCall.name;
|
| 247 |
+
toolCall.function.arguments = JSON.stringify(functionCall.args);
|
| 248 |
+
return toolCall;
|
|
|
|
|
|
|
|
|
|
| 249 |
}
|
| 250 |
|
| 251 |
// 解析并发送流式响应片段(会修改 state 并触发 callback)
|
| 252 |
+
// 支持 DeepSeek 格式:思维链内容通过 reasoning_content 字段输出
|
| 253 |
function parseAndEmitStreamChunk(line, state, callback) {
|
| 254 |
if (!line.startsWith('data: ')) return;
|
| 255 |
|
|
|
|
| 261 |
if (parts) {
|
| 262 |
for (const part of parts) {
|
| 263 |
if (part.thought === true) {
|
| 264 |
+
// 思维链内容 - 使用 DeepSeek 格式的 reasoning_content
|
| 265 |
+
callback({ type: 'reasoning', reasoning_content: part.text || '' });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
} else if (part.text !== undefined) {
|
| 267 |
// 普通文本内容
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
callback({ type: 'text', content: part.text });
|
| 269 |
} else if (part.functionCall) {
|
| 270 |
// 工具调用
|
|
|
|
| 275 |
|
| 276 |
// 响应结束时发送工具调用和使用统计
|
| 277 |
if (data.response?.candidates?.[0]?.finishReason) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
if (state.toolCalls.length > 0) {
|
| 279 |
callback({ type: 'tool_calls', tool_calls: state.toolCalls });
|
| 280 |
state.toolCalls = [];
|
|
|
|
| 282 |
// 提取 token 使用统计
|
| 283 |
const usage = data.response?.usageMetadata;
|
| 284 |
if (usage) {
|
| 285 |
+
callback({
|
| 286 |
+
type: 'usage',
|
| 287 |
usage: {
|
| 288 |
prompt_tokens: usage.promptTokenCount || 0,
|
| 289 |
completion_tokens: usage.candidatesTokenCount || 0,
|
|
|
|
| 302 |
export async function generateAssistantResponse(requestBody, token, callback) {
|
| 303 |
|
| 304 |
const headers = buildHeaders(token);
|
| 305 |
+
const state = { toolCalls: [] };
|
| 306 |
+
const lineBuffer = getLineBuffer(); // 从对象池获取
|
| 307 |
|
| 308 |
const processChunk = (chunk) => {
|
| 309 |
+
const lines = lineBuffer.append(chunk);
|
| 310 |
+
for (let i = 0; i < lines.length; i++) {
|
| 311 |
+
parseAndEmitStreamChunk(lines[i], state, callback);
|
| 312 |
+
}
|
| 313 |
};
|
| 314 |
|
| 315 |
+
try {
|
| 316 |
+
if (useAxios) {
|
| 317 |
const axiosConfig = { ...buildAxiosConfig(config.api.url, headers, requestBody), responseType: 'stream' };
|
| 318 |
const response = await axios(axiosConfig);
|
| 319 |
|
| 320 |
+
// 使用 Buffer 直接处理,避免 toString 的内存分配
|
| 321 |
+
response.data.on('data', chunk => {
|
| 322 |
+
processChunk(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
|
| 323 |
+
});
|
| 324 |
+
|
| 325 |
await new Promise((resolve, reject) => {
|
| 326 |
+
response.data.on('end', () => {
|
| 327 |
+
releaseLineBuffer(lineBuffer); // 归还到对象池
|
| 328 |
+
resolve();
|
| 329 |
+
});
|
| 330 |
response.data.on('error', reject);
|
| 331 |
});
|
| 332 |
+
} else {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
const streamResponse = requester.antigravity_fetchStream(config.api.url, buildRequesterConfig(headers, requestBody));
|
| 334 |
let errorBody = '';
|
| 335 |
let statusCode = null;
|
|
|
|
| 337 |
await new Promise((resolve, reject) => {
|
| 338 |
streamResponse
|
| 339 |
.onStart(({ status }) => { statusCode = status; })
|
| 340 |
+
.onData((chunk) => {
|
| 341 |
+
if (statusCode !== 200) {
|
| 342 |
+
errorBody += chunk;
|
| 343 |
+
} else {
|
| 344 |
+
processChunk(chunk);
|
| 345 |
+
}
|
| 346 |
+
})
|
| 347 |
+
.onEnd(() => {
|
| 348 |
+
releaseLineBuffer(lineBuffer); // 归还到对象池
|
| 349 |
+
if (statusCode !== 200) {
|
| 350 |
+
reject({ status: statusCode, message: errorBody });
|
| 351 |
+
} else {
|
| 352 |
+
resolve();
|
| 353 |
+
}
|
| 354 |
+
})
|
| 355 |
.onError(reject);
|
| 356 |
});
|
|
|
|
|
|
|
| 357 |
}
|
| 358 |
+
} catch (error) {
|
| 359 |
+
releaseLineBuffer(lineBuffer); // 确保归还
|
| 360 |
+
await handleApiError(error, token);
|
| 361 |
}
|
| 362 |
}
|
| 363 |
|
| 364 |
export async function getAvailableModels() {
|
| 365 |
+
// 检查缓存是否有效(动态 TTL)
|
| 366 |
+
const now = Date.now();
|
| 367 |
+
const ttl = getModelCacheTTL();
|
| 368 |
+
if (modelListCache && (now - modelListCacheTime) < ttl) {
|
| 369 |
+
logger.info(`使用缓存的模型列表 (剩余有效期: ${Math.round((ttl - (now - modelListCacheTime)) / 1000)}秒)`);
|
| 370 |
+
return modelListCache;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
const token = await tokenManager.getToken();
|
| 374 |
+
if (!token) {
|
| 375 |
+
// 没有 token 时返回默认模型列表
|
| 376 |
+
logger.warn('没有可用的 token,返回默认模型列表');
|
| 377 |
+
return getDefaultModelList();
|
| 378 |
+
}
|
| 379 |
|
| 380 |
const headers = buildHeaders(token);
|
| 381 |
|
|
|
|
| 392 |
data = await response.json();
|
| 393 |
}
|
| 394 |
//console.log(JSON.stringify(data,null,2));
|
| 395 |
+
const created = Math.floor(Date.now() / 1000);
|
| 396 |
+
const modelList = Object.keys(data.models || {}).map(id => ({
|
| 397 |
id,
|
| 398 |
object: 'model',
|
| 399 |
+
created,
|
| 400 |
owned_by: 'google'
|
| 401 |
}));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
|
| 403 |
+
// 添加默认模型(如果 API 返回的列表中没有)
|
| 404 |
+
const existingIds = new Set(modelList.map(m => m.id));
|
| 405 |
+
for (const defaultModel of DEFAULT_MODELS) {
|
| 406 |
+
if (!existingIds.has(defaultModel)) {
|
| 407 |
+
modelList.push({
|
| 408 |
+
id: defaultModel,
|
| 409 |
+
object: 'model',
|
| 410 |
+
created,
|
| 411 |
+
owned_by: 'google'
|
| 412 |
+
});
|
| 413 |
+
}
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
const result = {
|
| 417 |
object: 'list',
|
| 418 |
data: modelList
|
| 419 |
};
|
| 420 |
+
|
| 421 |
+
// 更新缓存
|
| 422 |
+
modelListCache = result;
|
| 423 |
+
modelListCacheTime = now;
|
| 424 |
+
const currentTTL = getModelCacheTTL();
|
| 425 |
+
logger.info(`模型列表已缓存 (有效期: ${currentTTL / 1000}秒, 模型数量: ${modelList.length})`);
|
| 426 |
+
|
| 427 |
+
return result;
|
| 428 |
} catch (error) {
|
| 429 |
+
// 如果请求失败但有缓存,返回过期的缓存
|
| 430 |
+
if (modelListCache) {
|
| 431 |
+
logger.warn(`获取模型列表失败,使用过期缓存: ${error.message}`);
|
| 432 |
+
return modelListCache;
|
| 433 |
+
}
|
| 434 |
+
// 没有缓存时返回默认模型列表
|
| 435 |
+
logger.warn(`获取模型列表失败,返回默认模型列表: ${error.message}`);
|
| 436 |
+
return getDefaultModelList();
|
| 437 |
}
|
| 438 |
}
|
| 439 |
|
| 440 |
+
// 清除模型列表缓存(可用于手动刷新)
|
| 441 |
+
export function clearModelListCache() {
|
| 442 |
+
modelListCache = null;
|
| 443 |
+
modelListCacheTime = 0;
|
| 444 |
+
logger.info('模型列表缓存已清除');
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
export async function getModelsWithQuotas(token) {
|
| 448 |
const headers = buildHeaders(token);
|
| 449 |
|
|
|
|
| 499 |
// 解析响应内容
|
| 500 |
const parts = data.response?.candidates?.[0]?.content?.parts || [];
|
| 501 |
let content = '';
|
| 502 |
+
let reasoningContent = '';
|
| 503 |
const toolCalls = [];
|
| 504 |
const imageUrls = [];
|
| 505 |
|
| 506 |
for (const part of parts) {
|
| 507 |
if (part.thought === true) {
|
| 508 |
+
// 思维链内容 - 使用 DeepSeek 格式的 reasoning_content
|
| 509 |
+
reasoningContent += part.text || '';
|
| 510 |
} else if (part.text !== undefined) {
|
| 511 |
content += part.text;
|
| 512 |
} else if (part.functionCall) {
|
|
|
|
| 518 |
}
|
| 519 |
}
|
| 520 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
// 提取 token 使用统计
|
| 522 |
const usage = data.response?.usageMetadata;
|
| 523 |
const usageData = usage ? {
|
|
|
|
| 530 |
if (imageUrls.length > 0) {
|
| 531 |
let markdown = content ? content + '\n\n' : '';
|
| 532 |
markdown += imageUrls.map(url => ``).join('\n\n');
|
| 533 |
+
return { content: markdown, reasoningContent: reasoningContent || null, toolCalls, usage: usageData };
|
| 534 |
}
|
| 535 |
|
| 536 |
+
return { content, reasoningContent: reasoningContent || null, toolCalls, usage: usageData };
|
| 537 |
}
|
| 538 |
|
| 539 |
export async function generateImageForSD(requestBody, token) {
|
|
|
|
| 565 |
export function closeRequester() {
|
| 566 |
if (requester) requester.close();
|
| 567 |
}
|
| 568 |
+
|
| 569 |
+
// 导出内存清理注册函数(供外部调用)
|
| 570 |
+
export { registerMemoryCleanup };
|
src/auth/quota_manager.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'fs';
|
|
| 2 |
import path from 'path';
|
| 3 |
import { fileURLToPath } from 'url';
|
| 4 |
import { log } from '../utils/logger.js';
|
|
|
|
| 5 |
|
| 6 |
const __filename = fileURLToPath(import.meta.url);
|
| 7 |
const __dirname = path.dirname(__filename);
|
|
@@ -12,9 +13,11 @@ class QuotaManager {
|
|
| 12 |
this.cache = new Map();
|
| 13 |
this.CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存
|
| 14 |
this.CLEANUP_INTERVAL = 60 * 60 * 1000; // 1小时清理一次
|
|
|
|
| 15 |
this.ensureFileExists();
|
| 16 |
this.loadFromFile();
|
| 17 |
this.startCleanupTimer();
|
|
|
|
| 18 |
}
|
| 19 |
|
| 20 |
ensureFileExists() {
|
|
@@ -93,7 +96,35 @@ class QuotaManager {
|
|
| 93 |
}
|
| 94 |
|
| 95 |
startCleanupTimer() {
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
}
|
| 98 |
|
| 99 |
convertToBeijingTime(utcTimeStr) {
|
|
|
|
| 2 |
import path from 'path';
|
| 3 |
import { fileURLToPath } from 'url';
|
| 4 |
import { log } from '../utils/logger.js';
|
| 5 |
+
import memoryManager, { MemoryPressure } from '../utils/memoryManager.js';
|
| 6 |
|
| 7 |
const __filename = fileURLToPath(import.meta.url);
|
| 8 |
const __dirname = path.dirname(__filename);
|
|
|
|
| 13 |
this.cache = new Map();
|
| 14 |
this.CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存
|
| 15 |
this.CLEANUP_INTERVAL = 60 * 60 * 1000; // 1小时清理一次
|
| 16 |
+
this.cleanupTimer = null;
|
| 17 |
this.ensureFileExists();
|
| 18 |
this.loadFromFile();
|
| 19 |
this.startCleanupTimer();
|
| 20 |
+
this.registerMemoryCleanup();
|
| 21 |
}
|
| 22 |
|
| 23 |
ensureFileExists() {
|
|
|
|
| 96 |
}
|
| 97 |
|
| 98 |
startCleanupTimer() {
|
| 99 |
+
if (this.cleanupTimer) {
|
| 100 |
+
clearInterval(this.cleanupTimer);
|
| 101 |
+
}
|
| 102 |
+
this.cleanupTimer = setInterval(() => this.cleanup(), this.CLEANUP_INTERVAL);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
stopCleanupTimer() {
|
| 106 |
+
if (this.cleanupTimer) {
|
| 107 |
+
clearInterval(this.cleanupTimer);
|
| 108 |
+
this.cleanupTimer = null;
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// 注册内存清理回调
|
| 113 |
+
registerMemoryCleanup() {
|
| 114 |
+
memoryManager.registerCleanup((pressure) => {
|
| 115 |
+
// 根据压力级别调整缓存 TTL
|
| 116 |
+
if (pressure === MemoryPressure.CRITICAL) {
|
| 117 |
+
// 紧急时清理所有缓存
|
| 118 |
+
const size = this.cache.size;
|
| 119 |
+
if (size > 0) {
|
| 120 |
+
this.cache.clear();
|
| 121 |
+
log.info(`紧急清理 ${size} 个额度缓存`);
|
| 122 |
+
}
|
| 123 |
+
} else if (pressure === MemoryPressure.HIGH) {
|
| 124 |
+
// 高压力时清理过期缓存
|
| 125 |
+
this.cleanup();
|
| 126 |
+
}
|
| 127 |
+
});
|
| 128 |
}
|
| 129 |
|
| 130 |
convertToBeijingTime(utcTimeStr) {
|
src/auth/token_manager.js
CHANGED
|
@@ -4,17 +4,30 @@ import { fileURLToPath } from 'url';
|
|
| 4 |
import axios from 'axios';
|
| 5 |
import { log } from '../utils/logger.js';
|
| 6 |
import { generateSessionId, generateProjectId } from '../utils/idGenerator.js';
|
| 7 |
-
import config from '../config/config.js';
|
| 8 |
import { OAUTH_CONFIG } from '../constants/oauth.js';
|
| 9 |
|
| 10 |
const __filename = fileURLToPath(import.meta.url);
|
| 11 |
const __dirname = path.dirname(__filename);
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
class TokenManager {
|
| 14 |
constructor(filePath = path.join(__dirname,'..','..','data' ,'accounts.json')) {
|
| 15 |
this.filePath = filePath;
|
| 16 |
this.tokens = [];
|
| 17 |
this.currentIndex = 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
this.ensureFileExists();
|
| 19 |
this.initialize();
|
| 20 |
}
|
|
@@ -42,12 +55,22 @@ class TokenManager {
|
|
| 42 |
}));
|
| 43 |
|
| 44 |
this.currentIndex = 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
if (this.tokens.length === 0) {
|
| 46 |
log.warn('⚠ 暂无可用账号,请使用以下方式添加:');
|
| 47 |
log.warn(' 方式1: 运行 npm run login 命令登录');
|
| 48 |
log.warn(' 方式2: 访问前端管理页面添加账号');
|
| 49 |
} else {
|
| 50 |
log.info(`成功加载 ${this.tokens.length} 个可用token`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
}
|
| 52 |
} catch (error) {
|
| 53 |
log.error('初始化token失败:', error.message);
|
|
@@ -55,6 +78,36 @@ class TokenManager {
|
|
| 55 |
}
|
| 56 |
}
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
async fetchProjectId(token) {
|
| 59 |
const response = await axios({
|
| 60 |
method: 'POST',
|
|
@@ -156,14 +209,79 @@ class TokenManager {
|
|
| 156 |
this.currentIndex = this.currentIndex % Math.max(this.tokens.length, 1);
|
| 157 |
}
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
async getToken() {
|
| 160 |
if (this.tokens.length === 0) return null;
|
| 161 |
|
| 162 |
-
//const startIndex = this.currentIndex;
|
| 163 |
const totalTokens = this.tokens.length;
|
|
|
|
| 164 |
|
| 165 |
for (let i = 0; i < totalTokens; i++) {
|
| 166 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
try {
|
| 169 |
if (this.isExpired(token)) {
|
|
@@ -187,12 +305,19 @@ class TokenManager {
|
|
| 187 |
this.saveToFile(token);
|
| 188 |
} catch (error) {
|
| 189 |
log.error(`...${token.access_token.slice(-8)}: 获取projectId失败:`, error.message);
|
| 190 |
-
this.currentIndex = (this.currentIndex + 1) % this.tokens.length;
|
| 191 |
continue;
|
| 192 |
}
|
| 193 |
}
|
| 194 |
}
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
return token;
|
| 197 |
} catch (error) {
|
| 198 |
if (error.statusCode === 403 || error.statusCode === 400) {
|
|
@@ -201,11 +326,21 @@ class TokenManager {
|
|
| 201 |
if (this.tokens.length === 0) return null;
|
| 202 |
} else {
|
| 203 |
log.error(`...${token.access_token.slice(-8)} 刷新失败:`, error.message);
|
| 204 |
-
this.currentIndex = (this.currentIndex + 1) % this.tokens.length;
|
| 205 |
}
|
| 206 |
}
|
| 207 |
}
|
| 208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
return null;
|
| 210 |
}
|
| 211 |
|
|
@@ -242,6 +377,9 @@ class TokenManager {
|
|
| 242 |
if (tokenData.email) {
|
| 243 |
newToken.email = tokenData.email;
|
| 244 |
}
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
allTokens.push(newToken);
|
| 247 |
fs.writeFileSync(this.filePath, JSON.stringify(allTokens, null, 2), 'utf8');
|
|
@@ -311,13 +449,28 @@ class TokenManager {
|
|
| 311 |
timestamp: token.timestamp,
|
| 312 |
enable: token.enable !== false,
|
| 313 |
projectId: token.projectId || null,
|
| 314 |
-
email: token.email || null
|
|
|
|
| 315 |
}));
|
| 316 |
} catch (error) {
|
| 317 |
log.error('获取Token列表失败:', error.message);
|
| 318 |
return [];
|
| 319 |
}
|
| 320 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
const tokenManager = new TokenManager();
|
| 323 |
export default tokenManager;
|
|
|
|
| 4 |
import axios from 'axios';
|
| 5 |
import { log } from '../utils/logger.js';
|
| 6 |
import { generateSessionId, generateProjectId } from '../utils/idGenerator.js';
|
| 7 |
+
import config, { getConfigJson } from '../config/config.js';
|
| 8 |
import { OAUTH_CONFIG } from '../constants/oauth.js';
|
| 9 |
|
| 10 |
const __filename = fileURLToPath(import.meta.url);
|
| 11 |
const __dirname = path.dirname(__filename);
|
| 12 |
|
| 13 |
+
// 轮询策略枚举
|
| 14 |
+
const RotationStrategy = {
|
| 15 |
+
ROUND_ROBIN: 'round_robin', // 均衡负载:每次请求切换
|
| 16 |
+
QUOTA_EXHAUSTED: 'quota_exhausted', // 额度耗尽才切换
|
| 17 |
+
REQUEST_COUNT: 'request_count' // 自定义次数后切换
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
class TokenManager {
|
| 21 |
constructor(filePath = path.join(__dirname,'..','..','data' ,'accounts.json')) {
|
| 22 |
this.filePath = filePath;
|
| 23 |
this.tokens = [];
|
| 24 |
this.currentIndex = 0;
|
| 25 |
+
|
| 26 |
+
// 轮询策略相关 - 使用原子操作避免锁
|
| 27 |
+
this.rotationStrategy = RotationStrategy.ROUND_ROBIN;
|
| 28 |
+
this.requestCountPerToken = 50; // request_count 策略下每个token请求次数后切换
|
| 29 |
+
this.tokenRequestCounts = new Map(); // 记录每个token的请求次数
|
| 30 |
+
|
| 31 |
this.ensureFileExists();
|
| 32 |
this.initialize();
|
| 33 |
}
|
|
|
|
| 55 |
}));
|
| 56 |
|
| 57 |
this.currentIndex = 0;
|
| 58 |
+
this.tokenRequestCounts.clear();
|
| 59 |
+
|
| 60 |
+
// 加载轮询策略配置
|
| 61 |
+
this.loadRotationConfig();
|
| 62 |
+
|
| 63 |
if (this.tokens.length === 0) {
|
| 64 |
log.warn('⚠ 暂无可用账号,请使用以下方式添加:');
|
| 65 |
log.warn(' 方式1: 运行 npm run login 命令登录');
|
| 66 |
log.warn(' 方式2: 访问前端管理页面添加账号');
|
| 67 |
} else {
|
| 68 |
log.info(`成功加载 ${this.tokens.length} 个可用token`);
|
| 69 |
+
if (this.rotationStrategy === RotationStrategy.REQUEST_COUNT) {
|
| 70 |
+
log.info(`轮询策略: ${this.rotationStrategy}, 每token请求 ${this.requestCountPerToken} 次后切换`);
|
| 71 |
+
} else {
|
| 72 |
+
log.info(`轮询策略: ${this.rotationStrategy}`);
|
| 73 |
+
}
|
| 74 |
}
|
| 75 |
} catch (error) {
|
| 76 |
log.error('初始化token失败:', error.message);
|
|
|
|
| 78 |
}
|
| 79 |
}
|
| 80 |
|
| 81 |
+
// 加载轮询策略配置
|
| 82 |
+
loadRotationConfig() {
|
| 83 |
+
try {
|
| 84 |
+
const jsonConfig = getConfigJson();
|
| 85 |
+
if (jsonConfig.rotation) {
|
| 86 |
+
this.rotationStrategy = jsonConfig.rotation.strategy || RotationStrategy.ROUND_ROBIN;
|
| 87 |
+
this.requestCountPerToken = jsonConfig.rotation.requestCount || 10;
|
| 88 |
+
}
|
| 89 |
+
} catch (error) {
|
| 90 |
+
log.warn('加载轮询配置失败,使用默认值:', error.message);
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// 更新轮询策略(热更新)
|
| 95 |
+
updateRotationConfig(strategy, requestCount) {
|
| 96 |
+
if (strategy && Object.values(RotationStrategy).includes(strategy)) {
|
| 97 |
+
this.rotationStrategy = strategy;
|
| 98 |
+
}
|
| 99 |
+
if (requestCount && requestCount > 0) {
|
| 100 |
+
this.requestCountPerToken = requestCount;
|
| 101 |
+
}
|
| 102 |
+
// 重置计数器
|
| 103 |
+
this.tokenRequestCounts.clear();
|
| 104 |
+
if (this.rotationStrategy === RotationStrategy.REQUEST_COUNT) {
|
| 105 |
+
log.info(`轮询策略已更新: ${this.rotationStrategy}, 每token请求 ${this.requestCountPerToken} 次后切换`);
|
| 106 |
+
} else {
|
| 107 |
+
log.info(`轮询策略已更新: ${this.rotationStrategy}`);
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
async fetchProjectId(token) {
|
| 112 |
const response = await axios({
|
| 113 |
method: 'POST',
|
|
|
|
| 209 |
this.currentIndex = this.currentIndex % Math.max(this.tokens.length, 1);
|
| 210 |
}
|
| 211 |
|
| 212 |
+
// 原子操作:获取并递增请求计数
|
| 213 |
+
incrementRequestCount(tokenKey) {
|
| 214 |
+
const current = this.tokenRequestCounts.get(tokenKey) || 0;
|
| 215 |
+
const newCount = current + 1;
|
| 216 |
+
this.tokenRequestCounts.set(tokenKey, newCount);
|
| 217 |
+
return newCount;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
// 原子操作:重置请求计数
|
| 221 |
+
resetRequestCount(tokenKey) {
|
| 222 |
+
this.tokenRequestCounts.set(tokenKey, 0);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// 判断是否应该切换到下一个token
|
| 226 |
+
shouldRotate(token) {
|
| 227 |
+
switch (this.rotationStrategy) {
|
| 228 |
+
case RotationStrategy.ROUND_ROBIN:
|
| 229 |
+
// 均衡负载:每次请求后都切换
|
| 230 |
+
return true;
|
| 231 |
+
|
| 232 |
+
case RotationStrategy.QUOTA_EXHAUSTED:
|
| 233 |
+
// 额度耗尽才切换:检查token的hasQuota标记
|
| 234 |
+
// 如果hasQuota为false,说明额度已耗尽,需要切换
|
| 235 |
+
return token.hasQuota === false;
|
| 236 |
+
|
| 237 |
+
case RotationStrategy.REQUEST_COUNT:
|
| 238 |
+
// 自定义次数后切换
|
| 239 |
+
const tokenKey = token.refresh_token;
|
| 240 |
+
const count = this.incrementRequestCount(tokenKey);
|
| 241 |
+
if (count >= this.requestCountPerToken) {
|
| 242 |
+
this.resetRequestCount(tokenKey);
|
| 243 |
+
return true;
|
| 244 |
+
}
|
| 245 |
+
return false;
|
| 246 |
+
|
| 247 |
+
default:
|
| 248 |
+
return true;
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
// 标记token额度耗尽
|
| 253 |
+
markQuotaExhausted(token) {
|
| 254 |
+
token.hasQuota = false;
|
| 255 |
+
this.saveToFile(token);
|
| 256 |
+
log.warn(`...${token.access_token.slice(-8)}: 额度已耗尽,标记为无额度`);
|
| 257 |
+
|
| 258 |
+
// 如果是额度耗尽策略,立即切换到下一个token
|
| 259 |
+
if (this.rotationStrategy === RotationStrategy.QUOTA_EXHAUSTED) {
|
| 260 |
+
this.currentIndex = (this.currentIndex + 1) % Math.max(this.tokens.length, 1);
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
// 恢复token额度(用于额度重置后)
|
| 265 |
+
restoreQuota(token) {
|
| 266 |
+
token.hasQuota = true;
|
| 267 |
+
this.saveToFile(token);
|
| 268 |
+
log.info(`...${token.access_token.slice(-8)}: 额度已恢复`);
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
async getToken() {
|
| 272 |
if (this.tokens.length === 0) return null;
|
| 273 |
|
|
|
|
| 274 |
const totalTokens = this.tokens.length;
|
| 275 |
+
const startIndex = this.currentIndex;
|
| 276 |
|
| 277 |
for (let i = 0; i < totalTokens; i++) {
|
| 278 |
+
const index = (startIndex + i) % totalTokens;
|
| 279 |
+
const token = this.tokens[index];
|
| 280 |
+
|
| 281 |
+
// 额度耗尽策略:跳过无额度的token
|
| 282 |
+
if (this.rotationStrategy === RotationStrategy.QUOTA_EXHAUSTED && token.hasQuota === false) {
|
| 283 |
+
continue;
|
| 284 |
+
}
|
| 285 |
|
| 286 |
try {
|
| 287 |
if (this.isExpired(token)) {
|
|
|
|
| 305 |
this.saveToFile(token);
|
| 306 |
} catch (error) {
|
| 307 |
log.error(`...${token.access_token.slice(-8)}: 获取projectId失败:`, error.message);
|
|
|
|
| 308 |
continue;
|
| 309 |
}
|
| 310 |
}
|
| 311 |
}
|
| 312 |
+
|
| 313 |
+
// 更新当前索引
|
| 314 |
+
this.currentIndex = index;
|
| 315 |
+
|
| 316 |
+
// 根据策略决定是否切换
|
| 317 |
+
if (this.shouldRotate(token)) {
|
| 318 |
+
this.currentIndex = (this.currentIndex + 1) % totalTokens;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
return token;
|
| 322 |
} catch (error) {
|
| 323 |
if (error.statusCode === 403 || error.statusCode === 400) {
|
|
|
|
| 326 |
if (this.tokens.length === 0) return null;
|
| 327 |
} else {
|
| 328 |
log.error(`...${token.access_token.slice(-8)} 刷新失败:`, error.message);
|
|
|
|
| 329 |
}
|
| 330 |
}
|
| 331 |
}
|
| 332 |
|
| 333 |
+
// 如果所有token都无额度,重置所有token的额度状态并重试
|
| 334 |
+
if (this.rotationStrategy === RotationStrategy.QUOTA_EXHAUSTED) {
|
| 335 |
+
log.warn('所有token额度已耗尽,重置额度状态');
|
| 336 |
+
this.tokens.forEach(t => {
|
| 337 |
+
t.hasQuota = true;
|
| 338 |
+
});
|
| 339 |
+
this.saveToFile();
|
| 340 |
+
// 返回第一个可用token
|
| 341 |
+
return this.tokens[0] || null;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
return null;
|
| 345 |
}
|
| 346 |
|
|
|
|
| 377 |
if (tokenData.email) {
|
| 378 |
newToken.email = tokenData.email;
|
| 379 |
}
|
| 380 |
+
if (tokenData.hasQuota !== undefined) {
|
| 381 |
+
newToken.hasQuota = tokenData.hasQuota;
|
| 382 |
+
}
|
| 383 |
|
| 384 |
allTokens.push(newToken);
|
| 385 |
fs.writeFileSync(this.filePath, JSON.stringify(allTokens, null, 2), 'utf8');
|
|
|
|
| 449 |
timestamp: token.timestamp,
|
| 450 |
enable: token.enable !== false,
|
| 451 |
projectId: token.projectId || null,
|
| 452 |
+
email: token.email || null,
|
| 453 |
+
hasQuota: token.hasQuota !== false
|
| 454 |
}));
|
| 455 |
} catch (error) {
|
| 456 |
log.error('获取Token列表失败:', error.message);
|
| 457 |
return [];
|
| 458 |
}
|
| 459 |
}
|
| 460 |
+
|
| 461 |
+
// 获取当前轮询配置
|
| 462 |
+
getRotationConfig() {
|
| 463 |
+
return {
|
| 464 |
+
strategy: this.rotationStrategy,
|
| 465 |
+
requestCount: this.requestCountPerToken,
|
| 466 |
+
currentIndex: this.currentIndex,
|
| 467 |
+
tokenCounts: Object.fromEntries(this.tokenRequestCounts)
|
| 468 |
+
};
|
| 469 |
+
}
|
| 470 |
}
|
| 471 |
+
|
| 472 |
+
// 导出策略枚举
|
| 473 |
+
export { RotationStrategy };
|
| 474 |
+
|
| 475 |
const tokenManager = new TokenManager();
|
| 476 |
export default tokenManager;
|
src/config/config.js
CHANGED
|
@@ -27,10 +27,41 @@ if (fs.existsSync(configJsonPath)) {
|
|
| 27 |
// 加载 .env
|
| 28 |
dotenv.config();
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
const config = {
|
| 31 |
server: {
|
| 32 |
port: jsonConfig.server?.port || 8045,
|
| 33 |
-
host: jsonConfig.server?.host || '0.0.0.0'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
},
|
| 35 |
imageBaseUrl: process.env.IMAGE_BASE_URL || null,
|
| 36 |
maxImages: jsonConfig.other?.maxImages || 10,
|
|
@@ -45,7 +76,8 @@ const config = {
|
|
| 45 |
temperature: jsonConfig.defaults?.temperature || 1,
|
| 46 |
top_p: jsonConfig.defaults?.topP || 0.85,
|
| 47 |
top_k: jsonConfig.defaults?.topK || 50,
|
| 48 |
-
max_tokens: jsonConfig.defaults?.maxTokens ||
|
|
|
|
| 49 |
},
|
| 50 |
security: {
|
| 51 |
maxRequestSize: jsonConfig.server?.maxRequestSize || '50mb',
|
|
@@ -57,8 +89,8 @@ const config = {
|
|
| 57 |
jwtSecret: process.env.JWT_SECRET || 'your-jwt-secret-key-change-this-in-production'
|
| 58 |
},
|
| 59 |
useNativeAxios: jsonConfig.other?.useNativeAxios !== false,
|
| 60 |
-
timeout: jsonConfig.other?.timeout ||
|
| 61 |
-
proxy:
|
| 62 |
systemInstruction: process.env.SYSTEM_INSTRUCTION || '',
|
| 63 |
skipProjectIdFetch: jsonConfig.other?.skipProjectIdFetch === true
|
| 64 |
};
|
|
|
|
| 27 |
// 加载 .env
|
| 28 |
dotenv.config();
|
| 29 |
|
| 30 |
+
// 获取代理配置:优先使用 PROXY,其次使用系统代理环境变量
|
| 31 |
+
function getProxyConfig() {
|
| 32 |
+
// 优先使用显式配置的 PROXY
|
| 33 |
+
if (process.env.PROXY) {
|
| 34 |
+
return process.env.PROXY;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// 检查系统代理环境变量(按优先级)
|
| 38 |
+
const systemProxy = process.env.HTTPS_PROXY ||
|
| 39 |
+
process.env.https_proxy ||
|
| 40 |
+
process.env.HTTP_PROXY ||
|
| 41 |
+
process.env.http_proxy ||
|
| 42 |
+
process.env.ALL_PROXY ||
|
| 43 |
+
process.env.all_proxy;
|
| 44 |
+
|
| 45 |
+
if (systemProxy) {
|
| 46 |
+
log.info(`使用系统代理: ${systemProxy}`);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return systemProxy || null;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
const config = {
|
| 53 |
server: {
|
| 54 |
port: jsonConfig.server?.port || 8045,
|
| 55 |
+
host: jsonConfig.server?.host || '0.0.0.0',
|
| 56 |
+
heartbeatInterval: jsonConfig.server?.heartbeatInterval || 15000, // 心跳间隔(ms),防止CF超时
|
| 57 |
+
memoryThreshold: jsonConfig.server?.memoryThreshold || 500 // 内存阈值(MB),超过触发GC
|
| 58 |
+
},
|
| 59 |
+
cache: {
|
| 60 |
+
modelListTTL: jsonConfig.cache?.modelListTTL || 60 * 60 * 1000 // 模型列表缓存时间(ms),默认60分钟
|
| 61 |
+
},
|
| 62 |
+
rotation: {
|
| 63 |
+
strategy: jsonConfig.rotation?.strategy || 'round_robin', // 轮询策略: round_robin, quota_exhausted, request_count
|
| 64 |
+
requestCount: jsonConfig.rotation?.requestCount || 10 // request_count策略下每个token的请求次数
|
| 65 |
},
|
| 66 |
imageBaseUrl: process.env.IMAGE_BASE_URL || null,
|
| 67 |
maxImages: jsonConfig.other?.maxImages || 10,
|
|
|
|
| 76 |
temperature: jsonConfig.defaults?.temperature || 1,
|
| 77 |
top_p: jsonConfig.defaults?.topP || 0.85,
|
| 78 |
top_k: jsonConfig.defaults?.topK || 50,
|
| 79 |
+
max_tokens: jsonConfig.defaults?.maxTokens || 32000,
|
| 80 |
+
thinking_budget: jsonConfig.defaults?.thinkingBudget || 16000
|
| 81 |
},
|
| 82 |
security: {
|
| 83 |
maxRequestSize: jsonConfig.server?.maxRequestSize || '50mb',
|
|
|
|
| 89 |
jwtSecret: process.env.JWT_SECRET || 'your-jwt-secret-key-change-this-in-production'
|
| 90 |
},
|
| 91 |
useNativeAxios: jsonConfig.other?.useNativeAxios !== false,
|
| 92 |
+
timeout: jsonConfig.other?.timeout || 300000,
|
| 93 |
+
proxy: getProxyConfig(),
|
| 94 |
systemInstruction: process.env.SYSTEM_INSTRUCTION || '',
|
| 95 |
skipProjectIdFetch: jsonConfig.other?.skipProjectIdFetch === true
|
| 96 |
};
|
src/routes/admin.js
CHANGED
|
@@ -129,21 +129,26 @@ router.post('/oauth/exchange', authMiddleware, async (req, res) => {
|
|
| 129 |
logger.warn('获取用户邮箱失败:', err.message);
|
| 130 |
}
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
| 141 |
account.projectId = projectId;
|
|
|
|
| 142 |
logger.info('账号验证通过,projectId: ' + projectId);
|
| 143 |
-
} catch (error) {
|
| 144 |
-
logger.error('验证账号资格失败:', error.message);
|
| 145 |
-
return res.status(500).json({ success: false, message: '验证账号资格失败: ' + error.message });
|
| 146 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
}
|
| 148 |
|
| 149 |
res.json({ success: true, data: account });
|
|
@@ -191,6 +196,49 @@ router.put('/config', authMiddleware, (req, res) => {
|
|
| 191 |
}
|
| 192 |
});
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
// 获取指定Token的模型额度
|
| 195 |
router.get('/tokens/:refreshToken/quotas', authMiddleware, async (req, res) => {
|
| 196 |
try {
|
|
@@ -209,7 +257,8 @@ router.get('/tokens/:refreshToken/quotas', authMiddleware, async (req, res) => {
|
|
| 209 |
tokenData = await tokenManager.refreshToken(tokenData);
|
| 210 |
} catch (error) {
|
| 211 |
logger.error('刷新token失败:', error.message);
|
| 212 |
-
|
|
|
|
| 213 |
}
|
| 214 |
}
|
| 215 |
|
|
|
|
| 129 |
logger.warn('获取用户邮箱失败:', err.message);
|
| 130 |
}
|
| 131 |
|
| 132 |
+
// 始终尝试获取 projectId 进行资格校验
|
| 133 |
+
// 如果无资格,自动退回到无资格模式使用随机 projectId
|
| 134 |
+
try {
|
| 135 |
+
const projectId = await tokenManager.fetchProjectId(account);
|
| 136 |
+
if (projectId === undefined) {
|
| 137 |
+
// 无资格,自动退回到无资格模式
|
| 138 |
+
account.projectId = generateProjectId();
|
| 139 |
+
account.hasQuota = false;
|
| 140 |
+
logger.warn('该账号无资格使用,已自动退回无资格模式,使用随机projectId: ' + account.projectId);
|
| 141 |
+
} else {
|
| 142 |
account.projectId = projectId;
|
| 143 |
+
account.hasQuota = true;
|
| 144 |
logger.info('账号验证通过,projectId: ' + projectId);
|
|
|
|
|
|
|
|
|
|
| 145 |
}
|
| 146 |
+
} catch (error) {
|
| 147 |
+
// 获取失败时也退回到无资格模式
|
| 148 |
+
logger.warn('验证账号资格失败: ' + error.message + ',已自动退回无资格模式');
|
| 149 |
+
account.projectId = generateProjectId();
|
| 150 |
+
account.hasQuota = false;
|
| 151 |
+
logger.info('使用随机生成的projectId: ' + account.projectId);
|
| 152 |
}
|
| 153 |
|
| 154 |
res.json({ success: true, data: account });
|
|
|
|
| 196 |
}
|
| 197 |
});
|
| 198 |
|
| 199 |
+
// 获取轮询策略配置
|
| 200 |
+
router.get('/rotation', authMiddleware, (req, res) => {
|
| 201 |
+
try {
|
| 202 |
+
const rotationConfig = tokenManager.getRotationConfig();
|
| 203 |
+
res.json({ success: true, data: rotationConfig });
|
| 204 |
+
} catch (error) {
|
| 205 |
+
logger.error('获取轮询配置失败:', error.message);
|
| 206 |
+
res.status(500).json({ success: false, message: error.message });
|
| 207 |
+
}
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
// 更新轮询策略配置
|
| 211 |
+
router.put('/rotation', authMiddleware, (req, res) => {
|
| 212 |
+
try {
|
| 213 |
+
const { strategy, requestCount } = req.body;
|
| 214 |
+
|
| 215 |
+
// 验证策略值
|
| 216 |
+
const validStrategies = ['round_robin', 'quota_exhausted', 'request_count'];
|
| 217 |
+
if (strategy && !validStrategies.includes(strategy)) {
|
| 218 |
+
return res.status(400).json({
|
| 219 |
+
success: false,
|
| 220 |
+
message: `无效的策略,可选值: ${validStrategies.join(', ')}`
|
| 221 |
+
});
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
// 更新内存中的配置
|
| 225 |
+
tokenManager.updateRotationConfig(strategy, requestCount);
|
| 226 |
+
|
| 227 |
+
// 同时保存到config.json
|
| 228 |
+
const currentConfig = getConfigJson();
|
| 229 |
+
if (!currentConfig.rotation) currentConfig.rotation = {};
|
| 230 |
+
if (strategy) currentConfig.rotation.strategy = strategy;
|
| 231 |
+
if (requestCount) currentConfig.rotation.requestCount = requestCount;
|
| 232 |
+
saveConfigJson(currentConfig);
|
| 233 |
+
|
| 234 |
+
logger.info(`轮询策略已更新: ${strategy || '未变'}, 请求次数: ${requestCount || '未变'}`);
|
| 235 |
+
res.json({ success: true, message: '轮询策略已更新', data: tokenManager.getRotationConfig() });
|
| 236 |
+
} catch (error) {
|
| 237 |
+
logger.error('更新轮询配置失败:', error.message);
|
| 238 |
+
res.status(500).json({ success: false, message: error.message });
|
| 239 |
+
}
|
| 240 |
+
});
|
| 241 |
+
|
| 242 |
// 获取指定Token的模型额度
|
| 243 |
router.get('/tokens/:refreshToken/quotas', authMiddleware, async (req, res) => {
|
| 244 |
try {
|
|
|
|
| 257 |
tokenData = await tokenManager.refreshToken(tokenData);
|
| 258 |
} catch (error) {
|
| 259 |
logger.error('刷新token失败:', error.message);
|
| 260 |
+
// 使用 400 而不是 401,避免前端误认为 JWT 登录过期
|
| 261 |
+
return res.status(400).json({ success: false, message: 'Google Token已过期且刷新失败,请重新登录Google账号' });
|
| 262 |
}
|
| 263 |
}
|
| 264 |
|
src/server/index.js
CHANGED
|
@@ -9,12 +9,39 @@ import config from '../config/config.js';
|
|
| 9 |
import tokenManager from '../auth/token_manager.js';
|
| 10 |
import adminRouter from '../routes/admin.js';
|
| 11 |
import sdRouter from '../routes/sd.js';
|
|
|
|
| 12 |
|
| 13 |
const __filename = fileURLToPath(import.meta.url);
|
| 14 |
const __dirname = path.dirname(__filename);
|
| 15 |
|
| 16 |
const app = express();
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
// 工具函数:生成响应元数据
|
| 19 |
const createResponseMeta = () => ({
|
| 20 |
id: `chatcmpl-${Date.now()}`,
|
|
@@ -26,25 +53,54 @@ const setStreamHeaders = (res) => {
|
|
| 26 |
res.setHeader('Content-Type', 'text/event-stream');
|
| 27 |
res.setHeader('Cache-Control', 'no-cache');
|
| 28 |
res.setHeader('Connection', 'keep-alive');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
};
|
| 30 |
|
| 31 |
-
//
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
});
|
| 39 |
|
| 40 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
const writeStreamData = (res, data) => {
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
};
|
| 44 |
|
| 45 |
// 工具函数:结束流式响应
|
| 46 |
const endStream = (res) => {
|
| 47 |
-
res.write(
|
| 48 |
res.end();
|
| 49 |
};
|
| 50 |
|
|
@@ -102,6 +158,26 @@ app.get('/v1/models', async (req, res) => {
|
|
| 102 |
}
|
| 103 |
});
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
app.post('/v1/chat/completions', async (req, res) => {
|
|
@@ -136,35 +212,54 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
| 136 |
if (stream) {
|
| 137 |
setStreamHeaders(res);
|
| 138 |
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
endStream(res);
|
| 161 |
}
|
| 162 |
} else {
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
if (toolCalls.length > 0) message.tool_calls = toolCalls;
|
| 166 |
|
| 167 |
-
|
|
|
|
| 168 |
id,
|
| 169 |
object: 'chat.completion',
|
| 170 |
created,
|
|
@@ -175,7 +270,9 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
| 175 |
finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop'
|
| 176 |
}],
|
| 177 |
usage
|
| 178 |
-
}
|
|
|
|
|
|
|
| 179 |
}
|
| 180 |
} catch (error) {
|
| 181 |
logger.error('生成响应失败:', error.message);
|
|
@@ -224,13 +321,40 @@ server.on('error', (error) => {
|
|
| 224 |
|
| 225 |
const shutdown = () => {
|
| 226 |
logger.info('正在关闭服务器...');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
closeRequester();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
server.close(() => {
|
| 229 |
logger.info('服务器已关闭');
|
| 230 |
process.exit(0);
|
| 231 |
});
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
};
|
| 234 |
|
| 235 |
process.on('SIGINT', shutdown);
|
| 236 |
process.on('SIGTERM', shutdown);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
import tokenManager from '../auth/token_manager.js';
|
| 10 |
import adminRouter from '../routes/admin.js';
|
| 11 |
import sdRouter from '../routes/sd.js';
|
| 12 |
+
import memoryManager, { MemoryPressure } from '../utils/memoryManager.js';
|
| 13 |
|
| 14 |
const __filename = fileURLToPath(import.meta.url);
|
| 15 |
const __dirname = path.dirname(__filename);
|
| 16 |
|
| 17 |
const app = express();
|
| 18 |
|
| 19 |
+
// ==================== 心跳机制(防止 CF 超时) ====================
|
| 20 |
+
const HEARTBEAT_INTERVAL = config.server.heartbeatInterval || 15000; // 从配置读取心跳间隔
|
| 21 |
+
const SSE_HEARTBEAT = Buffer.from(': heartbeat\n\n');
|
| 22 |
+
|
| 23 |
+
// 创建心跳定时器
|
| 24 |
+
const createHeartbeat = (res) => {
|
| 25 |
+
const timer = setInterval(() => {
|
| 26 |
+
if (!res.writableEnded) {
|
| 27 |
+
res.write(SSE_HEARTBEAT);
|
| 28 |
+
} else {
|
| 29 |
+
clearInterval(timer);
|
| 30 |
+
}
|
| 31 |
+
}, HEARTBEAT_INTERVAL);
|
| 32 |
+
|
| 33 |
+
// 响应结束时清理
|
| 34 |
+
res.on('close', () => clearInterval(timer));
|
| 35 |
+
res.on('finish', () => clearInterval(timer));
|
| 36 |
+
|
| 37 |
+
return timer;
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
// 预编译的常量字符串(避免重复创建)
|
| 41 |
+
const SSE_PREFIX = Buffer.from('data: ');
|
| 42 |
+
const SSE_SUFFIX = Buffer.from('\n\n');
|
| 43 |
+
const SSE_DONE = Buffer.from('data: [DONE]\n\n');
|
| 44 |
+
|
| 45 |
// 工具函数:生成响应元数据
|
| 46 |
const createResponseMeta = () => ({
|
| 47 |
id: `chatcmpl-${Date.now()}`,
|
|
|
|
| 53 |
res.setHeader('Content-Type', 'text/event-stream');
|
| 54 |
res.setHeader('Cache-Control', 'no-cache');
|
| 55 |
res.setHeader('Connection', 'keep-alive');
|
| 56 |
+
res.setHeader('X-Accel-Buffering', 'no'); // 禁用 nginx 缓冲
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
// 工具函数:构建流式数据块(使用动态对象池减少 GC)
|
| 60 |
+
// 支持 DeepSeek 格式的 reasoning_content
|
| 61 |
+
const chunkPool = [];
|
| 62 |
+
const getChunkObject = () => chunkPool.pop() || { choices: [{ index: 0, delta: {}, finish_reason: null }] };
|
| 63 |
+
const releaseChunkObject = (obj) => {
|
| 64 |
+
const maxSize = memoryManager.getPoolSizes().chunk;
|
| 65 |
+
if (chunkPool.length < maxSize) chunkPool.push(obj);
|
| 66 |
};
|
| 67 |
|
| 68 |
+
// 注册内存清理回调
|
| 69 |
+
memoryManager.registerCleanup((pressure) => {
|
| 70 |
+
const poolSizes = memoryManager.getPoolSizes();
|
| 71 |
+
// 根据压力缩减对象池
|
| 72 |
+
while (chunkPool.length > poolSizes.chunk) {
|
| 73 |
+
chunkPool.pop();
|
| 74 |
+
}
|
| 75 |
});
|
| 76 |
|
| 77 |
+
// 启动内存管理器
|
| 78 |
+
memoryManager.start(30000);
|
| 79 |
+
|
| 80 |
+
const createStreamChunk = (id, created, model, delta, finish_reason = null) => {
|
| 81 |
+
const chunk = getChunkObject();
|
| 82 |
+
chunk.id = id;
|
| 83 |
+
chunk.object = 'chat.completion.chunk';
|
| 84 |
+
chunk.created = created;
|
| 85 |
+
chunk.model = model;
|
| 86 |
+
chunk.choices[0].delta = delta;
|
| 87 |
+
chunk.choices[0].finish_reason = finish_reason;
|
| 88 |
+
return chunk;
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
// 工具函数:零拷贝写入流式数据
|
| 92 |
const writeStreamData = (res, data) => {
|
| 93 |
+
const json = JSON.stringify(data);
|
| 94 |
+
// 释放对象回池
|
| 95 |
+
if (data.choices) releaseChunkObject(data);
|
| 96 |
+
res.write(SSE_PREFIX);
|
| 97 |
+
res.write(json);
|
| 98 |
+
res.write(SSE_SUFFIX);
|
| 99 |
};
|
| 100 |
|
| 101 |
// 工具函数:结束流式响应
|
| 102 |
const endStream = (res) => {
|
| 103 |
+
res.write(SSE_DONE);
|
| 104 |
res.end();
|
| 105 |
};
|
| 106 |
|
|
|
|
| 158 |
}
|
| 159 |
});
|
| 160 |
|
| 161 |
+
// 内存监控端点
|
| 162 |
+
app.get('/v1/memory', (req, res) => {
|
| 163 |
+
const usage = process.memoryUsage();
|
| 164 |
+
res.json({
|
| 165 |
+
heapUsed: usage.heapUsed,
|
| 166 |
+
heapTotal: usage.heapTotal,
|
| 167 |
+
rss: usage.rss,
|
| 168 |
+
external: usage.external,
|
| 169 |
+
arrayBuffers: usage.arrayBuffers,
|
| 170 |
+
pressure: memoryManager.getCurrentPressure(),
|
| 171 |
+
poolSizes: memoryManager.getPoolSizes(),
|
| 172 |
+
chunkPoolSize: chunkPool.length
|
| 173 |
+
});
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
// 健康检查端点
|
| 177 |
+
app.get('/health', (req, res) => {
|
| 178 |
+
res.json({ status: 'ok', uptime: process.uptime() });
|
| 179 |
+
});
|
| 180 |
+
|
| 181 |
|
| 182 |
|
| 183 |
app.post('/v1/chat/completions', async (req, res) => {
|
|
|
|
| 212 |
if (stream) {
|
| 213 |
setStreamHeaders(res);
|
| 214 |
|
| 215 |
+
// 启动心跳,防止 Cloudflare 超时断连
|
| 216 |
+
const heartbeatTimer = createHeartbeat(res);
|
| 217 |
+
|
| 218 |
+
try {
|
| 219 |
+
if (isImageModel) {
|
| 220 |
+
//console.log(JSON.stringify(requestBody,null,2));
|
| 221 |
+
const { content, usage } = await generateAssistantResponseNoStream(requestBody, token);
|
| 222 |
+
writeStreamData(res, createStreamChunk(id, created, model, { content }));
|
| 223 |
+
writeStreamData(res, { ...createStreamChunk(id, created, model, {}, 'stop'), usage });
|
| 224 |
+
} else {
|
| 225 |
+
let hasToolCall = false;
|
| 226 |
+
let usageData = null;
|
| 227 |
+
await generateAssistantResponse(requestBody, token, (data) => {
|
| 228 |
+
if (data.type === 'usage') {
|
| 229 |
+
usageData = data.usage;
|
| 230 |
+
} else if (data.type === 'reasoning') {
|
| 231 |
+
// DeepSeek 格式:思维链内容通过 reasoning_content 字段输出
|
| 232 |
+
const delta = { reasoning_content: data.reasoning_content };
|
| 233 |
+
writeStreamData(res, createStreamChunk(id, created, model, delta));
|
| 234 |
+
} else if (data.type === 'tool_calls') {
|
| 235 |
+
hasToolCall = true;
|
| 236 |
+
const delta = { tool_calls: data.tool_calls };
|
| 237 |
+
writeStreamData(res, createStreamChunk(id, created, model, delta));
|
| 238 |
+
} else {
|
| 239 |
+
const delta = { content: data.content };
|
| 240 |
+
writeStreamData(res, createStreamChunk(id, created, model, delta));
|
| 241 |
+
}
|
| 242 |
+
});
|
| 243 |
+
writeStreamData(res, { ...createStreamChunk(id, created, model, {}, hasToolCall ? 'tool_calls' : 'stop'), usage: usageData });
|
| 244 |
+
}
|
| 245 |
+
} finally {
|
| 246 |
+
clearInterval(heartbeatTimer);
|
| 247 |
endStream(res);
|
| 248 |
}
|
| 249 |
} else {
|
| 250 |
+
// 非流式请求:设置较长超时,避免大模型响应超时
|
| 251 |
+
req.setTimeout(0); // 禁用请求超时
|
| 252 |
+
res.setTimeout(0); // 禁用响应超时
|
| 253 |
+
|
| 254 |
+
const { content, reasoningContent, toolCalls, usage } = await generateAssistantResponseNoStream(requestBody, token);
|
| 255 |
+
// DeepSeek 格式:reasoning_content 在 content 之前
|
| 256 |
+
const message = { role: 'assistant' };
|
| 257 |
+
if (reasoningContent) message.reasoning_content = reasoningContent;
|
| 258 |
+
message.content = content;
|
| 259 |
if (toolCalls.length > 0) message.tool_calls = toolCalls;
|
| 260 |
|
| 261 |
+
// 使用预构建的响应对象,减少内存分配
|
| 262 |
+
const response = {
|
| 263 |
id,
|
| 264 |
object: 'chat.completion',
|
| 265 |
created,
|
|
|
|
| 270 |
finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop'
|
| 271 |
}],
|
| 272 |
usage
|
| 273 |
+
};
|
| 274 |
+
|
| 275 |
+
res.json(response);
|
| 276 |
}
|
| 277 |
} catch (error) {
|
| 278 |
logger.error('生成响应失败:', error.message);
|
|
|
|
| 321 |
|
| 322 |
const shutdown = () => {
|
| 323 |
logger.info('正在关闭服务器...');
|
| 324 |
+
|
| 325 |
+
// 停止内存管理器
|
| 326 |
+
memoryManager.stop();
|
| 327 |
+
logger.info('已停止内存管理器');
|
| 328 |
+
|
| 329 |
+
// 关闭子进程请求器
|
| 330 |
closeRequester();
|
| 331 |
+
logger.info('已关闭子进程请求器');
|
| 332 |
+
|
| 333 |
+
// 清理对象池
|
| 334 |
+
chunkPool.length = 0;
|
| 335 |
+
logger.info('已清理对象池');
|
| 336 |
+
|
| 337 |
server.close(() => {
|
| 338 |
logger.info('服务器已关闭');
|
| 339 |
process.exit(0);
|
| 340 |
});
|
| 341 |
+
|
| 342 |
+
// 5秒超时强制退出
|
| 343 |
+
setTimeout(() => {
|
| 344 |
+
logger.warn('服务器关闭超时,强制退出');
|
| 345 |
+
process.exit(0);
|
| 346 |
+
}, 5000);
|
| 347 |
};
|
| 348 |
|
| 349 |
process.on('SIGINT', shutdown);
|
| 350 |
process.on('SIGTERM', shutdown);
|
| 351 |
+
|
| 352 |
+
// 未捕获异常处理
|
| 353 |
+
process.on('uncaughtException', (error) => {
|
| 354 |
+
logger.error('未捕获异常:', error.message);
|
| 355 |
+
// 不立即退出,让当前请求完成
|
| 356 |
+
});
|
| 357 |
+
|
| 358 |
+
process.on('unhandledRejection', (reason, promise) => {
|
| 359 |
+
logger.error('未处理的 Promise 拒绝:', reason);
|
| 360 |
+
});
|
src/utils/configReloader.js
CHANGED
|
@@ -9,8 +9,9 @@ const CONFIG_MAPPING = [
|
|
| 9 |
{ target: 'defaults.temperature', source: 'defaults.temperature', default: 1 },
|
| 10 |
{ target: 'defaults.top_p', source: 'defaults.topP', default: 0.85 },
|
| 11 |
{ target: 'defaults.top_k', source: 'defaults.topK', default: 50 },
|
| 12 |
-
{ target: 'defaults.max_tokens', source: 'defaults.maxTokens', default:
|
| 13 |
-
{ target: '
|
|
|
|
| 14 |
{ target: 'skipProjectIdFetch', source: 'other.skipProjectIdFetch', default: false, transform: v => v === true },
|
| 15 |
{ target: 'maxImages', source: 'other.maxImages', default: 10 },
|
| 16 |
{ target: 'useNativeAxios', source: 'other.useNativeAxios', default: true, transform: v => v !== false },
|
|
@@ -21,9 +22,27 @@ const CONFIG_MAPPING = [
|
|
| 21 |
{ target: 'api.userAgent', source: 'api.userAgent', default: 'antigravity/1.11.3 windows/amd64' }
|
| 22 |
];
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
const ENV_MAPPING = [
|
| 25 |
{ target: 'security.apiKey', env: 'API_KEY', default: null },
|
| 26 |
-
{ target: 'proxy', env: 'PROXY', default: null },
|
| 27 |
{ target: 'systemInstruction', env: 'SYSTEM_INSTRUCTION', default: '' }
|
| 28 |
];
|
| 29 |
|
|
@@ -62,4 +81,7 @@ export function reloadConfig() {
|
|
| 62 |
const value = process.env[env] || defaultValue;
|
| 63 |
setNestedValue(config, target, value);
|
| 64 |
});
|
|
|
|
|
|
|
|
|
|
| 65 |
}
|
|
|
|
| 9 |
{ target: 'defaults.temperature', source: 'defaults.temperature', default: 1 },
|
| 10 |
{ target: 'defaults.top_p', source: 'defaults.topP', default: 0.85 },
|
| 11 |
{ target: 'defaults.top_k', source: 'defaults.topK', default: 50 },
|
| 12 |
+
{ target: 'defaults.max_tokens', source: 'defaults.maxTokens', default: 32000 },
|
| 13 |
+
{ target: 'defaults.thinking_budget', source: 'defaults.thinkingBudget', default: 16000 },
|
| 14 |
+
{ target: 'timeout', source: 'other.timeout', default: 300000 },
|
| 15 |
{ target: 'skipProjectIdFetch', source: 'other.skipProjectIdFetch', default: false, transform: v => v === true },
|
| 16 |
{ target: 'maxImages', source: 'other.maxImages', default: 10 },
|
| 17 |
{ target: 'useNativeAxios', source: 'other.useNativeAxios', default: true, transform: v => v !== false },
|
|
|
|
| 22 |
{ target: 'api.userAgent', source: 'api.userAgent', default: 'antigravity/1.11.3 windows/amd64' }
|
| 23 |
];
|
| 24 |
|
| 25 |
+
/**
|
| 26 |
+
* 获取代理配置:优先使用 PROXY,其次使用系统代理环境变量
|
| 27 |
+
*/
|
| 28 |
+
function getProxyConfig() {
|
| 29 |
+
// 优先使用显式配置的 PROXY
|
| 30 |
+
if (process.env.PROXY) {
|
| 31 |
+
return process.env.PROXY;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// 检查系统代理环境变量(按优先级)
|
| 35 |
+
return process.env.HTTPS_PROXY ||
|
| 36 |
+
process.env.https_proxy ||
|
| 37 |
+
process.env.HTTP_PROXY ||
|
| 38 |
+
process.env.http_proxy ||
|
| 39 |
+
process.env.ALL_PROXY ||
|
| 40 |
+
process.env.all_proxy ||
|
| 41 |
+
null;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
const ENV_MAPPING = [
|
| 45 |
{ target: 'security.apiKey', env: 'API_KEY', default: null },
|
|
|
|
| 46 |
{ target: 'systemInstruction', env: 'SYSTEM_INSTRUCTION', default: '' }
|
| 47 |
];
|
| 48 |
|
|
|
|
| 81 |
const value = process.env[env] || defaultValue;
|
| 82 |
setNestedValue(config, target, value);
|
| 83 |
});
|
| 84 |
+
|
| 85 |
+
// 单独处理代理配置(支持系统代理环境变量)
|
| 86 |
+
config.proxy = getProxyConfig();
|
| 87 |
}
|
src/utils/memoryManager.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 智能内存管理器
|
| 3 |
+
* 采用分级策略,根据内存压力动态调整缓存和对象池
|
| 4 |
+
* 目标:在保证性能的前提下,将内存稳定在 20MB 左右
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import logger from './logger.js';
|
| 8 |
+
|
| 9 |
+
// 内存压力级别
|
| 10 |
+
const MemoryPressure = {
|
| 11 |
+
LOW: 'low', // < 15MB - 正常运行
|
| 12 |
+
MEDIUM: 'medium', // 15-25MB - 轻度清理
|
| 13 |
+
HIGH: 'high', // 25-35MB - 积极清理
|
| 14 |
+
CRITICAL: 'critical' // > 35MB - 紧急清理
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
// 阈值配置(字节)
|
| 18 |
+
const THRESHOLDS = {
|
| 19 |
+
LOW: 15 * 1024 * 1024, // 15MB
|
| 20 |
+
MEDIUM: 25 * 1024 * 1024, // 25MB
|
| 21 |
+
HIGH: 35 * 1024 * 1024, // 35MB
|
| 22 |
+
TARGET: 20 * 1024 * 1024 // 20MB 目标
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
// 对象池最大大小配置(根据压力调整)
|
| 26 |
+
const POOL_SIZES = {
|
| 27 |
+
[MemoryPressure.LOW]: { chunk: 30, toolCall: 15, lineBuffer: 5 },
|
| 28 |
+
[MemoryPressure.MEDIUM]: { chunk: 20, toolCall: 10, lineBuffer: 3 },
|
| 29 |
+
[MemoryPressure.HIGH]: { chunk: 10, toolCall: 5, lineBuffer: 2 },
|
| 30 |
+
[MemoryPressure.CRITICAL]: { chunk: 5, toolCall: 3, lineBuffer: 1 }
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
class MemoryManager {
|
| 34 |
+
constructor() {
|
| 35 |
+
this.currentPressure = MemoryPressure.LOW;
|
| 36 |
+
this.cleanupCallbacks = new Set();
|
| 37 |
+
this.lastGCTime = 0;
|
| 38 |
+
this.gcCooldown = 10000; // GC 冷却时间 10秒
|
| 39 |
+
this.checkInterval = null;
|
| 40 |
+
this.isShuttingDown = false;
|
| 41 |
+
|
| 42 |
+
// 统计信息
|
| 43 |
+
this.stats = {
|
| 44 |
+
gcCount: 0,
|
| 45 |
+
cleanupCount: 0,
|
| 46 |
+
peakMemory: 0
|
| 47 |
+
};
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* 启动内存监控
|
| 52 |
+
* @param {number} interval - 检查间隔(毫秒)
|
| 53 |
+
*/
|
| 54 |
+
start(interval = 30000) {
|
| 55 |
+
if (this.checkInterval) return;
|
| 56 |
+
|
| 57 |
+
this.checkInterval = setInterval(() => {
|
| 58 |
+
if (!this.isShuttingDown) {
|
| 59 |
+
this.check();
|
| 60 |
+
}
|
| 61 |
+
}, interval);
|
| 62 |
+
|
| 63 |
+
// 首次立即检查
|
| 64 |
+
this.check();
|
| 65 |
+
logger.info(`内存管理器已启动 (检查间隔: ${interval/1000}秒)`);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* 停止内存监控
|
| 70 |
+
*/
|
| 71 |
+
stop() {
|
| 72 |
+
this.isShuttingDown = true;
|
| 73 |
+
if (this.checkInterval) {
|
| 74 |
+
clearInterval(this.checkInterval);
|
| 75 |
+
this.checkInterval = null;
|
| 76 |
+
}
|
| 77 |
+
this.cleanupCallbacks.clear();
|
| 78 |
+
logger.info('内存管理器已停止');
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/**
|
| 82 |
+
* 注册清理回调
|
| 83 |
+
* @param {Function} callback - 清理函数,接收 pressure 参数
|
| 84 |
+
*/
|
| 85 |
+
registerCleanup(callback) {
|
| 86 |
+
this.cleanupCallbacks.add(callback);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* 取消注册清理回调
|
| 91 |
+
* @param {Function} callback
|
| 92 |
+
*/
|
| 93 |
+
unregisterCleanup(callback) {
|
| 94 |
+
this.cleanupCallbacks.delete(callback);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
/**
|
| 98 |
+
* 获取当前内存使用情况
|
| 99 |
+
*/
|
| 100 |
+
getMemoryUsage() {
|
| 101 |
+
const usage = process.memoryUsage();
|
| 102 |
+
return {
|
| 103 |
+
heapUsed: usage.heapUsed,
|
| 104 |
+
heapTotal: usage.heapTotal,
|
| 105 |
+
rss: usage.rss,
|
| 106 |
+
external: usage.external,
|
| 107 |
+
heapUsedMB: Math.round(usage.heapUsed / 1024 / 1024 * 10) / 10
|
| 108 |
+
};
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/**
|
| 112 |
+
* 确定内存压力级别
|
| 113 |
+
*/
|
| 114 |
+
getPressureLevel(heapUsed) {
|
| 115 |
+
if (heapUsed < THRESHOLDS.LOW) return MemoryPressure.LOW;
|
| 116 |
+
if (heapUsed < THRESHOLDS.MEDIUM) return MemoryPressure.MEDIUM;
|
| 117 |
+
if (heapUsed < THRESHOLDS.HIGH) return MemoryPressure.HIGH;
|
| 118 |
+
return MemoryPressure.CRITICAL;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
/**
|
| 122 |
+
* 获取当前压力下的对象池大小配置
|
| 123 |
+
*/
|
| 124 |
+
getPoolSizes() {
|
| 125 |
+
return POOL_SIZES[this.currentPressure];
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/**
|
| 129 |
+
* 获取当前压力级别
|
| 130 |
+
*/
|
| 131 |
+
getCurrentPressure() {
|
| 132 |
+
return this.currentPressure;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/**
|
| 136 |
+
* 检查内存并触发相应清理
|
| 137 |
+
*/
|
| 138 |
+
check() {
|
| 139 |
+
const { heapUsed, heapUsedMB } = this.getMemoryUsage();
|
| 140 |
+
const newPressure = this.getPressureLevel(heapUsed);
|
| 141 |
+
|
| 142 |
+
// 更新峰值统计
|
| 143 |
+
if (heapUsed > this.stats.peakMemory) {
|
| 144 |
+
this.stats.peakMemory = heapUsed;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
// 压力级别变化时记录日志
|
| 148 |
+
if (newPressure !== this.currentPressure) {
|
| 149 |
+
logger.info(`内存压力变化: ${this.currentPressure} -> ${newPressure} (${heapUsedMB}MB)`);
|
| 150 |
+
this.currentPressure = newPressure;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// 根据压力级别执行不同策略
|
| 154 |
+
switch (newPressure) {
|
| 155 |
+
case MemoryPressure.CRITICAL:
|
| 156 |
+
this.handleCriticalPressure(heapUsedMB);
|
| 157 |
+
break;
|
| 158 |
+
case MemoryPressure.HIGH:
|
| 159 |
+
this.handleHighPressure(heapUsedMB);
|
| 160 |
+
break;
|
| 161 |
+
case MemoryPressure.MEDIUM:
|
| 162 |
+
this.handleMediumPressure(heapUsedMB);
|
| 163 |
+
break;
|
| 164 |
+
// LOW 压力不需要特殊处理
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
return newPressure;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/**
|
| 171 |
+
* 处理中等压力
|
| 172 |
+
*/
|
| 173 |
+
handleMediumPressure(heapUsedMB) {
|
| 174 |
+
// 通知各模块缩减对象池
|
| 175 |
+
this.notifyCleanup(MemoryPressure.MEDIUM);
|
| 176 |
+
this.stats.cleanupCount++;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
/**
|
| 180 |
+
* 处理高压力
|
| 181 |
+
*/
|
| 182 |
+
handleHighPressure(heapUsedMB) {
|
| 183 |
+
logger.info(`内存较高 (${heapUsedMB}MB),执行积极清理`);
|
| 184 |
+
this.notifyCleanup(MemoryPressure.HIGH);
|
| 185 |
+
this.stats.cleanupCount++;
|
| 186 |
+
|
| 187 |
+
// 尝试触发 GC(带冷却)
|
| 188 |
+
this.tryGC();
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/**
|
| 192 |
+
* 处理紧急压力
|
| 193 |
+
*/
|
| 194 |
+
handleCriticalPressure(heapUsedMB) {
|
| 195 |
+
logger.warn(`内存紧急 (${heapUsedMB}MB),执���紧急清理`);
|
| 196 |
+
this.notifyCleanup(MemoryPressure.CRITICAL);
|
| 197 |
+
this.stats.cleanupCount++;
|
| 198 |
+
|
| 199 |
+
// 强制 GC(忽略冷却)
|
| 200 |
+
this.forceGC();
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
/**
|
| 204 |
+
* 通知所有注册的清理回调
|
| 205 |
+
*/
|
| 206 |
+
notifyCleanup(pressure) {
|
| 207 |
+
for (const callback of this.cleanupCallbacks) {
|
| 208 |
+
try {
|
| 209 |
+
callback(pressure);
|
| 210 |
+
} catch (error) {
|
| 211 |
+
logger.error('清理回调执行失败:', error.message);
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
/**
|
| 217 |
+
* 尝试触发 GC(带冷却时间)
|
| 218 |
+
*/
|
| 219 |
+
tryGC() {
|
| 220 |
+
const now = Date.now();
|
| 221 |
+
if (now - this.lastGCTime < this.gcCooldown) {
|
| 222 |
+
return false;
|
| 223 |
+
}
|
| 224 |
+
return this.forceGC();
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
/**
|
| 228 |
+
* 强制触发 GC
|
| 229 |
+
*/
|
| 230 |
+
forceGC() {
|
| 231 |
+
if (global.gc) {
|
| 232 |
+
const before = this.getMemoryUsage().heapUsedMB;
|
| 233 |
+
global.gc();
|
| 234 |
+
this.lastGCTime = Date.now();
|
| 235 |
+
this.stats.gcCount++;
|
| 236 |
+
const after = this.getMemoryUsage().heapUsedMB;
|
| 237 |
+
logger.info(`GC 完成: ${before}MB -> ${after}MB (释放 ${(before - after).toFixed(1)}MB)`);
|
| 238 |
+
return true;
|
| 239 |
+
}
|
| 240 |
+
return false;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
/**
|
| 244 |
+
* 手动触发检查和清理
|
| 245 |
+
*/
|
| 246 |
+
cleanup() {
|
| 247 |
+
return this.check();
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
/**
|
| 251 |
+
* 获取统计信息
|
| 252 |
+
*/
|
| 253 |
+
getStats() {
|
| 254 |
+
const memory = this.getMemoryUsage();
|
| 255 |
+
return {
|
| 256 |
+
...this.stats,
|
| 257 |
+
currentPressure: this.currentPressure,
|
| 258 |
+
currentHeapMB: memory.heapUsedMB,
|
| 259 |
+
peakMemoryMB: Math.round(this.stats.peakMemory / 1024 / 1024 * 10) / 10,
|
| 260 |
+
poolSizes: this.getPoolSizes()
|
| 261 |
+
};
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
// 单例导出
|
| 266 |
+
const memoryManager = new MemoryManager();
|
| 267 |
+
export default memoryManager;
|
| 268 |
+
export { MemoryPressure, THRESHOLDS };
|
src/utils/utils.js
CHANGED
|
@@ -2,6 +2,43 @@ import config from '../config/config.js';
|
|
| 2 |
import tokenManager from '../auth/token_manager.js';
|
| 3 |
import { generateRequestId } from './idGenerator.js';
|
| 4 |
import os from 'os';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
function extractImagesFromContent(content) {
|
| 7 |
const result = { text: '', images: [] };
|
|
@@ -118,7 +155,11 @@ function handleToolCall(message, antigravityMessages){
|
|
| 118 |
function openaiMessageToAntigravity(openaiMessages){
|
| 119 |
const antigravityMessages = [];
|
| 120 |
for (const message of openaiMessages) {
|
| 121 |
-
if (message.role === "user"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
const extracted = extractImagesFromContent(message.content);
|
| 123 |
handleUserMessage(extracted, antigravityMessages);
|
| 124 |
} else if (message.role === "assistant") {
|
|
@@ -130,7 +171,72 @@ function openaiMessageToAntigravity(openaiMessages){
|
|
| 130 |
|
| 131 |
return antigravityMessages;
|
| 132 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
function generateGenerationConfig(parameters, enableThinking, actualModelName){
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
const generationConfig = {
|
| 135 |
topP: parameters.top_p ?? config.defaults.top_p,
|
| 136 |
topK: parameters.top_k ?? config.defaults.top_k,
|
|
@@ -146,7 +252,7 @@ function generateGenerationConfig(parameters, enableThinking, actualModelName){
|
|
| 146 |
],
|
| 147 |
thinkingConfig: {
|
| 148 |
includeThoughts: enableThinking,
|
| 149 |
-
thinkingBudget:
|
| 150 |
}
|
| 151 |
}
|
| 152 |
if (enableThinking && actualModelName.includes("claude")){
|
|
@@ -208,15 +314,25 @@ function generateRequestBody(openaiMessages,modelName,parameters,openaiTools,tok
|
|
| 208 |
const enableThinking = isEnableThinking(modelName);
|
| 209 |
const actualModelName = modelMapping(modelName);
|
| 210 |
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
project: token.projectId,
|
| 213 |
requestId: generateRequestId(),
|
| 214 |
request: {
|
| 215 |
-
contents: openaiMessageToAntigravity(
|
| 216 |
-
systemInstruction: {
|
| 217 |
-
role: "user",
|
| 218 |
-
parts: [{ text: config.systemInstruction }]
|
| 219 |
-
},
|
| 220 |
tools: convertOpenAIToolsToAntigravity(openaiTools),
|
| 221 |
toolConfig: {
|
| 222 |
functionCallingConfig: {
|
|
@@ -228,7 +344,17 @@ function generateRequestBody(openaiMessages,modelName,parameters,openaiTools,tok
|
|
| 228 |
},
|
| 229 |
model: actualModelName,
|
| 230 |
userAgent: "antigravity"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
}
|
|
|
|
|
|
|
| 232 |
}
|
| 233 |
function getDefaultIp(){
|
| 234 |
const interfaces = os.networkInterfaces();
|
|
@@ -250,5 +376,7 @@ function getDefaultIp(){
|
|
| 250 |
export{
|
| 251 |
generateRequestId,
|
| 252 |
generateRequestBody,
|
| 253 |
-
getDefaultIp
|
|
|
|
|
|
|
| 254 |
}
|
|
|
|
| 2 |
import tokenManager from '../auth/token_manager.js';
|
| 3 |
import { generateRequestId } from './idGenerator.js';
|
| 4 |
import os from 'os';
|
| 5 |
+
import dns from 'dns';
|
| 6 |
+
import http from 'http';
|
| 7 |
+
import https from 'https';
|
| 8 |
+
import logger from './logger.js';
|
| 9 |
+
|
| 10 |
+
// ==================== DNS 解析优化 ====================
|
| 11 |
+
// 自定义 DNS 解析:优先 IPv4,失败则回退 IPv6
|
| 12 |
+
function customLookup(hostname, options, callback) {
|
| 13 |
+
// 先尝试 IPv4
|
| 14 |
+
dns.lookup(hostname, { ...options, family: 4 }, (err4, address4, family4) => {
|
| 15 |
+
if (!err4 && address4) {
|
| 16 |
+
// IPv4 成功
|
| 17 |
+
return callback(null, address4, family4);
|
| 18 |
+
}
|
| 19 |
+
// IPv4 失败,尝试 IPv6
|
| 20 |
+
dns.lookup(hostname, { ...options, family: 6 }, (err6, address6, family6) => {
|
| 21 |
+
if (!err6 && address6) {
|
| 22 |
+
// IPv6 成功
|
| 23 |
+
logger.debug(`DNS: ${hostname} IPv4 失败,使用 IPv6: ${address6}`);
|
| 24 |
+
return callback(null, address6, family6);
|
| 25 |
+
}
|
| 26 |
+
// 都失败,返回 IPv4 的错误
|
| 27 |
+
callback(err4 || err6);
|
| 28 |
+
});
|
| 29 |
+
});
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// 创建使用自定义 DNS 解析的 HTTP/HTTPS Agent
|
| 33 |
+
const httpAgent = new http.Agent({
|
| 34 |
+
lookup: customLookup,
|
| 35 |
+
keepAlive: true
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
const httpsAgent = new https.Agent({
|
| 39 |
+
lookup: customLookup,
|
| 40 |
+
keepAlive: true
|
| 41 |
+
});
|
| 42 |
|
| 43 |
function extractImagesFromContent(content) {
|
| 44 |
const result = { text: '', images: [] };
|
|
|
|
| 155 |
function openaiMessageToAntigravity(openaiMessages){
|
| 156 |
const antigravityMessages = [];
|
| 157 |
for (const message of openaiMessages) {
|
| 158 |
+
if (message.role === "user") {
|
| 159 |
+
const extracted = extractImagesFromContent(message.content);
|
| 160 |
+
handleUserMessage(extracted, antigravityMessages);
|
| 161 |
+
} else if (message.role === "system") {
|
| 162 |
+
// 中间的 system 消息作为 user 处理(开头的 system 已在 generateRequestBody 中过滤)
|
| 163 |
const extracted = extractImagesFromContent(message.content);
|
| 164 |
handleUserMessage(extracted, antigravityMessages);
|
| 165 |
} else if (message.role === "assistant") {
|
|
|
|
| 171 |
|
| 172 |
return antigravityMessages;
|
| 173 |
}
|
| 174 |
+
|
| 175 |
+
/**
|
| 176 |
+
* 从 OpenAI 消息中提取并合并 system 指令
|
| 177 |
+
* 规则:
|
| 178 |
+
* 1. SYSTEM_INSTRUCTION 作为基础 system,可为空
|
| 179 |
+
* 2. 保留用户首条 system 信息,合并在基础 system 后面
|
| 180 |
+
* 3. 如果连续多条 system,合并成一条 system
|
| 181 |
+
* 4. 避免把真正的 system 重复作为 user 发送
|
| 182 |
+
*/
|
| 183 |
+
function extractSystemInstruction(openaiMessages) {
|
| 184 |
+
const baseSystem = config.systemInstruction || '';
|
| 185 |
+
|
| 186 |
+
// 收集开头连续的 system 消息
|
| 187 |
+
const systemTexts = [];
|
| 188 |
+
for (const message of openaiMessages) {
|
| 189 |
+
if (message.role === 'system') {
|
| 190 |
+
const content = typeof message.content === 'string'
|
| 191 |
+
? message.content
|
| 192 |
+
: (Array.isArray(message.content)
|
| 193 |
+
? message.content.filter(item => item.type === 'text').map(item => item.text).join('')
|
| 194 |
+
: '');
|
| 195 |
+
if (content.trim()) {
|
| 196 |
+
systemTexts.push(content.trim());
|
| 197 |
+
}
|
| 198 |
+
} else {
|
| 199 |
+
// 遇到非 system 消息就停止收集
|
| 200 |
+
break;
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
// 合并:基础 system + 用户的 system 消息
|
| 205 |
+
const parts = [];
|
| 206 |
+
if (baseSystem.trim()) {
|
| 207 |
+
parts.push(baseSystem.trim());
|
| 208 |
+
}
|
| 209 |
+
if (systemTexts.length > 0) {
|
| 210 |
+
parts.push(systemTexts.join('\n\n'));
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
return parts.join('\n\n');
|
| 214 |
+
}
|
| 215 |
+
// reasoning_effort 到 thinkingBudget 的映射
|
| 216 |
+
const REASONING_EFFORT_MAP = {
|
| 217 |
+
'low': 1024,
|
| 218 |
+
'medium': 16000,
|
| 219 |
+
'high': 32000
|
| 220 |
+
};
|
| 221 |
+
|
| 222 |
function generateGenerationConfig(parameters, enableThinking, actualModelName){
|
| 223 |
+
// 获取思考预算:
|
| 224 |
+
// 1. 优先使用 thinking_budget(直接数值)
|
| 225 |
+
// 2. 其次使用 reasoning_effort(OpenAI 格式:low/medium/high)
|
| 226 |
+
// 3. 最后使用配置默认值或硬编码默认值
|
| 227 |
+
const defaultThinkingBudget = config.defaults.thinking_budget ?? 16000;
|
| 228 |
+
|
| 229 |
+
let thinkingBudget = 0;
|
| 230 |
+
if (enableThinking) {
|
| 231 |
+
if (parameters.thinking_budget !== undefined) {
|
| 232 |
+
thinkingBudget = parameters.thinking_budget;
|
| 233 |
+
} else if (parameters.reasoning_effort !== undefined) {
|
| 234 |
+
thinkingBudget = REASONING_EFFORT_MAP[parameters.reasoning_effort] ?? defaultThinkingBudget;
|
| 235 |
+
} else {
|
| 236 |
+
thinkingBudget = defaultThinkingBudget;
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
const generationConfig = {
|
| 241 |
topP: parameters.top_p ?? config.defaults.top_p,
|
| 242 |
topK: parameters.top_k ?? config.defaults.top_k,
|
|
|
|
| 252 |
],
|
| 253 |
thinkingConfig: {
|
| 254 |
includeThoughts: enableThinking,
|
| 255 |
+
thinkingBudget: thinkingBudget
|
| 256 |
}
|
| 257 |
}
|
| 258 |
if (enableThinking && actualModelName.includes("claude")){
|
|
|
|
| 314 |
const enableThinking = isEnableThinking(modelName);
|
| 315 |
const actualModelName = modelMapping(modelName);
|
| 316 |
|
| 317 |
+
// 提取合并后的 system 指令
|
| 318 |
+
const mergedSystemInstruction = extractSystemInstruction(openaiMessages);
|
| 319 |
+
|
| 320 |
+
// 过滤掉开头连续的 system 消息,避免重复作为 user 发送
|
| 321 |
+
let startIndex = 0;
|
| 322 |
+
for (let i = 0; i < openaiMessages.length; i++) {
|
| 323 |
+
if (openaiMessages[i].role === 'system') {
|
| 324 |
+
startIndex = i + 1;
|
| 325 |
+
} else {
|
| 326 |
+
break;
|
| 327 |
+
}
|
| 328 |
+
}
|
| 329 |
+
const filteredMessages = openaiMessages.slice(startIndex);
|
| 330 |
+
|
| 331 |
+
const requestBody = {
|
| 332 |
project: token.projectId,
|
| 333 |
requestId: generateRequestId(),
|
| 334 |
request: {
|
| 335 |
+
contents: openaiMessageToAntigravity(filteredMessages),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
tools: convertOpenAIToolsToAntigravity(openaiTools),
|
| 337 |
toolConfig: {
|
| 338 |
functionCallingConfig: {
|
|
|
|
| 344 |
},
|
| 345 |
model: actualModelName,
|
| 346 |
userAgent: "antigravity"
|
| 347 |
+
};
|
| 348 |
+
|
| 349 |
+
// 只有当有 system 指令时才添加 systemInstruction 字段
|
| 350 |
+
if (mergedSystemInstruction) {
|
| 351 |
+
requestBody.request.systemInstruction = {
|
| 352 |
+
role: "user",
|
| 353 |
+
parts: [{ text: mergedSystemInstruction }]
|
| 354 |
+
};
|
| 355 |
}
|
| 356 |
+
|
| 357 |
+
return requestBody;
|
| 358 |
}
|
| 359 |
function getDefaultIp(){
|
| 360 |
const interfaces = os.networkInterfaces();
|
|
|
|
| 376 |
export{
|
| 377 |
generateRequestId,
|
| 378 |
generateRequestBody,
|
| 379 |
+
getDefaultIp,
|
| 380 |
+
httpAgent,
|
| 381 |
+
httpsAgent
|
| 382 |
}
|
test/test-memory.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 内存优化效果测试脚本
|
| 3 |
+
* 用于验证服务的内存使用是否控制在目标范围内(约20MB)
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const http = require('http');
|
| 7 |
+
const { spawn } = require('child_process');
|
| 8 |
+
const path = require('path');
|
| 9 |
+
|
| 10 |
+
// 配置
|
| 11 |
+
const PORT = process.env.PORT || 9876;
|
| 12 |
+
const BASE_URL = `http://localhost:${PORT}`;
|
| 13 |
+
const TEST_DURATION_MS = 60000; // 测试持续时间:60秒
|
| 14 |
+
const SAMPLE_INTERVAL_MS = 2000; // 采样间隔:2秒
|
| 15 |
+
const REQUEST_INTERVAL_MS = 1000; // 请求间隔:1秒
|
| 16 |
+
|
| 17 |
+
// 内存采样数据
|
| 18 |
+
const memorySamples = [];
|
| 19 |
+
let serverProcess = null;
|
| 20 |
+
let testStartTime = null;
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* 格式化内存大小
|
| 24 |
+
*/
|
| 25 |
+
function formatMemory(bytes) {
|
| 26 |
+
const mb = bytes / 1024 / 1024;
|
| 27 |
+
return `${mb.toFixed(2)} MB`;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* 发送HTTP请求
|
| 32 |
+
*/
|
| 33 |
+
function sendRequest(urlPath, method = 'GET', body = null) {
|
| 34 |
+
return new Promise((resolve, reject) => {
|
| 35 |
+
const url = new URL(urlPath, BASE_URL);
|
| 36 |
+
const options = {
|
| 37 |
+
hostname: url.hostname,
|
| 38 |
+
port: url.port,
|
| 39 |
+
path: url.pathname,
|
| 40 |
+
method: method,
|
| 41 |
+
headers: {
|
| 42 |
+
'Content-Type': 'application/json',
|
| 43 |
+
},
|
| 44 |
+
timeout: 5000
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const req = http.request(options, (res) => {
|
| 48 |
+
let data = '';
|
| 49 |
+
res.on('data', chunk => data += chunk);
|
| 50 |
+
res.on('end', () => resolve({ status: res.statusCode, data }));
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
req.on('error', reject);
|
| 54 |
+
req.on('timeout', () => {
|
| 55 |
+
req.destroy();
|
| 56 |
+
reject(new Error('Request timeout'));
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
if (body) {
|
| 60 |
+
req.write(JSON.stringify(body));
|
| 61 |
+
}
|
| 62 |
+
req.end();
|
| 63 |
+
});
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/**
|
| 67 |
+
* 获取服务器内存使用情况(通过 /v1/memory 端点)
|
| 68 |
+
*/
|
| 69 |
+
async function getServerMemory() {
|
| 70 |
+
try {
|
| 71 |
+
const response = await sendRequest('/v1/memory');
|
| 72 |
+
if (response.status === 200) {
|
| 73 |
+
const data = JSON.parse(response.data);
|
| 74 |
+
return data;
|
| 75 |
+
}
|
| 76 |
+
} catch (e) {
|
| 77 |
+
// 如果端点不存在,返回 null
|
| 78 |
+
}
|
| 79 |
+
return null;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* 模拟API请求
|
| 84 |
+
*/
|
| 85 |
+
async function simulateLoad() {
|
| 86 |
+
const requests = [
|
| 87 |
+
{ path: '/v1/models', method: 'GET' },
|
| 88 |
+
{ path: '/health', method: 'GET' },
|
| 89 |
+
{ path: '/v1/chat/completions', method: 'POST', body: {
|
| 90 |
+
model: 'test-model',
|
| 91 |
+
messages: [{ role: 'user', content: 'Hello, this is a test message for memory optimization.' }],
|
| 92 |
+
stream: false
|
| 93 |
+
}},
|
| 94 |
+
];
|
| 95 |
+
|
| 96 |
+
const randomRequest = requests[Math.floor(Math.random() * requests.length)];
|
| 97 |
+
try {
|
| 98 |
+
await sendRequest(randomRequest.path, randomRequest.method, randomRequest.body);
|
| 99 |
+
} catch (e) {
|
| 100 |
+
// 忽略请求错误,重点是测试内存
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/**
|
| 105 |
+
* 启动服务器进程
|
| 106 |
+
*/
|
| 107 |
+
function startServer() {
|
| 108 |
+
return new Promise((resolve, reject) => {
|
| 109 |
+
console.log('🚀 启动服务器...');
|
| 110 |
+
|
| 111 |
+
const serverPath = path.join(__dirname, '..', 'src', 'server', 'index.js');
|
| 112 |
+
serverProcess = spawn('node', ['--expose-gc', serverPath], {
|
| 113 |
+
cwd: path.join(__dirname, '..'),
|
| 114 |
+
env: { ...process.env, PORT: PORT.toString() },
|
| 115 |
+
stdio: ['pipe', 'pipe', 'pipe']
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
let started = false;
|
| 119 |
+
|
| 120 |
+
serverProcess.stdout.on('data', (data) => {
|
| 121 |
+
const output = data.toString();
|
| 122 |
+
if (!started && (output.includes('listening') || output.includes('Server started') || output.includes('服务器'))) {
|
| 123 |
+
started = true;
|
| 124 |
+
setTimeout(resolve, 1000); // 等待服务器完全就绪
|
| 125 |
+
}
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
serverProcess.stderr.on('data', (data) => {
|
| 129 |
+
console.error('Server stderr:', data.toString());
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
serverProcess.on('error', reject);
|
| 133 |
+
|
| 134 |
+
// 超时处理
|
| 135 |
+
setTimeout(() => {
|
| 136 |
+
if (!started) {
|
| 137 |
+
started = true;
|
| 138 |
+
resolve(); // 即使没有检测到启动消息,也继续测试
|
| 139 |
+
}
|
| 140 |
+
}, 5000);
|
| 141 |
+
});
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
/**
|
| 145 |
+
* 停止服务器进程
|
| 146 |
+
*/
|
| 147 |
+
function stopServer() {
|
| 148 |
+
if (serverProcess) {
|
| 149 |
+
console.log('\n🛑 停止服务器...');
|
| 150 |
+
serverProcess.kill('SIGTERM');
|
| 151 |
+
serverProcess = null;
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
/**
|
| 156 |
+
* 采集内存样本
|
| 157 |
+
*/
|
| 158 |
+
async function collectMemorySample() {
|
| 159 |
+
const memoryInfo = await getServerMemory();
|
| 160 |
+
const elapsed = Date.now() - testStartTime;
|
| 161 |
+
|
| 162 |
+
if (memoryInfo) {
|
| 163 |
+
memorySamples.push({
|
| 164 |
+
time: elapsed,
|
| 165 |
+
heapUsed: memoryInfo.heapUsed,
|
| 166 |
+
heapTotal: memoryInfo.heapTotal,
|
| 167 |
+
rss: memoryInfo.rss,
|
| 168 |
+
external: memoryInfo.external
|
| 169 |
+
});
|
| 170 |
+
|
| 171 |
+
console.log(`📊 [${(elapsed/1000).toFixed(1)}s] Heap: ${formatMemory(memoryInfo.heapUsed)} / ${formatMemory(memoryInfo.heapTotal)}, RSS: ${formatMemory(memoryInfo.rss)}`);
|
| 172 |
+
} else {
|
| 173 |
+
// 如果没有内存端点,使用进程内存估算
|
| 174 |
+
const usage = process.memoryUsage();
|
| 175 |
+
console.log(`📊 [${(elapsed/1000).toFixed(1)}s] 测试进程内存 - Heap: ${formatMemory(usage.heapUsed)}, RSS: ${formatMemory(usage.rss)}`);
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
/**
|
| 180 |
+
* 分析内存数据
|
| 181 |
+
*/
|
| 182 |
+
function analyzeResults() {
|
| 183 |
+
if (memorySamples.length === 0) {
|
| 184 |
+
console.log('\n⚠️ 没有采集到内存数据(服务器可能没有 /v1/memory 端点)');
|
| 185 |
+
console.log('请手动检查服���器日志中的内存使用情况。');
|
| 186 |
+
return;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
const heapValues = memorySamples.map(s => s.heapUsed);
|
| 190 |
+
const rssValues = memorySamples.map(s => s.rss);
|
| 191 |
+
|
| 192 |
+
const heapMin = Math.min(...heapValues);
|
| 193 |
+
const heapMax = Math.max(...heapValues);
|
| 194 |
+
const heapAvg = heapValues.reduce((a, b) => a + b, 0) / heapValues.length;
|
| 195 |
+
|
| 196 |
+
const rssMin = Math.min(...rssValues);
|
| 197 |
+
const rssMax = Math.max(...rssValues);
|
| 198 |
+
const rssAvg = rssValues.reduce((a, b) => a + b, 0) / rssValues.length;
|
| 199 |
+
|
| 200 |
+
console.log('\n📈 内存统计分析');
|
| 201 |
+
console.log('═'.repeat(50));
|
| 202 |
+
console.log(`采样数量: ${memorySamples.length}`);
|
| 203 |
+
console.log(`测试时长: ${((memorySamples[memorySamples.length-1]?.time || 0) / 1000).toFixed(1)} 秒`);
|
| 204 |
+
console.log('');
|
| 205 |
+
console.log('Heap 使用:');
|
| 206 |
+
console.log(` 最小: ${formatMemory(heapMin)}`);
|
| 207 |
+
console.log(` 最大: ${formatMemory(heapMax)}`);
|
| 208 |
+
console.log(` 平均: ${formatMemory(heapAvg)}`);
|
| 209 |
+
console.log('');
|
| 210 |
+
console.log('RSS (常驻内存):');
|
| 211 |
+
console.log(` 最小: ${formatMemory(rssMin)}`);
|
| 212 |
+
console.log(` 最大: ${formatMemory(rssMax)}`);
|
| 213 |
+
console.log(` 平均: ${formatMemory(rssAvg)}`);
|
| 214 |
+
console.log('');
|
| 215 |
+
|
| 216 |
+
// 评估是否达到目标
|
| 217 |
+
const TARGET_HEAP = 20 * 1024 * 1024; // 20MB
|
| 218 |
+
const TARGET_RSS = 50 * 1024 * 1024; // 50MB (RSS 通常比 heap 大)
|
| 219 |
+
|
| 220 |
+
if (heapAvg <= TARGET_HEAP) {
|
| 221 |
+
console.log('✅ 堆内存使用达标!平均使用低于 20MB 目标。');
|
| 222 |
+
} else {
|
| 223 |
+
console.log(`⚠️ 堆内存使用未达标。平均 ${formatMemory(heapAvg)},目标 20MB。`);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
if (heapMax - heapMin < 10 * 1024 * 1024) {
|
| 227 |
+
console.log('✅ 内存波动稳定!波动范围小于 10MB。');
|
| 228 |
+
} else {
|
| 229 |
+
console.log(`⚠️ 内存波动较大。范围: ${formatMemory(heapMax - heapMin)}`);
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
/**
|
| 234 |
+
* 主测试流程
|
| 235 |
+
*/
|
| 236 |
+
async function runTest() {
|
| 237 |
+
console.log('🧪 反重力服务内存优化测试');
|
| 238 |
+
console.log('═'.repeat(50));
|
| 239 |
+
console.log(`目标: 堆内存保持在 ~20MB`);
|
| 240 |
+
console.log(`测试时长: ${TEST_DURATION_MS / 1000} 秒`);
|
| 241 |
+
console.log(`采样间隔: ${SAMPLE_INTERVAL_MS / 1000} 秒`);
|
| 242 |
+
console.log('═'.repeat(50));
|
| 243 |
+
console.log('');
|
| 244 |
+
|
| 245 |
+
try {
|
| 246 |
+
await startServer();
|
| 247 |
+
console.log('✅ 服务器已启动\n');
|
| 248 |
+
|
| 249 |
+
testStartTime = Date.now();
|
| 250 |
+
|
| 251 |
+
// 设置采样定时器
|
| 252 |
+
const sampleInterval = setInterval(collectMemorySample, SAMPLE_INTERVAL_MS);
|
| 253 |
+
|
| 254 |
+
// 设置负载模拟定时器
|
| 255 |
+
const loadInterval = setInterval(simulateLoad, REQUEST_INTERVAL_MS);
|
| 256 |
+
|
| 257 |
+
// 等待测试完成
|
| 258 |
+
await new Promise(resolve => setTimeout(resolve, TEST_DURATION_MS));
|
| 259 |
+
|
| 260 |
+
// 清理定时器
|
| 261 |
+
clearInterval(sampleInterval);
|
| 262 |
+
clearInterval(loadInterval);
|
| 263 |
+
|
| 264 |
+
// 最后采集一次
|
| 265 |
+
await collectMemorySample();
|
| 266 |
+
|
| 267 |
+
// 分析结果
|
| 268 |
+
analyzeResults();
|
| 269 |
+
|
| 270 |
+
} catch (error) {
|
| 271 |
+
console.error('❌ 测试失败:', error.message);
|
| 272 |
+
} finally {
|
| 273 |
+
stopServer();
|
| 274 |
+
process.exit(0);
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
// 处理进程退出
|
| 279 |
+
process.on('SIGINT', () => {
|
| 280 |
+
console.log('\n收到中断信号...');
|
| 281 |
+
stopServer();
|
| 282 |
+
process.exit(0);
|
| 283 |
+
});
|
| 284 |
+
|
| 285 |
+
process.on('SIGTERM', () => {
|
| 286 |
+
stopServer();
|
| 287 |
+
process.exit(0);
|
| 288 |
+
});
|
| 289 |
+
|
| 290 |
+
// 运行测试
|
| 291 |
+
runTest();
|