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 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:7897
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
- 支持使用 大/小香蕉 模型生成图片,生成的图片会以 Markdown 格式返回:
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": "gemimi-3.0-pro-image",
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 参数,默认 0.85 |
133
  | `top_k` | number | ❌ | Top K 参数,默认 50 |
134
- | `max_tokens` | number | ❌ | 最大 token 数,默认 8096 |
 
 
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. 所有请求必须携带有效的 API Key
241
- 2. 图片输入需要使用 Base64 编码
242
- 3. 流式响应使用 Server-Sent Events (SSE) 格式
243
- 4. 工具调用需要模型支持 Function Calling
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": "![image](http://localhost:8045/images/abc123.png)"
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:7897
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": 0.85, // 默认 top_p
322
  "topK": 50, // 默认 top_k
323
- "maxTokens": 8096 // 默认最大 token 数
 
 
 
 
324
  },
325
  "other": {
326
- "timeout": 180000, // 请求超时时间(毫秒)
327
- "skipProjectIdFetch": true // 跳过 ProjectId 获取,直接随机生成
 
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:7897) | ❌ |
 
343
  | `SYSTEM_INSTRUCTION` | 系统提示词 | ❌ |
344
  | `IMAGE_BASE_URL` | 图片服务基础 URL | ❌ |
345
 
@@ -363,41 +413,56 @@ npm run login
363
  ```
364
  .
365
  ├── data/
366
- └── accounts.json # Token 存储(自动生成)
 
367
  ├── public/
368
  │ ├── index.html # Web 管理界面
369
  │ ├── app.js # 前端逻辑
370
- └── style.css # 界面样式
 
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
- │ │ └── token_manager.js # Token 管理
 
380
  │ ├── routes/
381
- │ │ └── admin.js # 管理接口路由
 
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
- │ │ └── config.js # 配置加载
 
 
 
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": 0.85,
17
  "topK": 50,
18
- "maxTokens": 8096
 
 
 
 
19
  },
20
  "other": {
21
- "timeout": 180000,
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('登录成功,欢迎回来!', 'success');
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: 16px;">
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="width: 44px; padding: 0; font-size: 18px;" title="复制授权链接">📋</button>
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.85rem; color: var(--text-light); margin-bottom: 16px;">💡 提示:过期时间默认3599秒(约1小时)</p>
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('授权链接已复制到剪贴板', 'success');
187
  }).catch(() => {
188
- showToast('复制失败,请手动复制', 'error');
189
  });
190
  }
191
 
@@ -206,7 +282,7 @@ async function processOAuthCallbackModal() {
206
 
207
  if (!code) {
208
  hideLoading();
209
- showToast('URL中未找到授权码,请检查URL是否完整', 'error');
210
  return;
211
  }
212
 
@@ -235,14 +311,14 @@ async function processOAuthCallbackModal() {
235
  hideLoading();
236
  if (addResult.success) {
237
  modal.remove();
238
- showToast('Token添加成功!', 'success');
239
  loadTokens();
240
  } else {
241
- showToast('Token添加失败: ' + addResult.message, 'error');
242
  }
243
  } else {
244
  hideLoading();
245
- showToast('Token交换失败: ' + result.message, 'error');
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添加成功!', 'success');
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">点击上方按钮添加您的第一个Token</div>
353
  </div>
354
  `;
355
  return;
356
  }
357
 
358
- tokenList.innerHTML = tokens.map(token => `
359
- <div class="token-card">
 
 
 
 
 
 
360
  <div class="token-header">
361
  <span class="status ${token.enable ? 'enabled' : 'disabled'}">
362
  ${token.enable ? '✅ 启用' : '❌ 禁用'}
363
  </span>
364
- <span class="token-id">#${token.refresh_token.substring(0, 8)}</span>
 
 
 
365
  </div>
366
  <div class="token-info">
367
  <div class="info-row">
368
- <span class="info-label">🎫 Access</span>
369
- <span class="info-value">${token.access_token_suffix}</span>
370
  </div>
371
- <div class="info-row">
372
- <span class="info-label">📦 Project</span>
373
- <span class="info-value">${token.projectId || 'N/A'}</span>
 
374
  </div>
375
- <div class="info-row">
376
- <span class="info-label">📧 邮箱</span>
377
- <span class="info-value">${token.email || 'N/A'}</span>
 
378
  </div>
379
- <div class="info-row">
380
- <span class="info-label">⏰ 过期</span>
381
- <span class="info-value">${new Date(token.timestamp + token.expires_in * 1000).toLocaleString('zh-CN', {month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'})}</span>
382
  </div>
383
  </div>
 
 
 
 
 
 
 
 
384
  <div class="token-actions">
385
- <button class="btn btn-info" onclick="showQuotaModal('${token.refresh_token}')">📊 查看额度</button>
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
- `).join('');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}Token...`);
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(`Token已${enable ? '启用' : '禁用'}`, 'success');
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('删除后无法恢复,确定要删除这个Token吗?', '⚠️ 删除确认');
427
  if (!confirmed) return;
428
 
429
- showLoading('正在删除Token...');
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('Token已删除', 'success');
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" style="max-width: 600px;">
455
- <div class="modal-title">📊 模型额度信息</div>
456
- <div id="quotaContent" style="max-height: 60vh; overflow-y: auto;">
 
 
 
 
 
 
457
  <div class="quota-loading">加载中...</div>
458
  </div>
459
  <div class="modal-actions">
460
- <button class="btn btn-info" onclick="refreshQuotaData('${refreshToken}')">🔄 立即刷新</button>
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.querySelector('.modal-content .btn-info');
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
- const quotaData = data.data;
493
- const models = quotaData.models;
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(refreshToken) {
587
- await loadQuotaData(refreshToken, true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 === 'API_URL') jsonConfig.api.url = value;
654
- else if (key === 'API_MODELS_URL') jsonConfig.api.modelsUrl = value;
655
- else if (key === 'API_NO_STREAM_URL') jsonConfig.api.noStreamUrl = value;
656
- else if (key === 'API_HOST') jsonConfig.api.host = value;
657
- else if (key === 'API_USER_AGENT') jsonConfig.api.userAgent = value;
658
- else if (key === 'DEFAULT_TEMPERATURE') jsonConfig.defaults.temperature = parseFloat(value);
659
- else if (key === 'DEFAULT_TOP_P') jsonConfig.defaults.topP = parseFloat(value);
660
- else if (key === 'DEFAULT_TOP_K') jsonConfig.defaults.topK = parseInt(value);
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(data.message, 'success');
 
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 管理系统</title>
 
 
 
 
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>🔐 Token 管理系统</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
- <div class="tabs">
31
- <button class="tab active" onclick="switchTab('tokens')">🎯 Token管理</button>
32
- <button class="tab" onclick="switchTab('settings')">⚙️ 设置</button>
 
 
 
 
33
  </div>
34
- <button onclick="logout()">🚪 退出</button>
35
  </div>
36
  <div class="content">
37
  <!-- Token管理页面 -->
38
  <div id="tokensPage">
39
- <!-- 统计卡片 -->
40
- <div class="stats">
41
- <div class="stat-card">
42
- <div class="stat-value" id="totalTokens">0</div>
43
- <div class="stat-label">总Token数</div>
44
- </div>
45
- <div class="stat-card" style="background: linear-gradient(135deg, var(--success), #059669);">
46
- <div class="stat-value" id="enabledTokens">0</div>
47
- <div class="stat-label">已启用</div>
48
- </div>
49
- <div class="stat-card" style="background: linear-gradient(135deg, var(--danger), #dc2626);">
50
- <div class="stat-value" id="disabledTokens">0</div>
51
- <div class="stat-label">已禁用</div>
 
 
 
 
 
 
 
 
 
52
  </div>
53
- </div>
54
 
55
- <!-- 添加Token按钮组 -->
56
- <div class="add-form">
57
- <h3>➕ 添加新Token<span class="help-tip" title="支持OAuth登录或手动填入Token">?</span></h3>
58
- <div class="btn-group">
59
- <button type="button" onclick="showOAuthModal()" class="btn btn-success">🔐 OAuth登录</button>
60
- <button type="button" onclick="showManualModal()" class="btn btn-secondary">✏️ 手动填入</button>
61
- <button type="button" onclick="loadTokens()" class="btn btn-warning">🔄 刷新</button>
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-section">
80
- <h4>服务器配置</h4>
81
- <div class="form-group"><label>端口 PORT</label><input type="number" name="PORT"></div>
82
- <div class="form-group"><label>监听地址 HOST</label><input type="text" name="HOST"></div>
83
- </div>
84
- <div class="config-section">
85
- <h4>默认参数</h4>
86
- <div class="form-group"><label>温度 TEMPERATURE</label><input type="number" step="0.1" name="DEFAULT_TEMPERATURE"></div>
87
- <div class="form-group"><label>Top P</label><input type="number" step="0.01" name="DEFAULT_TOP_P"></div>
88
- <div class="form-group"><label>Top K</label><input type="number" name="DEFAULT_TOP_K"></div>
89
- <div class="form-group"><label>最大Token数</label><input type="number" name="DEFAULT_MAX_TOKENS"></div>
90
- </div>
91
- <div class="config-section">
92
- <h4>安全配置</h4>
93
- <div class="form-group"><label>API密钥</label><input type="text" name="API_KEY"></div>
94
- <div class="form-group"><label>最大请求大小</label><input type="text" name="MAX_REQUEST_SIZE"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  </div>
96
- <div class="config-section">
97
- <h4>其他配置</h4>
98
- <div class="form-group"><label>超时时间(ms)</label><input type="number" name="TIMEOUT"></div>
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&#10;额度耗尽:用完额度才切换&#10;自定义次数:指定次数后切换">?</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: #6366f1;
3
- --primary-dark: #4f46e5;
4
  --success: #10b981;
5
  --danger: #ef4444;
6
  --warning: #f59e0b;
7
- --info: #3b82f6;
8
  --bg: #f8fafc;
9
  --card: #ffffff;
10
  --text: #1e293b;
11
  --text-light: #64748b;
12
  --border: #e2e8f0;
13
- --shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06);
 
14
  }
15
 
16
  @media (prefers-color-scheme: dark) {
@@ -23,164 +24,1215 @@
23
  }
24
  }
25
 
26
- .toast { position: fixed; top: 20px; right: 20px; background: var(--card); border-radius: 12px; padding: 16px 20px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); z-index: 9999; display: flex; align-items: center; gap: 12px; min-width: 280px; max-width: 400px; animation: slideIn 0.3s ease; border-left: 4px solid var(--primary); }
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
- @keyframes slideIn { from { transform: translateX(400px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
37
- @keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(400px); opacity: 0; } }
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
- .modal { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 9998; display: flex; align-items: center; justify-content: center; padding: 20px; animation: fadeIn 0.2s; }
40
- .modal-content { background: var(--card); border-radius: 16px; padding: 24px; max-width: 400px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.3); animation: scaleIn 0.2s; }
41
- .modal-title { font-size: 20px; font-weight: 700; margin-bottom: 12px; color: var(--text); }
42
- .modal-message { color: var(--text-light); margin-bottom: 24px; line-height: 1.6; }
43
- .modal-actions { display: flex; gap: 12px; }
44
- .modal-actions button { flex: 1; }
45
- .form-modal .modal-content { max-width: 500px; }
46
- .form-modal input { margin-bottom: 12px; }
47
- .form-modal .form-row { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; }
48
- .form-modal .oauth-steps { background: rgba(59,130,246,0.1); padding: 16px; border-radius: 8px; margin-bottom: 16px; border: 2px solid var(--info); }
49
- .form-modal .oauth-steps p { margin-bottom: 8px; font-size: 14px; }
50
- .form-modal .oauth-steps p:last-child { margin-bottom: 0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
- @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
53
- @keyframes scaleIn { from { transform: scale(0.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }
 
 
54
 
55
- .loading-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 9999; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 16px; }
56
- .spinner { width: 48px; height: 48px; border: 4px solid rgba(255,255,255,0.2); border-top-color: white; border-radius: 50%; animation: spin 0.8s linear infinite; }
57
- .loading-text { color: white; font-size: 16px; font-weight: 500; }
58
- .help-tip { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 50%; background: var(--info); color: white; font-size: 12px; font-weight: 600; cursor: help; margin-left: 6px; }
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
- * { margin: 0; padding: 0; box-sizing: border-box; }
65
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; transition: background 0.3s; min-height: 100vh; }
66
- .container { max-width: 1400px; margin: 0 auto; padding: 1rem; height: 100vh; display: flex; align-items: center; justify-content: center; }
67
-
68
- .login-form { background: var(--card); border-radius: 1.5rem; box-shadow: var(--shadow); max-width: 480px; width: 100%; padding: 3rem; }
69
- .login-form h2 { text-align: center; margin-bottom: 2.5rem; background: linear-gradient(135deg, var(--primary), var(--primary-dark)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; font-size: 2rem; font-weight: 700; }
70
- .form-group { margin-bottom: 1.5rem; }
71
- label { display: block; margin-bottom: 0.75rem; font-weight: 600; color: var(--text); font-size: 0.95rem; }
72
- input { width: 100%; min-height: 48px; padding: 0.875rem 1rem; border: 2px solid var(--border); border-radius: 0.75rem; font-size: 0.95rem; line-height: 1.5; background: var(--card); color: var(--text); transition: all 0.2s; text-indent: 0.25rem; -webkit-appearance: none; }
73
- input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 4px rgba(99,102,241,0.1); }
74
- input::placeholder { color: var(--text-light); opacity: 0.6; }
75
-
76
- button { width: 100%; min-height: 52px; padding: 1rem; background: linear-gradient(135deg, var(--primary), var(--primary-dark)); color: white; border: none; border-radius: 0.75rem; cursor: pointer; font-size: 1.05rem; font-weight: 600; letter-spacing: 0.5px; transition: all 0.2s; box-shadow: 0 4px 12px rgba(99,102,241,0.2); position: relative; }
77
- button:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(99,102,241,0.35); }
78
- button:active:not(:disabled) { transform: translateY(0); box-shadow: 0 2px 8px rgba(99,102,241,0.25); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  button:disabled { opacity: 0.7; cursor: not-allowed; }
80
- button.loading::after { content: ''; position: absolute; width: 16px; height: 16px; margin-left: 10px; border: 2px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 0.6s linear infinite; }
 
 
 
 
 
 
 
 
 
 
81
 
82
  @keyframes spin { to { transform: rotate(360deg); } }
83
 
84
- .main-content { background: var(--card); border-radius: 1.5rem; box-shadow: var(--shadow); width: 100%; max-width: 1400px; height: calc(100vh - 2rem); display: flex; flex-direction: column; }
85
- .header { display: flex; justify-content: space-between; align-items: center; padding: 1.5rem 2rem; border-bottom: 2px solid var(--border); flex-shrink: 0; }
86
- .header h1 { font-size: 1.75rem; background: linear-gradient(135deg, var(--primary), var(--primary-dark)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; font-weight: 700; }
87
- .header button { width: auto; padding: 0.625rem 1.5rem; font-size: 0.9rem; }
88
- .tabs { display: flex; gap: 0.5rem; }
89
- .tab { background: transparent; color: var(--text-light); border: none; padding: 0.625rem 1.25rem; border-radius: 0.5rem; cursor: pointer; font-weight: 600; transition: all 0.2s; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  .tab:hover { background: var(--bg); color: var(--text); }
91
- .tab.active { background: linear-gradient(135deg, var(--primary), var(--primary-dark)); color: white; box-shadow: 0 2px 8px rgba(99,102,241,0.3); }
92
- .content { padding: 2rem; flex: 1; overflow-y: auto; }
93
- .content::-webkit-scrollbar { width: 8px; }
94
- .content::-webkit-scrollbar-track { background: var(--bg); border-radius: 4px; }
95
- .content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
96
- .content::-webkit-scrollbar-thumb:hover { background: var(--text-light); }
97
-
98
- .add-form { background: var(--bg); padding: 1.5rem; border-radius: 1rem; margin-bottom: 2rem; border: 2px solid var(--border); }
99
- .add-form h3 { margin-bottom: 1.25rem; font-size: 1.25rem; font-weight: 600; }
100
- .btn-group { display: flex; gap: 0.75rem; margin-bottom: 1.25rem; flex-wrap: wrap; }
101
- .btn-group .btn { width: auto; flex: 1; min-width: 140px; }
102
- .oauth-box { background: rgba(251,191,36,0.1); padding: 1.5rem; border-radius: 1rem; margin-bottom: 1.25rem; border: 2px solid var(--warning); }
103
- .oauth-box p { margin-bottom: 1rem; color: var(--text); font-size: 0.95rem; }
104
- .form-row { display: flex; gap: 0.75rem; margin-bottom: 1rem; flex-wrap: wrap; }
105
- .form-row input { flex: 1; min-width: 200px; }
106
-
107
- .token-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 1.25rem; }
108
- .token-card { background: var(--bg); border: 2px solid var(--border); border-radius: 1rem; padding: 1.5rem; transition: all 0.3s; position: relative; overflow: hidden; }
109
- .token-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 4px; background: linear-gradient(90deg, var(--primary), var(--primary-dark)); opacity: 0; transition: opacity 0.3s; }
110
- .token-card:hover { border-color: var(--primary); box-shadow: var(--shadow); transform: translateY(-2px); }
111
- .token-card:hover::before { opacity: 1; }
112
- .token-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
113
- .token-id { font-size: 0.75rem; color: var(--text-light); font-family: monospace; }
114
- .token-info { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.25rem; }
115
- .info-row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; }
116
- .info-label { color: var(--text-light); min-width: 80px; font-weight: 500; }
117
- .info-value { color: var(--text); font-family: monospace; font-size: 0.8rem; }
118
- .token-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
119
-
120
- .btn { padding: 0.625rem 1rem; border: none; border-radius: 0.5rem; cursor: pointer; font-size: 0.875rem; font-weight: 600; transition: all 0.2s; white-space: nowrap; text-align: center; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- .hidden { display: none; }
128
- .status { padding: 0.375rem 0.875rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; display: inline-flex; align-items: center; gap: 0.25rem; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- .stats { display: flex; gap: 1.5rem; margin-bottom: 2rem; flex-wrap: wrap; }
133
- .stat-card { flex: 1; min-width: 150px; background: linear-gradient(135deg, var(--primary), var(--primary-dark)); padding: 1.25rem; border-radius: 1rem; color: white; }
134
- .stat-value { font-size: 2rem; font-weight: 700; margin-bottom: 0.25rem; }
135
- .stat-label { font-size: 0.875rem; opacity: 0.9; }
136
-
137
- .config-form { max-width: 800px; }
138
- .config-form h3 { margin-bottom: 1.5rem; font-size: 1.5rem; }
139
- .config-section { background: var(--bg); padding: 1.5rem; border-radius: 1rem; margin-bottom: 1.5rem; border: 2px solid var(--border); }
140
- .config-section h4 { margin-bottom: 1rem; color: var(--primary); font-size: 1.1rem; }
141
- .config-form .form-group { margin-bottom: 1rem; }
142
- .config-form label { display: block; margin-bottom: 0.5rem; font-weight: 600; font-size: 0.9rem; }
143
- .config-form input, .config-form select, .config-form textarea { width: 100%; padding: 0.75rem; border: 2px solid var(--border); border-radius: 0.5rem; background: var(--card); color: var(--text); font-size: 0.9rem; }
144
- .config-form textarea { resize: vertical; font-family: inherit; }
145
- .config-form button[type="submit"] { margin-top: 1rem; }
 
146
 
147
- @media (max-width: 768px) {
148
- .container { padding: 0; height: 100vh; }
149
- .login-form { margin: 1rem; padding: 2rem; border-radius: 1rem; }
150
- .login-form h2 { font-size: 1.5rem; }
151
- .main-content { height: 100vh; border-radius: 0; }
152
- .header { padding: 1rem; flex-wrap: wrap; gap: 1rem; }
153
- .header h1 { font-size: 1.25rem; }
154
- .tabs { width: 100%; }
155
- .tab { flex: 1; }
156
- .content { padding: 1rem; }
157
- .token-grid { grid-template-columns: 1fr; }
158
- .form-row { flex-direction: column; }
159
- .form-row input { min-width: 100%; }
160
- .btn-group { flex-direction: column; }
161
- .btn-group .btn { width: 100%; }
162
- .stats { flex-direction: column; }
163
- .toast { right: 10px; left: 10px; min-width: auto; max-width: none; }
164
- .modal { padding: 10px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  }
 
166
 
167
- .btn-info { background: var(--info); color: white; }
168
- .token-actions { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
- .quota-section { margin-top: 1rem; padding-top: 1rem; border-top: 2px solid var(--border); }
171
- .quota-loading, .quota-error, .quota-empty { text-align: center; padding: 1rem; color: var(--text-light); font-size: 0.875rem; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  .quota-error { color: var(--danger); }
173
- .quota-header { font-weight: 600; margin-bottom: 0.75rem; font-size: 0.9rem; color: var(--text); }
174
- .quota-item { margin-bottom: 1rem; }
175
- .quota-item:last-child { margin-bottom: 0; }
176
- .quota-model-name { font-size: 0.8rem; color: var(--text); margin-bottom: 0.5rem; font-family: monospace; font-weight: 500; }
177
- .quota-bar-container { position: relative; height: 24px; background: var(--border); border-radius: 12px; overflow: hidden; margin-bottom: 0.375rem; }
178
- .quota-bar { height: 100%; border-radius: 12px; transition: width 0.3s ease; position: relative; }
179
- .quota-percentage { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font-size: 0.75rem; font-weight: 600; color: white; text-shadow: 0 1px 2px rgba(0,0,0,0.3); z-index: 1; }
180
- .quota-reset { font-size: 0.75rem; color: var(--text-light); }
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
- .token-actions { grid-template-columns: 1fr; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- this.buffer += data.toString();
 
 
 
 
 
 
 
 
68
 
69
  // 使用 setImmediate 异步处理,避免阻塞 stdout 读取
70
  setImmediate(() => {
71
- const lines = this.buffer.split('\n');
72
- this.buffer = lines.pop();
73
-
74
- for (const line of lines) {
75
- if (!line.trim()) continue;
 
 
 
 
 
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
- console.error('Failed to parse response:', e, 'Line:', line);
96
  }
97
  }
 
 
 
 
98
  });
99
  });
100
 
@@ -168,9 +189,17 @@ class antigravityRequester {
168
  if (canWrite) {
169
  resolve();
170
  } else {
171
- // 等待 drain 事件
172
- this.proc.stdin.once('drain', resolve);
173
- this.proc.stdin.once('error', reject);
 
 
 
 
 
 
 
 
174
  }
175
  });
176
  }).catch(err => {
@@ -180,8 +209,49 @@ class antigravityRequester {
180
 
181
  close() {
182
  if (this.proc) {
183
- this.proc.stdin.end();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return {
92
- id: functionCall.id || generateToolCallId(),
93
- type: 'function',
94
- function: {
95
- name: functionCall.name,
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
- if (!state.thinkingStarted) {
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 = { thinkingStarted: false, toolCalls: [] };
167
- let buffer = ''; // 缓冲区:处理跨 chunk 的不完整行
168
 
169
  const processChunk = (chunk) => {
170
- buffer += chunk;
171
- const lines = buffer.split('\n');
172
- buffer = lines.pop(); // 保留最后一行(可能不完整)
173
- lines.forEach(line => parseAndEmitStreamChunk(line, state, callback));
174
  };
175
 
176
- if (useAxios) {
177
- try {
178
  const axiosConfig = { ...buildAxiosConfig(config.api.url, headers, requestBody), responseType: 'stream' };
179
  const response = await axios(axiosConfig);
180
 
181
- response.data.on('data', chunk => processChunk(chunk.toString()));
 
 
 
 
182
  await new Promise((resolve, reject) => {
183
- response.data.on('end', resolve);
 
 
 
184
  response.data.on('error', reject);
185
  });
186
- } catch (error) {
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) => statusCode !== 200 ? errorBody += chunk : processChunk(chunk))
199
- .onEnd(() => statusCode !== 200 ? reject({ status: statusCode, message: errorBody }) : resolve())
 
 
 
 
 
 
 
 
 
 
 
 
 
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) throw new Error('没有可用的token,请运行 npm run login 获取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 modelList = Object.keys(data.models).map(id => ({
 
228
  id,
229
  object: 'model',
230
- created: Math.floor(Date.now() / 1000),
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
- return {
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  object: 'list',
242
  data: modelList
243
  };
 
 
 
 
 
 
 
 
244
  } catch (error) {
245
- await handleApiError(error, token);
 
 
 
 
 
 
 
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 thinkingContent = '';
305
  const toolCalls = [];
306
  const imageUrls = [];
307
 
308
  for (const part of parts) {
309
  if (part.thought === true) {
310
- thinkingContent += part.text || '';
 
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 => `![image](${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 => `![image](${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
- setInterval(() => this.cleanup(), this.CLEANUP_INTERVAL);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 token = this.tokens[this.currentIndex];
 
 
 
 
 
 
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
- this.currentIndex = (this.currentIndex + 1) % this.tokens.length;
 
 
 
 
 
 
 
 
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 || 8096
 
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 || 180000,
61
- proxy: process.env.PROXY || null,
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
- if (config.skipProjectIdFetch) {
133
- account.projectId = generateProjectId();
134
- logger.info('使用随机生成的projectId: ' + account.projectId);
135
- } else {
136
- try {
137
- const projectId = await tokenManager.fetchProjectId(account);
138
- if (projectId === undefined) {
139
- return res.status(400).json({ success: false, message: '该账号无资格使用(无法获取projectId)' });
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
- return res.status(401).json({ success: false, message: 'Token已过期且刷新失败' });
 
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
- const createStreamChunk = (id, created, model, delta, finish_reason = null) => ({
33
- id,
34
- object: 'chat.completion.chunk',
35
- created,
36
- model,
37
- choices: [{ index: 0, delta, finish_reason }]
38
  });
39
 
40
- // 工具函数:写入流式数据
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  const writeStreamData = (res, data) => {
42
- res.write(`data: ${JSON.stringify(data)}\n\n`);
 
 
 
 
 
43
  };
44
 
45
  // 工具函数:结束流式响应
46
  const endStream = (res) => {
47
- res.write('data: [DONE]\n\n');
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
- if (isImageModel) {
140
- //console.log(JSON.stringify(requestBody,null,2));
141
- const { content, usage } = await generateAssistantResponseNoStream(requestBody, token);
142
- writeStreamData(res, createStreamChunk(id, created, model, { content }));
143
- writeStreamData(res, { ...createStreamChunk(id, created, model, {}, 'stop'), usage });
144
- endStream(res);
145
- } else {
146
- let hasToolCall = false;
147
- let usageData = null;
148
- await generateAssistantResponse(requestBody, token, (data) => {
149
- if (data.type === 'usage') {
150
- usageData = data.usage;
151
- } else {
152
- const delta = data.type === 'tool_calls'
153
- ? { tool_calls: data.tool_calls }
154
- : { content: data.content };
155
- if (data.type === 'tool_calls') hasToolCall = true;
156
- writeStreamData(res, createStreamChunk(id, created, model, delta));
157
- }
158
- });
159
- writeStreamData(res, { ...createStreamChunk(id, created, model, {}, hasToolCall ? 'tool_calls' : 'stop'), usage: usageData });
 
 
 
 
 
 
 
 
 
 
 
160
  endStream(res);
161
  }
162
  } else {
163
- const { content, toolCalls, usage } = await generateAssistantResponseNoStream(requestBody, token);
164
- const message = { role: 'assistant', content };
 
 
 
 
 
 
 
165
  if (toolCalls.length > 0) message.tool_calls = toolCalls;
166
 
167
- res.json({
 
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
- setTimeout(() => process.exit(0), 5000);
 
 
 
 
 
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: 8096 },
13
- { target: 'timeout', source: 'other.timeout', default: 180000 },
 
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" || message.role === "system") {
 
 
 
 
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: enableThinking ? 1024 : 0
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
- return{
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  project: token.projectId,
213
  requestId: generateRequestId(),
214
  request: {
215
- contents: openaiMessageToAntigravity(openaiMessages),
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();