Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
f120063
1
Parent(s): 8efba24
Sync from GitHub Viciy2023/Qwen2API-A@ae093476e9bc5b0a599620b5925df3a20057038e
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +90 -0
- .env.hf.example +117 -0
- .gitattributes +0 -35
- .gitignore +12 -0
- README.md +851 -7
- docker/Dockerfile +45 -0
- docker/docker-compose-redis.yml +51 -0
- docker/docker-compose.yml +41 -0
- docker/entrypoint.sh +19 -0
- ecosystem.config.js +53 -0
- package.json +40 -0
- public/.gitignore +23 -0
- public/index.html +13 -0
- public/package.json +23 -0
- public/postcss.config.js +6 -0
- public/src/App.vue +33 -0
- public/src/main.js +8 -0
- public/src/routes/index.js +74 -0
- public/src/style.css +3 -0
- public/src/views/auth.vue +66 -0
- public/src/views/dashboard.vue +1025 -0
- public/src/views/settings.vue +299 -0
- public/tailwind.config.js +11 -0
- public/vite.config.js +15 -0
- scripts/fingerprint-injector.js +20 -0
- scripts/hf-bucket-sync.py +151 -0
- src/config/index.js +52 -0
- src/controllers/chat.image.video.js +357 -0
- src/controllers/chat.js +449 -0
- src/controllers/cli.chat.js +213 -0
- src/controllers/models.js +75 -0
- src/middlewares/authorization.js +61 -0
- src/middlewares/chat-middleware.js +79 -0
- src/models/models-map.js +52 -0
- src/routes/accounts.js +259 -0
- src/routes/chat.js +34 -0
- src/routes/cli.chat.js +39 -0
- src/routes/models.js +31 -0
- src/routes/settings.js +161 -0
- src/routes/verify.js +24 -0
- src/server.js +81 -0
- src/start.js +113 -0
- src/utils/account-rotator.js +247 -0
- src/utils/account.js +670 -0
- src/utils/chat-helpers.js +376 -0
- src/utils/cli.manager.js +279 -0
- src/utils/cookie-generator.js +376 -0
- src/utils/data-persistence.js +240 -0
- src/utils/fingerprint.js +0 -0
- src/utils/img-caches.js +88 -0
.env.example
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# 如果需要修改Docker暴露端口,请修改ports中的参数
|
| 3 |
+
# 示例(8080:3000) 则访问 http://localhost:8080
|
| 4 |
+
SERVICE_PORT=3000
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
# 监听地址(非必填)
|
| 8 |
+
LISTEN_ADDRESS=
|
| 9 |
+
|
| 10 |
+
# PM2 多进程配置
|
| 11 |
+
# PM2进程数量配置
|
| 12 |
+
# max: 使用所有CPU核心
|
| 13 |
+
# 数字: 指定进程数量,如 4
|
| 14 |
+
# 1: 单进程模式
|
| 15 |
+
PM2_INSTANCES=1
|
| 16 |
+
|
| 17 |
+
# PM2内存限制,超过此限制将自动重启进程
|
| 18 |
+
# 支持格式: 100M, 1G, 2G 等
|
| 19 |
+
PM2_MAX_MEMORY=1G
|
| 20 |
+
|
| 21 |
+
# API 密钥配置
|
| 22 |
+
# 支持单个或多个API_KEY,用逗号分隔
|
| 23 |
+
# 第一个API_KEY为管理员密钥,拥有全部权限(可访问前端管理页面、修改设置)
|
| 24 |
+
# 其他API_KEY为普通密钥,仅有调用API的权限,不能访问前端管理页面
|
| 25 |
+
#
|
| 26 |
+
# 单个密钥示例:
|
| 27 |
+
# API_KEY=sk-admin123
|
| 28 |
+
#
|
| 29 |
+
# 多个密钥示例:
|
| 30 |
+
# API_KEY=sk-admin123,sk-user456,sk-user789
|
| 31 |
+
# 其中:
|
| 32 |
+
# - sk-admin123: 管理员密钥(可访问前端管理页面,可修改所有设置)
|
| 33 |
+
# - sk-user456,sk-user789: 普通密钥(仅可调用API,不能访问前端页面)
|
| 34 |
+
API_KEY=sk-123456
|
| 35 |
+
|
| 36 |
+
# 是否输出思考过程
|
| 37 |
+
OUTPUT_THINK=true
|
| 38 |
+
|
| 39 |
+
# 搜索信息显示模式
|
| 40 |
+
SEARCH_INFO_MODE=table
|
| 41 |
+
|
| 42 |
+
# 简化模型映射
|
| 43 |
+
# true: 只返回基础模型,不包含thinking、search、image等变体
|
| 44 |
+
# false: 返回完整模型列表,包含所有变体
|
| 45 |
+
SIMPLE_MODEL_MAP=false
|
| 46 |
+
|
| 47 |
+
# Redis链接(如果使用redis模式,则必填,当redis使用tls时将redis://替换为rediss://)
|
| 48 |
+
REDIS_URL=
|
| 49 |
+
|
| 50 |
+
# 数据保存模式
|
| 51 |
+
# none 不保存数据,仅使用环境变量中的设置
|
| 52 |
+
# file 保存在本地文件中
|
| 53 |
+
# redis 保存到远程/本地redis中
|
| 54 |
+
DATA_SAVE_MODE=none
|
| 55 |
+
|
| 56 |
+
# 账号与密码用:分隔,账号与账号间用,分隔(如果使用redis和file模式,则不需要填写)
|
| 57 |
+
ACCOUNTS=
|
| 58 |
+
|
| 59 |
+
# 日志配置
|
| 60 |
+
# 日志级别 (DEBUG, INFO, WARN, ERROR)
|
| 61 |
+
LOG_LEVEL=INFO
|
| 62 |
+
|
| 63 |
+
# 是否启用文件日志
|
| 64 |
+
ENABLE_FILE_LOG=false
|
| 65 |
+
|
| 66 |
+
# 日志文件目录
|
| 67 |
+
LOG_DIR=./logs
|
| 68 |
+
|
| 69 |
+
# 最大日志文件大小 (MB)
|
| 70 |
+
MAX_LOG_FILE_SIZE=10
|
| 71 |
+
|
| 72 |
+
# 保留的日志文件数量
|
| 73 |
+
MAX_LOG_FILES=5
|
| 74 |
+
|
| 75 |
+
# ========== 代理与反代配置 ==========
|
| 76 |
+
|
| 77 |
+
# 自定义反代URL配置
|
| 78 |
+
# QWEN_CHAT_PROXY_URL: 替代 https://chat.qwen.ai 的反代地址
|
| 79 |
+
# 示例: QWEN_CHAT_PROXY_URL=https://your-proxy.com
|
| 80 |
+
QWEN_CHAT_PROXY_URL=
|
| 81 |
+
|
| 82 |
+
# QWEN_CLI_PROXY_URL: 替代 https://portal.qwen.ai 的反代地址
|
| 83 |
+
# 示例: QWEN_CLI_PROXY_URL=https://your-cli-proxy.com
|
| 84 |
+
QWEN_CLI_PROXY_URL=
|
| 85 |
+
|
| 86 |
+
# HTTP/HTTPS 代理配置
|
| 87 |
+
# 支持 HTTP, HTTPS, SOCKS5 代理
|
| 88 |
+
# 示例: PROXY_URL=http://127.0.0.1:7890
|
| 89 |
+
# 示例: PROXY_URL=socks5://127.0.0.1:1080
|
| 90 |
+
PROXY_URL=
|
.env.hf.example
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces 推荐运行配置
|
| 2 |
+
# 说明:这个文件是示例模板,不要把真实密钥直接写进仓库
|
| 3 |
+
#
|
| 4 |
+
# 使用方式:
|
| 5 |
+
# 1. GitHub Actions 中配置自动同步所需变量
|
| 6 |
+
# - Secret: HF_TOKEN
|
| 7 |
+
# - Variable: HF_SPACE_ID=DanielleNguyen/Qwen2API-A
|
| 8 |
+
# 2. Hugging Face Space 中按下面两类分别配置:
|
| 9 |
+
# - HF Secrets:敏感信息,例如 API_KEY、HF_TOKEN
|
| 10 |
+
# - HF Variables:普通运行配置,例如端口、目录、开关
|
| 11 |
+
# 3. 不建议在仓库中提交真实 .env 文件
|
| 12 |
+
|
| 13 |
+
# ==================================================
|
| 14 |
+
# HF Secrets 建议新建这些
|
| 15 |
+
# ==================================================
|
| 16 |
+
# API_KEY=sk-admin-yourkey,sk-user-yourkey
|
| 17 |
+
# HF_TOKEN=hf_xxx
|
| 18 |
+
# HF_BUCKET_TOKEN=
|
| 19 |
+
# ACCOUNTS=
|
| 20 |
+
# REDIS_URL=
|
| 21 |
+
# PROXY_URL=
|
| 22 |
+
# QWEN_CHAT_PROXY_URL=
|
| 23 |
+
# QWEN_CLI_PROXY_URL=
|
| 24 |
+
|
| 25 |
+
# ==================================================
|
| 26 |
+
# HF Variables 建议新建这些
|
| 27 |
+
# ==================================================
|
| 28 |
+
|
| 29 |
+
# ==============================
|
| 30 |
+
# 服务监听配置
|
| 31 |
+
# Hugging Face Docker Space 建议固定如下:
|
| 32 |
+
# - SERVICE_PORT=7860
|
| 33 |
+
# - LISTEN_ADDRESS=0.0.0.0
|
| 34 |
+
# ==============================
|
| 35 |
+
SERVICE_PORT=7860
|
| 36 |
+
LISTEN_ADDRESS=0.0.0.0
|
| 37 |
+
|
| 38 |
+
# ==============================
|
| 39 |
+
# 运行模式配置
|
| 40 |
+
# Hugging Face 免费空间当前建议单进程运行,更稳定
|
| 41 |
+
# PM2_MAX_MEMORY 是自动重启阈值,不是实际只给 4G 内存
|
| 42 |
+
# ==============================
|
| 43 |
+
PM2_INSTANCES=1
|
| 44 |
+
PM2_MAX_MEMORY=4G
|
| 45 |
+
NODE_ENV=production
|
| 46 |
+
|
| 47 |
+
# ==============================
|
| 48 |
+
# API 访问密钥
|
| 49 |
+
# 第一个密钥是管理员密钥,后面的密钥是普通调用密钥
|
| 50 |
+
# 这是敏感信息,建议放到 HF Secrets
|
| 51 |
+
# ==============================
|
| 52 |
+
API_KEY=sk-admin-demo,sk-user-demo
|
| 53 |
+
|
| 54 |
+
# ==============================
|
| 55 |
+
# 功能配置
|
| 56 |
+
# 这些属于普通配置,建议放到 HF Variables
|
| 57 |
+
# ==============================
|
| 58 |
+
OUTPUT_THINK=true
|
| 59 |
+
SEARCH_INFO_MODE=table
|
| 60 |
+
SIMPLE_MODEL_MAP=false
|
| 61 |
+
|
| 62 |
+
# ==============================
|
| 63 |
+
# 数据存储配置
|
| 64 |
+
# 当前推荐方案:使用 Hugging Face Bucket 做文件持久化
|
| 65 |
+
# - 项目内部使用 DATA_SAVE_MODE=file
|
| 66 |
+
# - 启动时从 Bucket 拉取数据到本地目录
|
| 67 |
+
# - 运行中定时同步本地目录回 Bucket
|
| 68 |
+
# ==============================
|
| 69 |
+
DATA_SAVE_MODE=file
|
| 70 |
+
|
| 71 |
+
# 下面两项通常属于敏感信息,建议放到 HF Secrets
|
| 72 |
+
REDIS_URL=
|
| 73 |
+
ACCOUNTS=
|
| 74 |
+
|
| 75 |
+
# 下面这些路径配置建议放到 HF Variables
|
| 76 |
+
DATA_DIR=/data/qwen2api/data
|
| 77 |
+
CACHE_DIR=/data/qwen2api/caches
|
| 78 |
+
LOG_DIR=/data/qwen2api/logs
|
| 79 |
+
|
| 80 |
+
# ==============================
|
| 81 |
+
# Hugging Face Bucket 持久化配置
|
| 82 |
+
# HF_BUCKET_REPO:你的 Bucket 名称,建议放到 HF Variables
|
| 83 |
+
# HF_TOKEN:Hugging Face 账号 Token,Space 通过它访问 Bucket,建议放到 HF Secrets
|
| 84 |
+
# HF_BUCKET_TOKEN:可选;如果单独给 Bucket 配 token,则优先使用它,也建议放到 HF Secrets
|
| 85 |
+
# ==============================
|
| 86 |
+
HF_BUCKET_REPO=DanielleNguyen/Qwen2API-A-Storage
|
| 87 |
+
HF_TOKEN=
|
| 88 |
+
HF_BUCKET_TOKEN=
|
| 89 |
+
HF_BUCKET_LOCAL_DIR=/data/qwen2api
|
| 90 |
+
HF_BUCKET_REMOTE_DIR=runtime
|
| 91 |
+
HF_BUCKET_SYNC_INTERVAL=300
|
| 92 |
+
HF_BUCKET_SYNC_DEBOUNCE_SECONDS=5
|
| 93 |
+
HF_BUCKET_STARTUP_GRACE_SECONDS=30
|
| 94 |
+
|
| 95 |
+
# ==============================
|
| 96 |
+
# 日志配置
|
| 97 |
+
# Hugging Face 一般不建议开启文件日志,直接看容器日志即可
|
| 98 |
+
# ==============================
|
| 99 |
+
LOG_LEVEL=INFO
|
| 100 |
+
ENABLE_FILE_LOG=false
|
| 101 |
+
MAX_LOG_FILE_SIZE=10
|
| 102 |
+
MAX_LOG_FILES=5
|
| 103 |
+
|
| 104 |
+
# ==============================
|
| 105 |
+
# 代理 / 上游反代配置
|
| 106 |
+
# 按需填写;不需要就留空
|
| 107 |
+
# 如果地址中带认证信息,建议放到 HF Secrets
|
| 108 |
+
# ==============================
|
| 109 |
+
QWEN_CHAT_PROXY_URL=
|
| 110 |
+
QWEN_CLI_PROXY_URL=
|
| 111 |
+
PROXY_URL=
|
| 112 |
+
|
| 113 |
+
# ==============================
|
| 114 |
+
# 图片缓存配置
|
| 115 |
+
# 配合 Bucket 持久化时建议使用 file
|
| 116 |
+
# ==============================
|
| 117 |
+
CACHE_MODE=file
|
.gitattributes
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
package-lock.json
|
| 3 |
+
.env
|
| 4 |
+
data/data.json
|
| 5 |
+
data
|
| 6 |
+
caches
|
| 7 |
+
logs/
|
| 8 |
+
*.log
|
| 9 |
+
pkg_dist/*
|
| 10 |
+
pkg_dist/
|
| 11 |
+
.idea
|
| 12 |
+
/public/dist
|
README.md
CHANGED
|
@@ -1,10 +1,854 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div align="center">
|
| 2 |
+
|
| 3 |
+
# 🚀 Qwen-Proxy
|
| 4 |
+
|
| 5 |
+
[](https://github.com/Rfym21/Qwen2API)
|
| 6 |
+
[](https://nodejs.org/)
|
| 7 |
+
[](https://hub.docker.com/r/rfym21/qwen2api)
|
| 8 |
+
|
| 9 |
+
[🔗 加入交流群](https://t.me/nodejs_project) | [📖 文档](#api-文档) | [🐳 Docker 部署](#docker-部署)
|
| 10 |
+
|
| 11 |
+
</div>
|
| 12 |
+
|
| 13 |
+
## 🛠️ 快速开始
|
| 14 |
+
|
| 15 |
+
### 项目说明
|
| 16 |
+
|
| 17 |
+
Qwen-Proxy 是一个将 `https://chat.qwen.ai` 和 `Qwen Code / Qwen Cli` 转换为 OpenAI 兼容 API 的代理服务。通过本项目,您只需要一个账户,即可以使用任何支持 OpenAI API 的客户端(如 ChatGPT-Next-Web、LobeChat 等)来调用 `https://chat.qwen.ai` 和 `Qwen Code / Qwen Cli`的各种模型。其中 `/cli` 端点下的模型由 `Qwen Code / Qwen Cli` 提供,支持256k上下文,原生 tools 参数支持
|
| 18 |
+
|
| 19 |
+
**主要特性:**
|
| 20 |
+
- 兼容 OpenAI API 格式,无缝对接各类客户端
|
| 21 |
+
- 支持多账户轮询,提高可用性
|
| 22 |
+
- 支持流式/非流式响应
|
| 23 |
+
- 支持多模态(图片识别、图片生成)
|
| 24 |
+
- 支持智能搜索、深度思考等高级功能
|
| 25 |
+
- 支持 CLI 端点,提供 256K 上下文和工具调用能力
|
| 26 |
+
- 提供 Web 管理界面,方便配置和监控
|
| 27 |
+
|
| 28 |
+
### ⚠️ 高并发说明
|
| 29 |
+
|
| 30 |
+
> **重要提示**: `chat.qwen.ai` 对单 IP 有限速策略,目前已知该限制与 Cookie 无关,仅与 IP 相关。
|
| 31 |
+
|
| 32 |
+
**解决方案:**
|
| 33 |
+
|
| 34 |
+
如需高并发使用,建议配合代理池实现 IP 轮换:
|
| 35 |
+
|
| 36 |
+
| 方案 | 配置方式 | 说明 |
|
| 37 |
+
|------|----------|------|
|
| 38 |
+
| **方案一** | `PROXY_URL` + [ProxyFlow](https://github.com/Rfym21/ProxyFlow) | 直接配置代理地址,所有请求通过代理池轮换 IP |
|
| 39 |
+
| **方案二** | `QWEN_CHAT_PROXY_URL` + [UrlProxy](https://github.com/Rfym21/UrlProxy) + [ProxyFlow](https://github.com/Rfym21/ProxyFlow) | 通过反代 + 代理池组合,实现更灵活的 IP 轮换 |
|
| 40 |
+
|
| 41 |
+
**配置示例:**
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
# 方案一:直接使用代理池
|
| 45 |
+
PROXY_URL=http://127.0.0.1:8282 # ProxyFlow 代理地址
|
| 46 |
+
|
| 47 |
+
# 方案二:反代 + 代理池组合
|
| 48 |
+
QWEN_CHAT_PROXY_URL=http://127.0.0.1:8000/qwen # UrlProxy 反代地址(UrlProxy 配置 HTTP_PROXY 指向 ProxyFlow)
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
### 环境要求
|
| 52 |
+
|
| 53 |
+
- Node.js 18+ (源码部署时需要)
|
| 54 |
+
- Docker (可选)
|
| 55 |
+
- Redis (可选,用于数据持久化)
|
| 56 |
+
|
| 57 |
+
### ⚙️ 环境配置
|
| 58 |
+
|
| 59 |
+
创建 `.env` 文件并配置以下参数:
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
# 🌐 服务配置
|
| 63 |
+
LISTEN_ADDRESS=localhost # 监听地址
|
| 64 |
+
SERVICE_PORT=3000 # 服务端口
|
| 65 |
+
|
| 66 |
+
# 🔐 安全配置
|
| 67 |
+
API_KEY=sk-123456,sk-456789 # API 密钥 (必填,支持多密钥)
|
| 68 |
+
ACCOUNTS= # 账户配置 (格式: user1:pass1,user2:pass2)
|
| 69 |
+
|
| 70 |
+
# 🚀 PM2 多进程配置
|
| 71 |
+
PM2_INSTANCES=1 # PM2进程数量 (1/数字/max)
|
| 72 |
+
PM2_MAX_MEMORY=1G # PM2内存限制 (100M/1G/2G等)
|
| 73 |
+
# 注意: PM2集群模式下所有进程共用同一个端口
|
| 74 |
+
|
| 75 |
+
# 🔍 功能配置
|
| 76 |
+
SEARCH_INFO_MODE=table # 搜索信息展示模式 (table/text)
|
| 77 |
+
OUTPUT_THINK=true # 是否输出思考过程 (true/false)
|
| 78 |
+
SIMPLE_MODEL_MAP=false # 简化模型映射 (true/false)
|
| 79 |
+
|
| 80 |
+
# 🌐 代理与反代配置
|
| 81 |
+
QWEN_CHAT_PROXY_URL= # 自定义 Chat API 反代URL (默认: https://chat.qwen.ai)
|
| 82 |
+
QWEN_CLI_PROXY_URL= # 自定义 CLI API 反代URL (默认: https://portal.qwen.ai)
|
| 83 |
+
PROXY_URL= # HTTP/HTTPS/SOCKS5 代理地址 (例如: http://127.0.0.1:7890)
|
| 84 |
+
|
| 85 |
+
# 🗄️ 数据存储
|
| 86 |
+
DATA_SAVE_MODE=none # 数据保存模式 (none/file/redis)
|
| 87 |
+
REDIS_URL= # Redis 连接地址 (可选,使用TLS时为rediss://)
|
| 88 |
+
|
| 89 |
+
# 📸 缓存配置
|
| 90 |
+
CACHE_MODE=default # 图片缓存模式 (default/file)
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
#### 📋 配置说明
|
| 94 |
+
|
| 95 |
+
| 参数 | 说明 | 示例 |
|
| 96 |
+
|------|------|------|
|
| 97 |
+
| `LISTEN_ADDRESS` | 服务监听地址 | `localhost` 或 `0.0.0.0` |
|
| 98 |
+
| `SERVICE_PORT` | 服务运行端口 | `3000` |
|
| 99 |
+
| `API_KEY` | API 访问密钥,支持多密钥配置。第一个为管理员密钥(可访问前端管理页面),其他为普通密钥(仅可调用API)。多个密钥用逗号分隔 | `sk-admin123,sk-user456,sk-user789` |
|
| 100 |
+
| `PM2_INSTANCES` | PM2进程数量 | `1`/`4`/`max` |
|
| 101 |
+
| `PM2_MAX_MEMORY` | PM2内存限制 | `100M`/`1G`/`2G` |
|
| 102 |
+
| `SEARCH_INFO_MODE` | 搜索结果展示格式 | `table` 或 `text` |
|
| 103 |
+
| `OUTPUT_THINK` | 是否显示 AI 思考过程 | `true` 或 `false` |
|
| 104 |
+
| `SIMPLE_MODEL_MAP` | 简化模型映射,只返回基础模型不包含变体 | `true` 或 `false` |
|
| 105 |
+
| `QWEN_CHAT_PROXY_URL` | 自定义 Chat API 反代地址 | `https://your-proxy.com` |
|
| 106 |
+
| `QWEN_CLI_PROXY_URL` | 自定义 CLI API 反代地址 | `https://your-cli-proxy.com` |
|
| 107 |
+
| `PROXY_URL` | 出站请求代理地址,支持 HTTP/HTTPS/SOCKS5 | `http://127.0.0.1:7890` |
|
| 108 |
+
| `DATA_SAVE_MODE` | 数据持久化方式 | `none`/`file`/`redis` |
|
| 109 |
+
| `REDIS_URL` | Redis 数据库连接地址,使用TLS加密时需使用 `rediss://` 协议 | `redis://localhost:6379` 或 `rediss://xxx.upstash.io` |
|
| 110 |
+
| `CACHE_MODE` | 图片缓存存储方式 | `default`/`file` |
|
| 111 |
+
| `LOG_LEVEL` | 日志级别 | `DEBUG`/`INFO`/`WARN`/`ERROR` |
|
| 112 |
+
| `ENABLE_FILE_LOG` | 是否启用文件日志 | `true` 或 `false` |
|
| 113 |
+
| `LOG_DIR` | 日志文件目录 | `./logs` |
|
| 114 |
+
| `MAX_LOG_FILE_SIZE` | 最大日志文件大小(MB) | `10` |
|
| 115 |
+
| `MAX_LOG_FILES` | 保留的日志文件数量 | `5` |
|
| 116 |
+
|
| 117 |
+
> 💡 **提示**: 可以在 [Upstash](https://upstash.com/) 免费创建 Redis 实例,使用 TLS 协议时地址格式为 `rediss://...`
|
| 118 |
+
<div>
|
| 119 |
+
<img src="./docs/images/upstash.png" alt="Upstash Redis" width="600">
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
#### 🔑 多API_KEY配置说明
|
| 123 |
+
|
| 124 |
+
`API_KEY` 环境变量支持配置多个API密钥,用于实现不同权限级别的访问控制:
|
| 125 |
+
|
| 126 |
+
**配置格式:**
|
| 127 |
+
```bash
|
| 128 |
+
# 单个密钥(管理员权限)
|
| 129 |
+
API_KEY=sk-admin123
|
| 130 |
+
|
| 131 |
+
# 多个密钥(第一个为管理员,其他为普通用户)
|
| 132 |
+
API_KEY=sk-admin123,sk-user456,sk-user789
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
**权限说明:**
|
| 136 |
+
|
| 137 |
+
| 密钥类型 | 权限范围 | 功能描述 |
|
| 138 |
+
|----------|----------|----------|
|
| 139 |
+
| **管理员密钥** | 完整权限 | • 访问前端管理页面<br>• 修改系统设置<br>• 调用所有API接口<br>• 添加/删除普通密钥 |
|
| 140 |
+
| **普通密钥** | API调用权限 | • 仅可调用API接口<br>• 无法访问前端管理页面<br>• 无法修改系统设置 |
|
| 141 |
+
|
| 142 |
+
**使用场景:**
|
| 143 |
+
- **团队协作**: 为不同团队成员分配不同权限的API密钥
|
| 144 |
+
- **应用集成**: 为第三方应用提供受限的API访问权限
|
| 145 |
+
- **安全隔离**: 将管理权限与普通使用权限分离
|
| 146 |
+
|
| 147 |
+
**注意事项:**
|
| 148 |
+
- 第一个API_KEY自动成为管理员密钥,拥有最高权限
|
| 149 |
+
- 管理员可以通过前端页面动态添加或删除普通密钥
|
| 150 |
+
- 所有密钥都可以正常调用API接口,权限差异仅体现在管理功能上
|
| 151 |
+
|
| 152 |
+
#### 📸 CACHE_MODE 缓存模式说明
|
| 153 |
+
|
| 154 |
+
`CACHE_MODE` 环境变量控制图片缓存的存储方式,用于优化图片上传和处理性能:
|
| 155 |
+
|
| 156 |
+
| 模式 | 说明 | 适用场景 |
|
| 157 |
+
|------|------|----------|
|
| 158 |
+
| `default` | 内存缓存模式 (默认) | 单进程部署,重启后缓存丢失 |
|
| 159 |
+
| `file` | 文件缓存模式 | 多进程部署,缓存持久化到 `./caches/` 目录 |
|
| 160 |
+
|
| 161 |
+
**推荐配置:**
|
| 162 |
+
- **单进程部署**: 使用 `CACHE_MODE=default`,性能最佳
|
| 163 |
+
- **多进程/集群部署**: 使用 `CACHE_MODE=file`,确保进程间缓存共享
|
| 164 |
+
- **Docker 部署**: 建议使用 `CACHE_MODE=file` 并挂载 `./caches` 目录
|
| 165 |
+
|
| 166 |
+
**文件缓存目录结构:**
|
| 167 |
+
```
|
| 168 |
+
caches/
|
| 169 |
+
├── [signature1].txt # 缓存文件,包含图片URL
|
| 170 |
+
├── [signature2].txt
|
| 171 |
+
└── ...
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
---
|
| 175 |
+
|
| 176 |
+
## 🚀 部署方式
|
| 177 |
+
|
| 178 |
+
### 🐳 Docker 部署
|
| 179 |
+
|
| 180 |
+
#### 方式一:直接运行
|
| 181 |
+
|
| 182 |
+
```bash
|
| 183 |
+
docker run -d \
|
| 184 |
+
-p 3000:3000 \
|
| 185 |
+
-e API_KEY=sk-admin123,sk-user456,sk-user789 \
|
| 186 |
+
-e DATA_SAVE_MODE=none \
|
| 187 |
+
-e CACHE_MODE=file \
|
| 188 |
+
-e ACCOUNTS= \
|
| 189 |
+
-v ./caches:/app/caches \
|
| 190 |
+
--name qwen2api \
|
| 191 |
+
rfym21/qwen2api:latest
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
#### 方式二:Docker Compose
|
| 195 |
+
|
| 196 |
+
```bash
|
| 197 |
+
# 下载配置文件
|
| 198 |
+
curl -o docker-compose.yml https://raw.githubusercontent.com/Rfym21/Qwen2API/refs/heads/main/docker/docker-compose.yml
|
| 199 |
+
|
| 200 |
+
# 启动服务
|
| 201 |
+
docker compose pull && docker compose up -d
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
### 📦 本地部署
|
| 205 |
+
|
| 206 |
+
```bash
|
| 207 |
+
# 克隆项目
|
| 208 |
+
git clone https://github.com/Rfym21/Qwen2API.git
|
| 209 |
+
cd Qwen2API
|
| 210 |
+
|
| 211 |
+
# 安装依赖
|
| 212 |
+
npm install
|
| 213 |
+
|
| 214 |
+
# 配置环境变量
|
| 215 |
+
cp .env.example .env
|
| 216 |
+
# 编辑 .env 文件
|
| 217 |
+
|
| 218 |
+
# 智能启动 (推荐 - 自动判断单进程/多进程)
|
| 219 |
+
npm start
|
| 220 |
+
|
| 221 |
+
# 开发模式
|
| 222 |
+
npm run dev
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
### 🚀 PM2 多进程部署
|
| 226 |
+
|
| 227 |
+
使用 PM2 进行生产环境多进程部署,提供更好的性能和稳定性。
|
| 228 |
+
|
| 229 |
+
**重要说明**: PM2 集群模式下,所有进程共用同一个端口,PM2 会自动进行负载均衡。
|
| 230 |
+
|
| 231 |
+
### 🤖 智能启动模式
|
| 232 |
+
|
| 233 |
+
使用 `npm start` 可以自动判断启动方式:
|
| 234 |
+
|
| 235 |
+
- 当 `PM2_INSTANCES=1` 时,使用单进程模式
|
| 236 |
+
- 当 `PM2_INSTANCES>1` 时,使用 Node.js 集群模式
|
| 237 |
+
- 自动限制进程数不超过 CPU 核心数
|
| 238 |
+
|
| 239 |
+
### ☁️ Hugging Face 部署
|
| 240 |
+
|
| 241 |
+
推荐使用 **Docker Space + GitHub 自动同步** 的方式部署。这个项目本身已经提供 `docker/Dockerfile`,因此在 Hugging Face 上使用 Docker Space 是最稳妥的方案。
|
| 242 |
+
|
| 243 |
+
[](https://huggingface.co/spaces/devme/q2waepnilm)
|
| 244 |
+
|
| 245 |
+
<div>
|
| 246 |
+
<img src="./docs/images/hf.png" alt="Hugging Face Deployment" width="600">
|
| 247 |
+
</div>
|
| 248 |
+
|
| 249 |
+
#### 推荐部署方式
|
| 250 |
+
|
| 251 |
+
部署链路如下:
|
| 252 |
+
|
| 253 |
+
```text
|
| 254 |
+
本地修改代码 -> Push 到 GitHub main -> GitHub Actions 自动同步到 Hugging Face Space -> Hugging Face 自动重建并启动
|
| 255 |
+
```
|
| 256 |
+
|
| 257 |
+
#### 第一步:创建 Hugging Face Space
|
| 258 |
+
|
| 259 |
+
- 在 Hugging Face 新建 Space
|
| 260 |
+
- **SDK 请选择 `Docker`**
|
| 261 |
+
- Space 名称示例:`DanielleNguyen/Qwen2API-A`
|
| 262 |
+
|
| 263 |
+
#### 第二步:在 GitHub 配置自动同步变量
|
| 264 |
+
|
| 265 |
+
仓��中已提供自动同步工作流:`.github/workflows/huggingface-sync.yml`
|
| 266 |
+
|
| 267 |
+
请在 GitHub 仓库中配置以下内容:
|
| 268 |
+
|
| 269 |
+
**GitHub Actions Secrets:**
|
| 270 |
+
|
| 271 |
+
```bash
|
| 272 |
+
HF_TOKEN=你的_huggingface_token
|
| 273 |
+
```
|
| 274 |
+
|
| 275 |
+
**GitHub Actions Variables:**
|
| 276 |
+
|
| 277 |
+
```bash
|
| 278 |
+
HF_SPACE_ID=DanielleNguyen/Qwen2API-A
|
| 279 |
+
```
|
| 280 |
+
|
| 281 |
+
配置位置:`GitHub 仓库 -> Settings -> Secrets and variables -> Actions`
|
| 282 |
+
|
| 283 |
+
> ⚠️ 注意:`HF_TOKEN` 必须放在 `Secrets` 中,不要写入仓库文件,也不要提交到 `.env` 中。
|
| 284 |
+
|
| 285 |
+
#### 第三步:在 Hugging Face Space 配置运行时变量
|
| 286 |
+
|
| 287 |
+
进入:`Hugging Face Space -> Settings -> Variables and secrets`
|
| 288 |
+
|
| 289 |
+
如果你采用 **HF Bucket 持久化**,建议至少配置以下变量:
|
| 290 |
+
|
| 291 |
+
```bash
|
| 292 |
+
SERVICE_PORT=7860
|
| 293 |
+
LISTEN_ADDRESS=0.0.0.0
|
| 294 |
+
PM2_INSTANCES=1
|
| 295 |
+
PM2_MAX_MEMORY=4G
|
| 296 |
+
NODE_ENV=production
|
| 297 |
+
OUTPUT_THINK=true
|
| 298 |
+
SEARCH_INFO_MODE=table
|
| 299 |
+
SIMPLE_MODEL_MAP=false
|
| 300 |
+
DATA_SAVE_MODE=file
|
| 301 |
+
DATA_DIR=/data/qwen2api/data
|
| 302 |
+
CACHE_DIR=/data/qwen2api/caches
|
| 303 |
+
LOG_DIR=/data/qwen2api/logs
|
| 304 |
+
HF_BUCKET_REPO=DanielleNguyen/Qwen2API-A-Storage
|
| 305 |
+
HF_BUCKET_LOCAL_DIR=/data/qwen2api
|
| 306 |
+
HF_BUCKET_REMOTE_DIR=runtime
|
| 307 |
+
HF_BUCKET_SYNC_INTERVAL=300
|
| 308 |
+
LOG_LEVEL=INFO
|
| 309 |
+
ENABLE_FILE_LOG=false
|
| 310 |
+
CACHE_MODE=file
|
| 311 |
+
```
|
| 312 |
+
|
| 313 |
+
建议作为 Secret 配置的敏感项:
|
| 314 |
+
|
| 315 |
+
```bash
|
| 316 |
+
API_KEY=sk-admin-yourkey,sk-user-yourkey
|
| 317 |
+
HF_TOKEN=hf_xxx
|
| 318 |
+
HF_BUCKET_TOKEN=
|
| 319 |
+
REDIS_URL=
|
| 320 |
+
ACCOUNTS=
|
| 321 |
+
PROXY_URL=
|
| 322 |
+
QWEN_CHAT_PROXY_URL=
|
| 323 |
+
QWEN_CLI_PROXY_URL=
|
| 324 |
+
```
|
| 325 |
+
|
| 326 |
+
> 💡 项目中已提供 Hugging Face 示例模板:`.env.hf.example`
|
| 327 |
+
|
| 328 |
+
#### 第四步:HF Bucket 持久化工作方式
|
| 329 |
+
|
| 330 |
+
当前项目已适配如下持久化链路:
|
| 331 |
+
|
| 332 |
+
```text
|
| 333 |
+
容器启动 -> 从 HF Bucket 拉取 /data/qwen2api -> 应用用 file 模式读写本地文件 -> 后台定时同步回 HF Bucket
|
| 334 |
+
```
|
| 335 |
+
|
| 336 |
+
默认会持久化这些目录:
|
| 337 |
+
|
| 338 |
+
```bash
|
| 339 |
+
DATA_DIR=/data/qwen2api/data
|
| 340 |
+
CACHE_DIR=/data/qwen2api/caches
|
| 341 |
+
LOG_DIR=/data/qwen2api/logs
|
| 342 |
+
```
|
| 343 |
+
|
| 344 |
+
推荐的 Bucket 名称:
|
| 345 |
+
|
| 346 |
+
```bash
|
| 347 |
+
HF_BUCKET_REPO=DanielleNguyen/Qwen2API-A-Storage
|
| 348 |
+
```
|
| 349 |
+
|
| 350 |
+
#### 第五步:端口与监听地址说明
|
| 351 |
+
|
| 352 |
+
为兼容 Hugging Face Docker Space,推荐固定使用:
|
| 353 |
+
|
| 354 |
+
```bash
|
| 355 |
+
SERVICE_PORT=7860
|
| 356 |
+
LISTEN_ADDRESS=0.0.0.0
|
| 357 |
+
```
|
| 358 |
+
|
| 359 |
+
原因:
|
| 360 |
+
|
| 361 |
+
- `7860` 是 Hugging Face Space 常见服务端口
|
| 362 |
+
- `0.0.0.0` 可确保容器外部可以访问到服务
|
| 363 |
+
- 项目实际启动端口由环境变量 `SERVICE_PORT` 控制
|
| 364 |
+
|
| 365 |
+
#### 第六步:推送代码触发自动部署
|
| 366 |
+
|
| 367 |
+
当你推送代码到 `main` 分支后:
|
| 368 |
+
|
| 369 |
+
- GitHub Actions 会自动执行 `.github/workflows/huggingface-sync.yml`
|
| 370 |
+
- 自动将仓库代码同步到 Hugging Face Space
|
| 371 |
+
- Hugging Face 收到新代码后会自动重建容器并启动服务
|
| 372 |
+
|
| 373 |
+
#### 推荐的 Hugging Face 配置
|
| 374 |
+
|
| 375 |
+
如果你只是先跑通服务并启用 Bucket 持久化,建议使用:
|
| 376 |
+
|
| 377 |
+
```bash
|
| 378 |
+
SERVICE_PORT=7860
|
| 379 |
+
LISTEN_ADDRESS=0.0.0.0
|
| 380 |
+
PM2_INSTANCES=1
|
| 381 |
+
DATA_SAVE_MODE=file
|
| 382 |
+
CACHE_MODE=file
|
| 383 |
+
DATA_DIR=/data/qwen2api/data
|
| 384 |
+
CACHE_DIR=/data/qwen2api/caches
|
| 385 |
+
LOG_DIR=/data/qwen2api/logs
|
| 386 |
+
HF_BUCKET_REPO=DanielleNguyen/Qwen2API-A-Storage
|
| 387 |
+
ENABLE_FILE_LOG=false
|
| 388 |
+
```
|
| 389 |
+
|
| 390 |
+
如果你要让运行数据真正可恢复,请再补上 Secret:
|
| 391 |
+
|
| 392 |
+
```bash
|
| 393 |
+
HF_TOKEN=你的_huggingface_token
|
| 394 |
+
```
|
| 395 |
+
|
| 396 |
+
> Space 访问 Bucket 时可以直接使用 Hugging Face 账号 Token。若你希望和 GitHub Actions 用途隔离,也可以额外配置 `HF_BUCKET_TOKEN` 专供 Bucket 读写使用。
|
| 397 |
+
|
| 398 |
+
#### 常见问题
|
| 399 |
+
|
| 400 |
+
**1. 为什么不建议在仓库里提交真实 `.env`?**
|
| 401 |
+
|
| 402 |
+
因为 `API_KEY`、`HF_TOKEN`、`HF_BUCKET_TOKEN`、`REDIS_URL` 等都属于敏感信息,应该放在 GitHub Secrets 或 Hugging Face Secrets 中。
|
| 403 |
+
|
| 404 |
+
**2. 为什么推荐 Docker Space?**
|
| 405 |
+
|
| 406 |
+
因为本项目是完整的 Node.js 服务,并且已经自带 `docker/Dockerfile`,使用 Docker Space 可以直接复用现有构建和启动流程。
|
| 407 |
+
|
| 408 |
+
**3. 如果 GitHub 已经配置了 `HF_TOKEN` 和 `HF_SPACE_ID`,还需要在 HF 里配置它们吗?**
|
| 409 |
+
|
| 410 |
+
不需要。
|
| 411 |
+
|
| 412 |
+
- `HF_TOKEN` 和 `HF_SPACE_ID` 只用于 **GitHub -> Hugging Face 同步代码**
|
| 413 |
+
- Hugging Face Space 内只需要配置项目运行时环境变量,例如 `API_KEY`、`SERVICE_PORT`、`LISTEN_ADDRESS`
|
| 414 |
+
|
| 415 |
+
**4. Space 怎么和 Bucket 通信?一定要单独的 `HF_BUCKET_TOKEN` 吗?**
|
| 416 |
+
|
| 417 |
+
不一定。
|
| 418 |
+
|
| 419 |
+
- 在 Hugging Face Space 里,运行时可以直接使用你的 Hugging Face 账号 Token,即 `HF_TOKEN`
|
| 420 |
+
- 当前项目已兼容:优先读取 `HF_BUCKET_TOKEN`,如果没设置则自动回退到 `HF_TOKEN`
|
| 421 |
+
- 如果你想把“GitHub 同步代码”和“Space 访问 Bucket”分开控制,可以单独再配 `HF_BUCKET_TOKEN`
|
| 422 |
+
|
| 423 |
---
|
| 424 |
|
| 425 |
+
## 📁 项目结构
|
| 426 |
+
|
| 427 |
+
```
|
| 428 |
+
Qwen2API/
|
| 429 |
+
├── README.md
|
| 430 |
+
├── ecosystem.config.js # PM2配置文件
|
| 431 |
+
├── package.json
|
| 432 |
+
│
|
| 433 |
+
├── docker/ # Docker配置目录
|
| 434 |
+
│ ├── Dockerfile
|
| 435 |
+
│ ├── docker-compose.yml
|
| 436 |
+
│ └── docker-compose-redis.yml
|
| 437 |
+
│
|
| 438 |
+
├── caches/ # 缓存文件目录
|
| 439 |
+
├── data/ # 数据文件目录
|
| 440 |
+
│ ├── data.json
|
| 441 |
+
│ └── data_template.json
|
| 442 |
+
├── scripts/ # 脚本目录
|
| 443 |
+
│ └── fingerprint-injector.js # 浏览器指纹注入脚本
|
| 444 |
+
│
|
| 445 |
+
├── src/ # 后端源代码目录
|
| 446 |
+
│ ├── server.js # 主服务器文件
|
| 447 |
+
│ ├── start.js # 智能启动脚本 (自动判断单进程/多进程)
|
| 448 |
+
│ ├── config/
|
| 449 |
+
│ │ └── index.js # 配置文件
|
| 450 |
+
│ ├── controllers/ # 控制器目录
|
| 451 |
+
│ │ ├── chat.js # 聊天控制器
|
| 452 |
+
│ │ ├── chat.image.video.js # 图片/视频生成控制器
|
| 453 |
+
│ │ ├── cli.chat.js # CLI聊天控制器
|
| 454 |
+
│ │ └── models.js # 模型控制器
|
| 455 |
+
│ ├── middlewares/ # 中间件目录
|
| 456 |
+
│ │ ├── authorization.js # 授权中间件
|
| 457 |
+
│ │ └── chat-middleware.js # 聊天中间件
|
| 458 |
+
│ ├── models/ # 模型目录
|
| 459 |
+
│ │ └── models-map.js # 模型映射配置
|
| 460 |
+
│ ├── routes/ # 路由目录
|
| 461 |
+
│ │ ├── accounts.js # 账户路由
|
| 462 |
+
│ │ ├── chat.js # 聊天路由
|
| 463 |
+
│ │ ├── cli.chat.js # CLI聊天路由
|
| 464 |
+
│ │ ├── models.js # 模型路由
|
| 465 |
+
│ │ ├── settings.js # 设置路由
|
| 466 |
+
│ │ └── verify.js # 验证路由
|
| 467 |
+
│ └── utils/ # 工具函数目录
|
| 468 |
+
│ ├── account-rotator.js # 账户轮询器
|
| 469 |
+
│ ├── account.js # 账户管理
|
| 470 |
+
│ ├── chat-helpers.js # 聊天辅助函数
|
| 471 |
+
│ ├── cli.manager.js # CLI管理器
|
| 472 |
+
│ ├── cookie-generator.js # Cookie生成器
|
| 473 |
+
│ ├── data-persistence.js # 数据持久化
|
| 474 |
+
│ ├── fingerprint.js # 浏览器指纹生成
|
| 475 |
+
│ ├── img-caches.js # 图片缓存
|
| 476 |
+
│ ├── logger.js # 日志工具
|
| 477 |
+
│ ├── precise-tokenizer.js # 精确分词器
|
| 478 |
+
│ ├── proxy-helper.js # 代理辅助函数
|
| 479 |
+
│ ├── redis.js # Redis连接
|
| 480 |
+
│ ├── request.js # HTTP请求封装
|
| 481 |
+
│ ├── setting.js # 设置管理
|
| 482 |
+
│ ├── ssxmod-manager.js # ssxmod参数管理
|
| 483 |
+
│ ├── token-manager.js # Token管理器
|
| 484 |
+
│ ├── tools.js # 工具调用处理
|
| 485 |
+
│ └── upload.js # 文件上传
|
| 486 |
+
│
|
| 487 |
+
└── public/ # 前端项目目录
|
| 488 |
+
├── dist/ # 编译后的前端文件
|
| 489 |
+
│ ├── assets/ # 静态资源
|
| 490 |
+
│ ├── favicon.png
|
| 491 |
+
│ └── index.html
|
| 492 |
+
├── src/ # 前端源代码
|
| 493 |
+
│ ├── App.vue # 主应用组件
|
| 494 |
+
│ ├── main.js # 入口文件
|
| 495 |
+
│ ├── style.css # 全局样式
|
| 496 |
+
│ ├── assets/ # 静态资源
|
| 497 |
+
│ │ └── background.mp4
|
| 498 |
+
│ ├── routes/ # 路由配置
|
| 499 |
+
│ │ └── index.js
|
| 500 |
+
│ └── views/ # 页面组件
|
| 501 |
+
│ ├── auth.vue # 认证页面
|
| 502 |
+
│ ├── dashboard.vue # 仪表板页面
|
| 503 |
+
│ └── settings.vue # 设置页面
|
| 504 |
+
├── package.json # 前端依赖配置
|
| 505 |
+
├── package-lock.json
|
| 506 |
+
├── index.html # 前端入口HTML
|
| 507 |
+
├── postcss.config.js # PostCSS配置
|
| 508 |
+
├── tailwind.config.js # TailwindCSS配置
|
| 509 |
+
├── vite.config.js # Vite构建配置
|
| 510 |
+
└── public/ # 公共静态资源
|
| 511 |
+
└── favicon.png
|
| 512 |
+
```
|
| 513 |
+
|
| 514 |
+
## 📖 API 文档
|
| 515 |
+
|
| 516 |
+
### 🔐 API 认证说明
|
| 517 |
+
|
| 518 |
+
本API支持多密钥认证机制,所有API请求都需要在请求头中包含有效的API密钥:
|
| 519 |
+
|
| 520 |
+
```http
|
| 521 |
+
Authorization: Bearer sk-your-api-key
|
| 522 |
+
```
|
| 523 |
+
|
| 524 |
+
**支持的密钥类型:**
|
| 525 |
+
- **管理员密钥**: 第一个配置的API_KEY,拥有完整权限
|
| 526 |
+
- **普通密钥**: 其他配置的API_KEY,仅可调用API接口
|
| 527 |
+
|
| 528 |
+
**认证示例:**
|
| 529 |
+
```bash
|
| 530 |
+
# 使用管理员密钥
|
| 531 |
+
curl -H "Authorization: Bearer sk-admin123" http://localhost:3000/v1/models
|
| 532 |
+
|
| 533 |
+
# 使用普通密钥
|
| 534 |
+
curl -H "Authorization: Bearer sk-user456" http://localhost:3000/v1/chat/completions
|
| 535 |
+
```
|
| 536 |
+
|
| 537 |
+
### 🔍 获取模型列表
|
| 538 |
+
|
| 539 |
+
获取所有可用的 AI 模型列表。
|
| 540 |
+
|
| 541 |
+
```http
|
| 542 |
+
GET /v1/models
|
| 543 |
+
Authorization: Bearer sk-your-api-key
|
| 544 |
+
```
|
| 545 |
+
|
| 546 |
+
```http
|
| 547 |
+
GET /models (免认证)
|
| 548 |
+
```
|
| 549 |
+
|
| 550 |
+
**响应示例:**
|
| 551 |
+
```json
|
| 552 |
+
{
|
| 553 |
+
"object": "list",
|
| 554 |
+
"data": [
|
| 555 |
+
{
|
| 556 |
+
"id": "qwen-max-latest",
|
| 557 |
+
"object": "model",
|
| 558 |
+
"created": 1677610602,
|
| 559 |
+
"owned_by": "qwen"
|
| 560 |
+
}
|
| 561 |
+
]
|
| 562 |
+
}
|
| 563 |
+
```
|
| 564 |
+
|
| 565 |
+
### 💬 聊天对话
|
| 566 |
+
|
| 567 |
+
发送聊天消息并获取 AI 回复。
|
| 568 |
+
|
| 569 |
+
```http
|
| 570 |
+
POST /v1/chat/completions
|
| 571 |
+
Content-Type: application/json
|
| 572 |
+
Authorization: Bearer sk-your-api-key
|
| 573 |
+
```
|
| 574 |
+
|
| 575 |
+
**请求体:**
|
| 576 |
+
```json
|
| 577 |
+
{
|
| 578 |
+
"model": "qwen-max-latest",
|
| 579 |
+
"messages": [
|
| 580 |
+
{
|
| 581 |
+
"role": "system",
|
| 582 |
+
"content": "你是一个有用的助手。"
|
| 583 |
+
},
|
| 584 |
+
{
|
| 585 |
+
"role": "user",
|
| 586 |
+
"content": "你好,请介绍一下自己。"
|
| 587 |
+
}
|
| 588 |
+
],
|
| 589 |
+
"stream": false,
|
| 590 |
+
"temperature": 0.7,
|
| 591 |
+
"max_tokens": 2000
|
| 592 |
+
}
|
| 593 |
+
```
|
| 594 |
+
|
| 595 |
+
**响应示例:**
|
| 596 |
+
```json
|
| 597 |
+
{
|
| 598 |
+
"id": "chatcmpl-123",
|
| 599 |
+
"object": "chat.completion",
|
| 600 |
+
"created": 1677652288,
|
| 601 |
+
"model": "qwen-max-latest",
|
| 602 |
+
"choices": [
|
| 603 |
+
{
|
| 604 |
+
"index": 0,
|
| 605 |
+
"message": {
|
| 606 |
+
"role": "assistant",
|
| 607 |
+
"content": "你好!我是一个AI助手..."
|
| 608 |
+
},
|
| 609 |
+
"finish_reason": "stop"
|
| 610 |
+
}
|
| 611 |
+
],
|
| 612 |
+
"usage": {
|
| 613 |
+
"prompt_tokens": 20,
|
| 614 |
+
"completion_tokens": 50,
|
| 615 |
+
"total_tokens": 70
|
| 616 |
+
}
|
| 617 |
+
}
|
| 618 |
+
```
|
| 619 |
+
|
| 620 |
+
### 🎨 图像生成/编辑
|
| 621 |
+
|
| 622 |
+
使用 `-image` 模型启用文本到图像生成功能。
|
| 623 |
+
使用 `-image-edit` 模型启用图像修改功能。
|
| 624 |
+
当使用 `-image` 模型时你可以通过在请求体中添加 `size` 参数或在消息内容中包含特定关键词 `1:1`, `4:3`, `3:4`, `16:9`, `9:16` 来控制图片尺寸。
|
| 625 |
+
|
| 626 |
+
```http
|
| 627 |
+
POST /v1/chat/completions
|
| 628 |
+
Content-Type: application/json
|
| 629 |
+
Authorization: Bearer sk-your-api-key
|
| 630 |
+
```
|
| 631 |
+
|
| 632 |
+
**请求体:**
|
| 633 |
+
```json
|
| 634 |
+
{
|
| 635 |
+
"model": "qwen-max-latest-image",
|
| 636 |
+
"messages": [
|
| 637 |
+
{
|
| 638 |
+
"role": "user",
|
| 639 |
+
"content": "画一只在花园里玩耍的小猫咪,卡通风格"
|
| 640 |
+
}
|
| 641 |
+
],
|
| 642 |
+
"size": "1:1",
|
| 643 |
+
"stream": false
|
| 644 |
+
}
|
| 645 |
+
```
|
| 646 |
+
|
| 647 |
+
**支持的参数:**
|
| 648 |
+
- `size`: 图片尺寸,支持 `"1:1"`、`"4:3"`、`"3:4"`、`"16:9"`、`"9:16"`
|
| 649 |
+
- `stream`: 支持流式和非流式响应
|
| 650 |
+
|
| 651 |
+
**响应示例:**
|
| 652 |
+
```json
|
| 653 |
+
{
|
| 654 |
+
"created": 1677652288,
|
| 655 |
+
"model": "qwen-max-latest",
|
| 656 |
+
"choices": [
|
| 657 |
+
{
|
| 658 |
+
"index": 0,
|
| 659 |
+
"message": {
|
| 660 |
+
"role": "assistant",
|
| 661 |
+
"content": ""
|
| 662 |
+
},
|
| 663 |
+
"finish_reason": "stop"
|
| 664 |
+
}
|
| 665 |
+
]
|
| 666 |
+
}
|
| 667 |
+
```
|
| 668 |
+
|
| 669 |
+
### 🎯 高级功能
|
| 670 |
+
|
| 671 |
+
#### 🔍 智能搜索模式
|
| 672 |
+
|
| 673 |
+
在模型名称后添加 `-search` 后缀启用搜索功能:
|
| 674 |
+
|
| 675 |
+
```json
|
| 676 |
+
{
|
| 677 |
+
"model": "qwen-max-latest-search",
|
| 678 |
+
"messages": [...]
|
| 679 |
+
}
|
| 680 |
+
```
|
| 681 |
+
|
| 682 |
+
#### 🧠 推理模式
|
| 683 |
+
|
| 684 |
+
在模型名称后添加 `-thinking` 后缀启用思考过程输出:
|
| 685 |
+
|
| 686 |
+
```json
|
| 687 |
+
{
|
| 688 |
+
"model": "qwen-max-latest-thinking",
|
| 689 |
+
"messages": [...]
|
| 690 |
+
}
|
| 691 |
+
```
|
| 692 |
+
|
| 693 |
+
#### 🔍🧠 组合模式
|
| 694 |
+
|
| 695 |
+
同时启用搜索和推理功能:
|
| 696 |
+
|
| 697 |
+
```json
|
| 698 |
+
{
|
| 699 |
+
"model": "qwen-max-latest-thinking-search",
|
| 700 |
+
"messages": [...]
|
| 701 |
+
}
|
| 702 |
+
```
|
| 703 |
+
|
| 704 |
+
#### 🎨 T2I 生图模式
|
| 705 |
+
|
| 706 |
+
通过设置 `chat_type` 参数为 `t2i` 启用文本到图像生成功能:
|
| 707 |
+
|
| 708 |
+
```json
|
| 709 |
+
{
|
| 710 |
+
"model": "qwen-max-latest",
|
| 711 |
+
"chat_type": "t2i",
|
| 712 |
+
"messages": [
|
| 713 |
+
{
|
| 714 |
+
"role": "user",
|
| 715 |
+
"content": "画一只可爱的小猫咪"
|
| 716 |
+
}
|
| 717 |
+
],
|
| 718 |
+
"size": "1:1"
|
| 719 |
+
}
|
| 720 |
+
```
|
| 721 |
+
|
| 722 |
+
**支持的图片尺寸:** `1:1`、`4:3`、`3:4`、`16:9`、`9:16`
|
| 723 |
+
|
| 724 |
+
**智能尺寸识别:** 系统会自动从提示词中识别尺寸关键词并设置对应尺寸
|
| 725 |
+
|
| 726 |
+
#### 🖼️ 多模态支持
|
| 727 |
+
|
| 728 |
+
API 自动处理图像上传,支持在对话中发送图片:
|
| 729 |
+
|
| 730 |
+
```json
|
| 731 |
+
{
|
| 732 |
+
"model": "qwen-max-latest",
|
| 733 |
+
"messages": [
|
| 734 |
+
{
|
| 735 |
+
"role": "user",
|
| 736 |
+
"content": [
|
| 737 |
+
{
|
| 738 |
+
"type": "text",
|
| 739 |
+
"text": "这张图片里有什么?"
|
| 740 |
+
},
|
| 741 |
+
{
|
| 742 |
+
"type": "image_url",
|
| 743 |
+
"image_url": {
|
| 744 |
+
"url": "data:image/jpeg;base64,..."
|
| 745 |
+
}
|
| 746 |
+
}
|
| 747 |
+
]
|
| 748 |
+
}
|
| 749 |
+
]
|
| 750 |
+
}
|
| 751 |
+
```
|
| 752 |
+
|
| 753 |
+
### 🖥️ CLI 端点
|
| 754 |
+
|
| 755 |
+
CLI 端点使用 Qwen Code / Qwen Cli 的 OAuth 令牌访问,支持 256K 上下文和工具调用(Function Calling)。
|
| 756 |
+
|
| 757 |
+
**支持的模型:**
|
| 758 |
+
|
| 759 |
+
| 模型 ID | 说明 |
|
| 760 |
+
|---------|------|
|
| 761 |
+
| `qwen3-coder-plus` | Qwen3 Coder Plus |
|
| 762 |
+
| `qwen3-coder-flash` | Qwen3 Coder Flash(速度更快) |
|
| 763 |
+
| `coder-model` | Qwen 3.5 Plus(带思维链,256K 上下文) |
|
| 764 |
+
| `qwen3.5-plus` | `coder-model` 的别名,自动重定向 |
|
| 765 |
+
|
| 766 |
+
#### 💬 CLI 聊天对话
|
| 767 |
+
|
| 768 |
+
通过 CLI 端点发送聊天请求,支持流式和非流式响应。
|
| 769 |
+
|
| 770 |
+
```http
|
| 771 |
+
POST /cli/v1/chat/completions
|
| 772 |
+
Content-Type: application/json
|
| 773 |
+
Authorization: Bearer API_KEY
|
| 774 |
+
```
|
| 775 |
+
|
| 776 |
+
**请求体:**
|
| 777 |
+
```json
|
| 778 |
+
{
|
| 779 |
+
"model": "qwen3-coder-plus",
|
| 780 |
+
"messages": [
|
| 781 |
+
{
|
| 782 |
+
"role": "user",
|
| 783 |
+
"content": "你好,请介绍一下自己。"
|
| 784 |
+
}
|
| 785 |
+
],
|
| 786 |
+
"stream": false,
|
| 787 |
+
"temperature": 0.7,
|
| 788 |
+
"max_tokens": 2000
|
| 789 |
+
}
|
| 790 |
+
```
|
| 791 |
+
|
| 792 |
+
使用 `coder-model`(即 Qwen 3.5 Plus)或其别名 `qwen3.5-plus`:
|
| 793 |
+
```json
|
| 794 |
+
{
|
| 795 |
+
"model": "coder-model",
|
| 796 |
+
"messages": [
|
| 797 |
+
{
|
| 798 |
+
"role": "user",
|
| 799 |
+
"content": "写一个快速排序算法。"
|
| 800 |
+
}
|
| 801 |
+
],
|
| 802 |
+
"stream": false
|
| 803 |
+
}
|
| 804 |
+
```
|
| 805 |
+
|
| 806 |
+
**流式请求:**
|
| 807 |
+
```json
|
| 808 |
+
{
|
| 809 |
+
"model": "qwen3-coder-flash",
|
| 810 |
+
"messages": [
|
| 811 |
+
{
|
| 812 |
+
"role": "user",
|
| 813 |
+
"content": "写一首关于春天的诗。"
|
| 814 |
+
}
|
| 815 |
+
],
|
| 816 |
+
"stream": true
|
| 817 |
+
}
|
| 818 |
+
```
|
| 819 |
+
|
| 820 |
+
**响应格式:**
|
| 821 |
+
|
| 822 |
+
非流式响应与标准 OpenAI API 格式相同:
|
| 823 |
+
```json
|
| 824 |
+
{
|
| 825 |
+
"id": "chatcmpl-123",
|
| 826 |
+
"object": "chat.completion",
|
| 827 |
+
"created": 1677652288,
|
| 828 |
+
"model": "qwen3-coder-plus",
|
| 829 |
+
"choices": [
|
| 830 |
+
{
|
| 831 |
+
"index": 0,
|
| 832 |
+
"message": {
|
| 833 |
+
"role": "assistant",
|
| 834 |
+
"content": "你好!我是一个AI助手..."
|
| 835 |
+
},
|
| 836 |
+
"finish_reason": "stop"
|
| 837 |
+
}
|
| 838 |
+
],
|
| 839 |
+
"usage": {
|
| 840 |
+
"prompt_tokens": 20,
|
| 841 |
+
"completion_tokens": 50,
|
| 842 |
+
"total_tokens": 70
|
| 843 |
+
}
|
| 844 |
+
}
|
| 845 |
+
```
|
| 846 |
+
|
| 847 |
+
流式响应使用 Server-Sent Events (SSE) 格式:
|
| 848 |
+
```
|
| 849 |
+
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"qwen3-coder-flash","choices":[{"index":0,"delta":{"content":"你好"},"finish_reason":null}]}
|
| 850 |
+
|
| 851 |
+
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"qwen3-coder-flash","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":null}]}
|
| 852 |
+
|
| 853 |
+
data: [DONE]
|
| 854 |
+
```
|
docker/Dockerfile
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:lts-alpine
|
| 2 |
+
|
| 3 |
+
ENV NODE_ENV=production
|
| 4 |
+
ENV SERVICE_PORT=7860
|
| 5 |
+
ENV LISTEN_ADDRESS=0.0.0.0
|
| 6 |
+
|
| 7 |
+
RUN apk add --no-cache python3 py3-pip
|
| 8 |
+
|
| 9 |
+
# 全局安装PM2
|
| 10 |
+
RUN npm install -g pm2
|
| 11 |
+
|
| 12 |
+
RUN pip install --no-cache-dir huggingface_hub watchdog
|
| 13 |
+
|
| 14 |
+
WORKDIR /app
|
| 15 |
+
|
| 16 |
+
# 复制package文件
|
| 17 |
+
COPY package*.json ./
|
| 18 |
+
|
| 19 |
+
# 安装依赖
|
| 20 |
+
RUN npm install
|
| 21 |
+
|
| 22 |
+
# 复制应用代码
|
| 23 |
+
COPY . .
|
| 24 |
+
|
| 25 |
+
# 构建前端应用
|
| 26 |
+
RUN cd public && npm install && npm run build
|
| 27 |
+
|
| 28 |
+
# 删除前端不必要文件
|
| 29 |
+
RUN rm -rf public/src public/node_modules public/package*.json
|
| 30 |
+
|
| 31 |
+
# 设置权限
|
| 32 |
+
RUN chmod 777 /app
|
| 33 |
+
|
| 34 |
+
# 创建日志目录
|
| 35 |
+
RUN mkdir -p logs
|
| 36 |
+
|
| 37 |
+
# 允许执行入口脚本
|
| 38 |
+
RUN chmod +x /app/docker/entrypoint.sh
|
| 39 |
+
|
| 40 |
+
# 暴露 Hugging Face Docker Space 默认端口
|
| 41 |
+
# 应用实际监听端口仍由 SERVICE_PORT 控制
|
| 42 |
+
EXPOSE 7860
|
| 43 |
+
|
| 44 |
+
# 启动前自动从 HF Bucket 恢复数据,并在运行中后台同步
|
| 45 |
+
ENTRYPOINT ["/app/docker/entrypoint.sh"]
|
docker/docker-compose-redis.yml
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
qwen2api:
|
| 3 |
+
container_name: qwen2api
|
| 4 |
+
image: rfym21/qwen2api:latest
|
| 5 |
+
restart: always
|
| 6 |
+
ports:
|
| 7 |
+
- "3000:3000"
|
| 8 |
+
volumes:
|
| 9 |
+
- ./data:/app/data
|
| 10 |
+
- ./logs:/app/logs
|
| 11 |
+
environment:
|
| 12 |
+
# 如果需要修改Docker暴露端口,请修改ports中的参数
|
| 13 |
+
# 示例(8080:3000) 则访问 http://localhost:8080
|
| 14 |
+
- SERVICE_PORT=3000
|
| 15 |
+
# API 密钥 (非必填)
|
| 16 |
+
# 如果需要使用多账户或使用内置账户,请填写
|
| 17 |
+
- API_KEY=sk-123456
|
| 18 |
+
# 监听地址(非必填)
|
| 19 |
+
- LISTEN_ADDRESS=
|
| 20 |
+
# PM2 多进程配置
|
| 21 |
+
# PM2进程数量: max(使用所有CPU核心), 数字(指定进程数量), 1(单进程)
|
| 22 |
+
# 注意: PM2集群模式下所有进程共用同一个端口
|
| 23 |
+
- PM2_INSTANCES=1
|
| 24 |
+
# PM2内存限制,超过此限制将自动重启进程
|
| 25 |
+
- PM2_MAX_MEMORY=1G
|
| 26 |
+
# 搜索信息展示模式
|
| 27 |
+
# table: 使用折叠块和表格展示
|
| 28 |
+
# text: 使用纯文本
|
| 29 |
+
- SEARCH_INFO_MODE=table
|
| 30 |
+
# 是否输出思考过程
|
| 31 |
+
- OUTPUT_THINK=true
|
| 32 |
+
# 简化模型映射 (true: 只返回基础模型, false: 返回完整模型列表)
|
| 33 |
+
- SIMPLE_MODEL_MAP=false
|
| 34 |
+
# redis 连接地址(必填)
|
| 35 |
+
- REDIS_URL=redis://redis:6379
|
| 36 |
+
# 数据保存模式
|
| 37 |
+
- DATA_SAVE_MODE=redis
|
| 38 |
+
# 图片缓存
|
| 39 |
+
- CACHE_MODE=default
|
| 40 |
+
redis:
|
| 41 |
+
image: redis:7.2-alpine
|
| 42 |
+
container_name: redis_qwen2api
|
| 43 |
+
restart: always
|
| 44 |
+
ports:
|
| 45 |
+
- "6379:6379"
|
| 46 |
+
volumes:
|
| 47 |
+
- redis-data:/data
|
| 48 |
+
|
| 49 |
+
volumes:
|
| 50 |
+
redis-data:
|
| 51 |
+
|
docker/docker-compose.yml
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
qwen2api:
|
| 3 |
+
container_name: qwen2api
|
| 4 |
+
image: rfym21/qwen2api:latest
|
| 5 |
+
restart: always
|
| 6 |
+
ports:
|
| 7 |
+
- "3000:3000"
|
| 8 |
+
volumes:
|
| 9 |
+
- ./data:/app/data
|
| 10 |
+
- ./logs:/app/logs
|
| 11 |
+
environment:
|
| 12 |
+
# 如果需要修改Docker暴露端口,请修改ports中的参数
|
| 13 |
+
# 示例(8080:3000) 则访问 http://localhost:8080
|
| 14 |
+
- SERVICE_PORT=3000
|
| 15 |
+
# API 密钥 (非必填)
|
| 16 |
+
# 如果需要使用多账户或使用内置账户,请填写
|
| 17 |
+
- API_KEY=sk-123456
|
| 18 |
+
# 监听地址(非必填)
|
| 19 |
+
- LISTEN_ADDRESS=
|
| 20 |
+
# PM2 多进程配置
|
| 21 |
+
# PM2进程数量: max(使用所有CPU核心), 数字(指定进程数量), 1(单进程)
|
| 22 |
+
# 注意: PM2集群模式下所有进程共用同一个端口
|
| 23 |
+
- PM2_INSTANCES=1
|
| 24 |
+
# PM2内存限制,超过此限制将自动重启进程
|
| 25 |
+
- PM2_MAX_MEMORY=1G
|
| 26 |
+
# 搜索信息展示模式
|
| 27 |
+
# table: 使用折叠块和表格展示
|
| 28 |
+
# text: 使用纯文本
|
| 29 |
+
- SEARCH_INFO_MODE=table
|
| 30 |
+
# 是否输出思考过程
|
| 31 |
+
- OUTPUT_THINK=true
|
| 32 |
+
# 简化模型映射 (true: 只返回基础模型, false: 返回完整模型列表)
|
| 33 |
+
- SIMPLE_MODEL_MAP=false
|
| 34 |
+
# redis 连接地址(如果使用redis模式,则必填,当redis使用tls时将redis://替换为rediss://)
|
| 35 |
+
- REDIS_URL=
|
| 36 |
+
# 数据保存模式
|
| 37 |
+
- DATA_SAVE_MODE=none
|
| 38 |
+
# 账号(如果使用redis和file模式,则不需要填写)
|
| 39 |
+
- ACCOUNTS=
|
| 40 |
+
# 图片缓存
|
| 41 |
+
- CACHE_MODE=default
|
docker/entrypoint.sh
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/sh
|
| 2 |
+
set -eu
|
| 3 |
+
|
| 4 |
+
APP_DATA_ROOT="${HF_BUCKET_LOCAL_DIR:-/data/qwen2api}"
|
| 5 |
+
export HF_BUCKET_LOCAL_DIR="$APP_DATA_ROOT"
|
| 6 |
+
|
| 7 |
+
mkdir -p "$APP_DATA_ROOT/data" "$APP_DATA_ROOT/caches" "$APP_DATA_ROOT/logs"
|
| 8 |
+
|
| 9 |
+
export DATA_DIR="${DATA_DIR:-$APP_DATA_ROOT/data}"
|
| 10 |
+
export CACHE_DIR="${CACHE_DIR:-$APP_DATA_ROOT/caches}"
|
| 11 |
+
export LOG_DIR="${LOG_DIR:-$APP_DATA_ROOT/logs}"
|
| 12 |
+
export SERVICE_PORT="${SERVICE_PORT:-7860}"
|
| 13 |
+
export LISTEN_ADDRESS="${LISTEN_ADDRESS:-0.0.0.0}"
|
| 14 |
+
export HF_BUCKET_SYNC_INTERVAL="${HF_BUCKET_SYNC_INTERVAL:-300}"
|
| 15 |
+
|
| 16 |
+
python3 /app/scripts/hf-bucket-sync.py restore || true
|
| 17 |
+
python3 /app/scripts/hf-bucket-sync.py daemon &
|
| 18 |
+
|
| 19 |
+
exec npm start
|
ecosystem.config.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const os = require('os')
|
| 2 |
+
|
| 3 |
+
// 获取CPU核心数
|
| 4 |
+
const cpuCores = os.cpus().length
|
| 5 |
+
|
| 6 |
+
// 解析进程数配置
|
| 7 |
+
let instances = process.env.PM2_INSTANCES || 1
|
| 8 |
+
if (instances === 'max') {
|
| 9 |
+
instances = cpuCores
|
| 10 |
+
} else if (!isNaN(instances)) {
|
| 11 |
+
instances = parseInt(instances)
|
| 12 |
+
} else {
|
| 13 |
+
instances = 1
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// 限制进程数不能超过CPU核心数
|
| 17 |
+
if (instances > cpuCores) {
|
| 18 |
+
console.log(`⚠️ 警告: 配置的进程数(${instances})超过CPU核心数(${cpuCores}),自动调整为${cpuCores}`)
|
| 19 |
+
instances = cpuCores
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
module.exports = {
|
| 23 |
+
apps: [{
|
| 24 |
+
name: 'qwen2api',
|
| 25 |
+
script: './src/server.js',
|
| 26 |
+
instances: instances,
|
| 27 |
+
exec_mode: 'cluster',
|
| 28 |
+
|
| 29 |
+
// 环境变量
|
| 30 |
+
env: {
|
| 31 |
+
PM2_USAGE: 'true'
|
| 32 |
+
},
|
| 33 |
+
|
| 34 |
+
// 日志配置
|
| 35 |
+
log_file: './logs/pm2-combined.log',
|
| 36 |
+
out_file: './logs/pm2-out.log',
|
| 37 |
+
error_file: './logs/pm2-error.log',
|
| 38 |
+
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
| 39 |
+
|
| 40 |
+
// 进程管理配置
|
| 41 |
+
max_memory_restart: process.env.PM2_MAX_MEMORY || '1G',
|
| 42 |
+
min_uptime: '10s',
|
| 43 |
+
max_restarts: 10,
|
| 44 |
+
|
| 45 |
+
// 监听文件变化
|
| 46 |
+
watch: false,
|
| 47 |
+
ignore_watch: ['node_modules', 'logs', 'caches', 'data'],
|
| 48 |
+
|
| 49 |
+
// 其他配置
|
| 50 |
+
merge_logs: true,
|
| 51 |
+
time: true
|
| 52 |
+
}]
|
| 53 |
+
}
|
package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "qwen2api",
|
| 3 |
+
"version": "2026.03.04.10.58",
|
| 4 |
+
"main": "src/server.js",
|
| 5 |
+
"scripts": {
|
| 6 |
+
"start": "node src/start.js",
|
| 7 |
+
"dev": "nodemon src/server.js",
|
| 8 |
+
"pm2": "pm2 start ecosystem.config.js",
|
| 9 |
+
"pm2:stop": "pm2 stop qwen2api",
|
| 10 |
+
"pm2:restart": "pm2 restart qwen2api",
|
| 11 |
+
"pm2:reload": "pm2 reload qwen2api",
|
| 12 |
+
"pm2:delete": "pm2 delete qwen2api",
|
| 13 |
+
"pm2:logs": "pm2 logs qwen2api",
|
| 14 |
+
"pm2:status": "pm2 status",
|
| 15 |
+
"pm2:monit": "pm2 monit"
|
| 16 |
+
},
|
| 17 |
+
"keywords": [],
|
| 18 |
+
"author": "",
|
| 19 |
+
"license": "ISC",
|
| 20 |
+
"description": "",
|
| 21 |
+
"dependencies": {
|
| 22 |
+
"ali-oss": "^6.22.0",
|
| 23 |
+
"axios": "^1.11.0",
|
| 24 |
+
"body-parser": "^1.20.3",
|
| 25 |
+
"cors": "^2.8.5",
|
| 26 |
+
"dotenv": "^16.4.7",
|
| 27 |
+
"express": "^4.21.2",
|
| 28 |
+
"form-data": "^4.0.2",
|
| 29 |
+
"https-proxy-agent": "^7.0.6",
|
| 30 |
+
"ioredis": "^5.6.1",
|
| 31 |
+
"jwt-decode": "^4.0.0",
|
| 32 |
+
"mime-types": "^3.0.1",
|
| 33 |
+
"multer": "^1.4.5-lts.1",
|
| 34 |
+
"pm2": "^6.0.8",
|
| 35 |
+
"tiktoken": "^1.0.21"
|
| 36 |
+
},
|
| 37 |
+
"devDependencies": {
|
| 38 |
+
"nodemon": "^3.1.7"
|
| 39 |
+
}
|
| 40 |
+
}
|
public/.gitignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist-ssr
|
| 12 |
+
*.local
|
| 13 |
+
|
| 14 |
+
# Editor directories and files
|
| 15 |
+
.vscode/*
|
| 16 |
+
!.vscode/extensions.json
|
| 17 |
+
.idea
|
| 18 |
+
.DS_Store
|
| 19 |
+
*.suo
|
| 20 |
+
*.ntvs*
|
| 21 |
+
*.njsproj
|
| 22 |
+
*.sln
|
| 23 |
+
*.sw?
|
public/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Qwen2 API Dashboard</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="app"></div>
|
| 11 |
+
<script type="module" src="/src/main.js"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
public/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "dashboard",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite --port 6868",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"axios": "^1.8.4",
|
| 13 |
+
"vue": "^3.4.29",
|
| 14 |
+
"vue-router": "^4.5.0"
|
| 15 |
+
},
|
| 16 |
+
"devDependencies": {
|
| 17 |
+
"@vitejs/plugin-vue": "^5.0.5",
|
| 18 |
+
"autoprefixer": "^10.4.21",
|
| 19 |
+
"postcss": "^8.5.3",
|
| 20 |
+
"tailwindcss": "^3.4.3",
|
| 21 |
+
"vite": "^5.2.8"
|
| 22 |
+
}
|
| 23 |
+
}
|
public/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
public/src/App.vue
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<video id="video-background" autoplay loop muted>
|
| 3 |
+
<source src="./assets/background.mp4" type="video/mp4">
|
| 4 |
+
</video>
|
| 5 |
+
|
| 6 |
+
<router-view></router-view>
|
| 7 |
+
|
| 8 |
+
</template>
|
| 9 |
+
|
| 10 |
+
<script setup>
|
| 11 |
+
</script>
|
| 12 |
+
|
| 13 |
+
<style lang="css" scoped>
|
| 14 |
+
body,
|
| 15 |
+
html {
|
| 16 |
+
margin: 0;
|
| 17 |
+
padding: 0;
|
| 18 |
+
width: 100%;
|
| 19 |
+
height: 100%;
|
| 20 |
+
overflow: hidden;
|
| 21 |
+
position: relative;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
#video-background {
|
| 25 |
+
position: fixed;
|
| 26 |
+
top: 0;
|
| 27 |
+
left: 0;
|
| 28 |
+
width: 100%;
|
| 29 |
+
height: 100%;
|
| 30 |
+
z-index: -1;
|
| 31 |
+
object-fit: cover;
|
| 32 |
+
}
|
| 33 |
+
</style>
|
public/src/main.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createApp } from 'vue'
|
| 2 |
+
import router from './routes/index.js'
|
| 3 |
+
import App from './App.vue'
|
| 4 |
+
import "./style.css"
|
| 5 |
+
|
| 6 |
+
createApp(App)
|
| 7 |
+
.use(router)
|
| 8 |
+
.mount('#app')
|
public/src/routes/index.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createRouter, createWebHistory } from 'vue-router'
|
| 2 |
+
import axios from 'axios'
|
| 3 |
+
|
| 4 |
+
const routes = [
|
| 5 |
+
{
|
| 6 |
+
name: 'dashboard',
|
| 7 |
+
path: '/',
|
| 8 |
+
component: () => import('../views/dashboard.vue')
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
name: 'auth',
|
| 12 |
+
path: '/auth',
|
| 13 |
+
component: () => import('../views/auth.vue')
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
name: 'settings',
|
| 17 |
+
path: '/settings',
|
| 18 |
+
component: () => import('../views/settings.vue')
|
| 19 |
+
}
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
const router = createRouter({
|
| 23 |
+
history: createWebHistory(),
|
| 24 |
+
routes
|
| 25 |
+
})
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
// 路由守卫
|
| 29 |
+
router.beforeEach(async (to, from, next) => {
|
| 30 |
+
|
| 31 |
+
if (to.path === '/auth') {
|
| 32 |
+
next()
|
| 33 |
+
} else {
|
| 34 |
+
const apiKey = localStorage.getItem('apiKey')
|
| 35 |
+
if (!apiKey) {
|
| 36 |
+
alert('请先设置身份验证apiKey')
|
| 37 |
+
next({ path: '/auth' })
|
| 38 |
+
} else {
|
| 39 |
+
try {
|
| 40 |
+
const verifyResponse = await axios.post('/verify', {
|
| 41 |
+
apiKey: apiKey
|
| 42 |
+
})
|
| 43 |
+
|
| 44 |
+
if (verifyResponse.data.status === 200) {
|
| 45 |
+
const isAdmin = verifyResponse.data.isAdmin
|
| 46 |
+
|
| 47 |
+
// 存储用户权限信息
|
| 48 |
+
localStorage.setItem('isAdmin', isAdmin.toString())
|
| 49 |
+
|
| 50 |
+
// 检查是否需要管理员权限
|
| 51 |
+
if ((to.path === '/' || to.path === '/settings') && !isAdmin) {
|
| 52 |
+
alert('您没有访问管理页面的权限')
|
| 53 |
+
next({ path: '/auth' })
|
| 54 |
+
return
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
next()
|
| 58 |
+
} else {
|
| 59 |
+
localStorage.removeItem('apiKey')
|
| 60 |
+
localStorage.removeItem('isAdmin')
|
| 61 |
+
next({ path: '/auth' })
|
| 62 |
+
}
|
| 63 |
+
} catch (error) {
|
| 64 |
+
localStorage.removeItem('apiKey')
|
| 65 |
+
localStorage.removeItem('isAdmin')
|
| 66 |
+
next({ path: '/auth' })
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
})
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
export default router
|
public/src/style.css
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
public/src/views/auth.vue
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="flex flex-col items-center justify-center w-screen h-screen">
|
| 3 |
+
<transition name="fade-slide">
|
| 4 |
+
<div
|
| 5 |
+
class="flex flex-col items-center w-4/5 h-1/2 bg-opacity-50 bg-white rounded-3xl shadow-xl border-2 border-gray-200 animate-panel"
|
| 6 |
+
v-if="showPanel">
|
| 7 |
+
<h1 class="block mt-24 mb-10 text-2xl font-bold">管理员身份验证</h1>
|
| 8 |
+
<input type="text"
|
| 9 |
+
class="w-4/5 h-16 rounded-2xl bg-opacity-80 bg-white border-2 border-gray-100 pl-10 placeholder:text-gray-500 focus:shadow-lg focus:scale-105 transition-all duration-300"
|
| 10 |
+
placeholder="请输入管理员账号" v-model="apiKey" @keyup.enter="handleLogin">
|
| 11 |
+
<button class="mt-10 w-4/5 h-16 rounded-2xl bg-opacity-65 border-2 border-black bg-black text-white transition-transform duration-200 active:scale-95 hover:scale-105"
|
| 12 |
+
@click="handleLogin">登录</button>
|
| 13 |
+
</div>
|
| 14 |
+
</transition>
|
| 15 |
+
</div>
|
| 16 |
+
</template>
|
| 17 |
+
|
| 18 |
+
<script setup>
|
| 19 |
+
import { ref, onMounted } from 'vue'
|
| 20 |
+
import axios from 'axios'
|
| 21 |
+
import { useRouter } from 'vue-router'
|
| 22 |
+
|
| 23 |
+
const router = useRouter()
|
| 24 |
+
const apiKey = ref('')
|
| 25 |
+
const showPanel = ref(false)
|
| 26 |
+
|
| 27 |
+
const handleLogin = async () => {
|
| 28 |
+
try {
|
| 29 |
+
const res = await axios.post('/verify', {
|
| 30 |
+
apiKey: apiKey.value
|
| 31 |
+
}, {
|
| 32 |
+
headers: {
|
| 33 |
+
'Content-Type': 'application/json'
|
| 34 |
+
}
|
| 35 |
+
})
|
| 36 |
+
if (res.data.status == 200) {
|
| 37 |
+
localStorage.setItem('apiKey', apiKey.value)
|
| 38 |
+
router.push({ path: '/', replace: true })
|
| 39 |
+
} else {
|
| 40 |
+
alert('apiKey 校验失败,请重新输入!')
|
| 41 |
+
}
|
| 42 |
+
} catch (err) {
|
| 43 |
+
alert('apiKey 校验失败,请重新输入!')
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
onMounted(() => {
|
| 48 |
+
setTimeout(() => {
|
| 49 |
+
showPanel.value = true
|
| 50 |
+
}, 80)
|
| 51 |
+
})
|
| 52 |
+
</script>
|
| 53 |
+
|
| 54 |
+
<style lang="css" scoped>
|
| 55 |
+
.fade-slide-enter-active, .fade-slide-leave-active {
|
| 56 |
+
transition: opacity 0.5s, transform 0.5s;
|
| 57 |
+
}
|
| 58 |
+
.fade-slide-enter-from, .fade-slide-leave-to {
|
| 59 |
+
opacity: 0;
|
| 60 |
+
transform: translateY(40px);
|
| 61 |
+
}
|
| 62 |
+
.fade-slide-enter-to, .fade-slide-leave-from {
|
| 63 |
+
opacity: 1;
|
| 64 |
+
transform: translateY(0);
|
| 65 |
+
}
|
| 66 |
+
</style>
|
public/src/views/dashboard.vue
ADDED
|
@@ -0,0 +1,1025 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="w-100vw h-100vh p-4 overflow-y-auto">
|
| 3 |
+
<div class="container mx-auto">
|
| 4 |
+
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 px-4 space-y-4 md:space-y-0 pt-5">
|
| 5 |
+
<h1 class="text-4xl font-bold">Token Manager <span class="text-gray-500 text-sm">by 兜豆子</span></h1>
|
| 6 |
+
<div class="grid grid-cols-2 sm:flex sm:flex-row w-full md:w-auto gap-2 sm:gap-0 sm:space-x-2 lg:space-x-4">
|
| 7 |
+
<button @click="showAddModal = true"
|
| 8 |
+
class="action-button font-bold border border-green-200 bg-green-50 text-green-900 px-4 py-2 rounded-xl shadow-sm hover:bg-green-100 hover:border-green-400 transition-all duration-300 transform hover:-translate-y-1 active:translate-y-0 text-center">
|
| 9 |
+
添加账号
|
| 10 |
+
</button>
|
| 11 |
+
<button @click="refreshAllAccounts"
|
| 12 |
+
:disabled="isRefreshingAll"
|
| 13 |
+
:class="[
|
| 14 |
+
'action-button font-bold px-4 py-2 rounded-xl shadow-sm transition-all duration-300 transform active:translate-y-0',
|
| 15 |
+
isRefreshingAll
|
| 16 |
+
? 'bg-purple-400 text-white border-purple-400 refreshing-button-purple cursor-not-allowed transform-none'
|
| 17 |
+
: 'macaron-purple-button text-purple-800 hover:-translate-y-1'
|
| 18 |
+
]">
|
| 19 |
+
<span v-if="isRefreshingAll" class="flex items-center space-x-2">
|
| 20 |
+
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 21 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 22 |
+
<path class="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 23 |
+
</svg>
|
| 24 |
+
<span>刷新中...</span>
|
| 25 |
+
</span>
|
| 26 |
+
<span v-else>一键刷新</span>
|
| 27 |
+
</button>
|
| 28 |
+
<button @click="forceRefreshAllAccounts"
|
| 29 |
+
:disabled="isForceRefreshingAll"
|
| 30 |
+
:class="[
|
| 31 |
+
'action-button font-bold px-4 py-2 rounded-xl shadow-sm transition-all duration-300 transform active:translate-y-0',
|
| 32 |
+
isForceRefreshingAll
|
| 33 |
+
? 'bg-pink-400 text-white border-pink-400 refreshing-button-pink cursor-not-allowed transform-none'
|
| 34 |
+
: 'macaron-pink-button text-pink-800 hover:-translate-y-1'
|
| 35 |
+
]">
|
| 36 |
+
<span v-if="isForceRefreshingAll" class="flex items-center space-x-2">
|
| 37 |
+
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 38 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 39 |
+
<path class="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 40 |
+
</svg>
|
| 41 |
+
<span>强制刷新中...</span>
|
| 42 |
+
</span>
|
| 43 |
+
<span v-else>强制刷新</span>
|
| 44 |
+
</button>
|
| 45 |
+
<button @click="exportAccounts"
|
| 46 |
+
class="action-button font-bold border border-yellow-200 bg-yellow-50 text-yellow-900 px-4 py-2 rounded-xl shadow-sm hover:bg-yellow-100 hover:border-yellow-400 transition-all duration-300 transform hover:-translate-y-1 active:translate-y-0 text-center">
|
| 47 |
+
导出账号
|
| 48 |
+
</button>
|
| 49 |
+
<router-link to="/settings"
|
| 50 |
+
class="action-button col-span-2 sm:col-span-1 font-bold border border-blue-200 bg-blue-50 text-blue-900 px-4 py-2 rounded-xl shadow-sm hover:bg-blue-100 hover:border-blue-400 transition-all duration-300 transform hover:-translate-y-1 active:translate-y-0 text-center">
|
| 51 |
+
系统设置
|
| 52 |
+
</router-link>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<!-- 分页控制区 -->
|
| 57 |
+
<div class="flex justify-between items-center px-4 mb-4">
|
| 58 |
+
<div class="flex items-center space-x-2">
|
| 59 |
+
<span class="text-gray-700">每页显示:</span>
|
| 60 |
+
<select v-model="pageSize" @change="changePageSize" class="rounded-lg border-gray-300 bg-white/50 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 transition-all duration-300">
|
| 61 |
+
<option :value="10">10</option>
|
| 62 |
+
<option :value="20">20</option>
|
| 63 |
+
<option :value="50">50</option>
|
| 64 |
+
<option :value="100">100</option>
|
| 65 |
+
<option :value="200">200</option>
|
| 66 |
+
</select>
|
| 67 |
+
</div>
|
| 68 |
+
<div class="flex space-x-2 items-center">
|
| 69 |
+
<span class="text-gray-700">共 {{ totalItems }} 项</span>
|
| 70 |
+
<button
|
| 71 |
+
@click="changePage(currentPage - 1)"
|
| 72 |
+
:disabled="currentPage === 1"
|
| 73 |
+
:class="[
|
| 74 |
+
'px-3 py-1 rounded-lg transition-all duration-300',
|
| 75 |
+
currentPage === 1 ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-blue-50 text-blue-700 hover:bg-blue-100'
|
| 76 |
+
]"
|
| 77 |
+
>
|
| 78 |
+
上一页
|
| 79 |
+
</button>
|
| 80 |
+
<span class="text-gray-700">{{ currentPage }}/{{ totalPages }}</span>
|
| 81 |
+
<button
|
| 82 |
+
@click="changePage(currentPage + 1)"
|
| 83 |
+
:disabled="currentPage === totalPages || totalPages === 0"
|
| 84 |
+
:class="[
|
| 85 |
+
'px-3 py-1 rounded-lg transition-all duration-300',
|
| 86 |
+
currentPage === totalPages || totalPages === 0 ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-blue-50 text-blue-700 hover:bg-blue-100'
|
| 87 |
+
]"
|
| 88 |
+
>
|
| 89 |
+
下一页
|
| 90 |
+
</button>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<!-- 多选操作区 -->
|
| 95 |
+
<div class="flex justify-between items-center px-4 mb-4">
|
| 96 |
+
<div class="flex items-center space-x-3">
|
| 97 |
+
<label class="inline-flex items-center cursor-pointer group">
|
| 98 |
+
<div class="relative">
|
| 99 |
+
<input type="checkbox"
|
| 100 |
+
v-model="selectAll"
|
| 101 |
+
@change="toggleSelectAll"
|
| 102 |
+
class="sr-only peer">
|
| 103 |
+
<div class="w-6 h-6 bg-white border-2 border-gray-300 rounded-lg peer-checked:bg-indigo-500 peer-checked:border-indigo-500 transition-all duration-300 flex items-center justify-center">
|
| 104 |
+
<svg v-show="selectAll" class="w-4 h-4 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
| 105 |
+
<polyline points="20 6 9 17 4 12"></polyline>
|
| 106 |
+
</svg>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
<span class="ml-2 text-gray-700 group-hover:text-indigo-700 transition-colors duration-200">全选</span>
|
| 110 |
+
</label>
|
| 111 |
+
<button
|
| 112 |
+
@click="deleteSelected"
|
| 113 |
+
:disabled="selectedTokens.length === 0"
|
| 114 |
+
:class="[
|
| 115 |
+
'px-4 py-1.5 rounded-lg transition-all duration-300 border flex items-center space-x-1',
|
| 116 |
+
selectedTokens.length === 0 ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-red-50 text-red-600 border-red-200 hover:bg-red-100'
|
| 117 |
+
]"
|
| 118 |
+
>
|
| 119 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
| 120 |
+
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
|
| 121 |
+
</svg>
|
| 122 |
+
<span>删除选中 ({{ selectedTokens.length }})</span>
|
| 123 |
+
</button>
|
| 124 |
+
</div>
|
| 125 |
+
<button
|
| 126 |
+
@click="showDeleteAllConfirm = true"
|
| 127 |
+
class="px-4 py-1.5 rounded-lg border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 transition-all duration-300 flex items-center space-x-1"
|
| 128 |
+
>
|
| 129 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
| 130 |
+
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
|
| 131 |
+
</svg>
|
| 132 |
+
<span>删除全部账号</span>
|
| 133 |
+
</button>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
<!-- Token列表 -->
|
| 137 |
+
<div class="max-h-[calc(75vh)] overflow-y-auto pr-2 scrollbar-hidden">
|
| 138 |
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-4">
|
| 139 |
+
<div v-for="token in displayedTokens"
|
| 140 |
+
:key="token.email"
|
| 141 |
+
class="token-card group relative overflow-hidden rounded-2xl transition-all duration-300 hover:shadow-2xl pt-4"
|
| 142 |
+
:class="{'ring-2 ring-indigo-500 ring-opacity-75': isSelected(token.email)}">
|
| 143 |
+
<div class="absolute top-3 left-3 z-10">
|
| 144 |
+
<label class="custom-checkbox cursor-pointer">
|
| 145 |
+
<input type="checkbox"
|
| 146 |
+
:checked="isSelected(token.email)"
|
| 147 |
+
@change="toggleSelect(token.email)"
|
| 148 |
+
class="sr-only peer">
|
| 149 |
+
<div class="checkbox-icon w-6 h-6 bg-white/70 backdrop-blur-sm border-2 border-gray-300 rounded-lg peer-checked:bg-indigo-500 peer-checked:border-indigo-500 transition-all duration-300 flex items-center justify-center shadow-sm hover:shadow">
|
| 150 |
+
<svg v-show="isSelected(token.email)" class="w-4 h-4 text-white transform scale-0 peer-checked:scale-100 transition-transform duration-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
| 151 |
+
<polyline points="20 6 9 17 4 12"></polyline>
|
| 152 |
+
</svg>
|
| 153 |
+
</div>
|
| 154 |
+
</label>
|
| 155 |
+
</div>
|
| 156 |
+
<div class="absolute inset-0 bg-white/30 backdrop-blur-md border border-white/30"></div>
|
| 157 |
+
<div class="relative p-6 flex flex-col gap-4">
|
| 158 |
+
<div class="flex flex-col space-y-3">
|
| 159 |
+
<div class="relative flex items-center bg-blue-50/80 rounded-lg px-2 py-1">
|
| 160 |
+
<div class="overflow-x-auto scrollbar-hide flex-1 flex items-center space-x-2">
|
| 161 |
+
<span class="text-gray-700 min-w-[96px] text-left font-semibold">📧 Email:</span>
|
| 162 |
+
<span class="font-medium whitespace-nowrap text-left">{{ token.email }}</span>
|
| 163 |
+
</div>
|
| 164 |
+
<button @click="copyToClipboard(token.email)" class="absolute right-2 opacity-0 hover:opacity-100 transition-opacity bg-blue-200 hover:bg-blue-300 rounded px-2 py-1 text-base">📋</button>
|
| 165 |
+
</div>
|
| 166 |
+
<div class="relative flex items-center bg-blue-50/80 rounded-lg px-2 py-1">
|
| 167 |
+
<div class="overflow-x-auto scrollbar-hide flex-1 flex items-center space-x-2">
|
| 168 |
+
<span class="text-gray-700 min-w-[96px] text-left font-semibold">🔑 Passwd:</span>
|
| 169 |
+
<span class="font-medium whitespace-nowrap text-left">{{ token.password }}</span>
|
| 170 |
+
</div>
|
| 171 |
+
<button @click="copyToClipboard(token.password)" class="absolute right-2 opacity-0 hover:opacity-100 transition-opacity bg-blue-200 hover:bg-blue-300 rounded px-2 py-1 text-base">📋</button>
|
| 172 |
+
</div>
|
| 173 |
+
<div class="relative flex items-center bg-blue-50/80 rounded-lg px-2 py-1">
|
| 174 |
+
<div class="overflow-x-auto scrollbar-hide flex-1 flex items-center space-x-2">
|
| 175 |
+
<span class="text-gray-700 min-w-[96px] text-left font-semibold">🔐 Token:</span>
|
| 176 |
+
<span class="font-medium whitespace-nowrap text-left text-sm">{{ token.token }}</span>
|
| 177 |
+
</div>
|
| 178 |
+
<button @click="copyToClipboard(token.token)" class="absolute right-2 opacity-0 hover:opacity-100 transition-opacity bg-blue-200 hover:bg-blue-300 rounded px-2 py-1 text-base">📋</button>
|
| 179 |
+
</div>
|
| 180 |
+
<div class="relative flex items-center bg-blue-50/80 rounded-lg px-2 py-1">
|
| 181 |
+
<div class="overflow-x-auto scrollbar-hide flex-1 flex items-center space-x-2">
|
| 182 |
+
<span class="text-gray-700 min-w-[96px] text-left font-semibold">⏰ Expire:</span>
|
| 183 |
+
<span class="font-medium whitespace-nowrap text-left">{{ new Date(token.expires * 1000).toLocaleString() }}</span>
|
| 184 |
+
</div>
|
| 185 |
+
<button @click="copyToClipboard(new Date(token.expires * 1000).toLocaleString())" class="absolute right-2 opacity-0 hover:opacity-100 transition-opacity bg-blue-200 hover:bg-blue-300 rounded px-2 py-1 text-base">📋</button>
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
<div class="pt-4 mt-auto border-t border-gray-200/50 space-y-2">
|
| 190 |
+
<button @click="refreshToken(token.email)"
|
| 191 |
+
:disabled="refreshingTokens.includes(token.email)"
|
| 192 |
+
:class="[
|
| 193 |
+
'w-full py-2 rounded-lg transition-all duration-300 flex items-center justify-center space-x-2',
|
| 194 |
+
refreshingTokens.includes(token.email)
|
| 195 |
+
? 'bg-green-400 text-white refreshing-button-green cursor-not-allowed'
|
| 196 |
+
: 'macaron-green-button text-green-600 hover:bg-green-100 border border-green-200'
|
| 197 |
+
]">
|
| 198 |
+
<span v-if="refreshingTokens.includes(token.email)" class="flex items-center space-x-2">
|
| 199 |
+
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 200 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 201 |
+
<path class="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 202 |
+
</svg>
|
| 203 |
+
<span>刷新中...</span>
|
| 204 |
+
</span>
|
| 205 |
+
<span v-else>刷新令牌</span>
|
| 206 |
+
</button>
|
| 207 |
+
<button @click="deleteToken(token.email)"
|
| 208 |
+
class="w-full group-hover:bg-red-50 text-red-600 py-2 rounded-lg transition-all duration-300 hover:bg-red-100">
|
| 209 |
+
删除账号
|
| 210 |
+
</button>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
|
| 218 |
+
<!-- 删除全部确认对话框 -->
|
| 219 |
+
<div v-if="showDeleteAllConfirm"
|
| 220 |
+
class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50"
|
| 221 |
+
@click.self="showDeleteAllConfirm = false">
|
| 222 |
+
<div class="relative bg-white/90 backdrop-blur-lg rounded-2xl p-6 w-11/12 max-w-md transform transition-all duration-300 scale-100 opacity-100">
|
| 223 |
+
<h2 class="text-2xl font-bold text-red-600 mb-4">⚠️ 危险操作</h2>
|
| 224 |
+
<p class="text-gray-700 mb-6">您确定要删除<span class="font-bold">全部 {{ totalItems }} 个</span>账号吗?此操作不可恢复!</p>
|
| 225 |
+
<div class="flex justify-end space-x-4">
|
| 226 |
+
<button @click="showDeleteAllConfirm = false"
|
| 227 |
+
class="px-4 py-2 rounded-xl bg-gray-100 hover:bg-gray-200 transition-all duration-300">
|
| 228 |
+
取消
|
| 229 |
+
</button>
|
| 230 |
+
<button @click="deleteAllAccounts"
|
| 231 |
+
class="px-4 py-2 rounded-xl bg-red-600 text-white hover:bg-red-700 transition-all duration-300">
|
| 232 |
+
确认删除
|
| 233 |
+
</button>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
|
| 238 |
+
<!-- 添加账号模态框 -->
|
| 239 |
+
<div v-if="showAddModal"
|
| 240 |
+
class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50"
|
| 241 |
+
@click.self="showAddModal = false">
|
| 242 |
+
<div class="relative bg-white/80 backdrop-blur-lg rounded-2xl p-6 w-11/12 max-w-md transform transition-all duration-300 scale-100 opacity-100">
|
| 243 |
+
<div class="flex mb-6 border-b border-gray-200">
|
| 244 |
+
<button :class="['flex-1 py-2 font-bold transition-all rounded-t-xl duration-300', addMode==='single' ? 'text-gray-600 border-b-2 border-gray-500 bg-gray-50/60' : 'text-gray-500 bg-transparent']" @click="addMode='single'">单账号添加</button>
|
| 245 |
+
<button :class="['flex-1 py-2 font-bold transition-all rounded-t-xl duration-300', addMode==='batch' ? 'text-gray-600 border-b-2 border-gray-500 bg-gray-50/60' : 'text-gray-500 bg-transparent']" @click="addMode='batch'">批量添加</button>
|
| 246 |
+
</div>
|
| 247 |
+
<transition name="fade" mode="out-in">
|
| 248 |
+
<div v-if="addMode==='single'" key="single">
|
| 249 |
+
<h2 class="text-xl font-bold mb-4">添加账号</h2>
|
| 250 |
+
<div class="space-y-4">
|
| 251 |
+
<div>
|
| 252 |
+
<label class="block text-sm font-medium text-gray-700">Email</label>
|
| 253 |
+
<input v-model="newAccount.email" type="email"
|
| 254 |
+
class="mt-1 block w-full rounded-xl border-gray-300 bg-white/50 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 transition-all duration-300 h-12 text-base px-4">
|
| 255 |
+
</div>
|
| 256 |
+
<div>
|
| 257 |
+
<label class="block text-sm font-medium text-gray-700">Password</label>
|
| 258 |
+
<input v-model="newAccount.password" type="password"
|
| 259 |
+
class="mt-1 block w-full rounded-xl border-gray-300 bg-white/50 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 transition-all duration-300 h-12 text-base px-4">
|
| 260 |
+
</div>
|
| 261 |
+
<div class="flex justify-end space-x-4 pt-4">
|
| 262 |
+
<button @click="showAddModal = false"
|
| 263 |
+
class="px-4 py-2 rounded-xl bg-gray-100 hover:bg-gray-200 transition-all duration-300">
|
| 264 |
+
取消
|
| 265 |
+
</button>
|
| 266 |
+
<button @click="addToken"
|
| 267 |
+
class="px-4 py-2 rounded-xl bg-black text-white hover:bg-white hover:text-black transition-all duration-300">
|
| 268 |
+
添加
|
| 269 |
+
</button>
|
| 270 |
+
</div>
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
<div v-else key="batch">
|
| 274 |
+
<h2 class="text-xl font-bold mb-4 px-4">批量添加账号</h2>
|
| 275 |
+
<div class="space-y-4">
|
| 276 |
+
<div>
|
| 277 |
+
<label class="block text-sm font-medium text-gray-700 px-4 pb-2">账号列表(每行一个,格式:email:password)</label>
|
| 278 |
+
<textarea v-model="batchAccounts" rows="6" class="mt-1 block w-full rounded-xl border-gray-300 bg-white/50 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 transition-all duration-300 h-36 text-base px-4 py-3 resize-none"></textarea>
|
| 279 |
+
</div>
|
| 280 |
+
<div class="flex justify-end space-x-4 pt-4">
|
| 281 |
+
<button @click="showAddModal = false"
|
| 282 |
+
class="px-4 py-2 rounded-xl bg-gray-100 hover:bg-gray-200 transition-all duration-300">
|
| 283 |
+
取消
|
| 284 |
+
</button>
|
| 285 |
+
<button @click="addBatchTokens"
|
| 286 |
+
class="px-4 py-2 rounded-xl bg-black text-white hover:bg-white hover:text-black transition-all duration-300">
|
| 287 |
+
批量添加
|
| 288 |
+
</button>
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
</div>
|
| 292 |
+
</transition>
|
| 293 |
+
</div>
|
| 294 |
+
</div>
|
| 295 |
+
|
| 296 |
+
<!-- Toast 通知 -->
|
| 297 |
+
<div v-if="toast.show"
|
| 298 |
+
:class="[
|
| 299 |
+
'fixed top-4 right-4 z-50 px-6 py-4 rounded-xl shadow-lg transform transition-all duration-300',
|
| 300 |
+
toast.type === 'success' ? 'bg-emerald-500 text-white' : 'bg-red-500 text-white'
|
| 301 |
+
]">
|
| 302 |
+
<div class="flex items-center space-x-2">
|
| 303 |
+
<svg v-if="toast.type === 'success'" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
| 304 |
+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
| 305 |
+
</svg>
|
| 306 |
+
<svg v-else class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
| 307 |
+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
| 308 |
+
</svg>
|
| 309 |
+
<span>{{ toast.message }}</span>
|
| 310 |
+
</div>
|
| 311 |
+
</div>
|
| 312 |
+
</div>
|
| 313 |
+
</template>
|
| 314 |
+
|
| 315 |
+
<script setup>
|
| 316 |
+
import { ref, onMounted, computed } from 'vue'
|
| 317 |
+
import axios from 'axios'
|
| 318 |
+
|
| 319 |
+
const tokens = ref([])
|
| 320 |
+
const showAddModal = ref(false)
|
| 321 |
+
const addMode = ref('single')
|
| 322 |
+
const newAccount = ref({
|
| 323 |
+
email: '',
|
| 324 |
+
password: ''
|
| 325 |
+
})
|
| 326 |
+
const batchAccounts = ref('')
|
| 327 |
+
|
| 328 |
+
// 分页相关
|
| 329 |
+
const displayedTokens = ref([])
|
| 330 |
+
const currentPage = ref(1)
|
| 331 |
+
const pageSize = ref(10)
|
| 332 |
+
const totalItems = ref(0)
|
| 333 |
+
const totalPages = computed(() => Math.max(1, Math.ceil(totalItems.value / pageSize.value)))
|
| 334 |
+
const isLoading = ref(false)
|
| 335 |
+
|
| 336 |
+
// 多选相关
|
| 337 |
+
const selectedTokens = ref([])
|
| 338 |
+
const selectAll = ref(false)
|
| 339 |
+
const showDeleteAllConfirm = ref(false)
|
| 340 |
+
|
| 341 |
+
// 刷新相关
|
| 342 |
+
const isRefreshingAll = ref(false)
|
| 343 |
+
const isForceRefreshingAll = ref(false)
|
| 344 |
+
const refreshingTokens = ref([])
|
| 345 |
+
|
| 346 |
+
// Toast 通知
|
| 347 |
+
const toast = ref({
|
| 348 |
+
show: false,
|
| 349 |
+
message: '',
|
| 350 |
+
type: 'success'
|
| 351 |
+
})
|
| 352 |
+
|
| 353 |
+
const isSelected = (email) => {
|
| 354 |
+
return selectedTokens.value.includes(email)
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
const toggleSelect = (email) => {
|
| 358 |
+
const index = selectedTokens.value.indexOf(email)
|
| 359 |
+
if (index === -1) {
|
| 360 |
+
selectedTokens.value.push(email)
|
| 361 |
+
} else {
|
| 362 |
+
selectedTokens.value.splice(index, 1)
|
| 363 |
+
}
|
| 364 |
+
// 更新全选状态
|
| 365 |
+
selectAll.value = selectedTokens.value.length === displayedTokens.value.length
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
const toggleSelectAll = () => {
|
| 369 |
+
if (selectAll.value) {
|
| 370 |
+
// 全选当前页
|
| 371 |
+
selectedTokens.value = displayedTokens.value.map(token => token.email)
|
| 372 |
+
} else {
|
| 373 |
+
// 取消全选
|
| 374 |
+
selectedTokens.value = []
|
| 375 |
+
}
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
const deleteSelected = async () => {
|
| 379 |
+
if (selectedTokens.value.length === 0) return
|
| 380 |
+
|
| 381 |
+
if (!confirm(`确定要删除选中的 ${selectedTokens.value.length} 个账号吗?`)) return
|
| 382 |
+
|
| 383 |
+
try {
|
| 384 |
+
// 批量删除,这里假设后端支持批量删除,如果不支持,需要循环调用单个删除
|
| 385 |
+
const deletePromises = selectedTokens.value.map(email =>
|
| 386 |
+
axios.delete('/api/deleteAccount', {
|
| 387 |
+
data: { email },
|
| 388 |
+
headers: {
|
| 389 |
+
'Authorization': localStorage.getItem('apiKey') || ''
|
| 390 |
+
}
|
| 391 |
+
})
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
await Promise.all(deletePromises)
|
| 395 |
+
await getTokens()
|
| 396 |
+
selectedTokens.value = []
|
| 397 |
+
selectAll.value = false
|
| 398 |
+
showToast('删除成功')
|
| 399 |
+
} catch (error) {
|
| 400 |
+
console.error('批量删除失败:', error)
|
| 401 |
+
showToast('批量删除失败: ' + error.message, 'error')
|
| 402 |
+
}
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
const deleteAllAccounts = async () => {
|
| 406 |
+
try {
|
| 407 |
+
// 先获取全部账号数据
|
| 408 |
+
const res = await axios.get('/api/getAllAccounts', {
|
| 409 |
+
params: { page: 1, pageSize: 10000 },
|
| 410 |
+
headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
|
| 411 |
+
})
|
| 412 |
+
const allAccounts = res.data.data
|
| 413 |
+
|
| 414 |
+
const deletePromises = allAccounts.map(token =>
|
| 415 |
+
axios.delete('/api/deleteAccount', {
|
| 416 |
+
data: { email: token.email },
|
| 417 |
+
headers: {
|
| 418 |
+
'Authorization': localStorage.getItem('apiKey') || ''
|
| 419 |
+
}
|
| 420 |
+
})
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
await Promise.all(deletePromises)
|
| 424 |
+
showDeleteAllConfirm.value = false
|
| 425 |
+
currentPage.value = 1
|
| 426 |
+
await getTokens()
|
| 427 |
+
selectedTokens.value = []
|
| 428 |
+
selectAll.value = false
|
| 429 |
+
showToast('所有账号已删除')
|
| 430 |
+
} catch (error) {
|
| 431 |
+
console.error('删除所有账号失败:', error)
|
| 432 |
+
showToast('删除所有账号失败: ' + error.message, 'error')
|
| 433 |
+
}
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
const changePage = async (page) => {
|
| 437 |
+
if (page >= 1 && page <= totalPages.value) {
|
| 438 |
+
currentPage.value = page
|
| 439 |
+
// 重置选择状态
|
| 440 |
+
selectedTokens.value = []
|
| 441 |
+
selectAll.value = false
|
| 442 |
+
await getTokens()
|
| 443 |
+
}
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
const changePageSize = async () => {
|
| 447 |
+
currentPage.value = 1
|
| 448 |
+
// 重置选择状态
|
| 449 |
+
selectedTokens.value = []
|
| 450 |
+
selectAll.value = false
|
| 451 |
+
await getTokens()
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
const showToast = (message, type = 'success') => {
|
| 455 |
+
toast.value.message = message
|
| 456 |
+
toast.value.type = type
|
| 457 |
+
toast.value.show = true
|
| 458 |
+
|
| 459 |
+
setTimeout(() => {
|
| 460 |
+
toast.value.show = false
|
| 461 |
+
}, 3000)
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
const copyToClipboard = async (text) => {
|
| 465 |
+
try {
|
| 466 |
+
await navigator.clipboard.writeText(text)
|
| 467 |
+
showToast('已复制到剪贴板')
|
| 468 |
+
} catch (err) {
|
| 469 |
+
console.error('复制失败:', err)
|
| 470 |
+
showToast('复制失败', 'error')
|
| 471 |
+
}
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
const getTokens = async () => {
|
| 475 |
+
isLoading.value = true
|
| 476 |
+
try {
|
| 477 |
+
const res = await axios.get('/api/getAllAccounts', {
|
| 478 |
+
params: {
|
| 479 |
+
page: currentPage.value,
|
| 480 |
+
pageSize: pageSize.value
|
| 481 |
+
},
|
| 482 |
+
headers: {
|
| 483 |
+
'Authorization': localStorage.getItem('apiKey') || ''
|
| 484 |
+
}
|
| 485 |
+
})
|
| 486 |
+
|
| 487 |
+
displayedTokens.value = res.data.data
|
| 488 |
+
totalItems.value = res.data.total
|
| 489 |
+
|
| 490 |
+
// 如果当前页超出了总页数,重置到第一页并重新获取
|
| 491 |
+
if (currentPage.value > totalPages.value && totalPages.value > 0) {
|
| 492 |
+
currentPage.value = 1
|
| 493 |
+
await getTokens()
|
| 494 |
+
return
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
// 重置选择状态
|
| 498 |
+
selectedTokens.value = []
|
| 499 |
+
selectAll.value = false
|
| 500 |
+
|
| 501 |
+
} catch (error) {
|
| 502 |
+
console.error('获取Token列表失败:', error)
|
| 503 |
+
showToast('获取Token列表失败: ' + error.message, 'error')
|
| 504 |
+
} finally {
|
| 505 |
+
isLoading.value = false
|
| 506 |
+
}
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
const addToken = async () => {
|
| 510 |
+
try {
|
| 511 |
+
await axios.post('/api/setAccount', newAccount.value, {
|
| 512 |
+
headers: {
|
| 513 |
+
'Authorization': localStorage.getItem('apiKey') || ''
|
| 514 |
+
}
|
| 515 |
+
})
|
| 516 |
+
showAddModal.value = false
|
| 517 |
+
newAccount.value = { email: '', password: '' }
|
| 518 |
+
await getTokens()
|
| 519 |
+
showToast('添加账号成功')
|
| 520 |
+
} catch (error) {
|
| 521 |
+
console.error('添加账号失败:', error)
|
| 522 |
+
showToast('添加账号失败: ' + error.message, 'error')
|
| 523 |
+
}
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
const addBatchTokens = async () => {
|
| 527 |
+
try {
|
| 528 |
+
await axios.post('/api/setAccounts', { accounts: batchAccounts.value }, {
|
| 529 |
+
headers: {
|
| 530 |
+
'Authorization': localStorage.getItem('apiKey') || ''
|
| 531 |
+
}
|
| 532 |
+
})
|
| 533 |
+
showAddModal.value = false
|
| 534 |
+
batchAccounts.value = ''
|
| 535 |
+
await getTokens()
|
| 536 |
+
showToast('批量添加任务已提交')
|
| 537 |
+
} catch (error) {
|
| 538 |
+
console.error('批量添加失败:', error)
|
| 539 |
+
showToast('批量添加失败: ' + error.message, 'error')
|
| 540 |
+
}
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
const refreshToken = async (email) => {
|
| 544 |
+
if (refreshingTokens.value.includes(email)) return
|
| 545 |
+
|
| 546 |
+
refreshingTokens.value.push(email)
|
| 547 |
+
|
| 548 |
+
try {
|
| 549 |
+
await axios.post('/api/refreshAccount', { email }, {
|
| 550 |
+
headers: {
|
| 551 |
+
'Authorization': localStorage.getItem('apiKey') || ''
|
| 552 |
+
}
|
| 553 |
+
})
|
| 554 |
+
|
| 555 |
+
// 刷新成功后重新获取账号列表
|
| 556 |
+
await getTokens()
|
| 557 |
+
showToast(`账号 ${email} 令牌刷新成功`)
|
| 558 |
+
} catch (error) {
|
| 559 |
+
console.error('刷新账号令牌失败:', error)
|
| 560 |
+
showToast('刷新账号令牌失败: ' + error.message, 'error')
|
| 561 |
+
} finally {
|
| 562 |
+
// 移除刷新状态
|
| 563 |
+
const index = refreshingTokens.value.indexOf(email)
|
| 564 |
+
if (index > -1) {
|
| 565 |
+
refreshingTokens.value.splice(index, 1)
|
| 566 |
+
}
|
| 567 |
+
}
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
const refreshAllAccounts = async () => {
|
| 571 |
+
if (isRefreshingAll.value) return
|
| 572 |
+
|
| 573 |
+
if (!confirm('确定要刷新所有账号的令牌吗?这可能需要一些时间。')) return
|
| 574 |
+
|
| 575 |
+
isRefreshingAll.value = true
|
| 576 |
+
|
| 577 |
+
try {
|
| 578 |
+
const response = await axios.post('/api/refreshAllAccounts', {
|
| 579 |
+
thresholdHours: 24
|
| 580 |
+
}, {
|
| 581 |
+
headers: {
|
| 582 |
+
'Authorization': localStorage.getItem('apiKey') || ''
|
| 583 |
+
}
|
| 584 |
+
})
|
| 585 |
+
|
| 586 |
+
// 刷新成功后重新获取账号列表
|
| 587 |
+
await getTokens()
|
| 588 |
+
showToast(`批量刷新完成,成功刷新了 ${response.data.refreshedCount} 个账号`)
|
| 589 |
+
} catch (error) {
|
| 590 |
+
console.error('批量刷新失败:', error)
|
| 591 |
+
showToast('批量刷新失败: ' + error.message, 'error')
|
| 592 |
+
} finally {
|
| 593 |
+
isRefreshingAll.value = false
|
| 594 |
+
}
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
const forceRefreshAllAccounts = async () => {
|
| 598 |
+
if (isForceRefreshingAll.value) return
|
| 599 |
+
|
| 600 |
+
if (!confirm('确定要强制刷新所有账号的令牌吗?这将刷新所有账号,不管它们是否即将过期,可能需要较长时间。')) return
|
| 601 |
+
|
| 602 |
+
isForceRefreshingAll.value = true
|
| 603 |
+
|
| 604 |
+
try {
|
| 605 |
+
const response = await axios.post('/api/forceRefreshAllAccounts', {}, {
|
| 606 |
+
headers: {
|
| 607 |
+
'Authorization': localStorage.getItem('apiKey') || ''
|
| 608 |
+
}
|
| 609 |
+
})
|
| 610 |
+
|
| 611 |
+
// 刷新成功后重新获取账号列表
|
| 612 |
+
await getTokens()
|
| 613 |
+
showToast(`强制刷新完成,成功刷新了 ${response.data.refreshedCount} 个账号`)
|
| 614 |
+
} catch (error) {
|
| 615 |
+
console.error('强制刷新失败:', error)
|
| 616 |
+
showToast('强制刷新失败: ' + error.message, 'error')
|
| 617 |
+
} finally {
|
| 618 |
+
isForceRefreshingAll.value = false
|
| 619 |
+
}
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
const deleteToken = async (email) => {
|
| 623 |
+
if (!confirm('确定要删除此账号吗?')) return
|
| 624 |
+
|
| 625 |
+
try {
|
| 626 |
+
await axios.delete('/api/deleteAccount', {
|
| 627 |
+
data: { email },
|
| 628 |
+
headers: {
|
| 629 |
+
'Authorization': localStorage.getItem('apiKey') || ''
|
| 630 |
+
}
|
| 631 |
+
})
|
| 632 |
+
await getTokens()
|
| 633 |
+
showToast('删除账号成功')
|
| 634 |
+
} catch (error) {
|
| 635 |
+
console.error('删除账号失败:', error)
|
| 636 |
+
showToast('删除账号失败: ' + error.message, 'error')
|
| 637 |
+
}
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
const exportAccounts = async () => {
|
| 641 |
+
try {
|
| 642 |
+
// 获取全部账号用于导出
|
| 643 |
+
const res = await axios.get('/api/getAllAccounts', {
|
| 644 |
+
params: { page: 1, pageSize: 10000 },
|
| 645 |
+
headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
|
| 646 |
+
})
|
| 647 |
+
const allAccounts = res.data.data
|
| 648 |
+
|
| 649 |
+
if (allAccounts.length === 0) {
|
| 650 |
+
showToast('没有可导出的账号', 'error')
|
| 651 |
+
return
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
// 构建导出内容,格式为"账号:密码",每行一个
|
| 655 |
+
const content = allAccounts.map(token => `${token.email}:${token.password}`).join('\n')
|
| 656 |
+
|
| 657 |
+
// 创建Blob对象
|
| 658 |
+
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
|
| 659 |
+
|
| 660 |
+
// 创建下载链接并触发下载
|
| 661 |
+
const url = URL.createObjectURL(blob)
|
| 662 |
+
const link = document.createElement('a')
|
| 663 |
+
link.href = url
|
| 664 |
+
link.download = 'qwen_accounts.txt'
|
| 665 |
+
document.body.appendChild(link)
|
| 666 |
+
link.click()
|
| 667 |
+
|
| 668 |
+
// 清理
|
| 669 |
+
setTimeout(() => {
|
| 670 |
+
document.body.removeChild(link)
|
| 671 |
+
URL.revokeObjectURL(url)
|
| 672 |
+
}, 100)
|
| 673 |
+
|
| 674 |
+
showToast('导出完成')
|
| 675 |
+
} catch (error) {
|
| 676 |
+
console.error('导出失败:', error)
|
| 677 |
+
showToast('导出失败: ' + error.message, 'error')
|
| 678 |
+
}
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
onMounted(() => {
|
| 682 |
+
getTokens()
|
| 683 |
+
})
|
| 684 |
+
</script>
|
| 685 |
+
|
| 686 |
+
<style lang="css" scoped>
|
| 687 |
+
@media (max-width: 640px) {
|
| 688 |
+
.container {
|
| 689 |
+
padding: 0;
|
| 690 |
+
}
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
.fade-enter-active, .fade-leave-active {
|
| 694 |
+
transition: opacity 0.3s, transform 0.3s;
|
| 695 |
+
}
|
| 696 |
+
.fade-enter-from, .fade-leave-to {
|
| 697 |
+
opacity: 0;
|
| 698 |
+
transform: translateY(10px);
|
| 699 |
+
}
|
| 700 |
+
.fade-enter-to, .fade-leave-from {
|
| 701 |
+
opacity: 1;
|
| 702 |
+
transform: translateY(0);
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
.token-card {
|
| 706 |
+
background: linear-gradient(135deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.3));
|
| 707 |
+
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15);
|
| 708 |
+
transform: translateY(0);
|
| 709 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
.token-card:hover {
|
| 713 |
+
transform: translateY(-5px);
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
.scrollbar-hide {
|
| 717 |
+
-ms-overflow-style: none;
|
| 718 |
+
scrollbar-width: none;
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
.scrollbar-hide::-webkit-scrollbar {
|
| 722 |
+
display: none;
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
@keyframes slideIn {
|
| 726 |
+
from {
|
| 727 |
+
transform: translateY(20px);
|
| 728 |
+
opacity: 0;
|
| 729 |
+
}
|
| 730 |
+
to {
|
| 731 |
+
transform: translateY(0);
|
| 732 |
+
opacity: 1;
|
| 733 |
+
}
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
.token-card {
|
| 737 |
+
animation: slideIn 0.5s ease-out;
|
| 738 |
+
animation-fill-mode: both;
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
.token-card:nth-child(3n+1) { animation-delay: 0.1s; }
|
| 742 |
+
.token-card:nth-child(3n+2) { animation-delay: 0.2s; }
|
| 743 |
+
.token-card:nth-child(3n+3) { animation-delay: 0.3s; }
|
| 744 |
+
|
| 745 |
+
.overflow-x-auto {
|
| 746 |
+
position: relative;
|
| 747 |
+
cursor: pointer;
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
.overflow-x-auto::after {
|
| 751 |
+
content: '';
|
| 752 |
+
position: absolute;
|
| 753 |
+
right: 0;
|
| 754 |
+
top: 0;
|
| 755 |
+
bottom: 0;
|
| 756 |
+
width: 24px;
|
| 757 |
+
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.8));
|
| 758 |
+
pointer-events: none;
|
| 759 |
+
opacity: 0;
|
| 760 |
+
transition: opacity 0.3s;
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
.overflow-x-auto:hover::after {
|
| 764 |
+
opacity: 1;
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
/* 隐藏滚动条样式 */
|
| 768 |
+
.scrollbar-hidden {
|
| 769 |
+
-ms-overflow-style: none; /* IE and Edge */
|
| 770 |
+
scrollbar-width: none; /* Firefox */
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
.scrollbar-hidden::-webkit-scrollbar {
|
| 774 |
+
display: none; /* Chrome, Safari and Opera */
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
/* 自定义滚动条样式(备用) */
|
| 778 |
+
.max-h-\[calc\(100vh-200px\)\]::-webkit-scrollbar {
|
| 779 |
+
width: 6px;
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
.max-h-\[calc\(100vh-200px\)\]::-webkit-scrollbar-track {
|
| 783 |
+
background: rgba(0, 0, 0, 0.05);
|
| 784 |
+
border-radius: 8px;
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
.max-h-\[calc\(100vh-200px\)\]::-webkit-scrollbar-thumb {
|
| 788 |
+
background-color: rgba(0, 0, 0, 0.1);
|
| 789 |
+
border-radius: 8px;
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
.max-h-\[calc\(100vh-200px\)\]::-webkit-scrollbar-thumb:hover {
|
| 793 |
+
background-color: rgba(0, 0, 0, 0.2);
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
/* 自定义复选框样式 */
|
| 797 |
+
.custom-checkbox .checkbox-icon {
|
| 798 |
+
position: relative;
|
| 799 |
+
overflow: hidden;
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
.custom-checkbox .checkbox-icon:before {
|
| 803 |
+
content: '';
|
| 804 |
+
position: absolute;
|
| 805 |
+
top: 0;
|
| 806 |
+
left: 0;
|
| 807 |
+
width: 0;
|
| 808 |
+
height: 100%;
|
| 809 |
+
background: rgba(99, 102, 241, 0.1);
|
| 810 |
+
transition: width 0.3s ease;
|
| 811 |
+
}
|
| 812 |
+
|
| 813 |
+
.custom-checkbox:hover .checkbox-icon:before {
|
| 814 |
+
width: 100%;
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
.custom-checkbox input:checked + .checkbox-icon svg {
|
| 818 |
+
animation: check-animation 0.5s cubic-bezier(0.17, 0.67, 0.83, 0.67);
|
| 819 |
+
transform: scale(1);
|
| 820 |
+
}
|
| 821 |
+
|
| 822 |
+
@keyframes check-animation {
|
| 823 |
+
0% {
|
| 824 |
+
transform: scale(0);
|
| 825 |
+
}
|
| 826 |
+
50% {
|
| 827 |
+
transform: scale(1.2);
|
| 828 |
+
}
|
| 829 |
+
100% {
|
| 830 |
+
transform: scale(1);
|
| 831 |
+
}
|
| 832 |
+
}
|
| 833 |
+
|
| 834 |
+
/* 给选中的卡片添加动画效果 */
|
| 835 |
+
.token-card.ring-2 {
|
| 836 |
+
animation: selected-pulse 2s infinite;
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
@keyframes selected-pulse {
|
| 840 |
+
0% {
|
| 841 |
+
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4);
|
| 842 |
+
}
|
| 843 |
+
70% {
|
| 844 |
+
box-shadow: 0 0 0 6px rgba(99, 102, 241, 0);
|
| 845 |
+
}
|
| 846 |
+
100% {
|
| 847 |
+
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0);
|
| 848 |
+
}
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
/* 马卡龙紫色刷新按钮动画 */
|
| 852 |
+
@keyframes refresh-pulse-purple {
|
| 853 |
+
0% {
|
| 854 |
+
box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.4);
|
| 855 |
+
}
|
| 856 |
+
70% {
|
| 857 |
+
box-shadow: 0 0 0 6px rgba(168, 85, 247, 0);
|
| 858 |
+
}
|
| 859 |
+
100% {
|
| 860 |
+
box-shadow: 0 0 0 0 rgba(168, 85, 247, 0);
|
| 861 |
+
}
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
/* 马卡龙绿色刷新按钮动画 */
|
| 865 |
+
@keyframes refresh-pulse-green {
|
| 866 |
+
0% {
|
| 867 |
+
box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.4);
|
| 868 |
+
}
|
| 869 |
+
70% {
|
| 870 |
+
box-shadow: 0 0 0 6px rgba(74, 222, 128, 0);
|
| 871 |
+
}
|
| 872 |
+
100% {
|
| 873 |
+
box-shadow: 0 0 0 0 rgba(74, 222, 128, 0);
|
| 874 |
+
}
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
/* 马卡龙粉色刷新按钮动画 */
|
| 878 |
+
@keyframes refresh-pulse-pink {
|
| 879 |
+
0% {
|
| 880 |
+
box-shadow: 0 0 0 0 rgba(236, 72, 153, 0.4);
|
| 881 |
+
}
|
| 882 |
+
70% {
|
| 883 |
+
box-shadow: 0 0 0 6px rgba(236, 72, 153, 0);
|
| 884 |
+
}
|
| 885 |
+
100% {
|
| 886 |
+
box-shadow: 0 0 0 0 rgba(236, 72, 153, 0);
|
| 887 |
+
}
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
.action-button:hover {
|
| 891 |
+
animation: refresh-pulse-purple 1.5s infinite;
|
| 892 |
+
}
|
| 893 |
+
|
| 894 |
+
/* 刷新中的按钮样式 - 马卡龙紫色 */
|
| 895 |
+
.refreshing-button-purple {
|
| 896 |
+
background: linear-gradient(45deg, #c084fc, #a855f7);
|
| 897 |
+
color: white;
|
| 898 |
+
animation: refresh-pulse-purple 1.5s infinite;
|
| 899 |
+
box-shadow: 0 4px 15px rgba(168, 85, 247, 0.3);
|
| 900 |
+
}
|
| 901 |
+
|
| 902 |
+
/* 刷新中的按钮样式 - 马卡龙绿色 */
|
| 903 |
+
.refreshing-button-green {
|
| 904 |
+
background: linear-gradient(45deg, #86efac, #4ade80);
|
| 905 |
+
color: white;
|
| 906 |
+
animation: refresh-pulse-green 1.5s infinite;
|
| 907 |
+
box-shadow: 0 4px 15px rgba(74, 222, 128, 0.3);
|
| 908 |
+
}
|
| 909 |
+
|
| 910 |
+
/* 刷新中的按钮样式 - 马卡龙粉色 */
|
| 911 |
+
.refreshing-button-pink {
|
| 912 |
+
background: linear-gradient(45deg, #f472b6, #ec4899);
|
| 913 |
+
color: white;
|
| 914 |
+
animation: refresh-pulse-pink 1.5s infinite;
|
| 915 |
+
box-shadow: 0 4px 15px rgba(236, 72, 153, 0.3);
|
| 916 |
+
}
|
| 917 |
+
|
| 918 |
+
/* 马卡龙色系按钮增强效果 */
|
| 919 |
+
.action-button {
|
| 920 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 921 |
+
backdrop-filter: blur(10px);
|
| 922 |
+
}
|
| 923 |
+
|
| 924 |
+
.action-button:hover {
|
| 925 |
+
transform: translateY(-2px);
|
| 926 |
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
| 927 |
+
}
|
| 928 |
+
|
| 929 |
+
/* 单个刷新按钮的马卡龙绿色样式增强 */
|
| 930 |
+
.text-green-600:hover {
|
| 931 |
+
background: linear-gradient(135deg, #dcfce7, #bbf7d0) !important;
|
| 932 |
+
border-color: #86efac !important;
|
| 933 |
+
transform: translateY(-1px);
|
| 934 |
+
box-shadow: 0 4px 12px rgba(74, 222, 128, 0.2);
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
/* 绿色刷新按钮的基础样式 */
|
| 938 |
+
.bg-green-50 {
|
| 939 |
+
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
|
| 940 |
+
border: 1px solid #bbf7d0;
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
.bg-green-50:hover {
|
| 944 |
+
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
|
| 945 |
+
border-color: #86efac;
|
| 946 |
+
transform: translateY(-1px);
|
| 947 |
+
box-shadow: 0 4px 12px rgba(74, 222, 128, 0.2);
|
| 948 |
+
}
|
| 949 |
+
|
| 950 |
+
/* 马卡龙绿色按钮样式 */
|
| 951 |
+
.macaron-green-button {
|
| 952 |
+
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
|
| 953 |
+
border: 1px solid #bbf7d0;
|
| 954 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 955 |
+
}
|
| 956 |
+
|
| 957 |
+
.macaron-green-button:hover {
|
| 958 |
+
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
|
| 959 |
+
border-color: #86efac;
|
| 960 |
+
transform: translateY(-1px);
|
| 961 |
+
box-shadow: 0 4px 12px rgba(74, 222, 128, 0.2);
|
| 962 |
+
}
|
| 963 |
+
|
| 964 |
+
/* 马卡龙紫色按钮样式 */
|
| 965 |
+
.macaron-purple-button {
|
| 966 |
+
background: linear-gradient(135deg, #faf5ff, #f3e8ff);
|
| 967 |
+
border: 1px solid #e9d5ff;
|
| 968 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 969 |
+
}
|
| 970 |
+
|
| 971 |
+
.macaron-purple-button:hover {
|
| 972 |
+
background: linear-gradient(135deg, #f3e8ff, #e9d5ff);
|
| 973 |
+
border-color: #c4b5fd;
|
| 974 |
+
transform: translateY(-2px);
|
| 975 |
+
box-shadow: 0 4px 15px rgba(168, 85, 247, 0.2);
|
| 976 |
+
}
|
| 977 |
+
|
| 978 |
+
/* 马卡龙粉色按钮样式 */
|
| 979 |
+
.macaron-pink-button {
|
| 980 |
+
background: linear-gradient(135deg, #fdf2f8, #fce7f3);
|
| 981 |
+
border: 1px solid #f9a8d4;
|
| 982 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
.macaron-pink-button:hover {
|
| 986 |
+
background: linear-gradient(135deg, #fce7f3, #fbcfe8);
|
| 987 |
+
border-color: #f472b6;
|
| 988 |
+
transform: translateY(-2px);
|
| 989 |
+
box-shadow: 0 4px 15px rgba(236, 72, 153, 0.2);
|
| 990 |
+
}
|
| 991 |
+
|
| 992 |
+
/* 响应式优化 */
|
| 993 |
+
@media (max-width: 640px) {
|
| 994 |
+
.action-button {
|
| 995 |
+
min-height: 44px;
|
| 996 |
+
font-size: 0.875rem;
|
| 997 |
+
padding: 0.6rem 1rem;
|
| 998 |
+
display: flex;
|
| 999 |
+
align-items: center;
|
| 1000 |
+
justify-content: center;
|
| 1001 |
+
}
|
| 1002 |
+
|
| 1003 |
+
.container {
|
| 1004 |
+
padding: 0 0.5rem;
|
| 1005 |
+
}
|
| 1006 |
+
|
| 1007 |
+
/* 分页按钮 */
|
| 1008 |
+
.flex.space-x-2.items-center button {
|
| 1009 |
+
min-height: 40px;
|
| 1010 |
+
min-width: 72px;
|
| 1011 |
+
font-size: 0.875rem;
|
| 1012 |
+
}
|
| 1013 |
+
|
| 1014 |
+
/* 多选操作按钮 */
|
| 1015 |
+
.flex.justify-between.items-center button {
|
| 1016 |
+
min-height: 40px;
|
| 1017 |
+
padding: 0.5rem 0.875rem;
|
| 1018 |
+
}
|
| 1019 |
+
|
| 1020 |
+
/* 卡片内按钮 */
|
| 1021 |
+
.token-card button {
|
| 1022 |
+
min-height: 44px;
|
| 1023 |
+
}
|
| 1024 |
+
}
|
| 1025 |
+
</style>
|
public/src/views/settings.vue
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="w-full min-h-screen p-4">
|
| 3 |
+
<div class="container mx-auto">
|
| 4 |
+
<div class="flex flex-col md:flex-row justify-between items-center mb-6 px-4 space-y-4 md:space-y-0 pt-5">
|
| 5 |
+
<h1 class="text-3xl font-bold">系统设置</h1>
|
| 6 |
+
<router-link to="/"
|
| 7 |
+
class="action-button font-bold border border-blue-200 bg-blue-50 text-blue-900 px-4 py-2 rounded-xl shadow-sm hover:bg-blue-100 hover:border-blue-400 transition-all duration-300 transform hover:-translate-y-1 active:translate-y-0 text-center">
|
| 8 |
+
返回Token管理
|
| 9 |
+
</router-link>
|
| 10 |
+
</div>
|
| 11 |
+
<div class="grid grid-cols-1 gap-6 p-4">
|
| 12 |
+
<!-- API Key 管理 -->
|
| 13 |
+
<div class="setting-card relative overflow-hidden rounded-2xl p-6 flex flex-col gap-4">
|
| 14 |
+
<div class="absolute inset-0 bg-white/30 backdrop-blur-md border border-white/30 rounded-2xl"></div>
|
| 15 |
+
<div class="relative flex flex-col gap-4">
|
| 16 |
+
<label class="text-gray-700 font-semibold text-lg">🔑 API Key 管理</label>
|
| 17 |
+
|
| 18 |
+
<!-- 管理员密钥 -->
|
| 19 |
+
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
| 20 |
+
<div class="flex items-center gap-2 mb-2">
|
| 21 |
+
<span class="text-yellow-600 font-semibold">👑 管理员密钥</span>
|
| 22 |
+
<span class="text-xs bg-yellow-200 text-yellow-800 px-2 py-1 rounded">不可修改</span>
|
| 23 |
+
</div>
|
| 24 |
+
<input :value="settings.adminKey" type="text" readonly
|
| 25 |
+
class="w-full rounded-lg border-gray-300 bg-gray-100 shadow-sm h-10 text-sm px-3 cursor-not-allowed">
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<!-- 普通密钥列表 -->
|
| 29 |
+
<div class="space-y-2">
|
| 30 |
+
<div class="flex items-center justify-between">
|
| 31 |
+
<span class="text-gray-700 font-semibold">🔐 普通密钥</span>
|
| 32 |
+
<button @click="showAddKeyModal = true"
|
| 33 |
+
class="bg-green-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-green-600 transition-all">
|
| 34 |
+
+ 添加密钥
|
| 35 |
+
</button>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<div v-if="settings.regularKeys.length === 0" class="text-gray-500 text-center py-4">
|
| 39 |
+
暂无普通密钥
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<div v-for="(key, index) in settings.regularKeys" :key="index"
|
| 43 |
+
class="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-lg p-3">
|
| 44 |
+
<input :value="key" type="text" readonly
|
| 45 |
+
class="flex-1 rounded-lg border-gray-300 bg-white shadow-sm h-8 text-sm px-3">
|
| 46 |
+
<button @click="deleteRegularKey(index)"
|
| 47 |
+
class="bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600 transition-all">
|
| 48 |
+
删除
|
| 49 |
+
</button>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<!-- 其他设置项 -->
|
| 56 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 57 |
+
<!-- 自动刷新 -->
|
| 58 |
+
<div class="setting-card relative overflow-hidden rounded-2xl p-6 flex flex-col gap-4">
|
| 59 |
+
<div class="absolute inset-0 bg-white/30 backdrop-blur-md border border-white/30 rounded-2xl">
|
| 60 |
+
</div>
|
| 61 |
+
<div class="relative flex flex-col gap-2">
|
| 62 |
+
<label class="text-gray-700 font-semibold">🔄 自动刷新</label>
|
| 63 |
+
<div class="flex items-center gap-2">
|
| 64 |
+
<input v-model="settings.autoRefresh" type="checkbox"
|
| 65 |
+
class="h-5 w-5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
| 66 |
+
<span>启用自动刷新</span>
|
| 67 |
+
</div>
|
| 68 |
+
<label class="text-gray-700">刷新间隔(秒)</label>
|
| 69 |
+
<input v-model.number="settings.autoRefreshInterval" type="number"
|
| 70 |
+
class="mt-1 block w-full rounded-xl border-gray-300 bg-white/60 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 transition-all duration-300 h-12 text-base px-4">
|
| 71 |
+
<button @click="saveAutoRefresh"
|
| 72 |
+
class="w-full mt-2 bg-black text-white rounded-lg py-2 hover:bg-white hover:text-black border border-black transition-all duration-300">��存</button>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
<!-- 思考输出 -->
|
| 76 |
+
<div class="setting-card relative overflow-hidden rounded-2xl p-6 flex flex-col gap-4">
|
| 77 |
+
<div class="absolute inset-0 bg-white/30 backdrop-blur-md border border-white/30 rounded-2xl">
|
| 78 |
+
</div>
|
| 79 |
+
<div class="relative flex flex-col gap-2">
|
| 80 |
+
<label class="text-gray-700 font-semibold">💡 思考输出</label>
|
| 81 |
+
<div class="flex items-center gap-2">
|
| 82 |
+
<input v-model="settings.outThink" type="checkbox"
|
| 83 |
+
class="h-5 w-5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
| 84 |
+
<span>启用思考输出</span>
|
| 85 |
+
</div>
|
| 86 |
+
<button @click="saveOutThink"
|
| 87 |
+
class="w-full mt-2 bg-black text-white rounded-lg py-2 hover:bg-white hover:text-black border border-black transition-all duration-300">保存</button>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
<!-- 搜索信息模式 -->
|
| 91 |
+
<div class="setting-card relative overflow-hidden rounded-2xl p-6 flex flex-col gap-4">
|
| 92 |
+
<div class="absolute inset-0 bg-white/30 backdrop-blur-md border border-white/30 rounded-2xl">
|
| 93 |
+
</div>
|
| 94 |
+
<div class="relative flex flex-col gap-2">
|
| 95 |
+
<label class="text-gray-700 font-semibold">🔍 搜索信息显示模式</label>
|
| 96 |
+
<select v-model="settings.searchInfoMode"
|
| 97 |
+
class="mt-1 block w-full rounded-xl border-gray-300 bg-white/60 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 transition-all duration-300 h-12 text-base px-4">
|
| 98 |
+
<option value="table">表格模式</option>
|
| 99 |
+
<option value="text">文本模式</option>
|
| 100 |
+
</select>
|
| 101 |
+
<button @click="saveSearchInfoMode"
|
| 102 |
+
class="w-full mt-2 bg-black text-white rounded-lg py-2 hover:bg-white hover:text-black border border-black transition-all duration-300">保存</button>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
<!-- 简化模型映射 -->
|
| 106 |
+
<div class="setting-card relative overflow-hidden rounded-2xl p-6 flex flex-col gap-4">
|
| 107 |
+
<div class="absolute inset-0 bg-white/30 backdrop-blur-md border border-white/30 rounded-2xl">
|
| 108 |
+
</div>
|
| 109 |
+
<div class="relative flex flex-col gap-2">
|
| 110 |
+
<label class="text-gray-700 font-semibold">🎯 简化模型映射</label>
|
| 111 |
+
<div class="flex items-center gap-2">
|
| 112 |
+
<input v-model="settings.simpleModelMap" type="checkbox"
|
| 113 |
+
class="h-5 w-5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
| 114 |
+
<span>只返回基础模型,不包含thinking、search、image等变体</span>
|
| 115 |
+
</div>
|
| 116 |
+
<button @click="saveSimpleModelMap"
|
| 117 |
+
class="w-full mt-2 bg-black text-white rounded-lg py-2 hover:bg-white hover:text-black border border-black transition-all duration-300">保存</button>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
<!-- 添加API Key模态框 -->
|
| 124 |
+
<div v-if="showAddKeyModal"
|
| 125 |
+
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
| 126 |
+
<div class="bg-white rounded-lg p-6 w-96 max-w-90vw">
|
| 127 |
+
<h3 class="text-lg font-semibold mb-4">添加普通API Key</h3>
|
| 128 |
+
<input v-model="newApiKey" type="text" placeholder="请输入API Key"
|
| 129 |
+
class="w-full rounded-lg border-gray-300 shadow-sm h-10 text-sm px-3 mb-4">
|
| 130 |
+
<div class="flex gap-2 justify-end">
|
| 131 |
+
<button @click="showAddKeyModal = false"
|
| 132 |
+
class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-all">
|
| 133 |
+
取消
|
| 134 |
+
</button>
|
| 135 |
+
<button @click="addRegularKey"
|
| 136 |
+
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-all">
|
| 137 |
+
添加
|
| 138 |
+
</button>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
</template>
|
| 145 |
+
|
| 146 |
+
<script setup>
|
| 147 |
+
import { ref, onMounted } from 'vue'
|
| 148 |
+
import axios from 'axios'
|
| 149 |
+
|
| 150 |
+
const settings = ref({
|
| 151 |
+
apiKey: localStorage.getItem('apiKey'),
|
| 152 |
+
adminKey: '',
|
| 153 |
+
regularKeys: [],
|
| 154 |
+
defaultHeaders: '',
|
| 155 |
+
defaultCookie: '',
|
| 156 |
+
autoRefresh: false,
|
| 157 |
+
autoRefreshInterval: 21600,
|
| 158 |
+
outThink: false,
|
| 159 |
+
searchInfoMode: 'table',
|
| 160 |
+
simpleModelMap: false
|
| 161 |
+
})
|
| 162 |
+
|
| 163 |
+
const showAddKeyModal = ref(false)
|
| 164 |
+
const newApiKey = ref('')
|
| 165 |
+
|
| 166 |
+
const loadSettings = async () => {
|
| 167 |
+
try {
|
| 168 |
+
const res = await axios.get('/api/settings', {
|
| 169 |
+
headers: {
|
| 170 |
+
'Authorization': localStorage.getItem('apiKey')
|
| 171 |
+
}
|
| 172 |
+
})
|
| 173 |
+
settings.value.apiKey = res.data.apiKey
|
| 174 |
+
settings.value.adminKey = res.data.adminKey || ''
|
| 175 |
+
settings.value.regularKeys = res.data.regularKeys || []
|
| 176 |
+
settings.value.defaultHeaders = JSON.stringify(res.data.defaultHeaders)
|
| 177 |
+
settings.value.defaultCookie = res.data.defaultCookie
|
| 178 |
+
settings.value.autoRefresh = res.data.autoRefresh
|
| 179 |
+
settings.value.autoRefreshInterval = res.data.autoRefreshInterval
|
| 180 |
+
settings.value.outThink = res.data.outThink
|
| 181 |
+
settings.value.searchInfoMode = res.data.searchInfoMode
|
| 182 |
+
settings.value.simpleModelMap = res.data.simpleModelMap
|
| 183 |
+
} catch (error) {
|
| 184 |
+
console.error('加载设置失败:', error)
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
const saveApiKey = async () => {
|
| 189 |
+
try {
|
| 190 |
+
await axios.post('/api/setApiKey', { apiKey: settings.value.apiKey }, {
|
| 191 |
+
headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
|
| 192 |
+
})
|
| 193 |
+
alert('API Key保存成功')
|
| 194 |
+
} catch (error) {
|
| 195 |
+
alert('API Key保存失败: ' + error.message)
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
const saveAutoRefresh = async () => {
|
| 199 |
+
try {
|
| 200 |
+
await axios.post('/api/setAutoRefresh', {
|
| 201 |
+
autoRefresh: settings.value.autoRefresh,
|
| 202 |
+
autoRefreshInterval: settings.value.autoRefreshInterval
|
| 203 |
+
}, {
|
| 204 |
+
headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
|
| 205 |
+
})
|
| 206 |
+
alert('自动刷新设置保存成功')
|
| 207 |
+
} catch (error) {
|
| 208 |
+
alert('自动刷新设置保存失败: ' + error.message)
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
const saveOutThink = async () => {
|
| 212 |
+
try {
|
| 213 |
+
await axios.post('/api/setOutThink', { outThink: settings.value.outThink }, {
|
| 214 |
+
headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
|
| 215 |
+
})
|
| 216 |
+
alert('思考输出设置保存成功')
|
| 217 |
+
} catch (error) {
|
| 218 |
+
alert('思考输出设置保存失败: ' + error.message)
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
const saveSearchInfoMode = async () => {
|
| 222 |
+
try {
|
| 223 |
+
await axios.post('/api/search-info-mode', { searchInfoMode: settings.value.searchInfoMode }, {
|
| 224 |
+
headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
|
| 225 |
+
})
|
| 226 |
+
alert('搜索信息模式保存成功')
|
| 227 |
+
} catch (error) {
|
| 228 |
+
alert('搜索信息模式保存失败: ' + error.message)
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
const saveSimpleModelMap = async () => {
|
| 232 |
+
try {
|
| 233 |
+
await axios.post('/api/simple-model-map', { simpleModelMap: settings.value.simpleModelMap }, {
|
| 234 |
+
headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
|
| 235 |
+
})
|
| 236 |
+
alert('简化模型映射设置保存成功')
|
| 237 |
+
} catch (error) {
|
| 238 |
+
alert('简化模型映射设置保存失败: ' + error.message)
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
// API Key 管理相关函数
|
| 243 |
+
const addRegularKey = async () => {
|
| 244 |
+
if (!newApiKey.value.trim()) {
|
| 245 |
+
alert('请输入API Key')
|
| 246 |
+
return
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
try {
|
| 250 |
+
await axios.post('/api/addRegularKey', { apiKey: newApiKey.value.trim() }, {
|
| 251 |
+
headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
|
| 252 |
+
})
|
| 253 |
+
alert('API Key添加成功')
|
| 254 |
+
newApiKey.value = ''
|
| 255 |
+
showAddKeyModal.value = false
|
| 256 |
+
await loadSettings()
|
| 257 |
+
} catch (error) {
|
| 258 |
+
alert('API Key添加失败: ' + error.message)
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
const deleteRegularKey = async (index) => {
|
| 263 |
+
if (!confirm('确定要删除此API Key吗?')) return
|
| 264 |
+
|
| 265 |
+
const keyToDelete = settings.value.regularKeys[index]
|
| 266 |
+
try {
|
| 267 |
+
await axios.post('/api/deleteRegularKey', { apiKey: keyToDelete }, {
|
| 268 |
+
headers: { 'Authorization': localStorage.getItem('apiKey') || '' }
|
| 269 |
+
})
|
| 270 |
+
alert('API Key删除成功')
|
| 271 |
+
await loadSettings()
|
| 272 |
+
} catch (error) {
|
| 273 |
+
alert('API Key删除失败: ' + error.message)
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
onMounted(() => {
|
| 278 |
+
loadSettings()
|
| 279 |
+
})
|
| 280 |
+
</script>
|
| 281 |
+
|
| 282 |
+
<style lang="css" scoped>
|
| 283 |
+
.setting-card {
|
| 284 |
+
background: linear-gradient(135deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.3));
|
| 285 |
+
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.10);
|
| 286 |
+
transition: box-shadow 0.3s, transform 0.3s;
|
| 287 |
+
position: relative;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.setting-card:hover {
|
| 291 |
+
box-shadow: 0 12px 36px 0 rgba(31, 38, 135, 0.18);
|
| 292 |
+
transform: translateY(-2px) scale(1.01);
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
.action-button {
|
| 296 |
+
backdrop-filter: blur(4px);
|
| 297 |
+
-webkit-backdrop-filter: blur(4px);
|
| 298 |
+
}
|
| 299 |
+
</style>
|
public/tailwind.config.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
export default {
|
| 3 |
+
content: [
|
| 4 |
+
"./index.html",
|
| 5 |
+
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
| 6 |
+
],
|
| 7 |
+
theme: {
|
| 8 |
+
extend: {},
|
| 9 |
+
},
|
| 10 |
+
plugins: [],
|
| 11 |
+
}
|
public/vite.config.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import vue from '@vitejs/plugin-vue'
|
| 3 |
+
|
| 4 |
+
// https://vitejs.dev/config/
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [vue()],
|
| 7 |
+
server: {
|
| 8 |
+
proxy: {
|
| 9 |
+
'/': {
|
| 10 |
+
target: 'http://localhost:4000', // 实际后端地址
|
| 11 |
+
changeOrigin: true,
|
| 12 |
+
}
|
| 13 |
+
}
|
| 14 |
+
}
|
| 15 |
+
})
|
scripts/fingerprint-injector.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(function() {
|
| 2 |
+
// 拦截 String.prototype.charAt
|
| 3 |
+
const originalCharAt = String.prototype.charAt;
|
| 4 |
+
let capturedData = null;
|
| 5 |
+
|
| 6 |
+
String.prototype.charAt = function(index) {
|
| 7 |
+
if (this.length > 200 && this.includes('^') && !capturedData) {
|
| 8 |
+
const fields = this.split('^');
|
| 9 |
+
if (fields.length === 37) {
|
| 10 |
+
capturedData = this.toString();
|
| 11 |
+
console.log('\n=== 检测到浏览器指纹 ===');
|
| 12 |
+
console.log(capturedData);
|
| 13 |
+
|
| 14 |
+
// 恢复原方法
|
| 15 |
+
String.prototype.charAt = originalCharAt;
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
return originalCharAt.call(this, index);
|
| 19 |
+
};
|
| 20 |
+
})();
|
scripts/hf-bucket-sync.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import time
|
| 4 |
+
import threading
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from huggingface_hub import sync_bucket
|
| 8 |
+
from watchdog.events import FileSystemEventHandler
|
| 9 |
+
from watchdog.observers import Observer
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
BUCKET_REPO = os.environ.get("HF_BUCKET_REPO", "").strip()
|
| 13 |
+
BUCKET_TOKEN = (
|
| 14 |
+
os.environ.get("HF_BUCKET_TOKEN", "").strip()
|
| 15 |
+
or os.environ.get("HF_TOKEN", "").strip()
|
| 16 |
+
)
|
| 17 |
+
LOCAL_ROOT = Path(os.environ.get("HF_BUCKET_LOCAL_DIR", "/data/qwen2api")).resolve()
|
| 18 |
+
REMOTE_ROOT = os.environ.get("HF_BUCKET_REMOTE_DIR", "runtime").strip("/")
|
| 19 |
+
SYNC_INTERVAL = int(os.environ.get("HF_BUCKET_SYNC_INTERVAL", "300"))
|
| 20 |
+
STARTUP_GRACE_SECONDS = int(os.environ.get("HF_BUCKET_STARTUP_GRACE_SECONDS", "30"))
|
| 21 |
+
SYNC_DEBOUNCE_SECONDS = int(os.environ.get("HF_BUCKET_SYNC_DEBOUNCE_SECONDS", "5"))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def log(message: str) -> None:
|
| 25 |
+
print(f"[hf-bucket] {message}", flush=True)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def bucket_path() -> str:
|
| 29 |
+
if REMOTE_ROOT:
|
| 30 |
+
return f"hf://buckets/{BUCKET_REPO}/{REMOTE_ROOT}"
|
| 31 |
+
return f"hf://buckets/{BUCKET_REPO}"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def ensure_local_root() -> None:
|
| 35 |
+
LOCAL_ROOT.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def can_use_bucket() -> bool:
|
| 39 |
+
if not BUCKET_REPO:
|
| 40 |
+
log("skip: HF_BUCKET_REPO 未设置")
|
| 41 |
+
return False
|
| 42 |
+
if not BUCKET_TOKEN:
|
| 43 |
+
log("skip: HF_TOKEN 或 HF_BUCKET_TOKEN 未设置")
|
| 44 |
+
return False
|
| 45 |
+
return True
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def restore() -> None:
|
| 49 |
+
if not can_use_bucket():
|
| 50 |
+
return
|
| 51 |
+
|
| 52 |
+
ensure_local_root()
|
| 53 |
+
try:
|
| 54 |
+
sync_bucket(bucket_path(), str(LOCAL_ROOT), token=BUCKET_TOKEN)
|
| 55 |
+
log(f"restore 完成: {bucket_path()} -> {LOCAL_ROOT}")
|
| 56 |
+
except Exception as exc:
|
| 57 |
+
log(f"restore 失败: {exc}")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def push() -> None:
|
| 61 |
+
if not can_use_bucket():
|
| 62 |
+
return
|
| 63 |
+
|
| 64 |
+
ensure_local_root()
|
| 65 |
+
try:
|
| 66 |
+
sync_bucket(str(LOCAL_ROOT), bucket_path(), token=BUCKET_TOKEN, delete=False)
|
| 67 |
+
log(f"sync 完成: {LOCAL_ROOT} -> {bucket_path()}")
|
| 68 |
+
except Exception as exc:
|
| 69 |
+
log(f"sync 失败: {exc}")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def daemon() -> None:
|
| 73 |
+
if not can_use_bucket():
|
| 74 |
+
return
|
| 75 |
+
|
| 76 |
+
class ChangeHandler(FileSystemEventHandler):
|
| 77 |
+
def __init__(self) -> None:
|
| 78 |
+
self._timer = None
|
| 79 |
+
self._lock = threading.Lock()
|
| 80 |
+
self._startup_time = time.time()
|
| 81 |
+
|
| 82 |
+
def _schedule(self) -> None:
|
| 83 |
+
if time.time() - self._startup_time < STARTUP_GRACE_SECONDS:
|
| 84 |
+
return
|
| 85 |
+
|
| 86 |
+
with self._lock:
|
| 87 |
+
if self._timer is not None:
|
| 88 |
+
self._timer.cancel()
|
| 89 |
+
self._timer = threading.Timer(SYNC_DEBOUNCE_SECONDS, push)
|
| 90 |
+
self._timer.daemon = True
|
| 91 |
+
self._timer.start()
|
| 92 |
+
|
| 93 |
+
def on_created(self, event):
|
| 94 |
+
if not event.is_directory:
|
| 95 |
+
self._schedule()
|
| 96 |
+
|
| 97 |
+
def on_modified(self, event):
|
| 98 |
+
if not event.is_directory:
|
| 99 |
+
self._schedule()
|
| 100 |
+
|
| 101 |
+
def on_deleted(self, event):
|
| 102 |
+
if not event.is_directory:
|
| 103 |
+
self._schedule()
|
| 104 |
+
|
| 105 |
+
def on_moved(self, event):
|
| 106 |
+
if not event.is_directory:
|
| 107 |
+
self._schedule()
|
| 108 |
+
|
| 109 |
+
observer = Observer()
|
| 110 |
+
observer.schedule(ChangeHandler(), str(LOCAL_ROOT), recursive=True)
|
| 111 |
+
observer.start()
|
| 112 |
+
|
| 113 |
+
log(
|
| 114 |
+
f"后台同步已启动,监听目录 {LOCAL_ROOT},即时同步防抖 {SYNC_DEBOUNCE_SECONDS} 秒,定时兜底 {SYNC_INTERVAL} 秒"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
def periodic_sync() -> None:
|
| 118 |
+
while True:
|
| 119 |
+
time.sleep(SYNC_INTERVAL)
|
| 120 |
+
push()
|
| 121 |
+
|
| 122 |
+
periodic_thread = threading.Thread(target=periodic_sync, daemon=True)
|
| 123 |
+
periodic_thread.start()
|
| 124 |
+
|
| 125 |
+
try:
|
| 126 |
+
while True:
|
| 127 |
+
time.sleep(1)
|
| 128 |
+
except KeyboardInterrupt:
|
| 129 |
+
observer.stop()
|
| 130 |
+
observer.join()
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def main() -> int:
|
| 134 |
+
command = sys.argv[1] if len(sys.argv) > 1 else "daemon"
|
| 135 |
+
|
| 136 |
+
if command == "restore":
|
| 137 |
+
restore()
|
| 138 |
+
return 0
|
| 139 |
+
if command == "push":
|
| 140 |
+
push()
|
| 141 |
+
return 0
|
| 142 |
+
if command == "daemon":
|
| 143 |
+
daemon()
|
| 144 |
+
return 0
|
| 145 |
+
|
| 146 |
+
log(f"未知命令: {command}")
|
| 147 |
+
return 1
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
if __name__ == "__main__":
|
| 151 |
+
raise SystemExit(main())
|
src/config/index.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const dotenv = require('dotenv')
|
| 2 |
+
dotenv.config()
|
| 3 |
+
const paths = require('../utils/paths')
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* 解析API_KEY环境变量,支持逗号分隔的多个key
|
| 7 |
+
* @returns {Object} 包含apiKeys数组和adminKey的对象
|
| 8 |
+
*/
|
| 9 |
+
const parseApiKeys = () => {
|
| 10 |
+
const apiKeyEnv = process.env.API_KEY
|
| 11 |
+
if (!apiKeyEnv) {
|
| 12 |
+
return { apiKeys: [], adminKey: null }
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const keys = apiKeyEnv.split(',').map(key => key.trim()).filter(key => key.length > 0)
|
| 16 |
+
return {
|
| 17 |
+
apiKeys: keys,
|
| 18 |
+
adminKey: keys.length > 0 ? keys[0] : null
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const { apiKeys, adminKey } = parseApiKeys()
|
| 23 |
+
|
| 24 |
+
const config = {
|
| 25 |
+
dataSaveMode: process.env.DATA_SAVE_MODE || "none",
|
| 26 |
+
apiKeys: apiKeys,
|
| 27 |
+
adminKey: adminKey,
|
| 28 |
+
simpleModelMap: process.env.SIMPLE_MODEL_MAP === 'true' ? true : false,
|
| 29 |
+
listenAddress: process.env.LISTEN_ADDRESS || null,
|
| 30 |
+
listenPort: process.env.SERVICE_PORT || 3000,
|
| 31 |
+
searchInfoMode: process.env.SEARCH_INFO_MODE === 'table' ? "table" : "text",
|
| 32 |
+
outThink: process.env.OUTPUT_THINK === 'true' ? true : false,
|
| 33 |
+
redisURL: process.env.REDIS_URL || null,
|
| 34 |
+
autoRefresh: true,
|
| 35 |
+
autoRefreshInterval: 6 * 60 * 60,
|
| 36 |
+
cacheMode: process.env.CACHE_MODE || "default",
|
| 37 |
+
logLevel: process.env.LOG_LEVEL || "INFO",
|
| 38 |
+
enableFileLog: process.env.ENABLE_FILE_LOG === 'true',
|
| 39 |
+
logDir: paths.logDir,
|
| 40 |
+
maxLogFileSize: parseInt(process.env.MAX_LOG_FILE_SIZE) || 10,
|
| 41 |
+
maxLogFiles: parseInt(process.env.MAX_LOG_FILES) || 5,
|
| 42 |
+
dataDir: paths.dataDir,
|
| 43 |
+
cacheDir: paths.cacheDir,
|
| 44 |
+
dataFilePath: paths.dataFilePath,
|
| 45 |
+
// 自定义反代URL配置
|
| 46 |
+
qwenChatProxyUrl: process.env.QWEN_CHAT_PROXY_URL || "https://chat.qwen.ai",
|
| 47 |
+
qwenCliProxyUrl: process.env.QWEN_CLI_PROXY_URL || "https://portal.qwen.ai",
|
| 48 |
+
// 代理配置
|
| 49 |
+
proxyUrl: process.env.PROXY_URL || null
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
module.exports = config
|
src/controllers/chat.image.video.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const axios = require('axios')
|
| 2 |
+
const { logger } = require('../utils/logger.js')
|
| 3 |
+
const { setResponseHeaders } = require('./chat.js')
|
| 4 |
+
const accountManager = require('../utils/account.js')
|
| 5 |
+
const { sleep } = require('../utils/tools.js')
|
| 6 |
+
const { generateChatID } = require('../utils/request.js')
|
| 7 |
+
const { getSsxmodItna, getSsxmodItna2 } = require('../utils/ssxmod-manager')
|
| 8 |
+
const { getProxyAgent, getChatBaseUrl, applyProxyToAxiosConfig } = require('../utils/proxy-helper')
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* 主要的聊天完成处理函数
|
| 12 |
+
* @param {object} req - Express 请求对象
|
| 13 |
+
* @param {object} res - Express 响应对象
|
| 14 |
+
*/
|
| 15 |
+
const handleImageVideoCompletion = async (req, res) => {
|
| 16 |
+
const { model, messages, size, chat_type } = req.body
|
| 17 |
+
// console.log(JSON.stringify(req.body.messages.filter(item => item.role == "user" || item.role == "assistant")))
|
| 18 |
+
const token = accountManager.getAccountToken()
|
| 19 |
+
|
| 20 |
+
try {
|
| 21 |
+
|
| 22 |
+
// 请求体模板
|
| 23 |
+
const reqBody = {
|
| 24 |
+
"stream": false,
|
| 25 |
+
"chat_id": null,
|
| 26 |
+
"model": model,
|
| 27 |
+
"messages": [
|
| 28 |
+
{
|
| 29 |
+
"role": "user",
|
| 30 |
+
"content": "",
|
| 31 |
+
"files": [],
|
| 32 |
+
"chat_type": chat_type,
|
| 33 |
+
"feature_config": {
|
| 34 |
+
"output_schema": "phase"
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
]
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const chat_id = await generateChatID(token, model)
|
| 41 |
+
|
| 42 |
+
if (!chat_id) {
|
| 43 |
+
// 如果生成chat_id失败,则返回错误
|
| 44 |
+
throw new Error()
|
| 45 |
+
} else {
|
| 46 |
+
reqBody.chat_id = chat_id
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// 拿到用户最后一句消息
|
| 50 |
+
const _userPrompt = messages[messages.length - 1].content
|
| 51 |
+
if (!_userPrompt) {
|
| 52 |
+
throw new Error()
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// 提取历史消息
|
| 56 |
+
const messagesHistory = messages.filter(item => item.role == "user" || item.role == "assistant")
|
| 57 |
+
// 聊天消息中所有图片url
|
| 58 |
+
const select_image_list = []
|
| 59 |
+
|
| 60 |
+
// 遍历模型回复消息,拿到所有图片
|
| 61 |
+
if (chat_type == "image_edit") {
|
| 62 |
+
for (const item of messagesHistory) {
|
| 63 |
+
if (item.role == "assistant") {
|
| 64 |
+
// 使用matchAll提取所有图片链接
|
| 65 |
+
const matches = [...item.content.matchAll(/!\[image\]\((.*?)\)/g)]
|
| 66 |
+
// 将所有匹配到的图片url添加到图片列表
|
| 67 |
+
for (const match of matches) {
|
| 68 |
+
select_image_list.push(match[1])
|
| 69 |
+
}
|
| 70 |
+
} else {
|
| 71 |
+
if (Array.isArray(item.content) && item.content.length > 0) {
|
| 72 |
+
for (const content of item.content) {
|
| 73 |
+
if (content.type == "image") {
|
| 74 |
+
select_image_list.push(content.image)
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
//分情况处理
|
| 83 |
+
if (chat_type == 't2i' || chat_type == 't2v') {
|
| 84 |
+
if (Array.isArray(_userPrompt)) {
|
| 85 |
+
reqBody.messages[0].content = _userPrompt.map(item => item.type == "text" ? item.text : "").join("\n\n")
|
| 86 |
+
} else {
|
| 87 |
+
reqBody.messages[0].content = _userPrompt
|
| 88 |
+
}
|
| 89 |
+
} else if (chat_type == 'image_edit') {
|
| 90 |
+
if (!Array.isArray(_userPrompt)) {
|
| 91 |
+
|
| 92 |
+
if (messagesHistory.length === 1) {
|
| 93 |
+
reqBody.messages[0].chat_type = "t2i"
|
| 94 |
+
} else if (select_image_list.length >= 1) {
|
| 95 |
+
reqBody.messages[0].files.push({
|
| 96 |
+
"type": "image",
|
| 97 |
+
"url": select_image_list[select_image_list.length - 1]
|
| 98 |
+
})
|
| 99 |
+
}
|
| 100 |
+
reqBody.messages[0].content += _userPrompt
|
| 101 |
+
} else {
|
| 102 |
+
const texts = _userPrompt.filter(item => item.type == "text")
|
| 103 |
+
if (texts.length === 0) {
|
| 104 |
+
throw new Error()
|
| 105 |
+
}
|
| 106 |
+
// 拼接提示词
|
| 107 |
+
for (const item of texts) {
|
| 108 |
+
reqBody.messages[0].content += item.text
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
const files = _userPrompt.filter(item => item.type == "image")
|
| 112 |
+
// 如果图片为空,则设置为t2i
|
| 113 |
+
if (files.length === 0) {
|
| 114 |
+
reqBody.messages[0].chat_type = "t2i"
|
| 115 |
+
}
|
| 116 |
+
// 遍历图片
|
| 117 |
+
for (const item of files) {
|
| 118 |
+
reqBody.messages[0].files.push({
|
| 119 |
+
"type": "image",
|
| 120 |
+
"url": item.image
|
| 121 |
+
})
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
// 处理图片视频尺寸
|
| 129 |
+
if (chat_type == 't2i' || chat_type == 't2v') {
|
| 130 |
+
// 获取图片尺寸,优先级 参数 > 提示词 > 默认
|
| 131 |
+
if (size != undefined && size != null) {
|
| 132 |
+
reqBody.size = "1:1"
|
| 133 |
+
} else if (_userPrompt.indexOf("@4:3") != -1) {
|
| 134 |
+
reqBody.size = "4:3"//"1024*768"
|
| 135 |
+
} else if (_userPrompt.indexOf("@3:4") != -1) {
|
| 136 |
+
reqBody.size = "3:4"//"768*1024"
|
| 137 |
+
} else if (_userPrompt.indexOf("@16:9") != -1) {
|
| 138 |
+
reqBody.size = "16:9"//"1280*720"
|
| 139 |
+
} else if (_userPrompt.indexOf("@9:16") != -1) {
|
| 140 |
+
reqBody.size = "9:16"//"720*1280"
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
const chatBaseUrl = getChatBaseUrl()
|
| 145 |
+
const proxyAgent = getProxyAgent()
|
| 146 |
+
|
| 147 |
+
logger.info('发送图片视频请求', 'CHAT')
|
| 148 |
+
logger.info(`选择图片: ${select_image_list[select_image_list.length - 1] || "未选择图片,切换生成图/视频模式"}`, 'CHAT')
|
| 149 |
+
logger.info(`使用提示: ${reqBody.messages[0].content}`, 'CHAT')
|
| 150 |
+
// console.log(JSON.stringify(reqBody))
|
| 151 |
+
const newChatType = reqBody.messages[0].chat_type
|
| 152 |
+
|
| 153 |
+
const requestConfig = {
|
| 154 |
+
headers: {
|
| 155 |
+
'Authorization': `Bearer ${token}`,
|
| 156 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0",
|
| 157 |
+
"Connection": "keep-alive",
|
| 158 |
+
"Accept": "application/json",
|
| 159 |
+
"Accept-Encoding": "gzip, deflate, br, zstd",
|
| 160 |
+
"Content-Type": "application/json",
|
| 161 |
+
"Timezone": "Mon Dec 08 2025 17:28:55 GMT+0800",
|
| 162 |
+
"sec-ch-ua": "\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"",
|
| 163 |
+
"source": "web",
|
| 164 |
+
"Version": "0.1.13",
|
| 165 |
+
"bx-v": "2.5.31",
|
| 166 |
+
"Origin": chatBaseUrl,
|
| 167 |
+
"Sec-Fetch-Site": "same-origin",
|
| 168 |
+
"Sec-Fetch-Mode": "cors",
|
| 169 |
+
"Sec-Fetch-Dest": "empty",
|
| 170 |
+
"Referer": `${chatBaseUrl}/c/guest`,
|
| 171 |
+
"Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
|
| 172 |
+
"Cookie": `ssxmod_itna=${getSsxmodItna()};ssxmod_itna2=${getSsxmodItna2()}`,
|
| 173 |
+
},
|
| 174 |
+
responseType: newChatType == 't2i' ? 'stream' : 'json',
|
| 175 |
+
timeout: 1000 * 60 * 5
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// 添加代理配置
|
| 179 |
+
if (proxyAgent) {
|
| 180 |
+
requestConfig.httpsAgent = proxyAgent
|
| 181 |
+
requestConfig.proxy = false
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
const response_data = await axios.post(`${chatBaseUrl}/api/v2/chat/completions?chat_id=${chat_id}`, reqBody, requestConfig)
|
| 185 |
+
|
| 186 |
+
try {
|
| 187 |
+
let contentUrl = null
|
| 188 |
+
if (newChatType == 't2i') {
|
| 189 |
+
const decoder = new TextDecoder('utf-8')
|
| 190 |
+
response_data.data.on('data', async (chunk) => {
|
| 191 |
+
const data = decoder.decode(chunk, { stream: true }).split('\n').filter(item => item.trim() != "")
|
| 192 |
+
console.log(data)
|
| 193 |
+
for (const item of data) {
|
| 194 |
+
const jsonObj = JSON.parse(item.replace("data:", '').trim())
|
| 195 |
+
if (jsonObj && jsonObj.choices && jsonObj.choices[0] && jsonObj.choices[0].delta && jsonObj.choices[0].delta.content.trim() != "" && contentUrl == null) {
|
| 196 |
+
contentUrl = jsonObj.choices[0].delta.content
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
})
|
| 200 |
+
|
| 201 |
+
response_data.data.on('end', () => {
|
| 202 |
+
return returnResponse(res, model, contentUrl, req.body.stream)
|
| 203 |
+
})
|
| 204 |
+
} else if (newChatType == 'image_edit') {
|
| 205 |
+
console.log(response_data.data)
|
| 206 |
+
contentUrl = response_data.data?.data?.choices[0]?.message?.content[0]?.image
|
| 207 |
+
return returnResponse(res, model, contentUrl, req.body.stream)
|
| 208 |
+
} else if (newChatType == 't2v') {
|
| 209 |
+
return handleVideoCompletion(req, res, response_data.data, token)
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
} catch (error) {
|
| 213 |
+
logger.error('图片处理错误', 'CHAT', error)
|
| 214 |
+
res.status(500).json({ error: "服务错误!!!" })
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
} catch (error) {
|
| 218 |
+
res.status(500).json({
|
| 219 |
+
error: "服务错误,请稍后再试"
|
| 220 |
+
})
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/**
|
| 225 |
+
* 返回响应
|
| 226 |
+
* @param {*} res
|
| 227 |
+
* @param {*} model
|
| 228 |
+
* @param {*} contentUrl
|
| 229 |
+
*/
|
| 230 |
+
const returnResponse = (res, model, contentUrl, stream) => {
|
| 231 |
+
setResponseHeaders(res, stream)
|
| 232 |
+
logger.info(`返回响应: ${contentUrl}`, 'CHAT')
|
| 233 |
+
|
| 234 |
+
const returnBody = {
|
| 235 |
+
"created": new Date().getTime(),
|
| 236 |
+
"model": model,
|
| 237 |
+
"choices": [
|
| 238 |
+
{
|
| 239 |
+
"index": 0,
|
| 240 |
+
"message": {
|
| 241 |
+
"role": "assistant",
|
| 242 |
+
"content": ``
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
]
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
if (stream) {
|
| 249 |
+
res.write(`data: ${JSON.stringify(returnBody)}\n\n`)
|
| 250 |
+
res.write(`data: [DONE]\n\n`)
|
| 251 |
+
res.end()
|
| 252 |
+
} else {
|
| 253 |
+
res.json(returnBody)
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
const handleVideoCompletion = async (req, res, response_data, token) => {
|
| 258 |
+
try {
|
| 259 |
+
const videoTaskID = response_data?.data?.messages[0]?.extra?.wanx?.task_id
|
| 260 |
+
if (!response_data || !response_data.success || !videoTaskID) {
|
| 261 |
+
throw new Error()
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
logger.info(`视频任务ID: ${videoTaskID}`, 'CHAT')
|
| 265 |
+
const returnBody = {
|
| 266 |
+
"id": `chatcmpl-${new Date().getTime()}`,
|
| 267 |
+
"object": "chat.completion.chunk",
|
| 268 |
+
"created": new Date().getTime(),
|
| 269 |
+
"model": response_data.data.model,
|
| 270 |
+
"choices": [
|
| 271 |
+
{
|
| 272 |
+
"index": 0,
|
| 273 |
+
"message": {
|
| 274 |
+
"role": "assistant",
|
| 275 |
+
"content": ""
|
| 276 |
+
},
|
| 277 |
+
"finish_reason": null
|
| 278 |
+
}
|
| 279 |
+
]
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
// 设置尝试次数
|
| 283 |
+
const maxAttempts = 60
|
| 284 |
+
// 设置每次请求的间隔时间
|
| 285 |
+
const delay = 20 * 1000
|
| 286 |
+
// 循环尝试获取任务状态
|
| 287 |
+
for (let i = 0; i < maxAttempts; i++) {
|
| 288 |
+
const content = await getVideoTaskStatus(videoTaskID, token)
|
| 289 |
+
if (content) {
|
| 290 |
+
returnBody.choices[0].message.content = `
|
| 291 |
+
<video controls = "controls">
|
| 292 |
+
${content}
|
| 293 |
+
</video>
|
| 294 |
+
|
| 295 |
+
[Download Video](${content})
|
| 296 |
+
`
|
| 297 |
+
// 设置响应头
|
| 298 |
+
setResponseHeaders(res, req.body.stream)
|
| 299 |
+
|
| 300 |
+
if (req.body.stream) {
|
| 301 |
+
res.write(`data: ${JSON.stringify(returnBody)}\n\n`)
|
| 302 |
+
res.write(`data: [DONE]\n\n`)
|
| 303 |
+
res.end()
|
| 304 |
+
} else {
|
| 305 |
+
res.json(returnBody)
|
| 306 |
+
}
|
| 307 |
+
return
|
| 308 |
+
} else if (content == null && req.body.stream) {
|
| 309 |
+
// 发送空数据保活
|
| 310 |
+
res.write(`data: ${JSON.stringify(returnBody)}\n\n`)
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
await sleep(delay)
|
| 314 |
+
}
|
| 315 |
+
} catch (error) {
|
| 316 |
+
logger.error('获取视频任务状态失败', 'CHAT', error)
|
| 317 |
+
res.status(500).json({ error: error.response_data?.data?.code || "可能该帐号今日生成次数已用完" })
|
| 318 |
+
}
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
const getVideoTaskStatus = async (videoTaskID, token) => {
|
| 322 |
+
try {
|
| 323 |
+
const chatBaseUrl = getChatBaseUrl()
|
| 324 |
+
const proxyAgent = getProxyAgent()
|
| 325 |
+
|
| 326 |
+
const requestConfig = {
|
| 327 |
+
headers: {
|
| 328 |
+
"Authorization": `Bearer ${token}`,
|
| 329 |
+
'Content-Type': 'application/json',
|
| 330 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
| 331 |
+
...(getSsxmodItna() && { 'Cookie': `ssxmod_itna=${getSsxmodItna()};ssxmod_itna2=${getSsxmodItna2()}` })
|
| 332 |
+
}
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
// 添加代理配置
|
| 336 |
+
if (proxyAgent) {
|
| 337 |
+
requestConfig.httpsAgent = proxyAgent
|
| 338 |
+
requestConfig.proxy = false
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
const response_data = await axios.get(`${chatBaseUrl}/api/v1/tasks/status/${videoTaskID}`, requestConfig)
|
| 342 |
+
|
| 343 |
+
if (response_data.data?.task_status == "success") {
|
| 344 |
+
logger.info('获取视频任务状态成功', 'CHAT', response_data.data?.content)
|
| 345 |
+
return response_data.data?.content
|
| 346 |
+
}
|
| 347 |
+
logger.info(`获取视频任务 ${videoTaskID} 状态: ${response_data.data?.task_status}`, 'CHAT')
|
| 348 |
+
return null
|
| 349 |
+
} catch (error) {
|
| 350 |
+
console.log(error.response.data)
|
| 351 |
+
return null
|
| 352 |
+
}
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
module.exports = {
|
| 356 |
+
handleImageVideoCompletion
|
| 357 |
+
}
|
src/controllers/chat.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { isJson, generateUUID } = require('../utils/tools.js')
|
| 2 |
+
const { createUsageObject } = require('../utils/precise-tokenizer.js')
|
| 3 |
+
const { sendChatRequest } = require('../utils/request.js')
|
| 4 |
+
const accountManager = require('../utils/account.js')
|
| 5 |
+
const config = require('../config/index.js')
|
| 6 |
+
const axios = require('axios')
|
| 7 |
+
const { logger } = require('../utils/logger')
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* 设置响应头
|
| 11 |
+
* @param {object} res - Express 响应对象
|
| 12 |
+
* @param {boolean} stream - 是否流式响应
|
| 13 |
+
*/
|
| 14 |
+
const setResponseHeaders = (res, stream) => {
|
| 15 |
+
try {
|
| 16 |
+
if (stream) {
|
| 17 |
+
res.set({
|
| 18 |
+
'Content-Type': 'text/event-stream',
|
| 19 |
+
'Cache-Control': 'no-cache',
|
| 20 |
+
'Connection': 'keep-alive',
|
| 21 |
+
})
|
| 22 |
+
} else {
|
| 23 |
+
res.set({
|
| 24 |
+
'Content-Type': 'application/json',
|
| 25 |
+
})
|
| 26 |
+
}
|
| 27 |
+
} catch (e) {
|
| 28 |
+
logger.error('处理聊天请求时发生错误', 'CHAT', '', e)
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* 处理流式响应
|
| 34 |
+
* @param {object} res - Express 响应对象
|
| 35 |
+
* @param {object} response - 上游响应流
|
| 36 |
+
* @param {boolean} enable_thinking - 是否启用思考模式
|
| 37 |
+
* @param {boolean} enable_web_search - 是否启用网络搜索
|
| 38 |
+
* @param {object} requestBody - 原始请求体,用于提取prompt信息
|
| 39 |
+
*/
|
| 40 |
+
const handleStreamResponse = async (res, response, enable_thinking, enable_web_search, requestBody = null) => {
|
| 41 |
+
try {
|
| 42 |
+
const message_id = generateUUID()
|
| 43 |
+
const decoder = new TextDecoder('utf-8')
|
| 44 |
+
let web_search_info = null
|
| 45 |
+
let thinking_start = false
|
| 46 |
+
let thinking_end = false
|
| 47 |
+
let buffer = ''
|
| 48 |
+
|
| 49 |
+
// Token消耗量统计
|
| 50 |
+
let totalTokens = {
|
| 51 |
+
prompt_tokens: 0,
|
| 52 |
+
completion_tokens: 0,
|
| 53 |
+
total_tokens: 0
|
| 54 |
+
}
|
| 55 |
+
let completionContent = '' // 收集完整的回复内容用于token估算
|
| 56 |
+
|
| 57 |
+
// 提取prompt文本用于token估算
|
| 58 |
+
let promptText = ''
|
| 59 |
+
if (requestBody && requestBody.messages) {
|
| 60 |
+
promptText = requestBody.messages.map(msg => {
|
| 61 |
+
if (typeof msg.content === 'string') {
|
| 62 |
+
return msg.content
|
| 63 |
+
} else if (Array.isArray(msg.content)) {
|
| 64 |
+
return msg.content.map(item => item.text || '').join('')
|
| 65 |
+
}
|
| 66 |
+
return ''
|
| 67 |
+
}).join('\n')
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
response.on('data', async (chunk) => {
|
| 71 |
+
const decodeText = decoder.decode(chunk, { stream: true })
|
| 72 |
+
// console.log(decodeText)
|
| 73 |
+
buffer += decodeText
|
| 74 |
+
|
| 75 |
+
const chunks = []
|
| 76 |
+
let startIndex = 0
|
| 77 |
+
|
| 78 |
+
while (true) {
|
| 79 |
+
const dataStart = buffer.indexOf('data: ', startIndex)
|
| 80 |
+
if (dataStart === -1) break
|
| 81 |
+
|
| 82 |
+
const dataEnd = buffer.indexOf('\n\n', dataStart)
|
| 83 |
+
if (dataEnd === -1) break
|
| 84 |
+
|
| 85 |
+
const dataChunk = buffer.substring(dataStart, dataEnd).trim()
|
| 86 |
+
chunks.push(dataChunk)
|
| 87 |
+
|
| 88 |
+
startIndex = dataEnd + 2
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if (startIndex > 0) {
|
| 92 |
+
buffer = buffer.substring(startIndex)
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
for (const item of chunks) {
|
| 96 |
+
try {
|
| 97 |
+
let dataContent = item.replace("data: ", '')
|
| 98 |
+
let decodeJson = isJson(dataContent) ? JSON.parse(dataContent) : null
|
| 99 |
+
if (decodeJson === null || !decodeJson.choices || decodeJson.choices.length === 0) {
|
| 100 |
+
continue
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// 提取真实的usage信息(如果上游API提供)
|
| 104 |
+
if (decodeJson.usage) {
|
| 105 |
+
totalTokens = {
|
| 106 |
+
prompt_tokens: decodeJson.usage.prompt_tokens || totalTokens.prompt_tokens,
|
| 107 |
+
completion_tokens: decodeJson.usage.completion_tokens || totalTokens.completion_tokens,
|
| 108 |
+
total_tokens: decodeJson.usage.total_tokens || totalTokens.total_tokens
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// 处理 web_search 信息
|
| 113 |
+
if (decodeJson.choices[0].delta && decodeJson.choices[0].delta.name === 'web_search') {
|
| 114 |
+
web_search_info = decodeJson.choices[0].delta.extra.web_search_info
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
if (!decodeJson.choices[0].delta || !decodeJson.choices[0].delta.content ||
|
| 118 |
+
(decodeJson.choices[0].delta.phase !== 'think' && decodeJson.choices[0].delta.phase !== 'answer')) {
|
| 119 |
+
continue
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
let content = decodeJson.choices[0].delta.content
|
| 123 |
+
completionContent += content // 累计完整内容用于token估算
|
| 124 |
+
|
| 125 |
+
if (decodeJson.choices[0].delta.phase === 'think' && !thinking_start) {
|
| 126 |
+
thinking_start = true
|
| 127 |
+
if (web_search_info) {
|
| 128 |
+
content = `<think>\n\n${await accountManager.generateMarkdownTable(web_search_info, config.searchInfoMode)}\n\n${content}`
|
| 129 |
+
} else {
|
| 130 |
+
content = `<think>\n\n${content}`
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
if (decodeJson.choices[0].delta.phase === 'answer' && !thinking_end && thinking_start) {
|
| 134 |
+
thinking_end = true
|
| 135 |
+
content = `\n\n</think>\n${content}`
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
const StreamTemplate = {
|
| 139 |
+
"id": `chatcmpl-${message_id}`,
|
| 140 |
+
"object": "chat.completion.chunk",
|
| 141 |
+
"created": new Date().getTime(),
|
| 142 |
+
"choices": [
|
| 143 |
+
{
|
| 144 |
+
"index": 0,
|
| 145 |
+
"delta": {
|
| 146 |
+
"content": content
|
| 147 |
+
},
|
| 148 |
+
"finish_reason": null
|
| 149 |
+
}
|
| 150 |
+
]
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
res.write(`data: ${JSON.stringify(StreamTemplate)}\n\n`)
|
| 154 |
+
} catch (error) {
|
| 155 |
+
logger.error('流式数据处理错误', 'CHAT', '', error)
|
| 156 |
+
res.status(500).json({ error: "服务错误!!!" })
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
})
|
| 160 |
+
|
| 161 |
+
response.on('end', async () => {
|
| 162 |
+
try {
|
| 163 |
+
// 处理最终的搜索信息
|
| 164 |
+
if ((config.outThink === false || !enable_thinking) && web_search_info && config.searchInfoMode === "text") {
|
| 165 |
+
const webSearchTable = await accountManager.generateMarkdownTable(web_search_info, "text")
|
| 166 |
+
res.write(`data: ${JSON.stringify({
|
| 167 |
+
"id": `chatcmpl-${message_id}`,
|
| 168 |
+
"object": "chat.completion.chunk",
|
| 169 |
+
"created": new Date().getTime(),
|
| 170 |
+
"choices": [
|
| 171 |
+
{
|
| 172 |
+
"index": 0,
|
| 173 |
+
"delta": {
|
| 174 |
+
"content": `\n\n---\n${webSearchTable}`
|
| 175 |
+
},
|
| 176 |
+
"finish_reason": null
|
| 177 |
+
}
|
| 178 |
+
]
|
| 179 |
+
})}\n\n`)
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// 计算最终的token使用量
|
| 183 |
+
if (totalTokens.prompt_tokens === 0 && totalTokens.completion_tokens === 0) {
|
| 184 |
+
totalTokens = createUsageObject(requestBody?.messages || promptText, completionContent, null)
|
| 185 |
+
logger.info(`流式使用tiktoken计算 - Prompt: ${totalTokens.prompt_tokens}, Completion: ${totalTokens.completion_tokens}, Total: ${totalTokens.total_tokens}`, 'CHAT')
|
| 186 |
+
} else {
|
| 187 |
+
logger.info(`流式使用上游真实Token - Prompt: ${totalTokens.prompt_tokens}, Completion: ${totalTokens.completion_tokens}, Total: ${totalTokens.total_tokens}`, 'CHAT')
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
// 确保token数量的有效性
|
| 191 |
+
totalTokens.prompt_tokens = Math.max(0, totalTokens.prompt_tokens || 0)
|
| 192 |
+
totalTokens.completion_tokens = Math.max(0, totalTokens.completion_tokens || 0)
|
| 193 |
+
totalTokens.total_tokens = totalTokens.prompt_tokens + totalTokens.completion_tokens
|
| 194 |
+
|
| 195 |
+
// 发送最终的finish chunk,包含finish_reason
|
| 196 |
+
res.write(`data: ${JSON.stringify({
|
| 197 |
+
"id": `chatcmpl-${message_id}`,
|
| 198 |
+
"object": "chat.completion.chunk",
|
| 199 |
+
"created": new Date().getTime(),
|
| 200 |
+
"choices": [
|
| 201 |
+
{
|
| 202 |
+
"index": 0,
|
| 203 |
+
"delta": {},
|
| 204 |
+
"finish_reason": "stop"
|
| 205 |
+
}
|
| 206 |
+
]
|
| 207 |
+
})}\n\n`)
|
| 208 |
+
|
| 209 |
+
// 发送usage信息chunk(符合OpenAI API标准)
|
| 210 |
+
res.write(`data: ${JSON.stringify({
|
| 211 |
+
"id": `chatcmpl-${message_id}`,
|
| 212 |
+
"object": "chat.completion.chunk",
|
| 213 |
+
"created": new Date().getTime(),
|
| 214 |
+
"choices": [],
|
| 215 |
+
"usage": totalTokens
|
| 216 |
+
})}\n\n`)
|
| 217 |
+
|
| 218 |
+
// 发送结束标记
|
| 219 |
+
res.write(`data: [DONE]\n\n`)
|
| 220 |
+
res.end()
|
| 221 |
+
} catch (e) {
|
| 222 |
+
logger.error('流式响应处理错误', 'CHAT', '', e)
|
| 223 |
+
res.status(500).json({ error: "服务错误!!!" })
|
| 224 |
+
}
|
| 225 |
+
})
|
| 226 |
+
} catch (error) {
|
| 227 |
+
logger.error('聊天处理错误', 'CHAT', '', error)
|
| 228 |
+
res.status(500).json({ error: "服务错误!!!" })
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
/**
|
| 233 |
+
* 处理非流式响应(从流式数据累积完整响应)
|
| 234 |
+
* @param {object} res - Express 响应对象
|
| 235 |
+
* @param {object} response - 上游响应流
|
| 236 |
+
* @param {boolean} enable_thinking - 是否启用思考模式
|
| 237 |
+
* @param {boolean} enable_web_search - 是否启用网络搜索
|
| 238 |
+
* @param {string} model - 模型名称
|
| 239 |
+
* @param {object} requestBody - 原始请求体,用于提取prompt信息
|
| 240 |
+
*/
|
| 241 |
+
const handleNonStreamResponse = async (res, response, enable_thinking, enable_web_search, model, requestBody = null) => {
|
| 242 |
+
try {
|
| 243 |
+
const decoder = new TextDecoder('utf-8')
|
| 244 |
+
let buffer = ''
|
| 245 |
+
let fullContent = ''
|
| 246 |
+
let web_search_info = null
|
| 247 |
+
let thinking_start = false
|
| 248 |
+
let thinking_end = false
|
| 249 |
+
|
| 250 |
+
// Token消耗量统计
|
| 251 |
+
let totalTokens = {
|
| 252 |
+
prompt_tokens: 0,
|
| 253 |
+
completion_tokens: 0,
|
| 254 |
+
total_tokens: 0
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
// 提取prompt文本用于token估算
|
| 258 |
+
let promptText = ''
|
| 259 |
+
if (requestBody && requestBody.messages) {
|
| 260 |
+
promptText = requestBody.messages.map(msg => {
|
| 261 |
+
if (typeof msg.content === 'string') {
|
| 262 |
+
return msg.content
|
| 263 |
+
} else if (Array.isArray(msg.content)) {
|
| 264 |
+
return msg.content.map(item => item.text || '').join('')
|
| 265 |
+
}
|
| 266 |
+
return ''
|
| 267 |
+
}).join('\n')
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
// 处理流式响应并累积内容
|
| 271 |
+
await new Promise((resolve, reject) => {
|
| 272 |
+
response.on('data', async (chunk) => {
|
| 273 |
+
const decodeText = decoder.decode(chunk, { stream: true })
|
| 274 |
+
buffer += decodeText
|
| 275 |
+
|
| 276 |
+
const chunks = []
|
| 277 |
+
let startIndex = 0
|
| 278 |
+
|
| 279 |
+
while (true) {
|
| 280 |
+
const dataStart = buffer.indexOf('data: ', startIndex)
|
| 281 |
+
if (dataStart === -1) break
|
| 282 |
+
|
| 283 |
+
const dataEnd = buffer.indexOf('\n\n', dataStart)
|
| 284 |
+
if (dataEnd === -1) break
|
| 285 |
+
|
| 286 |
+
const dataChunk = buffer.substring(dataStart, dataEnd).trim()
|
| 287 |
+
chunks.push(dataChunk)
|
| 288 |
+
|
| 289 |
+
startIndex = dataEnd + 2
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
if (startIndex > 0) {
|
| 293 |
+
buffer = buffer.substring(startIndex)
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
for (const item of chunks) {
|
| 297 |
+
try {
|
| 298 |
+
let dataContent = item.replace("data: ", '')
|
| 299 |
+
let decodeJson = isJson(dataContent) ? JSON.parse(dataContent) : null
|
| 300 |
+
if (decodeJson === null || !decodeJson.choices || decodeJson.choices.length === 0) {
|
| 301 |
+
continue
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
// 提取真实的usage信息(如果上游API提供)
|
| 305 |
+
if (decodeJson.usage) {
|
| 306 |
+
totalTokens = {
|
| 307 |
+
prompt_tokens: decodeJson.usage.prompt_tokens || totalTokens.prompt_tokens,
|
| 308 |
+
completion_tokens: decodeJson.usage.completion_tokens || totalTokens.completion_tokens,
|
| 309 |
+
total_tokens: decodeJson.usage.total_tokens || totalTokens.total_tokens
|
| 310 |
+
}
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
// 处理 web_search 信息
|
| 314 |
+
if (decodeJson.choices[0].delta && decodeJson.choices[0].delta.name === 'web_search') {
|
| 315 |
+
web_search_info = decodeJson.choices[0].delta.extra.web_search_info
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
if (!decodeJson.choices[0].delta || !decodeJson.choices[0].delta.content ||
|
| 319 |
+
(decodeJson.choices[0].delta.phase !== 'think' && decodeJson.choices[0].delta.phase !== 'answer')) {
|
| 320 |
+
continue
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
let content = decodeJson.choices[0].delta.content
|
| 324 |
+
|
| 325 |
+
// 处理thinking模式
|
| 326 |
+
if (decodeJson.choices[0].delta.phase === 'think' && !thinking_start) {
|
| 327 |
+
thinking_start = true
|
| 328 |
+
if (web_search_info) {
|
| 329 |
+
const webSearchTable = await accountManager.generateMarkdownTable(web_search_info, config.searchInfoMode)
|
| 330 |
+
content = `<think>\n\n${webSearchTable}\n\n${content}`
|
| 331 |
+
} else {
|
| 332 |
+
content = `<think>\n\n${content}`
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
if (decodeJson.choices[0].delta.phase === 'answer' && !thinking_end && thinking_start) {
|
| 336 |
+
thinking_end = true
|
| 337 |
+
content = `\n\n</think>\n${content}`
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
fullContent += content
|
| 341 |
+
} catch (error) {
|
| 342 |
+
logger.error('非流式数据处理错误', 'CHAT', '', error)
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
})
|
| 346 |
+
|
| 347 |
+
response.on('end', () => {
|
| 348 |
+
resolve()
|
| 349 |
+
})
|
| 350 |
+
|
| 351 |
+
response.on('error', (error) => {
|
| 352 |
+
logger.error('非流式响应流读取错误', 'CHAT', '', error)
|
| 353 |
+
reject(error)
|
| 354 |
+
})
|
| 355 |
+
})
|
| 356 |
+
|
| 357 |
+
// 处理最终的搜索信息
|
| 358 |
+
if ((config.outThink === false || !enable_thinking) && web_search_info && config.searchInfoMode === "text") {
|
| 359 |
+
const webSearchTable = await accountManager.generateMarkdownTable(web_search_info, "text")
|
| 360 |
+
fullContent += `\n\n---\n${webSearchTable}`
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
// 计算最终的token使用量
|
| 364 |
+
if (totalTokens.prompt_tokens === 0 && totalTokens.completion_tokens === 0) {
|
| 365 |
+
totalTokens = createUsageObject(requestBody?.messages || promptText, fullContent, null)
|
| 366 |
+
logger.info(`非流式使用tiktoken计算 - Prompt: ${totalTokens.prompt_tokens}, Completion: ${totalTokens.completion_tokens}, Total: ${totalTokens.total_tokens}`, 'CHAT')
|
| 367 |
+
} else {
|
| 368 |
+
logger.info(`非流式使用上游真实Token - Prompt: ${totalTokens.prompt_tokens}, Completion: ${totalTokens.completion_tokens}, Total: ${totalTokens.total_tokens}`, 'CHAT')
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
// 确保token数量的有效性
|
| 372 |
+
totalTokens.prompt_tokens = Math.max(0, totalTokens.prompt_tokens || 0)
|
| 373 |
+
totalTokens.completion_tokens = Math.max(0, totalTokens.completion_tokens || 0)
|
| 374 |
+
totalTokens.total_tokens = totalTokens.prompt_tokens + totalTokens.completion_tokens
|
| 375 |
+
|
| 376 |
+
// 返回完整的JSON响应
|
| 377 |
+
const bodyTemplate = {
|
| 378 |
+
"id": `chatcmpl-${generateUUID()}`,
|
| 379 |
+
"object": "chat.completion",
|
| 380 |
+
"created": new Date().getTime(),
|
| 381 |
+
"model": model,
|
| 382 |
+
"choices": [
|
| 383 |
+
{
|
| 384 |
+
"index": 0,
|
| 385 |
+
"message": {
|
| 386 |
+
"role": "assistant",
|
| 387 |
+
"content": fullContent
|
| 388 |
+
},
|
| 389 |
+
"finish_reason": "stop"
|
| 390 |
+
}
|
| 391 |
+
],
|
| 392 |
+
"usage": totalTokens
|
| 393 |
+
}
|
| 394 |
+
res.json(bodyTemplate)
|
| 395 |
+
} catch (error) {
|
| 396 |
+
logger.error('非流式聊天处理错误', 'CHAT', '', error)
|
| 397 |
+
res.status(500)
|
| 398 |
+
.json({
|
| 399 |
+
error: "服务错误!!!"
|
| 400 |
+
})
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
/**
|
| 406 |
+
* 主要的聊天完成处理函数
|
| 407 |
+
* @param {object} req - Express 请求对象
|
| 408 |
+
* @param {object} res - Express 响应对象
|
| 409 |
+
*/
|
| 410 |
+
const handleChatCompletion = async (req, res) => {
|
| 411 |
+
const { stream, model } = req.body
|
| 412 |
+
|
| 413 |
+
const enable_thinking = req.enable_thinking
|
| 414 |
+
const enable_web_search = req.enable_web_search
|
| 415 |
+
|
| 416 |
+
try {
|
| 417 |
+
const response_data = await sendChatRequest(req.body)
|
| 418 |
+
|
| 419 |
+
if (!response_data.status || !response_data.response) {
|
| 420 |
+
res.status(500)
|
| 421 |
+
.json({
|
| 422 |
+
error: "请求发送失败!!!"
|
| 423 |
+
})
|
| 424 |
+
return
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
if (stream) {
|
| 428 |
+
setResponseHeaders(res, true)
|
| 429 |
+
await handleStreamResponse(res, response_data.response, enable_thinking, enable_web_search, req.body)
|
| 430 |
+
} else {
|
| 431 |
+
setResponseHeaders(res, false)
|
| 432 |
+
await handleNonStreamResponse(res, response_data.response, enable_thinking, enable_web_search, model, req.body)
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
} catch (error) {
|
| 436 |
+
logger.error('聊天处理错误', 'CHAT', '', error)
|
| 437 |
+
res.status(500)
|
| 438 |
+
.json({
|
| 439 |
+
error: "token无效,请求发送失败!!!"
|
| 440 |
+
})
|
| 441 |
+
}
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
module.exports = {
|
| 445 |
+
handleChatCompletion,
|
| 446 |
+
handleStreamResponse,
|
| 447 |
+
handleNonStreamResponse,
|
| 448 |
+
setResponseHeaders
|
| 449 |
+
}
|
src/controllers/cli.chat.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const axios = require('axios')
|
| 2 |
+
const { logger } = require('../utils/logger')
|
| 3 |
+
const { getProxyAgent, getCliBaseUrl, applyProxyToAxiosConfig } = require('../utils/proxy-helper')
|
| 4 |
+
|
| 5 |
+
const MODEL_REDIRECT = {
|
| 6 |
+
'qwen3.5-plus': 'coder-model',
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
function preprocessCliRequestBody(rawBody) {
|
| 10 |
+
const body = rawBody && typeof rawBody === 'object' ? JSON.parse(JSON.stringify(rawBody)) : {}
|
| 11 |
+
|
| 12 |
+
if (body.model && MODEL_REDIRECT[body.model]) {
|
| 13 |
+
body.model = MODEL_REDIRECT[body.model]
|
| 14 |
+
}
|
| 15 |
+
const isStream = body.stream === true
|
| 16 |
+
|
| 17 |
+
if (isStream) {
|
| 18 |
+
const hasToolsArray = Array.isArray(body.tools)
|
| 19 |
+
if (!hasToolsArray || body.tools.length === 0) {
|
| 20 |
+
body.tools = [{
|
| 21 |
+
type: 'function',
|
| 22 |
+
function: {
|
| 23 |
+
name: 'do_not_call_me',
|
| 24 |
+
description: 'Do not call this tool.',
|
| 25 |
+
parameters: {
|
| 26 |
+
type: 'object',
|
| 27 |
+
properties: {
|
| 28 |
+
operation: { type: 'number', description: 'placeholder' }
|
| 29 |
+
},
|
| 30 |
+
required: ['operation']
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
}]
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
if (!body.stream_options || typeof body.stream_options !== 'object') {
|
| 37 |
+
body.stream_options = {}
|
| 38 |
+
}
|
| 39 |
+
body.stream_options.include_usage = true
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
return body
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function formatCliJsonResponse(data, fallbackModel) {
|
| 46 |
+
if (!data || typeof data !== 'object') {
|
| 47 |
+
return data
|
| 48 |
+
}
|
| 49 |
+
if (!data.object) {
|
| 50 |
+
data.object = 'chat.completion'
|
| 51 |
+
}
|
| 52 |
+
if (!data.model && fallbackModel) {
|
| 53 |
+
data.model = fallbackModel
|
| 54 |
+
}
|
| 55 |
+
if (!Array.isArray(data.choices)) {
|
| 56 |
+
data.choices = []
|
| 57 |
+
}
|
| 58 |
+
return data
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* 处理CLI聊天完成请求(支持OpenAI格式的流式和JSON响应)
|
| 63 |
+
* @param {Object} req - Express请求对象
|
| 64 |
+
* @param {Object} res - Express响应对象
|
| 65 |
+
*/
|
| 66 |
+
const handleCliChatCompletion = async (req, res) => {
|
| 67 |
+
try {
|
| 68 |
+
const access_token = req.account.cli_info.access_token
|
| 69 |
+
const body = preprocessCliRequestBody(req.body)
|
| 70 |
+
const isStream = body.stream === true
|
| 71 |
+
|
| 72 |
+
// 打印当前使用的账号邮箱
|
| 73 |
+
logger.info(`CLI请求使用账号[${req.account.email}]开始处理`, 'CLI', '🚀')
|
| 74 |
+
|
| 75 |
+
// 无论成功失败都增加请求计数
|
| 76 |
+
req.account.cli_info.request_number++
|
| 77 |
+
|
| 78 |
+
const cliBaseUrl = getCliBaseUrl()
|
| 79 |
+
const proxyAgent = getProxyAgent()
|
| 80 |
+
|
| 81 |
+
// 设置请求配置
|
| 82 |
+
const axiosConfig = {
|
| 83 |
+
method: 'POST',
|
| 84 |
+
url: `${cliBaseUrl}/v1/chat/completions`,
|
| 85 |
+
headers: {
|
| 86 |
+
'Authorization': `Bearer ${access_token}`,
|
| 87 |
+
'Content-Type': 'application/json',
|
| 88 |
+
'Accept': isStream ? 'text/event-stream' : 'application/json',
|
| 89 |
+
'User-Agent': 'QwenCode/0.10.3 (darwin; arm64)',
|
| 90 |
+
'X-Dashscope-Useragent': 'QwenCode/0.10.3 (darwin; arm64)',
|
| 91 |
+
'X-Stainless-Runtime-Version': 'v22.17.0',
|
| 92 |
+
'Sec-Fetch-Mode': 'cors',
|
| 93 |
+
'X-Stainless-Lang': 'js',
|
| 94 |
+
'X-Stainless-Arch': 'arm64',
|
| 95 |
+
'X-Stainless-Package-Version': '5.11.0',
|
| 96 |
+
'X-Dashscope-Cachecontrol': 'enable',
|
| 97 |
+
'X-Stainless-Retry-Count': '0',
|
| 98 |
+
'X-Stainless-Os': 'MacOS',
|
| 99 |
+
'X-Dashscope-Authtype': 'qwen-oauth',
|
| 100 |
+
'X-Stainless-Runtime': 'node'
|
| 101 |
+
},
|
| 102 |
+
data: body,
|
| 103 |
+
timeout: 5 * 60 * 1000,
|
| 104 |
+
validateStatus: function () {
|
| 105 |
+
return true
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// 添加代理配置
|
| 110 |
+
if (proxyAgent) {
|
| 111 |
+
axiosConfig.httpsAgent = proxyAgent
|
| 112 |
+
axiosConfig.proxy = false
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
// 如果是流式请求,设置响应类型为流
|
| 116 |
+
if (isStream) {
|
| 117 |
+
axiosConfig.responseType = 'stream'
|
| 118 |
+
|
| 119 |
+
// 设置响应头为流式
|
| 120 |
+
res.setHeader('Content-Type', 'text/event-stream')
|
| 121 |
+
res.setHeader('Cache-Control', 'no-cache')
|
| 122 |
+
res.setHeader('Connection', 'keep-alive')
|
| 123 |
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
| 124 |
+
res.setHeader('Access-Control-Allow-Headers', '*')
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
const response = await axios(axiosConfig)
|
| 128 |
+
|
| 129 |
+
// 检查响应状态
|
| 130 |
+
if (response.status !== 200) {
|
| 131 |
+
logger.error(`CLI请求使用账号[${req.account.email}]转发失败 - 状态码: ${response.status} - 当前请求数: ${req.account.cli_info.request_number}`, 'CLI', '❌')
|
| 132 |
+
return res.status(response.status).json({
|
| 133 |
+
error: {
|
| 134 |
+
message: `api_error`,
|
| 135 |
+
type: 'api_error',
|
| 136 |
+
code: response.status,
|
| 137 |
+
details: response.data
|
| 138 |
+
}
|
| 139 |
+
})
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
// 处理流式响应
|
| 143 |
+
if (isStream) {
|
| 144 |
+
// 逐���转发,确保始终输出标准 SSE 片段
|
| 145 |
+
response.data.on('data', (chunk) => {
|
| 146 |
+
const text = chunk.toString('utf8')
|
| 147 |
+
const lines = text.split('\n')
|
| 148 |
+
for (const line of lines) {
|
| 149 |
+
if (!line || !line.startsWith('data:')) continue
|
| 150 |
+
res.write(`${line}\n\n`)
|
| 151 |
+
}
|
| 152 |
+
})
|
| 153 |
+
|
| 154 |
+
// 处理流错误
|
| 155 |
+
response.data.on('error', (streamError) => {
|
| 156 |
+
logger.error(`CLI请求使用账号[${req.account.email}]流式传输失败 - 当前请求数: ${req.account.cli_info.request_number}`, 'CLI', '❌')
|
| 157 |
+
if (!res.headersSent) {
|
| 158 |
+
res.status(500).json({
|
| 159 |
+
error: {
|
| 160 |
+
message: 'stream_error',
|
| 161 |
+
type: 'stream_error',
|
| 162 |
+
code: 500
|
| 163 |
+
}
|
| 164 |
+
})
|
| 165 |
+
}
|
| 166 |
+
})
|
| 167 |
+
|
| 168 |
+
// 处理流结束
|
| 169 |
+
response.data.on('end', () => {
|
| 170 |
+
logger.success(`CLI请求使用账号[${req.account.email}]转发成功 (流式) - 当前请求数: ${req.account.cli_info.request_number}`, 'CLI')
|
| 171 |
+
res.end()
|
| 172 |
+
})
|
| 173 |
+
} else {
|
| 174 |
+
// 处理JSON响应
|
| 175 |
+
res.json(formatCliJsonResponse(response.data, body.model))
|
| 176 |
+
logger.success(`CLI请求使用账号[${req.account.email}]转发成功 (JSON) - 当前请求数: ${req.account.cli_info.request_number}`, 'CLI')
|
| 177 |
+
}
|
| 178 |
+
} catch (error) {
|
| 179 |
+
logger.error(`CLI请求使用账号[${req.account.email}]处理异常 - 当前请求数: ${req.account.cli_info.request_number}`, 'CLI', '💥', error.message)
|
| 180 |
+
|
| 181 |
+
// 如果是axios错误,提供更详细的错误信息
|
| 182 |
+
if (error.response) {
|
| 183 |
+
return res.status(error.response.status).json({
|
| 184 |
+
error: {
|
| 185 |
+
message: "api_error",
|
| 186 |
+
type: 'api_error',
|
| 187 |
+
code: error.response.status,
|
| 188 |
+
details: error.response.data
|
| 189 |
+
}
|
| 190 |
+
})
|
| 191 |
+
} else if (error.request) {
|
| 192 |
+
return res.status(503).json({
|
| 193 |
+
error: {
|
| 194 |
+
message: 'connection_error',
|
| 195 |
+
type: 'connection_error',
|
| 196 |
+
code: 503
|
| 197 |
+
}
|
| 198 |
+
})
|
| 199 |
+
} else {
|
| 200 |
+
return res.status(500).json({
|
| 201 |
+
error: {
|
| 202 |
+
message: 'internal_error',
|
| 203 |
+
type: 'internal_error',
|
| 204 |
+
code: 500
|
| 205 |
+
}
|
| 206 |
+
})
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
module.exports = {
|
| 212 |
+
handleCliChatCompletion
|
| 213 |
+
}
|
src/controllers/models.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { getLatestModels } = require('../models/models-map.js')
|
| 2 |
+
const config = require('../config/index.js')
|
| 3 |
+
|
| 4 |
+
const handleGetModels = async (req, res) => {
|
| 5 |
+
const models = []
|
| 6 |
+
|
| 7 |
+
const ModelsMap = await getLatestModels()
|
| 8 |
+
|
| 9 |
+
for (const model of ModelsMap) {
|
| 10 |
+
delete model.name
|
| 11 |
+
models.push(model)
|
| 12 |
+
|
| 13 |
+
if (config.simpleModelMap) {
|
| 14 |
+
continue
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const isThinking = model?.info?.meta?.abilities?.thinking
|
| 18 |
+
const isSearch = model?.info?.meta?.chat_type?.includes('search')
|
| 19 |
+
const isImage = model?.info?.meta?.chat_type?.includes('t2i')
|
| 20 |
+
const isVideo = model?.info?.meta?.chat_type?.includes('t2v')
|
| 21 |
+
const isImageEdit = model?.info?.meta?.chat_type?.includes('image_edit')
|
| 22 |
+
const isDeepResearch = model?.info?.meta?.chat_type?.includes('deep_research')
|
| 23 |
+
|
| 24 |
+
if (isThinking) {
|
| 25 |
+
const newModelData = JSON.parse(JSON.stringify(model))
|
| 26 |
+
newModelData.id = `${model.id}-thinking`
|
| 27 |
+
|
| 28 |
+
models.push(newModelData)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
if (isSearch) {
|
| 32 |
+
const newModelData = JSON.parse(JSON.stringify(model))
|
| 33 |
+
newModelData.id = `${model.id}-search`
|
| 34 |
+
models.push(newModelData)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
if (isThinking && isSearch) {
|
| 38 |
+
const newModelData = JSON.parse(JSON.stringify(model))
|
| 39 |
+
newModelData.id = `${model.id}-thinking-search`
|
| 40 |
+
models.push(newModelData)
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
if (isImage) {
|
| 44 |
+
const newModelData = JSON.parse(JSON.stringify(model))
|
| 45 |
+
newModelData.id = `${model.id}-image`
|
| 46 |
+
models.push(newModelData)
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
if (isVideo) {
|
| 50 |
+
const newModelData = JSON.parse(JSON.stringify(model))
|
| 51 |
+
newModelData.id = `${model.id}-video`
|
| 52 |
+
models.push(newModelData)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
if (isImageEdit) {
|
| 56 |
+
const newModelData = JSON.parse(JSON.stringify(model))
|
| 57 |
+
newModelData.id = `${model.id}-image-edit`
|
| 58 |
+
models.push(newModelData)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// if (isDeepResearch) {
|
| 62 |
+
// const newModelData = JSON.parse(JSON.stringify(model))
|
| 63 |
+
// newModelData.id = `${model.id}-deep-research`
|
| 64 |
+
// models.push(newModelData)
|
| 65 |
+
// }
|
| 66 |
+
}
|
| 67 |
+
res.json({
|
| 68 |
+
"object": "list",
|
| 69 |
+
"data": models
|
| 70 |
+
})
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
module.exports = {
|
| 74 |
+
handleGetModels
|
| 75 |
+
}
|
src/middlewares/authorization.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = require('../config')
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* 验证API Key是否有效
|
| 5 |
+
* @param {string} providedKey - 提供的API Key
|
| 6 |
+
* @returns {Object} 验证结果 { isValid: boolean, isAdmin: boolean }
|
| 7 |
+
*/
|
| 8 |
+
const validateApiKey = (providedKey) => {
|
| 9 |
+
if (!providedKey) {
|
| 10 |
+
return { isValid: false, isAdmin: false }
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
// 移除Bearer前缀
|
| 14 |
+
const cleanKey = providedKey.startsWith('Bearer ') ? providedKey.slice(7) : providedKey
|
| 15 |
+
|
| 16 |
+
// 检查是否在有效的API keys列表中
|
| 17 |
+
const isValid = config.apiKeys.includes(cleanKey)
|
| 18 |
+
const isAdmin = cleanKey === config.adminKey
|
| 19 |
+
|
| 20 |
+
return { isValid, isAdmin }
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* API Key验证中间件 - 验证任何有效的API Key
|
| 25 |
+
*/
|
| 26 |
+
const apiKeyVerify = (req, res, next) => {
|
| 27 |
+
const apiKey = req.headers['authorization'] || req.headers['Authorization'] || req.headers['x-api-key']
|
| 28 |
+
const { isValid, isAdmin } = validateApiKey(apiKey)
|
| 29 |
+
|
| 30 |
+
if (!isValid) {
|
| 31 |
+
return res.status(401).json({ error: 'Unauthorized' })
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// 将权限信息附加到请求对象
|
| 35 |
+
req.isAdmin = isAdmin
|
| 36 |
+
req.apiKey = apiKey
|
| 37 |
+
next()
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* 管理员权限验证中间件 - 只允许管理员API Key
|
| 42 |
+
*/
|
| 43 |
+
const adminKeyVerify = (req, res, next) => {
|
| 44 |
+
const apiKey = req.headers['authorization'] || req.headers['Authorization'] || req.headers['x-api-key']
|
| 45 |
+
const { isValid, isAdmin } = validateApiKey(apiKey)
|
| 46 |
+
|
| 47 |
+
if (!isValid || !isAdmin) {
|
| 48 |
+
return res.status(403).json({ error: 'Admin access required' })
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
req.isAdmin = isAdmin
|
| 52 |
+
req.apiKey = apiKey
|
| 53 |
+
next()
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
module.exports = {
|
| 57 |
+
apiKeyVerify,
|
| 58 |
+
adminKeyVerify,
|
| 59 |
+
validateApiKey
|
| 60 |
+
}
|
| 61 |
+
|
src/middlewares/chat-middleware.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { generateUUID } = require('../utils/tools.js')
|
| 2 |
+
const { isChatType, isThinkingEnabled, parserModel, parserMessages } = require('../utils/chat-helpers.js')
|
| 3 |
+
const { logger } = require('../utils/logger')
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* 处理聊天请求体的中间件
|
| 7 |
+
* 解析和转换请求参数为内部格式
|
| 8 |
+
*/
|
| 9 |
+
const processRequestBody = async (req, res, next) => {
|
| 10 |
+
try {
|
| 11 |
+
// 构建请求体
|
| 12 |
+
const body = {
|
| 13 |
+
"stream": true,
|
| 14 |
+
"incremental_output": true,
|
| 15 |
+
"chat_type": "t2t",
|
| 16 |
+
"model": "qwen3-235b-a22b",
|
| 17 |
+
"messages": [],
|
| 18 |
+
"session_id": generateUUID(),
|
| 19 |
+
"id": generateUUID(),
|
| 20 |
+
"sub_chat_type": "t2t",
|
| 21 |
+
"chat_mode": "normal"
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// 获取请求体原始数据
|
| 25 |
+
let {
|
| 26 |
+
messages, // 消息历史
|
| 27 |
+
model, // 模型
|
| 28 |
+
stream, // 流式输出
|
| 29 |
+
enable_thinking, // 是否启用思考
|
| 30 |
+
thinking_budget, // 思考预算
|
| 31 |
+
size //图片尺寸
|
| 32 |
+
} = req.body
|
| 33 |
+
|
| 34 |
+
// 处理 stream 参数
|
| 35 |
+
if (stream === true || stream === 'true') {
|
| 36 |
+
body.stream = true
|
| 37 |
+
} else {
|
| 38 |
+
body.stream = false
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// 处理 chat_type 参数 : 聊天类型
|
| 42 |
+
body.chat_type = isChatType(model)
|
| 43 |
+
|
| 44 |
+
req.enable_web_search = body.chat_type === 'search' ? true : false
|
| 45 |
+
|
| 46 |
+
// 处理 model 参数 : 模型
|
| 47 |
+
body.model = parserModel(model)
|
| 48 |
+
|
| 49 |
+
// 处理 messages 参数 : 消息历史
|
| 50 |
+
body.messages = await parserMessages(messages, isThinkingEnabled(model, enable_thinking, thinking_budget), body.chat_type)
|
| 51 |
+
|
| 52 |
+
// 处理 enable_thinking 参数 : 是否启用思考
|
| 53 |
+
req.enable_thinking = isThinkingEnabled(model, enable_thinking, thinking_budget).thinking_enabled
|
| 54 |
+
|
| 55 |
+
// 处理 sub_chat_type 参数 : 子聊天类型
|
| 56 |
+
body.sub_chat_type = body.chat_type
|
| 57 |
+
|
| 58 |
+
// 处理图片尺寸
|
| 59 |
+
if (size) {
|
| 60 |
+
body.size = size
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// 处理请求体,将body赋值给req.body
|
| 64 |
+
req.body = body
|
| 65 |
+
|
| 66 |
+
next()
|
| 67 |
+
} catch (e) {
|
| 68 |
+
logger.error('处理请求体时发生错误', 'MIDDLEWARE', '', e)
|
| 69 |
+
res.status(500)
|
| 70 |
+
.json({
|
| 71 |
+
status: 500,
|
| 72 |
+
message: "在处理请求体时发生错误 ~ ~ ~"
|
| 73 |
+
})
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
module.exports = {
|
| 78 |
+
processRequestBody
|
| 79 |
+
}
|
src/models/models-map.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const axios = require('axios')
|
| 2 |
+
const accountManager = require('../utils/account.js')
|
| 3 |
+
const { getSsxmodItna, getSsxmodItna2 } = require('../utils/ssxmod-manager')
|
| 4 |
+
const { getProxyAgent, getChatBaseUrl, applyProxyToAxiosConfig } = require('../utils/proxy-helper')
|
| 5 |
+
|
| 6 |
+
let cachedModels = null
|
| 7 |
+
let fetchPromise = null
|
| 8 |
+
|
| 9 |
+
const getLatestModels = async (force = false) => {
|
| 10 |
+
// 如果有缓存且不强制刷新,直接返回
|
| 11 |
+
if (cachedModels && !force) {
|
| 12 |
+
return cachedModels
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// 如果正在获取,返回当前的 Promise
|
| 16 |
+
if (fetchPromise) {
|
| 17 |
+
return fetchPromise
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const chatBaseUrl = getChatBaseUrl()
|
| 21 |
+
const proxyAgent = getProxyAgent()
|
| 22 |
+
|
| 23 |
+
const requestConfig = {
|
| 24 |
+
headers: {
|
| 25 |
+
'Authorization': `Bearer ${accountManager.getAccountToken()}`,
|
| 26 |
+
'Content-Type': 'application/json',
|
| 27 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
| 28 |
+
...(getSsxmodItna() && { 'Cookie': `ssxmod_itna=${getSsxmodItna()};ssxmod_itna2=${getSsxmodItna2()}` })
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// 添加代理配置
|
| 33 |
+
if (proxyAgent) {
|
| 34 |
+
requestConfig.httpsAgent = proxyAgent
|
| 35 |
+
requestConfig.proxy = false
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
fetchPromise = axios.get(`${chatBaseUrl}/api/models`, requestConfig).then(response => {
|
| 39 |
+
// console.log(response)
|
| 40 |
+
cachedModels = response.data.data
|
| 41 |
+
fetchPromise = null
|
| 42 |
+
return cachedModels
|
| 43 |
+
}).catch(error => {
|
| 44 |
+
console.error('Error fetching latest models:', error)
|
| 45 |
+
fetchPromise = null
|
| 46 |
+
return []
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
return fetchPromise
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
module.exports = { getLatestModels }
|
src/routes/accounts.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express')
|
| 2 |
+
const router = express.Router()
|
| 3 |
+
const accountManager = require('../utils/account')
|
| 4 |
+
const { logger } = require('../utils/logger')
|
| 5 |
+
const { JwtDecode } = require('../utils/tools')
|
| 6 |
+
const { adminKeyVerify } = require('../middlewares/authorization')
|
| 7 |
+
const { deleteAccount, saveAccounts, refreshAccountToken } = require('../utils/setting')
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* 获取所有账号(分页)
|
| 11 |
+
*
|
| 12 |
+
* @param {number} page 页码
|
| 13 |
+
* @param {number} pageSize 每页数量
|
| 14 |
+
* @returns {Object} 账号列表
|
| 15 |
+
*/
|
| 16 |
+
router.get('/getAllAccounts', adminKeyVerify, async (req, res) => {
|
| 17 |
+
try {
|
| 18 |
+
const page = parseInt(req.query.page) || 1
|
| 19 |
+
const pageSize = parseInt(req.query.pageSize) || 1000
|
| 20 |
+
const start = (page - 1) * pageSize
|
| 21 |
+
|
| 22 |
+
// 获取所有账号键
|
| 23 |
+
const allAccounts = accountManager.getAllAccountKeys()
|
| 24 |
+
const total = allAccounts.length
|
| 25 |
+
|
| 26 |
+
// 分页处理
|
| 27 |
+
const paginatedAccounts = allAccounts.slice(start, start + pageSize)
|
| 28 |
+
|
| 29 |
+
// 获取每个账号的详细信息
|
| 30 |
+
const accounts = paginatedAccounts.map(account => {
|
| 31 |
+
return {
|
| 32 |
+
email: account.email,
|
| 33 |
+
password: account.password,
|
| 34 |
+
token: account.token,
|
| 35 |
+
expires: account.expires
|
| 36 |
+
}
|
| 37 |
+
})
|
| 38 |
+
|
| 39 |
+
res.json({
|
| 40 |
+
total,
|
| 41 |
+
page,
|
| 42 |
+
pageSize,
|
| 43 |
+
data: accounts
|
| 44 |
+
})
|
| 45 |
+
} catch (error) {
|
| 46 |
+
logger.error('获取账号列表失败', 'ACCOUNT', '', error)
|
| 47 |
+
res.status(500).json({ error: error.message })
|
| 48 |
+
}
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
/**
|
| 52 |
+
* POST /setAccount
|
| 53 |
+
* 添加账号
|
| 54 |
+
*
|
| 55 |
+
* @param {string} email 邮箱
|
| 56 |
+
* @param {string} password 密码
|
| 57 |
+
* @returns {Object} 账号信息
|
| 58 |
+
*/
|
| 59 |
+
router.post('/setAccount', adminKeyVerify, async (req, res) => {
|
| 60 |
+
try {
|
| 61 |
+
const { email, password } = req.body
|
| 62 |
+
|
| 63 |
+
if (!email || !password) {
|
| 64 |
+
return res.status(400).json({ error: '邮箱和密码不能为空' })
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// 检查账号是否已存在
|
| 68 |
+
const exists = accountManager.accountTokens.find(item => item.email === email)
|
| 69 |
+
if (exists) {
|
| 70 |
+
return res.status(409).json({ error: '账号已存在' })
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
const authToken = await accountManager.login(email, password)
|
| 74 |
+
if (!authToken) {
|
| 75 |
+
return res.status(401).json({ error: '登录失败' })
|
| 76 |
+
}
|
| 77 |
+
// 解析JWT
|
| 78 |
+
const decoded = JwtDecode(authToken)
|
| 79 |
+
const expires = decoded.exp
|
| 80 |
+
|
| 81 |
+
const success = await saveAccounts(email, password, authToken, expires)
|
| 82 |
+
|
| 83 |
+
if (success) {
|
| 84 |
+
res.status(200).json({
|
| 85 |
+
email,
|
| 86 |
+
message: '账号创建成功'
|
| 87 |
+
})
|
| 88 |
+
} else {
|
| 89 |
+
res.status(500).json({ error: '账号创建失败' })
|
| 90 |
+
}
|
| 91 |
+
} catch (error) {
|
| 92 |
+
logger.error('创建账号失败', 'ACCOUNT', '', error)
|
| 93 |
+
res.status(500).json({ error: error.message })
|
| 94 |
+
}
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
/**
|
| 98 |
+
* DELETE /deleteAccount
|
| 99 |
+
* 删除账号
|
| 100 |
+
*
|
| 101 |
+
* @param {string} email 邮箱
|
| 102 |
+
* @returns {Object} 账号信息
|
| 103 |
+
*/
|
| 104 |
+
router.delete('/deleteAccount', adminKeyVerify, async (req, res) => {
|
| 105 |
+
try {
|
| 106 |
+
const { email } = req.body
|
| 107 |
+
|
| 108 |
+
// 检查账号是否存在
|
| 109 |
+
const exists = await accountManager.accountTokens.find(item => item.email === email)
|
| 110 |
+
if (!exists) {
|
| 111 |
+
return res.status(404).json({ error: '账号不存在' })
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// 删除账号
|
| 115 |
+
const success = await deleteAccount(email)
|
| 116 |
+
|
| 117 |
+
if (success) {
|
| 118 |
+
res.json({ message: '账号删除成功' })
|
| 119 |
+
} else {
|
| 120 |
+
res.status(500).json({ error: '账号删除失败' })
|
| 121 |
+
}
|
| 122 |
+
} catch (error) {
|
| 123 |
+
logger.error('删除账号失败', 'ACCOUNT', '', error)
|
| 124 |
+
res.status(500).json({ error: error.message })
|
| 125 |
+
}
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
/**
|
| 130 |
+
* POST /setAccounts
|
| 131 |
+
* 批量添加账号
|
| 132 |
+
*
|
| 133 |
+
* @param {string} accounts 账号列表
|
| 134 |
+
* @returns {Object} 账号信息
|
| 135 |
+
*/
|
| 136 |
+
router.post('/setAccounts', adminKeyVerify, async (req, res) => {
|
| 137 |
+
try {
|
| 138 |
+
let { accounts } = req.body
|
| 139 |
+
if (!accounts) {
|
| 140 |
+
return res.status(400).json({ error: '账号列表不能为空' })
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
accounts = accounts.replace(/[\r]/g, '\n')
|
| 144 |
+
accounts = accounts.split('\n').filter(item => item.trim() !== '')
|
| 145 |
+
|
| 146 |
+
for (const account of accounts) {
|
| 147 |
+
const [email, password] = account.split(':')
|
| 148 |
+
if (!email || !password) {
|
| 149 |
+
continue
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
const authToken = await accountManager.login(email, password)
|
| 153 |
+
if (!authToken) {
|
| 154 |
+
continue
|
| 155 |
+
}
|
| 156 |
+
// 解析JWT
|
| 157 |
+
const decoded = JwtDecode(authToken)
|
| 158 |
+
const expires = decoded.exp
|
| 159 |
+
|
| 160 |
+
const success = await saveAccounts(email, password, authToken, expires)
|
| 161 |
+
if (!success) {
|
| 162 |
+
continue
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
res.json({ message: '账号批量添加任务提交成功' })
|
| 167 |
+
} catch (error) {
|
| 168 |
+
logger.error('批量创建账号失败', 'ACCOUNT', '', error)
|
| 169 |
+
res.status(500).json({ error: error.message })
|
| 170 |
+
}
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
/**
|
| 174 |
+
* POST /refreshAccount
|
| 175 |
+
* 刷新单个账号的令牌
|
| 176 |
+
*
|
| 177 |
+
* @param {string} email 邮箱
|
| 178 |
+
* @returns {Object} 刷新结果
|
| 179 |
+
*/
|
| 180 |
+
router.post('/refreshAccount', adminKeyVerify, async (req, res) => {
|
| 181 |
+
try {
|
| 182 |
+
const { email } = req.body
|
| 183 |
+
|
| 184 |
+
if (!email) {
|
| 185 |
+
return res.status(400).json({ error: '邮箱不能为空' })
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
// 检查账号是否存在
|
| 189 |
+
const exists = accountManager.accountTokens.find(item => item.email === email)
|
| 190 |
+
if (!exists) {
|
| 191 |
+
return res.status(404).json({ error: '账号不存在' })
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
// 刷新账号令牌
|
| 195 |
+
const success = await accountManager.refreshAccountToken(email)
|
| 196 |
+
|
| 197 |
+
if (success) {
|
| 198 |
+
res.json({
|
| 199 |
+
message: '账号令牌刷新成功',
|
| 200 |
+
email: email
|
| 201 |
+
})
|
| 202 |
+
} else {
|
| 203 |
+
res.status(500).json({ error: '账号令牌刷新失败' })
|
| 204 |
+
}
|
| 205 |
+
} catch (error) {
|
| 206 |
+
logger.error('刷新账号令牌失败', 'ACCOUNT', '', error)
|
| 207 |
+
res.status(500).json({ error: error.message })
|
| 208 |
+
}
|
| 209 |
+
})
|
| 210 |
+
|
| 211 |
+
/**
|
| 212 |
+
* POST /refreshAllAccounts
|
| 213 |
+
* 刷新所有账号的令牌
|
| 214 |
+
*
|
| 215 |
+
* @param {number} thresholdHours 过期阈值(小时),默认24小时
|
| 216 |
+
* @returns {Object} 刷新结果
|
| 217 |
+
*/
|
| 218 |
+
router.post('/refreshAllAccounts', adminKeyVerify, async (req, res) => {
|
| 219 |
+
try {
|
| 220 |
+
const { thresholdHours = 24 } = req.body
|
| 221 |
+
|
| 222 |
+
// 执行批量刷新
|
| 223 |
+
const refreshedCount = await accountManager.autoRefreshTokens(thresholdHours)
|
| 224 |
+
|
| 225 |
+
res.json({
|
| 226 |
+
message: '批量刷新完成',
|
| 227 |
+
refreshedCount: refreshedCount,
|
| 228 |
+
thresholdHours: thresholdHours
|
| 229 |
+
})
|
| 230 |
+
} catch (error) {
|
| 231 |
+
logger.error('批量刷新账号令牌失败', 'ACCOUNT', '', error)
|
| 232 |
+
res.status(500).json({ error: error.message })
|
| 233 |
+
}
|
| 234 |
+
})
|
| 235 |
+
|
| 236 |
+
/**
|
| 237 |
+
* POST /forceRefreshAllAccounts
|
| 238 |
+
* 强制刷新所有账号的令牌(不管是否即将过期)
|
| 239 |
+
*
|
| 240 |
+
* @returns {Object} 刷新结果
|
| 241 |
+
*/
|
| 242 |
+
router.post('/forceRefreshAllAccounts', adminKeyVerify, async (req, res) => {
|
| 243 |
+
try {
|
| 244 |
+
// 强制刷新所有账号(设置阈值为很大的值,确保所有账号都会被刷新)
|
| 245 |
+
const refreshedCount = await accountManager.autoRefreshTokens(8760) // 365天
|
| 246 |
+
|
| 247 |
+
res.json({
|
| 248 |
+
message: '强制刷新完成',
|
| 249 |
+
refreshedCount: refreshedCount,
|
| 250 |
+
totalAccounts: accountManager.getAllAccountKeys().length
|
| 251 |
+
})
|
| 252 |
+
} catch (error) {
|
| 253 |
+
logger.error('强制刷新账号令牌失败', 'ACCOUNT', '', error)
|
| 254 |
+
res.status(500).json({ error: error.message })
|
| 255 |
+
}
|
| 256 |
+
})
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
module.exports = router
|
src/routes/chat.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express')
|
| 2 |
+
const router = express.Router()
|
| 3 |
+
const { apiKeyVerify } = require('../middlewares/authorization.js')
|
| 4 |
+
const { processRequestBody } = require('../middlewares/chat-middleware.js')
|
| 5 |
+
const { handleChatCompletion } = require('../controllers/chat.js')
|
| 6 |
+
const { handleImageVideoCompletion } = require('../controllers/chat.image.video.js')
|
| 7 |
+
|
| 8 |
+
const selectChatCompletion = (req, res, next) => {
|
| 9 |
+
const ChatCompletionMap = {
|
| 10 |
+
't2t': handleChatCompletion,
|
| 11 |
+
'search': handleChatCompletion,
|
| 12 |
+
't2i': handleImageVideoCompletion,
|
| 13 |
+
't2v': handleImageVideoCompletion,
|
| 14 |
+
'image_edit': handleImageVideoCompletion,
|
| 15 |
+
// 'deep_research': handleDeepResearchCompletion
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const chatType = req.body.chat_type
|
| 19 |
+
const chatCompletion = ChatCompletionMap[chatType]
|
| 20 |
+
if (chatCompletion) {
|
| 21 |
+
chatCompletion(req, res, next)
|
| 22 |
+
} else {
|
| 23 |
+
handleImageCompletion(req, res, next)
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
router.post('/v1/chat/completions',
|
| 28 |
+
apiKeyVerify,
|
| 29 |
+
processRequestBody,
|
| 30 |
+
selectChatCompletion
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
module.exports = router
|
src/routes/cli.chat.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express')
|
| 2 |
+
const router = express.Router()
|
| 3 |
+
const { apiKeyVerify } = require('../middlewares/authorization.js')
|
| 4 |
+
const { handleCliChatCompletion } = require('../controllers/cli.chat.js')
|
| 5 |
+
const accountManager = require('../utils/account.js')
|
| 6 |
+
|
| 7 |
+
router.post('/cli/v1/chat/completions',
|
| 8 |
+
apiKeyVerify,
|
| 9 |
+
async (req, res, next) => {
|
| 10 |
+
// 异步初始化新账号(不阻塞当前请求)
|
| 11 |
+
const noCliAccount = accountManager.accountTokens.filter(account => !account.cli_info)
|
| 12 |
+
if (noCliAccount.length > 0) {
|
| 13 |
+
const randomNewAccount = noCliAccount[Math.floor(Math.random() * noCliAccount.length)]
|
| 14 |
+
// 异步初始化,不等待结果
|
| 15 |
+
accountManager.initializeCliForAccount(randomNewAccount).catch(error => {
|
| 16 |
+
console.error(`异步初始化CLI账户失败 (${randomNewAccount.email}):`, error)
|
| 17 |
+
})
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
// 获取当前可用的CLI账户用于本次请求
|
| 21 |
+
const availableAccounts = accountManager.accountTokens.filter(account =>
|
| 22 |
+
account.cli_info && account.cli_info.request_number < 2000
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
if (availableAccounts.length === 0) {
|
| 26 |
+
return res.status(503).json({
|
| 27 |
+
error: '没有可用的CLI账户,请稍后重试'
|
| 28 |
+
})
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
// 随机选择一个可用账户用于本次请求
|
| 32 |
+
const randomAccount = availableAccounts[Math.floor(Math.random() * availableAccounts.length)]
|
| 33 |
+
req.account = randomAccount
|
| 34 |
+
next()
|
| 35 |
+
},
|
| 36 |
+
handleCliChatCompletion
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
module.exports = router
|
src/routes/models.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express')
|
| 2 |
+
const router = express.Router()
|
| 3 |
+
const { apiKeyVerify } = require('../middlewares/authorization')
|
| 4 |
+
const { handleGetModels } = require('../controllers/models.js')
|
| 5 |
+
|
| 6 |
+
router.get('/v1/models', apiKeyVerify, handleGetModels)
|
| 7 |
+
|
| 8 |
+
router.get('/models', handleGetModels)
|
| 9 |
+
|
| 10 |
+
router.post('/cli/v1/models', async (req, res) => {
|
| 11 |
+
res.json({
|
| 12 |
+
object: 'list',
|
| 13 |
+
data: [
|
| 14 |
+
{
|
| 15 |
+
id: 'qwen3-coder-plus',
|
| 16 |
+
object: 'model',
|
| 17 |
+
created: 1719878112,
|
| 18 |
+
owned_by: 'qwen-code'
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
id: 'qwen3-coder-flash',
|
| 22 |
+
object: 'model',
|
| 23 |
+
created: 1719878112,
|
| 24 |
+
owned_by: 'qwen-code'
|
| 25 |
+
},
|
| 26 |
+
]
|
| 27 |
+
})
|
| 28 |
+
})
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
module.exports = router
|
src/routes/settings.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express')
|
| 2 |
+
const router = express.Router()
|
| 3 |
+
const config = require('../config')
|
| 4 |
+
const { apiKeyVerify, adminKeyVerify } = require('../middlewares/authorization')
|
| 5 |
+
const { logger } = require('../utils/logger')
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
router.get('/settings', adminKeyVerify, async (req, res) => {
|
| 9 |
+
// 分离管理员密钥和普通密钥
|
| 10 |
+
const regularKeys = config.apiKeys.filter(key => key !== config.adminKey)
|
| 11 |
+
|
| 12 |
+
res.json({
|
| 13 |
+
apiKey: config.apiKey, // 保持向后兼容
|
| 14 |
+
adminKey: config.adminKey,
|
| 15 |
+
regularKeys: regularKeys,
|
| 16 |
+
defaultHeaders: config.defaultHeaders,
|
| 17 |
+
defaultCookie: config.defaultCookie,
|
| 18 |
+
autoRefresh: config.autoRefresh,
|
| 19 |
+
autoRefreshInterval: config.autoRefreshInterval,
|
| 20 |
+
outThink: config.outThink,
|
| 21 |
+
searchInfoMode: config.searchInfoMode,
|
| 22 |
+
simpleModelMap: config.simpleModelMap
|
| 23 |
+
})
|
| 24 |
+
})
|
| 25 |
+
|
| 26 |
+
// 添加普通API Key
|
| 27 |
+
router.post('/addRegularKey', adminKeyVerify, async (req, res) => {
|
| 28 |
+
try {
|
| 29 |
+
const { apiKey } = req.body
|
| 30 |
+
if (!apiKey) {
|
| 31 |
+
return res.status(400).json({ error: 'API Key不能为空' })
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// 检查是否已存在
|
| 35 |
+
if (config.apiKeys.includes(apiKey)) {
|
| 36 |
+
return res.status(409).json({ error: 'API Key已存在' })
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// 添加到配置中
|
| 40 |
+
config.apiKeys.push(apiKey)
|
| 41 |
+
|
| 42 |
+
res.json({ message: 'API Key添加成功' })
|
| 43 |
+
} catch (error) {
|
| 44 |
+
logger.error('添加API Key失败', 'CONFIG', '', error)
|
| 45 |
+
res.status(500).json({ error: error.message })
|
| 46 |
+
}
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
// 删除普通API Key
|
| 50 |
+
router.post('/deleteRegularKey', adminKeyVerify, async (req, res) => {
|
| 51 |
+
try {
|
| 52 |
+
const { apiKey } = req.body
|
| 53 |
+
if (!apiKey) {
|
| 54 |
+
return res.status(400).json({ error: 'API Key不能为空' })
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// 不能删除管理员密钥
|
| 58 |
+
if (apiKey === config.adminKey) {
|
| 59 |
+
return res.status(403).json({ error: '不能删除管理员密钥' })
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// 从配置中移除
|
| 63 |
+
const index = config.apiKeys.indexOf(apiKey)
|
| 64 |
+
if (index === -1) {
|
| 65 |
+
return res.status(404).json({ error: 'API Key不存在' })
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
config.apiKeys.splice(index, 1)
|
| 69 |
+
|
| 70 |
+
res.json({ message: 'API Key删除成功' })
|
| 71 |
+
} catch (error) {
|
| 72 |
+
logger.error('删除API Key失败', 'CONFIG', '', error)
|
| 73 |
+
res.status(500).json({ error: error.message })
|
| 74 |
+
}
|
| 75 |
+
})
|
| 76 |
+
|
| 77 |
+
// 更新自动刷新设置
|
| 78 |
+
router.post('/setAutoRefresh', adminKeyVerify, async (req, res) => {
|
| 79 |
+
try {
|
| 80 |
+
const { autoRefresh, autoRefreshInterval } = req.body
|
| 81 |
+
|
| 82 |
+
if (typeof autoRefresh !== 'boolean') {
|
| 83 |
+
return res.status(400).json({ error: '无效的自动刷新设置' })
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
if (autoRefreshInterval !== undefined) {
|
| 87 |
+
const interval = parseInt(autoRefreshInterval)
|
| 88 |
+
if (isNaN(interval) || interval < 0) {
|
| 89 |
+
return res.status(400).json({ error: '无效的自动刷新间隔' })
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
config.autoRefresh = autoRefresh
|
| 93 |
+
config.autoRefreshInterval = autoRefreshInterval || 6 * 60 * 60
|
| 94 |
+
res.json({
|
| 95 |
+
status: true,
|
| 96 |
+
message: '自动刷新设置更新成功'
|
| 97 |
+
})
|
| 98 |
+
} catch (error) {
|
| 99 |
+
logger.error('更新自动刷新设置失败', 'CONFIG', '', error)
|
| 100 |
+
res.status(500).json({ error: error.message })
|
| 101 |
+
}
|
| 102 |
+
})
|
| 103 |
+
|
| 104 |
+
// 更新思考输出设置
|
| 105 |
+
router.post('/setOutThink', adminKeyVerify, async (req, res) => {
|
| 106 |
+
try {
|
| 107 |
+
const { outThink } = req.body;
|
| 108 |
+
if (typeof outThink !== 'boolean') {
|
| 109 |
+
return res.status(400).json({ error: '无效的思考输出设置' })
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
config.outThink = outThink
|
| 113 |
+
res.json({
|
| 114 |
+
status: true,
|
| 115 |
+
message: '思考输出设置更新成功'
|
| 116 |
+
})
|
| 117 |
+
} catch (error) {
|
| 118 |
+
logger.error('更新思考输出设置失败', 'CONFIG', '', error)
|
| 119 |
+
res.status(500).json({ error: error.message })
|
| 120 |
+
}
|
| 121 |
+
})
|
| 122 |
+
|
| 123 |
+
// 更新搜索信息模式
|
| 124 |
+
router.post('/search-info-mode', adminKeyVerify, async (req, res) => {
|
| 125 |
+
try {
|
| 126 |
+
const { searchInfoMode } = req.body
|
| 127 |
+
if (!['table', 'text'].includes(searchInfoMode)) {
|
| 128 |
+
return res.status(400).json({ error: '无效的搜索信息模式' })
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
config.searchInfoMode = searchInfoMode
|
| 132 |
+
res.json({
|
| 133 |
+
status: true,
|
| 134 |
+
message: '搜索信息模式更新成功'
|
| 135 |
+
})
|
| 136 |
+
} catch (error) {
|
| 137 |
+
logger.error('更新搜索信息模式失败', 'CONFIG', '', error)
|
| 138 |
+
res.status(500).json({ error: error.message })
|
| 139 |
+
}
|
| 140 |
+
})
|
| 141 |
+
|
| 142 |
+
// 更新简化模型映射设置
|
| 143 |
+
router.post('/simple-model-map', adminKeyVerify, async (req, res) => {
|
| 144 |
+
try {
|
| 145 |
+
const { simpleModelMap } = req.body
|
| 146 |
+
if (typeof simpleModelMap !== 'boolean') {
|
| 147 |
+
return res.status(400).json({ error: '无效的简化模型映射设置' })
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
config.simpleModelMap = simpleModelMap
|
| 151 |
+
res.json({
|
| 152 |
+
status: true,
|
| 153 |
+
message: '简化模型映射设置更新成功'
|
| 154 |
+
})
|
| 155 |
+
} catch (error) {
|
| 156 |
+
logger.error('更新简化模型映射设置失败', 'CONFIG', '', error)
|
| 157 |
+
res.status(500).json({ error: error.message })
|
| 158 |
+
}
|
| 159 |
+
})
|
| 160 |
+
|
| 161 |
+
module.exports = router
|
src/routes/verify.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express')
|
| 2 |
+
const router = express.Router()
|
| 3 |
+
const config = require('../config/index.js')
|
| 4 |
+
const { validateApiKey } = require('../middlewares/authorization')
|
| 5 |
+
|
| 6 |
+
router.post('/verify', (req, res) => {
|
| 7 |
+
const apiKey = req.body.apiKey
|
| 8 |
+
const { isValid, isAdmin } = validateApiKey(apiKey)
|
| 9 |
+
|
| 10 |
+
if (!isValid) {
|
| 11 |
+
return res.status(401).json({
|
| 12 |
+
status: 401,
|
| 13 |
+
message: 'Unauthorized'
|
| 14 |
+
})
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
res.status(200).json({
|
| 18 |
+
status: 200,
|
| 19 |
+
message: 'success',
|
| 20 |
+
isAdmin: isAdmin
|
| 21 |
+
})
|
| 22 |
+
})
|
| 23 |
+
|
| 24 |
+
module.exports = router
|
src/server.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express')
|
| 2 |
+
const bodyParser = require('body-parser')
|
| 3 |
+
const config = require('./config/index.js')
|
| 4 |
+
const cors = require('cors')
|
| 5 |
+
const { logger } = require('./utils/logger')
|
| 6 |
+
const { initSsxmodManager } = require('./utils/ssxmod-manager')
|
| 7 |
+
const app = express()
|
| 8 |
+
const path = require('path')
|
| 9 |
+
const fs = require('fs')
|
| 10 |
+
const paths = require('./utils/paths')
|
| 11 |
+
const modelsRouter = require('./routes/models.js')
|
| 12 |
+
const chatRouter = require('./routes/chat.js')
|
| 13 |
+
const cliChatRouter = require('./routes/cli.chat.js')
|
| 14 |
+
const verifyRouter = require('./routes/verify.js')
|
| 15 |
+
const accountsRouter = require('./routes/accounts.js')
|
| 16 |
+
const settingsRouter = require('./routes/settings.js')
|
| 17 |
+
|
| 18 |
+
if (config.dataSaveMode === 'file') {
|
| 19 |
+
if (!fs.existsSync(paths.dataFilePath)) {
|
| 20 |
+
fs.mkdirSync(paths.dataDir, { recursive: true })
|
| 21 |
+
fs.writeFileSync(paths.dataFilePath, JSON.stringify({"accounts": [] }, null, 2))
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// 初始化 SSXMOD Cookie 管理器
|
| 26 |
+
initSsxmodManager()
|
| 27 |
+
|
| 28 |
+
app.use(bodyParser.json({ limit: '128mb' }))
|
| 29 |
+
app.use(bodyParser.urlencoded({ limit: '128mb', extended: true }))
|
| 30 |
+
app.use(cors())
|
| 31 |
+
|
| 32 |
+
// API路由
|
| 33 |
+
app.use(modelsRouter)
|
| 34 |
+
app.use(chatRouter)
|
| 35 |
+
app.use(cliChatRouter)
|
| 36 |
+
app.use(verifyRouter)
|
| 37 |
+
app.use('/api', accountsRouter)
|
| 38 |
+
app.use('/api', settingsRouter)
|
| 39 |
+
|
| 40 |
+
app.use(express.static(path.join(__dirname, '../public/dist')))
|
| 41 |
+
|
| 42 |
+
app.get('*', (req, res) => {
|
| 43 |
+
res.sendFile(path.join(__dirname, '../public/dist/index.html'), (err) => {
|
| 44 |
+
if (err) {
|
| 45 |
+
logger.error('管理页面加载失败', 'SERVER', '', err)
|
| 46 |
+
res.status(500).send('服务器内部错误')
|
| 47 |
+
}
|
| 48 |
+
})
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
// 处理错误中间件(必须放在所有路由之后)
|
| 52 |
+
app.use((err, req, res, next) => {
|
| 53 |
+
logger.error('服务器内部错误', 'SERVER', '', err)
|
| 54 |
+
res.status(500).send('服务器内部错误')
|
| 55 |
+
})
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
// 服务器启动信息
|
| 59 |
+
const serverInfo = {
|
| 60 |
+
address: config.listenAddress || 'localhost',
|
| 61 |
+
port: config.listenPort,
|
| 62 |
+
outThink: config.outThink ? '开启' : '关闭',
|
| 63 |
+
searchInfoMode: config.searchInfoMode === 'table' ? '表格' : '文本',
|
| 64 |
+
dataSaveMode: config.dataSaveMode,
|
| 65 |
+
logLevel: config.logLevel,
|
| 66 |
+
enableFileLog: config.enableFileLog
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
if (config.listenAddress) {
|
| 70 |
+
app.listen(config.listenPort, config.listenAddress, () => {
|
| 71 |
+
logger.server('服务器启动成功', 'SERVER', serverInfo)
|
| 72 |
+
logger.info('开源地址: https://github.com/Rfym21/Qwen2API', 'INFO')
|
| 73 |
+
logger.info('电报群聊: https://t.me/nodejs_project', 'INFO')
|
| 74 |
+
})
|
| 75 |
+
} else {
|
| 76 |
+
app.listen(config.listenPort, () => {
|
| 77 |
+
logger.server('服务器启动成功', 'SERVER', serverInfo)
|
| 78 |
+
logger.info('开源地址: https://github.com/Rfym21/Qwen2API', 'INFO')
|
| 79 |
+
logger.info('电报群聊: https://t.me/nodejs_project', 'INFO')
|
| 80 |
+
})
|
| 81 |
+
}
|
src/start.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const cluster = require('cluster')
|
| 2 |
+
const os = require('os')
|
| 3 |
+
const { logger } = require('./utils/logger')
|
| 4 |
+
|
| 5 |
+
// 加载环境变量
|
| 6 |
+
require('dotenv').config()
|
| 7 |
+
|
| 8 |
+
// 获取CPU核心数
|
| 9 |
+
const cpuCores = os.cpus().length
|
| 10 |
+
|
| 11 |
+
// 获取环境变量配置
|
| 12 |
+
const PM2_INSTANCES = process.env.PM2_INSTANCES || '1'
|
| 13 |
+
const SERVICE_PORT = process.env.SERVICE_PORT || 3000
|
| 14 |
+
const NODE_ENV = process.env.NODE_ENV || 'production'
|
| 15 |
+
|
| 16 |
+
// 解析进程数
|
| 17 |
+
let instances
|
| 18 |
+
if (PM2_INSTANCES === 'max') {
|
| 19 |
+
instances = cpuCores
|
| 20 |
+
} else if (!isNaN(PM2_INSTANCES)) {
|
| 21 |
+
instances = parseInt(PM2_INSTANCES)
|
| 22 |
+
} else {
|
| 23 |
+
instances = 1
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// 限制进程数不能超过CPU核心数
|
| 27 |
+
if (instances > cpuCores) {
|
| 28 |
+
logger.warn(`配置的进程数(${instances})超过CPU核心数(${cpuCores}),自动调整为${cpuCores}`, 'AUTO')
|
| 29 |
+
instances = cpuCores
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
logger.info('🚀 Qwen2API 智能启动', 'AUTO')
|
| 33 |
+
logger.info(`CPU核心数: ${cpuCores}`, 'AUTO')
|
| 34 |
+
logger.info(`配置的进程数: ${PM2_INSTANCES}`, 'AUTO')
|
| 35 |
+
logger.info(`实际启动进程数: ${instances}`, 'AUTO')
|
| 36 |
+
logger.info(`服务端口: ${SERVICE_PORT}`, 'AUTO')
|
| 37 |
+
|
| 38 |
+
// 智能判断启动方式
|
| 39 |
+
if (instances === 1) {
|
| 40 |
+
logger.info('📦 使用单进程模式启动', 'AUTO')
|
| 41 |
+
// 直接启动服务器
|
| 42 |
+
require('./server.js')
|
| 43 |
+
} else {
|
| 44 |
+
// 检查是否通过PM2启动
|
| 45 |
+
if (process.env.PM2_USAGE || process.env.pm_id !== undefined) {
|
| 46 |
+
logger.info(`PM2进程启动 - 进程ID: ${process.pid}, 工作进程ID: ${process.env.pm_id || 'unknown'}`, 'PM2')
|
| 47 |
+
require('./server.js')
|
| 48 |
+
} else if (cluster.isMaster) {
|
| 49 |
+
logger.info(`🔥 使用Node.js集群模式启动 (${instances}个进程)`, 'AUTO')
|
| 50 |
+
|
| 51 |
+
logger.info(`启动主进程 - PID: ${process.pid}`, 'CLUSTER')
|
| 52 |
+
logger.info(`运行环境: ${NODE_ENV}`, 'CLUSTER')
|
| 53 |
+
|
| 54 |
+
// 创建工作进程
|
| 55 |
+
for (let i = 0; i < instances; i++) {
|
| 56 |
+
const worker = cluster.fork()
|
| 57 |
+
logger.info(`启动工作进程 ${i + 1}/${instances} - PID: ${worker.process.pid}`, 'CLUSTER')
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// 监听工作进程退出
|
| 61 |
+
cluster.on('exit', (worker, code, signal) => {
|
| 62 |
+
logger.error(`工作进程 ${worker.process.pid} 已退出 - 退出码: ${code}, 信号: ${signal}`, 'CLUSTER')
|
| 63 |
+
|
| 64 |
+
// 自动重启工作进程
|
| 65 |
+
if (!worker.exitedAfterDisconnect) {
|
| 66 |
+
logger.info('正在重启工作进程...', 'CLUSTER')
|
| 67 |
+
const newWorker = cluster.fork()
|
| 68 |
+
logger.info(`新工作进程已启动 - PID: ${newWorker.process.pid}`, 'CLUSTER')
|
| 69 |
+
}
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
// 监听工作进程在线
|
| 73 |
+
cluster.on('online', (worker) => {
|
| 74 |
+
logger.info(`工作进程 ${worker.process.pid} 已上线`, 'CLUSTER')
|
| 75 |
+
})
|
| 76 |
+
|
| 77 |
+
// 监听工作进程断开连接
|
| 78 |
+
cluster.on('disconnect', (worker) => {
|
| 79 |
+
logger.warn(`工作进程 ${worker.process.pid} 已断开连接`, 'CLUSTER')
|
| 80 |
+
})
|
| 81 |
+
|
| 82 |
+
// 优雅关闭处理
|
| 83 |
+
process.on('SIGTERM', () => {
|
| 84 |
+
logger.info('收到SIGTERM信号,正在优雅关闭...', 'CLUSTER')
|
| 85 |
+
cluster.disconnect(() => {
|
| 86 |
+
process.exit(0)
|
| 87 |
+
})
|
| 88 |
+
})
|
| 89 |
+
|
| 90 |
+
process.on('SIGINT', () => {
|
| 91 |
+
logger.info('收到SIGINT信号,正在优雅关闭...', 'CLUSTER')
|
| 92 |
+
cluster.disconnect(() => {
|
| 93 |
+
process.exit(0)
|
| 94 |
+
})
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
} else {
|
| 98 |
+
// 工作进程逻辑
|
| 99 |
+
logger.info(`工作进程启动 - PID: ${process.pid}`, 'WORKER')
|
| 100 |
+
require('./server.js')
|
| 101 |
+
|
| 102 |
+
// 工作进程优雅关闭处理
|
| 103 |
+
process.on('SIGTERM', () => {
|
| 104 |
+
logger.info(`工作进程 ${process.pid} 收到SIGTERM信号,正在关闭...`, 'WORKER')
|
| 105 |
+
process.exit(0)
|
| 106 |
+
})
|
| 107 |
+
|
| 108 |
+
process.on('SIGINT', () => {
|
| 109 |
+
logger.info(`工作进程 ${process.pid} 收到SIGINT信号,正在关闭...`, 'WORKER')
|
| 110 |
+
process.exit(0)
|
| 111 |
+
})
|
| 112 |
+
}
|
| 113 |
+
}
|
src/utils/account-rotator.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { logger } = require('./logger')
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* 账户轮询管理器
|
| 5 |
+
* 负责账户的轮询选择和负载均衡
|
| 6 |
+
*/
|
| 7 |
+
class AccountRotator {
|
| 8 |
+
constructor() {
|
| 9 |
+
this.accounts = []
|
| 10 |
+
this.currentIndex = 0
|
| 11 |
+
this.lastUsedTimes = new Map() // 记录每个账户的最后使用时间
|
| 12 |
+
this.failureCounts = new Map() // 记录每个账户的失败次数
|
| 13 |
+
this.maxFailures = 3 // 最大失败次数
|
| 14 |
+
this.cooldownPeriod = 5 * 60 * 1000 // 5分钟冷却期
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
/**
|
| 18 |
+
* 设置账户列表
|
| 19 |
+
* @param {Array} accounts - 账户列表
|
| 20 |
+
*/
|
| 21 |
+
setAccounts(accounts) {
|
| 22 |
+
if (!Array.isArray(accounts)) {
|
| 23 |
+
logger.error('账户列表必须是数组', 'ACCOUNT')
|
| 24 |
+
throw new Error('账户列表必须是数组')
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
this.accounts = [...accounts]
|
| 28 |
+
this.currentIndex = 0
|
| 29 |
+
|
| 30 |
+
// 清理不存在账户的记录
|
| 31 |
+
this._cleanupRecords()
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* 获取下一个可用的账户令牌
|
| 36 |
+
* @returns {string|null} 账户令牌或null
|
| 37 |
+
*/
|
| 38 |
+
getNextToken() {
|
| 39 |
+
if (this.accounts.length === 0) {
|
| 40 |
+
logger.error('没有可用的账户', 'ACCOUNT')
|
| 41 |
+
return null
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const availableAccounts = this._getAvailableAccounts()
|
| 45 |
+
if (availableAccounts.length === 0) {
|
| 46 |
+
logger.warn('所有账户都不可用,使用轮询策略', 'ACCOUNT')
|
| 47 |
+
return this._getTokenByRoundRobin()
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// 从可用账户中选择最少使用的
|
| 51 |
+
const selectedAccount = this._selectLeastUsedAccount(availableAccounts)
|
| 52 |
+
this._recordUsage(selectedAccount.email)
|
| 53 |
+
|
| 54 |
+
return selectedAccount.token
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* 获取指定邮箱的账户令牌
|
| 59 |
+
* @param {string} email - 邮箱地址
|
| 60 |
+
* @returns {string|null} 账户令牌或null
|
| 61 |
+
*/
|
| 62 |
+
getTokenByEmail(email) {
|
| 63 |
+
const account = this.accounts.find(acc => acc.email === email)
|
| 64 |
+
if (!account) {
|
| 65 |
+
logger.error(`未找到邮箱为 ${email} 的账户`, 'ACCOUNT')
|
| 66 |
+
return null
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
if (!this._isAccountAvailable(account)) {
|
| 70 |
+
logger.warn(`账户 ${email} 当前不可用`, 'ACCOUNT')
|
| 71 |
+
return null
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
this._recordUsage(email)
|
| 75 |
+
return account.token
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/**
|
| 79 |
+
* 记录账户使用失败
|
| 80 |
+
* @param {string} email - 邮箱地址
|
| 81 |
+
*/
|
| 82 |
+
recordFailure(email) {
|
| 83 |
+
const currentFailures = this.failureCounts.get(email) || 0
|
| 84 |
+
this.failureCounts.set(email, currentFailures + 1)
|
| 85 |
+
|
| 86 |
+
if (currentFailures + 1 >= this.maxFailures) {
|
| 87 |
+
logger.warn(`账户 ${email} 失败次数达到上限,将进入冷却期`, 'ACCOUNT')
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/**
|
| 92 |
+
* 重置账户失败计数
|
| 93 |
+
* @param {string} email - 邮箱地址
|
| 94 |
+
*/
|
| 95 |
+
resetFailures(email) {
|
| 96 |
+
this.failureCounts.delete(email)
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/**
|
| 100 |
+
* 获取账户统计信息
|
| 101 |
+
* @returns {Object} 统计信息
|
| 102 |
+
*/
|
| 103 |
+
getStats() {
|
| 104 |
+
const total = this.accounts.length
|
| 105 |
+
const available = this._getAvailableAccounts().length
|
| 106 |
+
const inCooldown = total - available
|
| 107 |
+
|
| 108 |
+
const usageStats = {}
|
| 109 |
+
this.accounts.forEach(account => {
|
| 110 |
+
const email = account.email
|
| 111 |
+
usageStats[email] = {
|
| 112 |
+
failures: this.failureCounts.get(email) || 0,
|
| 113 |
+
lastUsed: this.lastUsedTimes.get(email) || null,
|
| 114 |
+
available: this._isAccountAvailable(account)
|
| 115 |
+
}
|
| 116 |
+
})
|
| 117 |
+
|
| 118 |
+
return {
|
| 119 |
+
total,
|
| 120 |
+
available,
|
| 121 |
+
inCooldown,
|
| 122 |
+
currentIndex: this.currentIndex,
|
| 123 |
+
usageStats
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/**
|
| 128 |
+
* 获取可用账户列表
|
| 129 |
+
* @private
|
| 130 |
+
*/
|
| 131 |
+
_getAvailableAccounts() {
|
| 132 |
+
return this.accounts.filter(account => this._isAccountAvailable(account))
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/**
|
| 136 |
+
* 检查账户是否可用
|
| 137 |
+
* @param {Object} account - 账户对象
|
| 138 |
+
* @returns {boolean} 是否可用
|
| 139 |
+
* @private
|
| 140 |
+
*/
|
| 141 |
+
_isAccountAvailable(account) {
|
| 142 |
+
if (!account.token) {
|
| 143 |
+
return false
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
const failures = this.failureCounts.get(account.email) || 0
|
| 147 |
+
if (failures >= this.maxFailures) {
|
| 148 |
+
const lastUsed = this.lastUsedTimes.get(account.email)
|
| 149 |
+
if (lastUsed && Date.now() - lastUsed < this.cooldownPeriod) {
|
| 150 |
+
return false // 仍在冷却期
|
| 151 |
+
} else {
|
| 152 |
+
// 冷却期结束,重置失败计数
|
| 153 |
+
this.failureCounts.delete(account.email)
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
return true
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/**
|
| 161 |
+
* 选择最少使用的账户
|
| 162 |
+
* @param {Array} accounts - 可用账户列表
|
| 163 |
+
* @returns {Object} 选中的账户
|
| 164 |
+
* @private
|
| 165 |
+
*/
|
| 166 |
+
_selectLeastUsedAccount(accounts) {
|
| 167 |
+
if (accounts.length === 1) {
|
| 168 |
+
return accounts[0]
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
// 按最后使用时间排序,选择最久未使用的
|
| 172 |
+
return accounts.reduce((least, current) => {
|
| 173 |
+
const leastLastUsed = this.lastUsedTimes.get(least.email) || 0
|
| 174 |
+
const currentLastUsed = this.lastUsedTimes.get(current.email) || 0
|
| 175 |
+
|
| 176 |
+
return currentLastUsed < leastLastUsed ? current : least
|
| 177 |
+
})
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
/**
|
| 181 |
+
* 轮询策略获取令牌
|
| 182 |
+
* @returns {string|null} 账户令牌或null
|
| 183 |
+
* @private
|
| 184 |
+
*/
|
| 185 |
+
_getTokenByRoundRobin() {
|
| 186 |
+
if (this.currentIndex >= this.accounts.length) {
|
| 187 |
+
this.currentIndex = 0
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
const account = this.accounts[this.currentIndex]
|
| 191 |
+
this.currentIndex++
|
| 192 |
+
|
| 193 |
+
if (account && account.token) {
|
| 194 |
+
this._recordUsage(account.email)
|
| 195 |
+
return account.token
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
// 如果当前账户无效,尝试下一个
|
| 199 |
+
if (this.currentIndex < this.accounts.length) {
|
| 200 |
+
return this._getTokenByRoundRobin()
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
return null
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
/**
|
| 207 |
+
* 记录账户使用
|
| 208 |
+
* @param {string} email - 邮箱地址
|
| 209 |
+
* @private
|
| 210 |
+
*/
|
| 211 |
+
_recordUsage(email) {
|
| 212 |
+
this.lastUsedTimes.set(email, Date.now())
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
/**
|
| 216 |
+
* 清理不存在账户的记录
|
| 217 |
+
* @private
|
| 218 |
+
*/
|
| 219 |
+
_cleanupRecords() {
|
| 220 |
+
const currentEmails = new Set(this.accounts.map(acc => acc.email))
|
| 221 |
+
|
| 222 |
+
// 清理失败计数记录
|
| 223 |
+
for (const email of this.failureCounts.keys()) {
|
| 224 |
+
if (!currentEmails.has(email)) {
|
| 225 |
+
this.failureCounts.delete(email)
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
// 清理使用时间记录
|
| 230 |
+
for (const email of this.lastUsedTimes.keys()) {
|
| 231 |
+
if (!currentEmails.has(email)) {
|
| 232 |
+
this.lastUsedTimes.delete(email)
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
/**
|
| 238 |
+
* 重置所有统计数据
|
| 239 |
+
*/
|
| 240 |
+
reset() {
|
| 241 |
+
this.currentIndex = 0
|
| 242 |
+
this.lastUsedTimes.clear()
|
| 243 |
+
this.failureCounts.clear()
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
module.exports = AccountRotator
|
src/utils/account.js
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = require('../config/index.js')
|
| 2 |
+
const DataPersistence = require('./data-persistence')
|
| 3 |
+
const TokenManager = require('./token-manager')
|
| 4 |
+
const AccountRotator = require('./account-rotator')
|
| 5 |
+
const { logger } = require('./logger')
|
| 6 |
+
/**
|
| 7 |
+
* 账户管理器
|
| 8 |
+
* 统一管理账户、令牌、模型等功能
|
| 9 |
+
*/
|
| 10 |
+
class Account {
|
| 11 |
+
constructor() {
|
| 12 |
+
// 初始化各个管理器
|
| 13 |
+
this.dataPersistence = new DataPersistence()
|
| 14 |
+
this.tokenManager = new TokenManager()
|
| 15 |
+
this.accountRotator = new AccountRotator()
|
| 16 |
+
|
| 17 |
+
// 账户数据
|
| 18 |
+
this.accountTokens = []
|
| 19 |
+
this.isInitialized = false
|
| 20 |
+
|
| 21 |
+
// 配置信息
|
| 22 |
+
this.defaultHeaders = config.defaultHeaders || {}
|
| 23 |
+
|
| 24 |
+
// cli请求次数定时刷新器
|
| 25 |
+
this.cliRequestNumberInterval = null
|
| 26 |
+
this.cliDailyResetInterval = null
|
| 27 |
+
|
| 28 |
+
// 初始化
|
| 29 |
+
this._initialize()
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* 异步初始化
|
| 34 |
+
* @private
|
| 35 |
+
*/
|
| 36 |
+
async _initialize() {
|
| 37 |
+
try {
|
| 38 |
+
// 加载账户信息
|
| 39 |
+
await this.loadAccountTokens()
|
| 40 |
+
|
| 41 |
+
// 设置定期刷新令牌
|
| 42 |
+
if (config.autoRefresh) {
|
| 43 |
+
this.refreshInterval = setInterval(
|
| 44 |
+
() => this.autoRefreshTokens(),
|
| 45 |
+
(config.autoRefreshInterval || 21600) * 1000 // 默认6小时
|
| 46 |
+
)
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
this.isInitialized = true
|
| 50 |
+
logger.success(`账户管理器初始化完成,共加载 ${this.accountTokens.length} 个账户`, 'ACCOUNT')
|
| 51 |
+
} catch (error) {
|
| 52 |
+
logger.error('账户管理器初始化失败', 'ACCOUNT', '', error)
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/**
|
| 57 |
+
* 加载账户令牌数据
|
| 58 |
+
* @returns {Promise<void>}
|
| 59 |
+
*/
|
| 60 |
+
async loadAccountTokens() {
|
| 61 |
+
try {
|
| 62 |
+
this.accountTokens = await this.dataPersistence.loadAccounts()
|
| 63 |
+
|
| 64 |
+
// 如果是环境变量模式,需要进行登录获取令牌
|
| 65 |
+
if (config.dataSaveMode === 'none' && this.accountTokens.length > 0) {
|
| 66 |
+
await this._loginEnvironmentAccounts()
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// 验证和清理无效令牌
|
| 70 |
+
await this._validateAndCleanTokens()
|
| 71 |
+
|
| 72 |
+
// 更新账户轮询器
|
| 73 |
+
this.accountRotator.setAccounts(this.accountTokens)
|
| 74 |
+
|
| 75 |
+
// 初始化 CLI 账户,随机初始化一个账号
|
| 76 |
+
if (this.accountTokens.length > 0) {
|
| 77 |
+
const randomIndex = Math.floor(Math.random() * this.accountTokens.length)
|
| 78 |
+
const randomAccount = this.accountTokens[randomIndex]
|
| 79 |
+
logger.info(`初始化 CLI 账户, 随机初始化账号: ${randomAccount.email}`, 'ACCOUNT')
|
| 80 |
+
await this._initializeCliAccount(randomAccount)
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// 设置cli定时器 每天00:00:00刷新请求次数
|
| 84 |
+
this._setupDailyResetTimer()
|
| 85 |
+
|
| 86 |
+
logger.success(`成功加载 ${this.accountTokens.length} 个账户`, 'ACCOUNT')
|
| 87 |
+
} catch (error) {
|
| 88 |
+
logger.error('加载账户令牌失败', 'ACCOUNT', '', error)
|
| 89 |
+
this.accountTokens = []
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/**
|
| 94 |
+
* 为环境变量模式的账户进行登录
|
| 95 |
+
* @private
|
| 96 |
+
*/
|
| 97 |
+
async _loginEnvironmentAccounts() {
|
| 98 |
+
const loginPromises = this.accountTokens.map(async (account) => {
|
| 99 |
+
if (!account.token && account.email && account.password) {
|
| 100 |
+
const token = await this.tokenManager.login(account.email, account.password)
|
| 101 |
+
if (token) {
|
| 102 |
+
const decoded = this.tokenManager.validateToken(token)
|
| 103 |
+
if (decoded) {
|
| 104 |
+
account.token = token
|
| 105 |
+
account.expires = decoded.exp
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
return account
|
| 110 |
+
})
|
| 111 |
+
|
| 112 |
+
this.accountTokens = await Promise.all(loginPromises)
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/**
|
| 116 |
+
* 初始化CLI账户
|
| 117 |
+
* @param {Object} account - 账户对象
|
| 118 |
+
* @private
|
| 119 |
+
*/
|
| 120 |
+
async _initializeCliAccount(account) {
|
| 121 |
+
try {
|
| 122 |
+
const cliManager = require('./cli.manager')
|
| 123 |
+
const cliAccount = await cliManager.initCliAccount(account.token)
|
| 124 |
+
|
| 125 |
+
if (cliAccount.access_token && cliAccount.refresh_token && cliAccount.expiry_date) {
|
| 126 |
+
account.cli_info = {
|
| 127 |
+
access_token: cliAccount.access_token,
|
| 128 |
+
refresh_token: cliAccount.refresh_token,
|
| 129 |
+
expiry_date: cliAccount.expiry_date,
|
| 130 |
+
refresh_token_interval: setInterval(async () => {
|
| 131 |
+
try {
|
| 132 |
+
const refreshToken = await cliManager.refreshAccessToken({
|
| 133 |
+
access_token: account.cli_info.access_token,
|
| 134 |
+
refresh_token: account.cli_info.refresh_token,
|
| 135 |
+
expiry_date: account.cli_info.expiry_date
|
| 136 |
+
})
|
| 137 |
+
if (refreshToken.access_token && refreshToken.refresh_token && refreshToken.expiry_date) {
|
| 138 |
+
account.cli_info.access_token = refreshToken.access_token
|
| 139 |
+
account.cli_info.refresh_token = refreshToken.refresh_token
|
| 140 |
+
account.cli_info.expiry_date = refreshToken.expiry_date
|
| 141 |
+
logger.info(`CLI账户 ${account.email} 令牌刷新成功`, 'CLI')
|
| 142 |
+
}
|
| 143 |
+
} catch (error) {
|
| 144 |
+
logger.error(`CLI账户 ${account.email} 令牌刷新失败`, 'CLI', '', error)
|
| 145 |
+
}
|
| 146 |
+
// 每2小时刷新一次
|
| 147 |
+
}, 1000 * 60 * 60 * 2),
|
| 148 |
+
request_number: 0
|
| 149 |
+
}
|
| 150 |
+
logger.success(`CLI账户 ${account.email} 初始化成功`, 'CLI')
|
| 151 |
+
} else {
|
| 152 |
+
logger.error(`CLI账户 ${account.email} 初始化失败:无效的响应数据`, 'CLI')
|
| 153 |
+
}
|
| 154 |
+
} catch (error) {
|
| 155 |
+
logger.error(`CLI账户 ${account.email} 初始化失败`, 'CLI', '', error)
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
/**
|
| 160 |
+
* 设置每日重置定时器
|
| 161 |
+
* @private
|
| 162 |
+
*/
|
| 163 |
+
_setupDailyResetTimer() {
|
| 164 |
+
logger.info('设置CLI请求次数每日重置定时器', 'CLI')
|
| 165 |
+
|
| 166 |
+
// 计算到下一天00:00:00的毫秒数
|
| 167 |
+
const now = new Date()
|
| 168 |
+
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0)
|
| 169 |
+
const timeDiff = tomorrow.getTime() - now.getTime()
|
| 170 |
+
|
| 171 |
+
logger.info(`距离下次重置还有 ${Math.round(timeDiff / 1000 / 60)} 分钟`, 'CLI')
|
| 172 |
+
|
| 173 |
+
// 首次执行使用setTimeout
|
| 174 |
+
this.cliRequestNumberInterval = setTimeout(() => {
|
| 175 |
+
// 重置所有CLI账户的请求次数
|
| 176 |
+
this._resetCliRequestNumbers()
|
| 177 |
+
|
| 178 |
+
// 设置每24小时执行一次的定时器
|
| 179 |
+
this.cliDailyResetInterval = setInterval(() => {
|
| 180 |
+
this._resetCliRequestNumbers()
|
| 181 |
+
}, 24 * 60 * 60 * 1000)
|
| 182 |
+
}, timeDiff)
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
/**
|
| 186 |
+
* 重置CLI请求次数
|
| 187 |
+
* @private
|
| 188 |
+
*/
|
| 189 |
+
_resetCliRequestNumbers() {
|
| 190 |
+
const cliAccounts = this.accountTokens.filter(account => account.cli_info)
|
| 191 |
+
cliAccounts.forEach(account => {
|
| 192 |
+
account.cli_info.request_number = 0
|
| 193 |
+
})
|
| 194 |
+
logger.info(`已重置 ${cliAccounts.length} 个CLI账户的请求次数`, 'CLI')
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/**
|
| 198 |
+
* 验证和清理无效令牌
|
| 199 |
+
* @private
|
| 200 |
+
*/
|
| 201 |
+
async _validateAndCleanTokens() {
|
| 202 |
+
const validAccounts = []
|
| 203 |
+
|
| 204 |
+
for (const account of this.accountTokens) {
|
| 205 |
+
if (account.token && this.tokenManager.validateToken(account.token)) {
|
| 206 |
+
validAccounts.push(account)
|
| 207 |
+
} else if (account.email && account.password) {
|
| 208 |
+
// 尝试重新登录
|
| 209 |
+
logger.info(`令牌无效,尝试重新登录: ${account.email}`, 'TOKEN', '🔄')
|
| 210 |
+
const newToken = await this.tokenManager.login(account.email, account.password)
|
| 211 |
+
if (newToken) {
|
| 212 |
+
const decoded = this.tokenManager.validateToken(newToken)
|
| 213 |
+
if (decoded) {
|
| 214 |
+
account.token = newToken
|
| 215 |
+
account.expires = decoded.exp
|
| 216 |
+
validAccounts.push(account)
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
this.accountTokens = validAccounts
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
/**
|
| 227 |
+
* 自动刷新即将过期的令牌
|
| 228 |
+
* @param {number} thresholdHours - 过期阈值(小时)
|
| 229 |
+
* @returns {Promise<number>} 成功刷新的令牌数量
|
| 230 |
+
*/
|
| 231 |
+
async autoRefreshTokens(thresholdHours = 24) {
|
| 232 |
+
if (!this.isInitialized) {
|
| 233 |
+
logger.warn('账户管理器尚未初始化,跳过自动刷新', 'TOKEN')
|
| 234 |
+
return 0
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
logger.info('开始自动刷新令牌...', 'TOKEN', '🔄')
|
| 238 |
+
|
| 239 |
+
// 获取需要刷新的账户
|
| 240 |
+
const needsRefresh = this.accountTokens.filter(account =>
|
| 241 |
+
this.tokenManager.isTokenExpiringSoon(account.token, thresholdHours)
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
if (needsRefresh.length === 0) {
|
| 245 |
+
logger.info('没有需要刷新的令牌', 'TOKEN')
|
| 246 |
+
return 0
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
logger.info(`发现 ${needsRefresh.length} 个令牌需要刷新`, 'TOKEN')
|
| 250 |
+
|
| 251 |
+
let successCount = 0
|
| 252 |
+
let failedCount = 0
|
| 253 |
+
|
| 254 |
+
// 逐个刷新账户,每次成功后立即保存
|
| 255 |
+
for (const account of needsRefresh) {
|
| 256 |
+
try {
|
| 257 |
+
const updatedAccount = await this.tokenManager.refreshToken(account)
|
| 258 |
+
if (updatedAccount) {
|
| 259 |
+
// 立即更新内存中的账户数据
|
| 260 |
+
const index = this.accountTokens.findIndex(acc => acc.email === account.email)
|
| 261 |
+
if (index !== -1) {
|
| 262 |
+
this.accountTokens[index] = updatedAccount
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
// 立即保存���持久化存储
|
| 266 |
+
await this.dataPersistence.saveAccount(account.email, {
|
| 267 |
+
password: updatedAccount.password,
|
| 268 |
+
token: updatedAccount.token,
|
| 269 |
+
expires: updatedAccount.expires
|
| 270 |
+
})
|
| 271 |
+
|
| 272 |
+
// 重置失败计数
|
| 273 |
+
this.accountRotator.resetFailures(account.email)
|
| 274 |
+
successCount++
|
| 275 |
+
|
| 276 |
+
logger.info(`账户 ${account.email} 令牌刷新并保存成功 (${successCount}/${needsRefresh.length})`, 'TOKEN', '✅')
|
| 277 |
+
} else {
|
| 278 |
+
// 记录失败的账户
|
| 279 |
+
this.accountRotator.recordFailure(account.email)
|
| 280 |
+
failedCount++
|
| 281 |
+
logger.error(`账户 ${account.email} 令牌刷新失败 (${failedCount} 个失败)`, 'TOKEN', '❌')
|
| 282 |
+
}
|
| 283 |
+
} catch (error) {
|
| 284 |
+
this.accountRotator.recordFailure(account.email)
|
| 285 |
+
failedCount++
|
| 286 |
+
logger.error(`账户 ${account.email} 刷新过程中出错`, 'TOKEN', '', error)
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
// 添加延迟避免请求过于频繁
|
| 290 |
+
await this._delay(1000)
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
// 更新轮询器
|
| 294 |
+
this.accountRotator.setAccounts(this.accountTokens)
|
| 295 |
+
|
| 296 |
+
logger.success(`令牌刷新完成: 成功 ${successCount} 个,失败 ${failedCount} 个`, 'TOKEN')
|
| 297 |
+
return successCount
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
/**
|
| 301 |
+
* 获取可用的账户令牌
|
| 302 |
+
* @returns {string|null} 账户令牌或null
|
| 303 |
+
*/
|
| 304 |
+
getAccountToken() {
|
| 305 |
+
if (!this.isInitialized) {
|
| 306 |
+
logger.warn('账户管理器尚未初始化完成', 'ACCOUNT')
|
| 307 |
+
return null
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
if (this.accountTokens.length === 0) {
|
| 311 |
+
logger.error('没有可用的账户令牌', 'ACCOUNT')
|
| 312 |
+
return null
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
const token = this.accountRotator.getNextToken()
|
| 316 |
+
if (!token) {
|
| 317 |
+
logger.error('所有账户令牌都不可用', 'ACCOUNT')
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
return token
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
/**
|
| 324 |
+
* 根据邮箱获取特定账户的令牌
|
| 325 |
+
* @param {string} email - 邮箱地址
|
| 326 |
+
* @returns {string|null} 账户令牌或null
|
| 327 |
+
*/
|
| 328 |
+
getTokenByEmail(email) {
|
| 329 |
+
return this.accountRotator.getTokenByEmail(email)
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
/**
|
| 333 |
+
* 保存更新后的账户数据
|
| 334 |
+
* @param {Array} updatedAccounts - 更新后的账户列表
|
| 335 |
+
* @private
|
| 336 |
+
*/
|
| 337 |
+
async _saveUpdatedAccounts(updatedAccounts) {
|
| 338 |
+
try {
|
| 339 |
+
for (const account of updatedAccounts) {
|
| 340 |
+
await this.dataPersistence.saveAccount(account.email, {
|
| 341 |
+
password: account.password,
|
| 342 |
+
token: account.token,
|
| 343 |
+
expires: account.expires
|
| 344 |
+
})
|
| 345 |
+
}
|
| 346 |
+
} catch (error) {
|
| 347 |
+
logger.error('保存更新后的账户数据失败', 'ACCOUNT', '', error)
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
/**
|
| 352 |
+
* 手动刷新指定账户的令牌
|
| 353 |
+
* @param {string} email - 邮箱地址
|
| 354 |
+
* @returns {Promise<boolean>} 刷新是否成功
|
| 355 |
+
*/
|
| 356 |
+
async refreshAccountToken(email) {
|
| 357 |
+
const account = this.accountTokens.find(acc => acc.email === email)
|
| 358 |
+
if (!account) {
|
| 359 |
+
logger.error(`未找到邮箱为 ${email} 的账户`, 'ACCOUNT')
|
| 360 |
+
return false
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
const updatedAccount = await this.tokenManager.refreshToken(account)
|
| 364 |
+
if (updatedAccount) {
|
| 365 |
+
// 更新内存中的数据
|
| 366 |
+
const index = this.accountTokens.findIndex(acc => acc.email === email)
|
| 367 |
+
if (index !== -1) {
|
| 368 |
+
this.accountTokens[index] = updatedAccount
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
// 保存到持久化存储
|
| 372 |
+
await this.dataPersistence.saveAccount(email, {
|
| 373 |
+
password: updatedAccount.password,
|
| 374 |
+
token: updatedAccount.token,
|
| 375 |
+
expires: updatedAccount.expires
|
| 376 |
+
})
|
| 377 |
+
|
| 378 |
+
// 重置失败计数
|
| 379 |
+
this.accountRotator.resetFailures(email)
|
| 380 |
+
|
| 381 |
+
return true
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
return false
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
// 更新销毁方法,清除定时器
|
| 388 |
+
destroy() {
|
| 389 |
+
if (this.saveInterval) {
|
| 390 |
+
clearInterval(this.saveInterval)
|
| 391 |
+
}
|
| 392 |
+
if (this.refreshInterval) {
|
| 393 |
+
clearInterval(this.refreshInterval)
|
| 394 |
+
}
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
/**
|
| 400 |
+
* 生成 Markdown 表格
|
| 401 |
+
* @param {Array} websites - 网站信息数组
|
| 402 |
+
* @param {string} mode - 模式 ('table' 或 'text')
|
| 403 |
+
* @returns {Promise<string>} Markdown 字符串
|
| 404 |
+
*/
|
| 405 |
+
async generateMarkdownTable(websites, mode) {
|
| 406 |
+
// 输入校验
|
| 407 |
+
if (!Array.isArray(websites) || websites.length === 0) {
|
| 408 |
+
return ''
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
let markdown = ''
|
| 412 |
+
if (mode === 'table') {
|
| 413 |
+
markdown += '| **序号** | **网站URL** | **来源** |\n'
|
| 414 |
+
markdown += '|:---|:---|:---|\n'
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
// 默认值
|
| 418 |
+
const DEFAULT_TITLE = '未知标题'
|
| 419 |
+
const DEFAULT_URL = 'https://www.baidu.com'
|
| 420 |
+
const DEFAULT_HOSTNAME = '未知来源'
|
| 421 |
+
|
| 422 |
+
// 表格内容
|
| 423 |
+
websites.forEach((site, index) => {
|
| 424 |
+
const { title, url, hostname } = site
|
| 425 |
+
// 处理字段值,若为空则使用默认值
|
| 426 |
+
const urlCell = `[${title || DEFAULT_TITLE}](${url || DEFAULT_URL})`
|
| 427 |
+
const hostnameCell = hostname || DEFAULT_HOSTNAME
|
| 428 |
+
if (mode === 'table') {
|
| 429 |
+
markdown += `| ${index + 1} | ${urlCell} | ${hostnameCell} |\n`
|
| 430 |
+
} else {
|
| 431 |
+
markdown += `[${index + 1}] ${urlCell} | 来源: ${hostnameCell}\n`
|
| 432 |
+
}
|
| 433 |
+
})
|
| 434 |
+
|
| 435 |
+
return markdown
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
/**
|
| 441 |
+
* 获取所有账户信息
|
| 442 |
+
* @returns {Array} 账户列表
|
| 443 |
+
*/
|
| 444 |
+
getAllAccountKeys() {
|
| 445 |
+
return this.accountTokens
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
/**
|
| 449 |
+
* 用户登录(委托给 TokenManager)
|
| 450 |
+
* @param {string} email - 邮箱
|
| 451 |
+
* @param {string} password - 密码
|
| 452 |
+
* @returns {Promise<string|null>} 令牌或null
|
| 453 |
+
*/
|
| 454 |
+
async login(email, password) {
|
| 455 |
+
return await this.tokenManager.login(email, password)
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
/**
|
| 459 |
+
* 获取账户健康状态统计
|
| 460 |
+
* @returns {Object} 健康状态统计
|
| 461 |
+
*/
|
| 462 |
+
getHealthStats() {
|
| 463 |
+
const tokenStats = this.tokenManager.getTokenHealthStats(this.accountTokens)
|
| 464 |
+
const rotatorStats = this.accountRotator.getStats()
|
| 465 |
+
|
| 466 |
+
return {
|
| 467 |
+
accounts: tokenStats,
|
| 468 |
+
rotation: rotatorStats,
|
| 469 |
+
initialized: this.isInitialized
|
| 470 |
+
}
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
/**
|
| 474 |
+
* 记录账户使用失败
|
| 475 |
+
* @param {string} email - 邮箱地址
|
| 476 |
+
*/
|
| 477 |
+
recordAccountFailure(email) {
|
| 478 |
+
this.accountRotator.recordFailure(email)
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
/**
|
| 482 |
+
* 重置账户失败计数
|
| 483 |
+
* @param {string} email - 邮箱地址
|
| 484 |
+
*/
|
| 485 |
+
resetAccountFailures(email) {
|
| 486 |
+
this.accountRotator.resetFailures(email)
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
/**
|
| 490 |
+
* 添加新账户
|
| 491 |
+
* @param {string} email - 邮箱
|
| 492 |
+
* @param {string} password - 密码
|
| 493 |
+
* @returns {Promise<boolean>} 添加是否成功
|
| 494 |
+
*/
|
| 495 |
+
async addAccount(email, password) {
|
| 496 |
+
try {
|
| 497 |
+
// 检查账户是否已存在
|
| 498 |
+
const existingAccount = this.accountTokens.find(acc => acc.email === email)
|
| 499 |
+
if (existingAccount) {
|
| 500 |
+
logger.warn(`账户 ${email} 已存在`, 'ACCOUNT')
|
| 501 |
+
return false
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
// 尝试登录获取令牌
|
| 505 |
+
const token = await this.tokenManager.login(email, password)
|
| 506 |
+
if (!token) {
|
| 507 |
+
logger.error(`账户 ${email} 登录失败,无法添加`, 'ACCOUNT')
|
| 508 |
+
return false
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
const decoded = this.tokenManager.validateToken(token)
|
| 512 |
+
if (!decoded) {
|
| 513 |
+
logger.error(`账户 ${email} 令牌无效,无法添加`, 'ACCOUNT')
|
| 514 |
+
return false
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
const newAccount = {
|
| 518 |
+
email,
|
| 519 |
+
password,
|
| 520 |
+
token,
|
| 521 |
+
expires: decoded.exp
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
// 添加到内存
|
| 525 |
+
this.accountTokens.push(newAccount)
|
| 526 |
+
|
| 527 |
+
// 保存到持久化存储
|
| 528 |
+
await this.dataPersistence.saveAccount(email, newAccount)
|
| 529 |
+
|
| 530 |
+
// 更新轮询器
|
| 531 |
+
this.accountRotator.setAccounts(this.accountTokens)
|
| 532 |
+
|
| 533 |
+
logger.success(`成功添加账户: ${email}`, 'ACCOUNT')
|
| 534 |
+
return true
|
| 535 |
+
} catch (error) {
|
| 536 |
+
logger.error(`添加账户失败 (${email})`, 'ACCOUNT', '', error)
|
| 537 |
+
return false
|
| 538 |
+
}
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
/**
|
| 542 |
+
* 移除账户
|
| 543 |
+
* @param {string} email - 邮箱地址
|
| 544 |
+
* @returns {Promise<boolean>} 移除是否成功
|
| 545 |
+
*/
|
| 546 |
+
async removeAccount(email) {
|
| 547 |
+
try {
|
| 548 |
+
const index = this.accountTokens.findIndex(acc => acc.email === email)
|
| 549 |
+
if (index === -1) {
|
| 550 |
+
logger.warn(`账户 ${email} 不存在`, 'ACCOUNT')
|
| 551 |
+
return false
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
// 从内存中移除
|
| 555 |
+
this.accountTokens.splice(index, 1)
|
| 556 |
+
|
| 557 |
+
// 更新轮询器
|
| 558 |
+
this.accountRotator.setAccounts(this.accountTokens)
|
| 559 |
+
|
| 560 |
+
logger.success(`成功移除账户: ${email}`, 'ACCOUNT')
|
| 561 |
+
return true
|
| 562 |
+
} catch (error) {
|
| 563 |
+
logger.error(`移除账户失败 (${email})`, 'ACCOUNT', '', error)
|
| 564 |
+
return false
|
| 565 |
+
}
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
/**
|
| 569 |
+
* 删除账户(向后兼容)
|
| 570 |
+
* @param {string} email - 邮箱地址
|
| 571 |
+
* @returns {boolean} 删除是否成功
|
| 572 |
+
*/
|
| 573 |
+
deleteAccount(email) {
|
| 574 |
+
const index = this.accountTokens.findIndex(t => t.email === email)
|
| 575 |
+
if (index !== -1) {
|
| 576 |
+
this.accountTokens.splice(index, 1)
|
| 577 |
+
this.accountRotator.setAccounts(this.accountTokens)
|
| 578 |
+
return true
|
| 579 |
+
}
|
| 580 |
+
return false
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
/**
|
| 584 |
+
* 为指定账户初始化CLI信��(公共方法)
|
| 585 |
+
* @param {Object} account - 账户对象
|
| 586 |
+
* @returns {Promise<boolean>} 初始化是否成功
|
| 587 |
+
*/
|
| 588 |
+
async initializeCliForAccount(account) {
|
| 589 |
+
if (!account) {
|
| 590 |
+
logger.error('账户对象不能为空', 'CLI')
|
| 591 |
+
return false
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
try {
|
| 595 |
+
await this._initializeCliAccount(account)
|
| 596 |
+
return true
|
| 597 |
+
} catch (error) {
|
| 598 |
+
logger.error(`为账户 ${account.email} 初始化CLI失败`, 'CLI', '', error)
|
| 599 |
+
return false
|
| 600 |
+
}
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
/**
|
| 604 |
+
* 延迟函数
|
| 605 |
+
* @param {number} ms - 延迟毫秒数
|
| 606 |
+
* @private
|
| 607 |
+
*/
|
| 608 |
+
async _delay(ms) {
|
| 609 |
+
return new Promise(resolve => setTimeout(resolve, ms))
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
/**
|
| 613 |
+
* 清理资源
|
| 614 |
+
*/
|
| 615 |
+
destroy() {
|
| 616 |
+
// 清理自动刷新定时器
|
| 617 |
+
if (this.refreshInterval) {
|
| 618 |
+
clearInterval(this.refreshInterval)
|
| 619 |
+
this.refreshInterval = null
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
// 清理CLI请求次数重置定时器
|
| 623 |
+
if (this.cliRequestNumberInterval) {
|
| 624 |
+
clearTimeout(this.cliRequestNumberInterval)
|
| 625 |
+
this.cliRequestNumberInterval = null
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
if (this.cliDailyResetInterval) {
|
| 629 |
+
clearInterval(this.cliDailyResetInterval)
|
| 630 |
+
this.cliDailyResetInterval = null
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
// 清理所有CLI账户的刷新定时器
|
| 634 |
+
this.accountTokens.forEach(account => {
|
| 635 |
+
if (account.cli_info && account.cli_info.refresh_token_interval) {
|
| 636 |
+
clearInterval(account.cli_info.refresh_token_interval)
|
| 637 |
+
account.cli_info.refresh_token_interval = null
|
| 638 |
+
}
|
| 639 |
+
})
|
| 640 |
+
|
| 641 |
+
this.accountRotator.reset()
|
| 642 |
+
logger.info('账户管理器已清理资源', 'ACCOUNT', '🧹')
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
if (!(process.env.API_KEY || config.apiKey)) {
|
| 648 |
+
logger.error('请务必设置 API_KEY 环境变量', 'CONFIG', '⚙️')
|
| 649 |
+
process.exit(1)
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
const accountManager = new Account()
|
| 653 |
+
|
| 654 |
+
// 添加进程退出时的清理
|
| 655 |
+
process.on('exit', () => {
|
| 656 |
+
if (accountManager) {
|
| 657 |
+
accountManager.destroy()
|
| 658 |
+
}
|
| 659 |
+
})
|
| 660 |
+
|
| 661 |
+
// 处理意外退出
|
| 662 |
+
process.on('SIGINT', () => {
|
| 663 |
+
if (accountManager) {
|
| 664 |
+
accountManager.destroy()
|
| 665 |
+
}
|
| 666 |
+
process.exit(0)
|
| 667 |
+
})
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
module.exports = accountManager
|
src/utils/chat-helpers.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { logger } = require('./logger');
|
| 2 |
+
const { sha256Encrypt, generateUUID } = require('./tools.js');
|
| 3 |
+
const { uploadFileToQwenOss } = require('./upload.js');
|
| 4 |
+
const accountManager = require('./account.js');
|
| 5 |
+
const CacheManager = require('./img-caches.js');
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* 判断聊天类型
|
| 9 |
+
* @param {string} model - 模型名称
|
| 10 |
+
* @param {boolean} search - 是否搜索模式
|
| 11 |
+
* @returns {string} 聊天类型 ('search' 或 't2t')
|
| 12 |
+
*/
|
| 13 |
+
const isChatType = (model) => {
|
| 14 |
+
if (!model) return 't2t';
|
| 15 |
+
if (model.includes('-search')) {
|
| 16 |
+
return 'search';
|
| 17 |
+
} else if (model.includes('-image-edit')) {
|
| 18 |
+
return 'image_edit';
|
| 19 |
+
} else if (model.includes('-image')) {
|
| 20 |
+
return 't2i';
|
| 21 |
+
} else if (model.includes('-video')) {
|
| 22 |
+
return 't2v';
|
| 23 |
+
} else if (model.includes('-deep-research')) {
|
| 24 |
+
return 'deep_research';
|
| 25 |
+
} else {
|
| 26 |
+
return 't2t';
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* 判断是否启用思考模式
|
| 32 |
+
* @param {string} model - 模型名称
|
| 33 |
+
* @param {boolean} enable_thinking - 是否启用思考
|
| 34 |
+
* @param {number} thinking_budget - 思考预算
|
| 35 |
+
* @returns {object} 思考配置对象
|
| 36 |
+
*/
|
| 37 |
+
const isThinkingEnabled = (model, enable_thinking, thinking_budget) => {
|
| 38 |
+
const thinking_config = {
|
| 39 |
+
"output_schema": "phase",
|
| 40 |
+
"thinking_enabled": false,
|
| 41 |
+
"thinking_budget": 81920
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
if (!model) return thinking_config;
|
| 45 |
+
|
| 46 |
+
if (model.includes('-thinking') || enable_thinking) {
|
| 47 |
+
thinking_config.thinking_enabled = true;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
if (thinking_budget && Number(thinking_budget) !== Number.NaN && Number(thinking_budget) > 0 && Number(thinking_budget) < 38912) {
|
| 51 |
+
thinking_config.budget = Number(thinking_budget);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
return thinking_config;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* 解析模型名称,移除特殊后缀
|
| 59 |
+
* @param {string} model - 原始模型名称
|
| 60 |
+
* @returns {string} 解析后的模型名称
|
| 61 |
+
*/
|
| 62 |
+
const parserModel = (model) => {
|
| 63 |
+
if (!model) return 'qwen3-coder-plus';
|
| 64 |
+
|
| 65 |
+
try {
|
| 66 |
+
model = String(model);
|
| 67 |
+
model = model.replace('-search', '');
|
| 68 |
+
model = model.replace('-thinking', '');
|
| 69 |
+
model = model.replace('-edit', '');
|
| 70 |
+
model = model.replace('-video', '');
|
| 71 |
+
model = model.replace('-deep-research', '');
|
| 72 |
+
model = model.replace('-image', '');
|
| 73 |
+
return model;
|
| 74 |
+
} catch (e) {
|
| 75 |
+
return 'qwen3-coder-plus';
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* 从消息中提取文本内容
|
| 81 |
+
* @param {string|Array} content - 消息内容
|
| 82 |
+
* @returns {string} 提取的文本
|
| 83 |
+
*/
|
| 84 |
+
const extractTextFromContent = (content) => {
|
| 85 |
+
if (typeof content === 'string') {
|
| 86 |
+
return content;
|
| 87 |
+
} else if (Array.isArray(content)) {
|
| 88 |
+
const textParts = content
|
| 89 |
+
.filter(item => item.type === 'text')
|
| 90 |
+
.map(item => item.text || '');
|
| 91 |
+
return textParts.join(' ');
|
| 92 |
+
}
|
| 93 |
+
return '';
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/**
|
| 97 |
+
* 格式化消息为文本(包含角色标注)
|
| 98 |
+
* @param {object} message - 单条消息
|
| 99 |
+
* @returns {string} 格式化后的消息文本
|
| 100 |
+
*/
|
| 101 |
+
const formatSingleMessage = (message) => {
|
| 102 |
+
const role = message.role;
|
| 103 |
+
const content = extractTextFromContent(message.content);
|
| 104 |
+
return content.trim() ? `${role}:${content}` : '';
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/**
|
| 108 |
+
* 格式化历史消息为文本前缀
|
| 109 |
+
* @param {Array} messages - 消息数组(不包含最后一条)
|
| 110 |
+
* @returns {string} 格式化后的历史消息
|
| 111 |
+
*/
|
| 112 |
+
const formatHistoryMessages = (messages) => {
|
| 113 |
+
const formattedParts = [];
|
| 114 |
+
|
| 115 |
+
for (let message of messages) {
|
| 116 |
+
const formatted = formatSingleMessage(message);
|
| 117 |
+
if (formatted) {
|
| 118 |
+
formattedParts.push(formatted);
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
return formattedParts.length > 0 ? formattedParts.join(';') : '';
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/**
|
| 126 |
+
* 解析消息格式,处理图片上传和消息结构
|
| 127 |
+
* @param {Array} messages - 原始消息数组
|
| 128 |
+
* @param {object} thinking_config - 思考配置
|
| 129 |
+
* @param {string} chat_type - 聊天类型
|
| 130 |
+
* @returns {Promise<Array>} 解析后的消息数组
|
| 131 |
+
*/
|
| 132 |
+
const parserMessages = async (messages, thinking_config, chat_type) => {
|
| 133 |
+
try {
|
| 134 |
+
const feature_config = thinking_config;
|
| 135 |
+
const imgCacheManager = new CacheManager();
|
| 136 |
+
|
| 137 |
+
// 如果只有一条消息,使用原有逻辑处理(不标注角色)
|
| 138 |
+
if (messages.length <= 1) {
|
| 139 |
+
logger.network('单条消息,使用原格式处理', 'PARSER');
|
| 140 |
+
return await processOriginalLogic(messages, thinking_config, chat_type, imgCacheManager);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
// 多条消息的情况:分离历史消息和最后一条消息
|
| 144 |
+
logger.network('多条消息,格式化处理并标注角色', 'PARSER');
|
| 145 |
+
const historyMessages = messages.slice(0, -1);
|
| 146 |
+
const lastMessage = messages[messages.length - 1];
|
| 147 |
+
|
| 148 |
+
// 格式化历史消息为文本前缀
|
| 149 |
+
const historyText = formatHistoryMessages(historyMessages);
|
| 150 |
+
|
| 151 |
+
// 处理最后一条消息
|
| 152 |
+
let finalContent = [];
|
| 153 |
+
let lastMessageText = '';
|
| 154 |
+
const lastMessageRole = lastMessage.role;
|
| 155 |
+
|
| 156 |
+
if (typeof lastMessage.content === 'string') {
|
| 157 |
+
lastMessageText = lastMessage.content;
|
| 158 |
+
} else if (Array.isArray(lastMessage.content)) {
|
| 159 |
+
// 处理最后一条消息中的内容
|
| 160 |
+
for (let item of lastMessage.content) {
|
| 161 |
+
if (item.type === 'text') {
|
| 162 |
+
lastMessageText += item.text || '';
|
| 163 |
+
} else if (item.type === 'image' || item.type === 'image_url') {
|
| 164 |
+
// 处理图片上传
|
| 165 |
+
let base64 = null;
|
| 166 |
+
if (item.type === 'image_url') {
|
| 167 |
+
base64 = item.image_url.url;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
if (base64) {
|
| 171 |
+
const regex = /data:(.+);base64,/;
|
| 172 |
+
const fileType = base64.match(regex);
|
| 173 |
+
const fileExtension = fileType && fileType[1] ? fileType[1].split('/')[1] || 'png' : 'png';
|
| 174 |
+
const filename = `${generateUUID()}.${fileExtension}`;
|
| 175 |
+
base64 = base64.replace(regex, '');
|
| 176 |
+
const signature = sha256Encrypt(base64);
|
| 177 |
+
|
| 178 |
+
try {
|
| 179 |
+
const buffer = Buffer.from(base64, 'base64');
|
| 180 |
+
const cacheIsExist = imgCacheManager.cacheIsExist(signature);
|
| 181 |
+
|
| 182 |
+
if (cacheIsExist) {
|
| 183 |
+
finalContent.push({
|
| 184 |
+
type: 'image',
|
| 185 |
+
image: imgCacheManager.getCache(signature).url
|
| 186 |
+
});
|
| 187 |
+
} else {
|
| 188 |
+
const uploadResult = await uploadFileToQwenOss(buffer, filename, accountManager.getAccountToken());
|
| 189 |
+
if (uploadResult && uploadResult.status === 200) {
|
| 190 |
+
finalContent.push({
|
| 191 |
+
type: 'image',
|
| 192 |
+
image: uploadResult.file_url
|
| 193 |
+
});
|
| 194 |
+
imgCacheManager.addCache(signature, uploadResult.file_url);
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
} catch (error) {
|
| 198 |
+
logger.error('图片上传失败', 'UPLOAD', '', error);
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// 组合最终内容:历史文本 + 当前消息(带角色标注)
|
| 206 |
+
let combinedText = '';
|
| 207 |
+
if (historyText) {
|
| 208 |
+
combinedText = historyText + ';';
|
| 209 |
+
}
|
| 210 |
+
// 添加最后一条消息,带角色标注
|
| 211 |
+
if (lastMessageText.trim()) {
|
| 212 |
+
combinedText += `${lastMessageRole}:${lastMessageText}`;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// 如果有图片,创建包含文本和图片的content数组
|
| 216 |
+
if (finalContent.length > 0) {
|
| 217 |
+
finalContent.unshift({
|
| 218 |
+
type: 'text',
|
| 219 |
+
text: combinedText,
|
| 220 |
+
chat_type: 't2t',
|
| 221 |
+
feature_config: {
|
| 222 |
+
"output_schema": "phase",
|
| 223 |
+
"thinking_enabled": false,
|
| 224 |
+
}
|
| 225 |
+
});
|
| 226 |
+
|
| 227 |
+
return [
|
| 228 |
+
{
|
| 229 |
+
"role": "user",
|
| 230 |
+
"content": finalContent,
|
| 231 |
+
"chat_type": chat_type,
|
| 232 |
+
"extra": {},
|
| 233 |
+
"feature_config": feature_config
|
| 234 |
+
}
|
| 235 |
+
];
|
| 236 |
+
} else {
|
| 237 |
+
// 纯文本情况
|
| 238 |
+
return [
|
| 239 |
+
{
|
| 240 |
+
"role": "user",
|
| 241 |
+
"content": combinedText,
|
| 242 |
+
"chat_type": chat_type,
|
| 243 |
+
"extra": {},
|
| 244 |
+
"feature_config": feature_config
|
| 245 |
+
}
|
| 246 |
+
];
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
} catch (e) {
|
| 250 |
+
logger.error('消息解析失败', 'PARSER', '', e);
|
| 251 |
+
return [
|
| 252 |
+
{
|
| 253 |
+
"role": "user",
|
| 254 |
+
"content": "直接返回字符串: '聊天历史处理有误...'",
|
| 255 |
+
"chat_type": "t2t",
|
| 256 |
+
"extra": {},
|
| 257 |
+
"feature_config": {
|
| 258 |
+
"output_schema": "phase",
|
| 259 |
+
"enabled": false,
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
];
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
/**
|
| 267 |
+
* 原有的单条消息处理逻辑
|
| 268 |
+
* @param {Array} messages - 消息数组
|
| 269 |
+
* @param {object} thinking_config - 思考配置
|
| 270 |
+
* @param {string} chat_type - 聊天类型
|
| 271 |
+
* @param {object} imgCacheManager - 图片缓存管理器
|
| 272 |
+
* @returns {Promise<Array>} 处理后的消息数组
|
| 273 |
+
*/
|
| 274 |
+
const processOriginalLogic = async (messages, thinking_config, chat_type, imgCacheManager) => {
|
| 275 |
+
const feature_config = thinking_config;
|
| 276 |
+
|
| 277 |
+
for (let message of messages) {
|
| 278 |
+
if (message.role === 'user' || message.role === 'assistant') {
|
| 279 |
+
message.chat_type = "t2t";
|
| 280 |
+
message.extra = {};
|
| 281 |
+
message.feature_config = {
|
| 282 |
+
"output_schema": "phase",
|
| 283 |
+
"thinking_enabled": false,
|
| 284 |
+
};
|
| 285 |
+
|
| 286 |
+
if (!Array.isArray(message.content)) continue;
|
| 287 |
+
|
| 288 |
+
const newContent = [];
|
| 289 |
+
|
| 290 |
+
for (let item of message.content) {
|
| 291 |
+
if (item.type === 'image' || item.type === 'image_url') {
|
| 292 |
+
let base64 = null;
|
| 293 |
+
if (item.type === 'image_url') {
|
| 294 |
+
base64 = item.image_url.url;
|
| 295 |
+
}
|
| 296 |
+
if (base64) {
|
| 297 |
+
const regex = /data:(.+);base64,/;
|
| 298 |
+
const fileType = base64.match(regex);
|
| 299 |
+
const fileExtension = fileType && fileType[1] ? fileType[1].split('/')[1] || 'png' : 'png';
|
| 300 |
+
const filename = `${generateUUID()}.${fileExtension}`;
|
| 301 |
+
base64 = base64.replace(regex, '');
|
| 302 |
+
const signature = sha256Encrypt(base64);
|
| 303 |
+
|
| 304 |
+
try {
|
| 305 |
+
const buffer = Buffer.from(base64, 'base64');
|
| 306 |
+
const cacheIsExist = imgCacheManager.cacheIsExist(signature);
|
| 307 |
+
if (cacheIsExist) {
|
| 308 |
+
delete item.image_url;
|
| 309 |
+
item.type = 'image';
|
| 310 |
+
item.image = imgCacheManager.getCache(signature).url;
|
| 311 |
+
newContent.push(item);
|
| 312 |
+
} else {
|
| 313 |
+
const uploadResult = await uploadFileToQwenOss(buffer, filename, accountManager.getAccountToken());
|
| 314 |
+
if (uploadResult && uploadResult.status === 200) {
|
| 315 |
+
delete item.image_url;
|
| 316 |
+
item.type = 'image';
|
| 317 |
+
item.image = uploadResult.file_url;
|
| 318 |
+
imgCacheManager.addCache(signature, uploadResult.file_url);
|
| 319 |
+
newContent.push(item);
|
| 320 |
+
}
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
} catch (error) {
|
| 324 |
+
logger.error('图片上传失败', 'UPLOAD', '', error);
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
+
} else if (item.type === 'text') {
|
| 328 |
+
item.chat_type = 't2t';
|
| 329 |
+
item.feature_config = {
|
| 330 |
+
"output_schema": "phase",
|
| 331 |
+
"thinking_enabled": false,
|
| 332 |
+
};
|
| 333 |
+
|
| 334 |
+
if (newContent.length >= 2) {
|
| 335 |
+
messages.push({
|
| 336 |
+
"role": "user",
|
| 337 |
+
"content": item.text,
|
| 338 |
+
"chat_type": "t2t",
|
| 339 |
+
"extra": {},
|
| 340 |
+
"feature_config": {
|
| 341 |
+
"output_schema": "phase",
|
| 342 |
+
"thinking_enabled": false,
|
| 343 |
+
}
|
| 344 |
+
});
|
| 345 |
+
} else {
|
| 346 |
+
newContent.push(item);
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
} else {
|
| 351 |
+
if (Array.isArray(message.content)) {
|
| 352 |
+
let system_prompt = '';
|
| 353 |
+
for (let item of message.content) {
|
| 354 |
+
if (item.type === 'text') {
|
| 355 |
+
system_prompt += item.text;
|
| 356 |
+
}
|
| 357 |
+
}
|
| 358 |
+
if (system_prompt) {
|
| 359 |
+
message.content = system_prompt;
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
}
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
messages[messages.length - 1].feature_config = feature_config;
|
| 366 |
+
messages[messages.length - 1].chat_type = chat_type;
|
| 367 |
+
|
| 368 |
+
return messages;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
module.exports = {
|
| 372 |
+
isChatType,
|
| 373 |
+
isThinkingEnabled,
|
| 374 |
+
parserModel,
|
| 375 |
+
parserMessages
|
| 376 |
+
}
|
src/utils/cli.manager.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const crypto = require('crypto')
|
| 2 |
+
const { getProxyAgent, getChatBaseUrl, applyProxyToFetchOptions } = require('./proxy-helper')
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* 为 PKCE 生成随机代码验证器
|
| 6 |
+
* @returns {string} 43-128个字符的随机字符串
|
| 7 |
+
*/
|
| 8 |
+
function generateCodeVerifier() {
|
| 9 |
+
return crypto.randomBytes(32).toString('base64url')
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* 使用 SHA-256 从代码验证器生成代码挑战
|
| 14 |
+
* @param {string} codeVerifier - 代码验证器字符串
|
| 15 |
+
* @returns {string} 代码挑战字符串
|
| 16 |
+
*/
|
| 17 |
+
function generateCodeChallenge(codeVerifier) {
|
| 18 |
+
const hash = crypto.createHash('sha256')
|
| 19 |
+
hash.update(codeVerifier)
|
| 20 |
+
return hash.digest('base64url')
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* 生成 PKCE 代码验证器和挑战对
|
| 25 |
+
* @returns {Object} 包含 code_verifier 和 code_challenge 的对象
|
| 26 |
+
*/
|
| 27 |
+
function generatePKCEPair() {
|
| 28 |
+
const codeVerifier = generateCodeVerifier()
|
| 29 |
+
const codeChallenge = generateCodeChallenge(codeVerifier)
|
| 30 |
+
return {
|
| 31 |
+
code_verifier: codeVerifier,
|
| 32 |
+
code_challenge: codeChallenge
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
class CliAuthManager {
|
| 37 |
+
/**
|
| 38 |
+
* 启动 OAuth 设备授权流程
|
| 39 |
+
* @returns {Promise<Object>} 包含设备代码、验证URL和代码验证器的对象
|
| 40 |
+
*/
|
| 41 |
+
async initiateDeviceFlow() {
|
| 42 |
+
// 生成 PKCE 代码验证器和挑战
|
| 43 |
+
const { code_verifier, code_challenge } = generatePKCEPair()
|
| 44 |
+
|
| 45 |
+
const bodyData = new URLSearchParams({
|
| 46 |
+
client_id: "f0304373b74a44d2b584a3fb70ca9e56",
|
| 47 |
+
scope: "openid profile email model.completion",
|
| 48 |
+
code_challenge: code_challenge,
|
| 49 |
+
code_challenge_method: 'S256',
|
| 50 |
+
})
|
| 51 |
+
|
| 52 |
+
const chatBaseUrl = getChatBaseUrl()
|
| 53 |
+
const proxyAgent = getProxyAgent()
|
| 54 |
+
|
| 55 |
+
const fetchOptions = {
|
| 56 |
+
method: 'POST',
|
| 57 |
+
headers: {
|
| 58 |
+
'Content-Type': 'application/x-www-form-urlencoded',
|
| 59 |
+
Accept: 'application/json',
|
| 60 |
+
},
|
| 61 |
+
body: bodyData,
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// 添加代理配置
|
| 65 |
+
if (proxyAgent) {
|
| 66 |
+
fetchOptions.agent = proxyAgent
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
try {
|
| 70 |
+
const response = await fetch(`${chatBaseUrl}/api/v1/oauth2/device/code`, fetchOptions)
|
| 71 |
+
|
| 72 |
+
if (response.ok) {
|
| 73 |
+
const result = await response.json()
|
| 74 |
+
return {
|
| 75 |
+
status: true,
|
| 76 |
+
...result,
|
| 77 |
+
code_verifier: code_verifier
|
| 78 |
+
}
|
| 79 |
+
} else {
|
| 80 |
+
throw new Error()
|
| 81 |
+
}
|
| 82 |
+
} catch (error) {
|
| 83 |
+
return {
|
| 84 |
+
status: false,
|
| 85 |
+
device_code: null,
|
| 86 |
+
user_code: null,
|
| 87 |
+
verification_uri: null,
|
| 88 |
+
verification_uri_complete: null,
|
| 89 |
+
expires_in: null,
|
| 90 |
+
code_verifier: null
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/**
|
| 96 |
+
* 授权登录
|
| 97 |
+
* @param {string} user_code - 用户代码
|
| 98 |
+
* @param {string} access_token - 访问令牌
|
| 99 |
+
* @returns {Promise<boolean>} 是否授权成功
|
| 100 |
+
*/
|
| 101 |
+
async authorizeLogin(user_code, access_token) {
|
| 102 |
+
try {
|
| 103 |
+
const chatBaseUrl = getChatBaseUrl()
|
| 104 |
+
const proxyAgent = getProxyAgent()
|
| 105 |
+
|
| 106 |
+
const fetchOptions = {
|
| 107 |
+
method: 'POST',
|
| 108 |
+
headers: {
|
| 109 |
+
'Content-Type': 'application/json',
|
| 110 |
+
"authorization": `Bearer ${access_token}`,
|
| 111 |
+
},
|
| 112 |
+
body: JSON.stringify({
|
| 113 |
+
"approved": true,
|
| 114 |
+
"user_code": user_code
|
| 115 |
+
})
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
if (proxyAgent) {
|
| 119 |
+
fetchOptions.agent = proxyAgent
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
const response = await fetch(`${chatBaseUrl}/api/v2/oauth2/authorize`, fetchOptions)
|
| 123 |
+
|
| 124 |
+
if (response.ok) {
|
| 125 |
+
return true
|
| 126 |
+
} else {
|
| 127 |
+
throw new Error()
|
| 128 |
+
}
|
| 129 |
+
} catch (error) {
|
| 130 |
+
return false
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/**
|
| 135 |
+
* 轮询获取访问令牌
|
| 136 |
+
* @param {string} device_code - 设备代码
|
| 137 |
+
* @param {string} code_verifier - 代码验证器
|
| 138 |
+
* @returns {Promise<Object>} 访问令牌信息
|
| 139 |
+
*/
|
| 140 |
+
async pollForToken(device_code, code_verifier) {
|
| 141 |
+
let pollInterval = 5000
|
| 142 |
+
const maxAttempts = 60
|
| 143 |
+
const chatBaseUrl = getChatBaseUrl()
|
| 144 |
+
const proxyAgent = getProxyAgent()
|
| 145 |
+
|
| 146 |
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
| 147 |
+
const bodyData = new URLSearchParams({
|
| 148 |
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
| 149 |
+
client_id: "f0304373b74a44d2b584a3fb70ca9e56",
|
| 150 |
+
device_code: device_code,
|
| 151 |
+
code_verifier: code_verifier,
|
| 152 |
+
})
|
| 153 |
+
|
| 154 |
+
const fetchOptions = {
|
| 155 |
+
method: 'POST',
|
| 156 |
+
headers: {
|
| 157 |
+
'Content-Type': 'application/x-www-form-urlencoded',
|
| 158 |
+
Accept: 'application/json',
|
| 159 |
+
},
|
| 160 |
+
body: bodyData,
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
if (proxyAgent) {
|
| 164 |
+
fetchOptions.agent = proxyAgent
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
try {
|
| 168 |
+
const response = await fetch(`${chatBaseUrl}/api/v1/oauth2/token`, fetchOptions)
|
| 169 |
+
|
| 170 |
+
if (response.ok) {
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
const tokenData = await response.json()
|
| 174 |
+
|
| 175 |
+
// 转换为凭据格式
|
| 176 |
+
const credentials = {
|
| 177 |
+
access_token: tokenData.access_token,
|
| 178 |
+
refresh_token: tokenData.refresh_token || undefined,
|
| 179 |
+
expiry_date: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : undefined,
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
return credentials
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// 等待5秒, 然后继续轮询
|
| 186 |
+
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
| 187 |
+
} catch (error) {
|
| 188 |
+
// 等待5秒, 然后继续轮询
|
| 189 |
+
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
| 190 |
+
console.log(`轮询尝试 ${attempt + 1}/${maxAttempts} 失败:`, error)
|
| 191 |
+
continue
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
return {
|
| 196 |
+
status: false,
|
| 197 |
+
access_token: null,
|
| 198 |
+
refresh_token: null,
|
| 199 |
+
expiry_date: null
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
/**
|
| 204 |
+
* 初始化 CLI 账户
|
| 205 |
+
* @param {string} access_token - 访问令牌
|
| 206 |
+
* @returns {Promise<Object>} 账户信息
|
| 207 |
+
*/
|
| 208 |
+
async initCliAccount(access_token) {
|
| 209 |
+
const deviceFlow = await this.initiateDeviceFlow()
|
| 210 |
+
if (!deviceFlow.status || !await this.authorizeLogin(deviceFlow.user_code, access_token)) {
|
| 211 |
+
return {
|
| 212 |
+
status: false,
|
| 213 |
+
access_token: null,
|
| 214 |
+
refresh_token: null,
|
| 215 |
+
expiry_date: null
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
return await this.pollForToken(deviceFlow.device_code, deviceFlow.code_verifier)
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
/**
|
| 223 |
+
* 刷新访问令牌
|
| 224 |
+
* @param {Object} CliAccount - 账户信息
|
| 225 |
+
* @returns {Promise<Object>} 账户信息
|
| 226 |
+
*/
|
| 227 |
+
async refreshAccessToken(CliAccount) {
|
| 228 |
+
try {
|
| 229 |
+
|
| 230 |
+
if (!CliAccount || !CliAccount.refresh_token) {
|
| 231 |
+
throw new Error()
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
const chatBaseUrl = getChatBaseUrl()
|
| 235 |
+
const proxyAgent = getProxyAgent()
|
| 236 |
+
|
| 237 |
+
const bodyData = new URLSearchParams({
|
| 238 |
+
grant_type: 'refresh_token',
|
| 239 |
+
refresh_token: CliAccount.refresh_token,
|
| 240 |
+
client_id: "f0304373b74a44d2b584a3fb70ca9e56",
|
| 241 |
+
})
|
| 242 |
+
|
| 243 |
+
const fetchOptions = {
|
| 244 |
+
method: 'POST',
|
| 245 |
+
headers: {
|
| 246 |
+
'Content-Type': 'application/x-www-form-urlencoded',
|
| 247 |
+
Accept: 'application/json',
|
| 248 |
+
},
|
| 249 |
+
body: bodyData
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
if (proxyAgent) {
|
| 253 |
+
fetchOptions.agent = proxyAgent
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
const response = await fetch(`${chatBaseUrl}/api/v1/oauth2/token`, fetchOptions)
|
| 257 |
+
|
| 258 |
+
if (response.ok) {
|
| 259 |
+
const tokenData = await response.json()
|
| 260 |
+
|
| 261 |
+
return {
|
| 262 |
+
access_token: tokenData.access_token,
|
| 263 |
+
refresh_token: tokenData.refresh_token || CliAccount.refresh_token,
|
| 264 |
+
expiry_date: Date.now() + tokenData.expires_in * 1000,
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
} catch (error) {
|
| 268 |
+
return {
|
| 269 |
+
status: false,
|
| 270 |
+
access_token: null,
|
| 271 |
+
refresh_token: null,
|
| 272 |
+
expiry_date: null
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
module.exports = new CliAuthManager()
|
src/utils/cookie-generator.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 导入指纹生成器
|
| 2 |
+
const { generateFingerprint } = require('./fingerprint');
|
| 3 |
+
|
| 4 |
+
// 自定义Base64字符表
|
| 5 |
+
const CUSTOM_BASE64_CHARS = "DGi0YA7BemWnQjCl4_bR3f8SKIF9tUz/xhr2oEOgPpac=61ZqwTudLkM5vHyNXsVJ";
|
| 6 |
+
|
| 7 |
+
// 哈希字段位置(这些字段需要随机生成)
|
| 8 |
+
const HASH_FIELDS = {
|
| 9 |
+
16: 'split', // 插件哈希(格式: count|hash,只替换hash部分)
|
| 10 |
+
17: 'full', // Canvas指纹哈希
|
| 11 |
+
18: 'full', // UserAgent哈希
|
| 12 |
+
31: 'full', // UserAgent哈希2
|
| 13 |
+
34: 'full', // 文档URL哈希
|
| 14 |
+
36: 'full' // 文档属性哈希
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
// ==================== LZW压缩算法 ====================
|
| 18 |
+
|
| 19 |
+
function lzwCompress(data, bits, charFunc) {
|
| 20 |
+
if (data == null) return '';
|
| 21 |
+
|
| 22 |
+
let dict = {};
|
| 23 |
+
let dictToCreate = {};
|
| 24 |
+
let c = '';
|
| 25 |
+
let wc = '';
|
| 26 |
+
let w = '';
|
| 27 |
+
let enlargeIn = 2;
|
| 28 |
+
let dictSize = 3;
|
| 29 |
+
let numBits = 2;
|
| 30 |
+
let result = [];
|
| 31 |
+
let value = 0;
|
| 32 |
+
let position = 0;
|
| 33 |
+
|
| 34 |
+
for (let i = 0; i < data.length; i++) {
|
| 35 |
+
c = data.charAt(i);
|
| 36 |
+
|
| 37 |
+
if (!Object.prototype.hasOwnProperty.call(dict, c)) {
|
| 38 |
+
dict[c] = dictSize++;
|
| 39 |
+
dictToCreate[c] = true;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
wc = w + c;
|
| 43 |
+
|
| 44 |
+
if (Object.prototype.hasOwnProperty.call(dict, wc)) {
|
| 45 |
+
w = wc;
|
| 46 |
+
} else {
|
| 47 |
+
if (Object.prototype.hasOwnProperty.call(dictToCreate, w)) {
|
| 48 |
+
if (w.charCodeAt(0) < 256) {
|
| 49 |
+
for (let j = 0; j < numBits; j++) {
|
| 50 |
+
value = (value << 1);
|
| 51 |
+
if (position === bits - 1) {
|
| 52 |
+
position = 0;
|
| 53 |
+
result.push(charFunc(value));
|
| 54 |
+
value = 0;
|
| 55 |
+
} else {
|
| 56 |
+
position++;
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
let charCode = w.charCodeAt(0);
|
| 61 |
+
for (let j = 0; j < 8; j++) {
|
| 62 |
+
value = (value << 1) | (charCode & 1);
|
| 63 |
+
if (position === bits - 1) {
|
| 64 |
+
position = 0;
|
| 65 |
+
result.push(charFunc(value));
|
| 66 |
+
value = 0;
|
| 67 |
+
} else {
|
| 68 |
+
position++;
|
| 69 |
+
}
|
| 70 |
+
charCode >>= 1;
|
| 71 |
+
}
|
| 72 |
+
} else {
|
| 73 |
+
let charCode = 1;
|
| 74 |
+
for (let j = 0; j < numBits; j++) {
|
| 75 |
+
value = (value << 1) | charCode;
|
| 76 |
+
if (position === bits - 1) {
|
| 77 |
+
position = 0;
|
| 78 |
+
result.push(charFunc(value));
|
| 79 |
+
value = 0;
|
| 80 |
+
} else {
|
| 81 |
+
position++;
|
| 82 |
+
}
|
| 83 |
+
charCode = 0;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
charCode = w.charCodeAt(0);
|
| 87 |
+
for (let j = 0; j < 16; j++) {
|
| 88 |
+
value = (value << 1) | (charCode & 1);
|
| 89 |
+
if (position === bits - 1) {
|
| 90 |
+
position = 0;
|
| 91 |
+
result.push(charFunc(value));
|
| 92 |
+
value = 0;
|
| 93 |
+
} else {
|
| 94 |
+
position++;
|
| 95 |
+
}
|
| 96 |
+
charCode >>= 1;
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
enlargeIn--;
|
| 101 |
+
if (enlargeIn === 0) {
|
| 102 |
+
enlargeIn = Math.pow(2, numBits);
|
| 103 |
+
numBits++;
|
| 104 |
+
}
|
| 105 |
+
delete dictToCreate[w];
|
| 106 |
+
} else {
|
| 107 |
+
let charCode = dict[w];
|
| 108 |
+
for (let j = 0; j < numBits; j++) {
|
| 109 |
+
value = (value << 1) | (charCode & 1);
|
| 110 |
+
if (position === bits - 1) {
|
| 111 |
+
position = 0;
|
| 112 |
+
result.push(charFunc(value));
|
| 113 |
+
value = 0;
|
| 114 |
+
} else {
|
| 115 |
+
position++;
|
| 116 |
+
}
|
| 117 |
+
charCode >>= 1;
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
enlargeIn--;
|
| 122 |
+
if (enlargeIn === 0) {
|
| 123 |
+
enlargeIn = Math.pow(2, numBits);
|
| 124 |
+
numBits++;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
dict[wc] = dictSize++;
|
| 128 |
+
w = String(c);
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
if (w !== '') {
|
| 133 |
+
if (Object.prototype.hasOwnProperty.call(dictToCreate, w)) {
|
| 134 |
+
if (w.charCodeAt(0) < 256) {
|
| 135 |
+
for (let j = 0; j < numBits; j++) {
|
| 136 |
+
value = (value << 1);
|
| 137 |
+
if (position === bits - 1) {
|
| 138 |
+
position = 0;
|
| 139 |
+
result.push(charFunc(value));
|
| 140 |
+
value = 0;
|
| 141 |
+
} else {
|
| 142 |
+
position++;
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
let charCode = w.charCodeAt(0);
|
| 147 |
+
for (let j = 0; j < 8; j++) {
|
| 148 |
+
value = (value << 1) | (charCode & 1);
|
| 149 |
+
if (position === bits - 1) {
|
| 150 |
+
position = 0;
|
| 151 |
+
result.push(charFunc(value));
|
| 152 |
+
value = 0;
|
| 153 |
+
} else {
|
| 154 |
+
position++;
|
| 155 |
+
}
|
| 156 |
+
charCode >>= 1;
|
| 157 |
+
}
|
| 158 |
+
} else {
|
| 159 |
+
let charCode = 1;
|
| 160 |
+
for (let j = 0; j < numBits; j++) {
|
| 161 |
+
value = (value << 1) | charCode;
|
| 162 |
+
if (position === bits - 1) {
|
| 163 |
+
position = 0;
|
| 164 |
+
result.push(charFunc(value));
|
| 165 |
+
value = 0;
|
| 166 |
+
} else {
|
| 167 |
+
position++;
|
| 168 |
+
}
|
| 169 |
+
charCode = 0;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
charCode = w.charCodeAt(0);
|
| 173 |
+
for (let j = 0; j < 16; j++) {
|
| 174 |
+
value = (value << 1) | (charCode & 1);
|
| 175 |
+
if (position === bits - 1) {
|
| 176 |
+
position = 0;
|
| 177 |
+
result.push(charFunc(value));
|
| 178 |
+
value = 0;
|
| 179 |
+
} else {
|
| 180 |
+
position++;
|
| 181 |
+
}
|
| 182 |
+
charCode >>= 1;
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
enlargeIn--;
|
| 187 |
+
if (enlargeIn === 0) {
|
| 188 |
+
enlargeIn = Math.pow(2, numBits);
|
| 189 |
+
numBits++;
|
| 190 |
+
}
|
| 191 |
+
delete dictToCreate[w];
|
| 192 |
+
} else {
|
| 193 |
+
let charCode = dict[w];
|
| 194 |
+
for (let j = 0; j < numBits; j++) {
|
| 195 |
+
value = (value << 1) | (charCode & 1);
|
| 196 |
+
if (position === bits - 1) {
|
| 197 |
+
position = 0;
|
| 198 |
+
result.push(charFunc(value));
|
| 199 |
+
value = 0;
|
| 200 |
+
} else {
|
| 201 |
+
position++;
|
| 202 |
+
}
|
| 203 |
+
charCode >>= 1;
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
enlargeIn--;
|
| 208 |
+
if (enlargeIn === 0) {
|
| 209 |
+
enlargeIn = Math.pow(2, numBits);
|
| 210 |
+
numBits++;
|
| 211 |
+
}
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
let charCode = 2;
|
| 215 |
+
for (let j = 0; j < numBits; j++) {
|
| 216 |
+
value = (value << 1) | (charCode & 1);
|
| 217 |
+
if (position === bits - 1) {
|
| 218 |
+
position = 0;
|
| 219 |
+
result.push(charFunc(value));
|
| 220 |
+
value = 0;
|
| 221 |
+
} else {
|
| 222 |
+
position++;
|
| 223 |
+
}
|
| 224 |
+
charCode >>= 1;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
while (true) {
|
| 228 |
+
value = (value << 1);
|
| 229 |
+
if (position === bits - 1) {
|
| 230 |
+
result.push(charFunc(value));
|
| 231 |
+
break;
|
| 232 |
+
}
|
| 233 |
+
position++;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
return result.join('');
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
// ==================== 编码函数 ====================
|
| 240 |
+
|
| 241 |
+
function customEncode(data, urlSafe) {
|
| 242 |
+
if (data == null) return '';
|
| 243 |
+
|
| 244 |
+
const base64Chars = CUSTOM_BASE64_CHARS;
|
| 245 |
+
|
| 246 |
+
let compressed = lzwCompress(data, 6, function(index) {
|
| 247 |
+
return base64Chars.charAt(index);
|
| 248 |
+
});
|
| 249 |
+
|
| 250 |
+
if (!urlSafe) {
|
| 251 |
+
switch (compressed.length % 4) {
|
| 252 |
+
case 1: return compressed + '===';
|
| 253 |
+
case 2: return compressed + '==';
|
| 254 |
+
case 3: return compressed + '=';
|
| 255 |
+
default: return compressed;
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
return compressed;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
// ==================== 辅助函数 ====================
|
| 263 |
+
|
| 264 |
+
function randomHash() {
|
| 265 |
+
return Math.floor(Math.random() * 4294967296);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
function generateDeviceId() {
|
| 269 |
+
return Array.from({ length: 20 }, () =>
|
| 270 |
+
Math.floor(Math.random() * 16).toString(16)
|
| 271 |
+
).join('');
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
// ==================== 数据解析和处理 ====================
|
| 275 |
+
|
| 276 |
+
function parseRealData(realData) {
|
| 277 |
+
const fields = realData.split('^');
|
| 278 |
+
return fields;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
function processFields(fields) {
|
| 282 |
+
const processed = [...fields];
|
| 283 |
+
const currentTimestamp = Date.now();
|
| 284 |
+
|
| 285 |
+
// 替换哈希字段
|
| 286 |
+
for (const [index, type] of Object.entries(HASH_FIELDS)) {
|
| 287 |
+
const idx = parseInt(index);
|
| 288 |
+
|
| 289 |
+
if (type === 'split') {
|
| 290 |
+
// 字段16: 格式为 "count|hash",只替换hash部分
|
| 291 |
+
const parts = processed[idx].split('|');
|
| 292 |
+
if (parts.length === 2) {
|
| 293 |
+
processed[idx] = `${parts[0]}|${randomHash()}`;
|
| 294 |
+
}
|
| 295 |
+
} else if (type === 'full') {
|
| 296 |
+
// 完全替换为随机哈希
|
| 297 |
+
if (idx === 36) {
|
| 298 |
+
// 字段36: 文档属性哈希(10-100的随机整数)
|
| 299 |
+
processed[idx] = Math.floor(Math.random() * 91) + 10;
|
| 300 |
+
} else {
|
| 301 |
+
processed[idx] = randomHash();
|
| 302 |
+
}
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
processed[33] = currentTimestamp; // 字段33: 当前时间戳
|
| 307 |
+
|
| 308 |
+
return processed;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
// ==================== Cookie生成 ====================
|
| 312 |
+
|
| 313 |
+
function generateCookies(realData = null, fingerprintOptions = {}) {
|
| 314 |
+
// 使用传入的指纹或生成新的随机指纹
|
| 315 |
+
const fingerprint = realData || generateFingerprint(fingerprintOptions);
|
| 316 |
+
|
| 317 |
+
// 解析指纹数据
|
| 318 |
+
const fields = parseRealData(fingerprint);
|
| 319 |
+
|
| 320 |
+
// 处理字段(���机化哈希,更新时间戳)
|
| 321 |
+
const processedFields = processFields(fields);
|
| 322 |
+
|
| 323 |
+
// 生成 ssxmod_itna (37字段)
|
| 324 |
+
const ssxmod_itna_data = processedFields.join('^');
|
| 325 |
+
const ssxmod_itna = '1-' + customEncode(ssxmod_itna_data, true);
|
| 326 |
+
|
| 327 |
+
// 生成 ssxmod_itna2 (18字段)
|
| 328 |
+
// 只使用: 字段0, 字段1, 字段23, 字段32, 字段33
|
| 329 |
+
const ssxmod_itna2_data = [
|
| 330 |
+
processedFields[0], // 设备ID
|
| 331 |
+
processedFields[1], // SDK版本
|
| 332 |
+
processedFields[23], // 模式 (P/M)
|
| 333 |
+
0, '', 0, '', '', 0, // 事件相关(P模式下为空)
|
| 334 |
+
0, 0,
|
| 335 |
+
processedFields[32], // 常量 (11)
|
| 336 |
+
processedFields[33], // 当前时间戳
|
| 337 |
+
0, 0, 0, 0, 0
|
| 338 |
+
].join('^');
|
| 339 |
+
const ssxmod_itna2 = '1-' + customEncode(ssxmod_itna2_data, true);
|
| 340 |
+
|
| 341 |
+
return {
|
| 342 |
+
ssxmod_itna,
|
| 343 |
+
ssxmod_itna2,
|
| 344 |
+
timestamp: parseInt(processedFields[33]),
|
| 345 |
+
rawData: ssxmod_itna_data,
|
| 346 |
+
rawData2: ssxmod_itna2_data
|
| 347 |
+
};
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
function generateBatch(count = 10, realData = null, fingerprintOptions = {}) {
|
| 351 |
+
const results = [];
|
| 352 |
+
for (let i = 0; i < count; i++) {
|
| 353 |
+
results.push(generateCookies(realData, fingerprintOptions));
|
| 354 |
+
}
|
| 355 |
+
return results;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
// ==================== 主程序 ====================
|
| 359 |
+
|
| 360 |
+
if (require.main === module) {
|
| 361 |
+
const result = generateCookies();
|
| 362 |
+
console.log('ssxmod_itna:', result.ssxmod_itna);
|
| 363 |
+
console.log('ssxmod_itna2:', result.ssxmod_itna2);
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
// ==================== 导出 ====================
|
| 367 |
+
|
| 368 |
+
module.exports = {
|
| 369 |
+
generateCookies,
|
| 370 |
+
generateBatch,
|
| 371 |
+
customEncode,
|
| 372 |
+
randomHash,
|
| 373 |
+
generateDeviceId,
|
| 374 |
+
parseRealData,
|
| 375 |
+
generateFingerprint
|
| 376 |
+
};
|
src/utils/data-persistence.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require('fs').promises
|
| 2 |
+
const path = require('path')
|
| 3 |
+
const config = require('../config/index.js')
|
| 4 |
+
const redisClient = require('./redis')
|
| 5 |
+
const { logger } = require('./logger')
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* 数据持久化管理器
|
| 9 |
+
* 统一处理账户数据的存储和读取
|
| 10 |
+
*/
|
| 11 |
+
class DataPersistence {
|
| 12 |
+
constructor() {
|
| 13 |
+
this.dataFilePath = config.dataFilePath
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* 加载所有账户数据
|
| 18 |
+
* @returns {Promise<Array>} 账户列表
|
| 19 |
+
*/
|
| 20 |
+
async loadAccounts() {
|
| 21 |
+
try {
|
| 22 |
+
switch (config.dataSaveMode) {
|
| 23 |
+
case 'redis':
|
| 24 |
+
return await this._loadFromRedis()
|
| 25 |
+
case 'file':
|
| 26 |
+
return await this._loadFromFile()
|
| 27 |
+
case 'none':
|
| 28 |
+
return await this._loadFromEnv()
|
| 29 |
+
default:
|
| 30 |
+
logger.error(`不支持的数据保存模式: ${config.dataSaveMode}`, 'DATA')
|
| 31 |
+
throw new Error(`不支持的数据保存模式: ${config.dataSaveMode}`)
|
| 32 |
+
}
|
| 33 |
+
} catch (error) {
|
| 34 |
+
logger.error('加载账户数据失败', 'DATA', '', error)
|
| 35 |
+
return []
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* 保存单个账户数据
|
| 41 |
+
* @param {string} email - 邮箱
|
| 42 |
+
* @param {Object} accountData - 账户数据
|
| 43 |
+
* @returns {Promise<boolean>} 保存是否成功
|
| 44 |
+
*/
|
| 45 |
+
async saveAccount(email, accountData) {
|
| 46 |
+
try {
|
| 47 |
+
switch (config.dataSaveMode) {
|
| 48 |
+
case 'redis':
|
| 49 |
+
return await this._saveToRedis(email, accountData)
|
| 50 |
+
case 'file':
|
| 51 |
+
return await this._saveToFile(email, accountData)
|
| 52 |
+
case 'none':
|
| 53 |
+
logger.warn('环境变量模式不支持保存账户数据', 'DATA')
|
| 54 |
+
return false
|
| 55 |
+
default:
|
| 56 |
+
logger.error(`不支持的数据保存模式: ${config.dataSaveMode}`, 'DATA')
|
| 57 |
+
throw new Error(`不支持的数据保存模式: ${config.dataSaveMode}`)
|
| 58 |
+
}
|
| 59 |
+
} catch (error) {
|
| 60 |
+
logger.error(`保存账户数据失败 (${email})`, 'DATA', '', error)
|
| 61 |
+
return false
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/**
|
| 66 |
+
* 批量保存账户数据
|
| 67 |
+
* @param {Array} accounts - 账户列表
|
| 68 |
+
* @returns {Promise<boolean>} 保存是否成功
|
| 69 |
+
*/
|
| 70 |
+
async saveAllAccounts(accounts) {
|
| 71 |
+
try {
|
| 72 |
+
switch (config.dataSaveMode) {
|
| 73 |
+
case 'redis':
|
| 74 |
+
return await this._saveAllToRedis(accounts)
|
| 75 |
+
case 'file':
|
| 76 |
+
return await this._saveAllToFile(accounts)
|
| 77 |
+
case 'none':
|
| 78 |
+
logger.warn('环境变量模式不支持保存账户数据', 'DATA')
|
| 79 |
+
return false
|
| 80 |
+
default:
|
| 81 |
+
logger.error(`不支持的数据保存模式: ${config.dataSaveMode}`, 'DATA')
|
| 82 |
+
throw new Error(`不支持的数据保存模式: ${config.dataSaveMode}`)
|
| 83 |
+
}
|
| 84 |
+
} catch (error) {
|
| 85 |
+
logger.error('批量保存账户数据失败', 'DATA', '', error)
|
| 86 |
+
return false
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/**
|
| 91 |
+
* 从 Redis 加载账户数据
|
| 92 |
+
* @private
|
| 93 |
+
*/
|
| 94 |
+
async _loadFromRedis() {
|
| 95 |
+
const accounts = await redisClient.getAllAccounts()
|
| 96 |
+
return accounts.length > 0 ? accounts : []
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/**
|
| 100 |
+
* 从文件加载账户数据
|
| 101 |
+
* @private
|
| 102 |
+
*/
|
| 103 |
+
async _loadFromFile() {
|
| 104 |
+
// 确保文件存在
|
| 105 |
+
await this._ensureDataFileExists()
|
| 106 |
+
|
| 107 |
+
const fileContent = await fs.readFile(this.dataFilePath, 'utf-8')
|
| 108 |
+
const data = JSON.parse(fileContent)
|
| 109 |
+
|
| 110 |
+
return data.accounts || []
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/**
|
| 114 |
+
* 从环境变量加载账户数据
|
| 115 |
+
* @private
|
| 116 |
+
*/
|
| 117 |
+
async _loadFromEnv() {
|
| 118 |
+
if (!process.env.ACCOUNTS) {
|
| 119 |
+
return []
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
const { JwtDecode } = require('./tools')
|
| 123 |
+
const accountTokens = process.env.ACCOUNTS.split(',')
|
| 124 |
+
const accounts = []
|
| 125 |
+
|
| 126 |
+
for (const item of accountTokens) {
|
| 127 |
+
const [email, password] = item.split(':')
|
| 128 |
+
if (email && password) {
|
| 129 |
+
// 注意:这里需要登录获取token,但在加载阶段不应该进行网络请求
|
| 130 |
+
// 这个逻辑需要在Account类中处理
|
| 131 |
+
accounts.push({ email, password, token: null, expires: null })
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
return accounts
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/**
|
| 139 |
+
* 保存到 Redis
|
| 140 |
+
* @private
|
| 141 |
+
*/
|
| 142 |
+
async _saveToRedis(email, accountData) {
|
| 143 |
+
return await redisClient.setAccount(email, accountData)
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/**
|
| 147 |
+
* 保存到文件
|
| 148 |
+
* @private
|
| 149 |
+
*/
|
| 150 |
+
async _saveToFile(email, accountData) {
|
| 151 |
+
await this._ensureDataFileExists()
|
| 152 |
+
|
| 153 |
+
const fileContent = await fs.readFile(this.dataFilePath, 'utf-8')
|
| 154 |
+
const data = JSON.parse(fileContent)
|
| 155 |
+
|
| 156 |
+
if (!data.accounts) {
|
| 157 |
+
data.accounts = []
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// 查找现有账户或添加新账户
|
| 161 |
+
const existingIndex = data.accounts.findIndex(account => account.email === email)
|
| 162 |
+
const updatedAccount = {
|
| 163 |
+
email,
|
| 164 |
+
password: accountData.password,
|
| 165 |
+
token: accountData.token,
|
| 166 |
+
expires: accountData.expires
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
if (existingIndex !== -1) {
|
| 170 |
+
data.accounts[existingIndex] = updatedAccount
|
| 171 |
+
} else {
|
| 172 |
+
data.accounts.push(updatedAccount)
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
await fs.writeFile(this.dataFilePath, JSON.stringify(data, null, 2), 'utf-8')
|
| 176 |
+
return true
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
/**
|
| 180 |
+
* 批量保存到 Redis
|
| 181 |
+
* @private
|
| 182 |
+
*/
|
| 183 |
+
async _saveAllToRedis(accounts) {
|
| 184 |
+
let successCount = 0
|
| 185 |
+
for (const account of accounts) {
|
| 186 |
+
const success = await this._saveToRedis(account.email, account)
|
| 187 |
+
if (success) successCount++
|
| 188 |
+
}
|
| 189 |
+
return successCount === accounts.length
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
/**
|
| 193 |
+
* 批量保存到文件
|
| 194 |
+
* @private
|
| 195 |
+
*/
|
| 196 |
+
async _saveAllToFile(accounts) {
|
| 197 |
+
await this._ensureDataFileExists()
|
| 198 |
+
|
| 199 |
+
const fileContent = await fs.readFile(this.dataFilePath, 'utf-8')
|
| 200 |
+
const data = JSON.parse(fileContent)
|
| 201 |
+
|
| 202 |
+
data.accounts = accounts.map(account => ({
|
| 203 |
+
email: account.email,
|
| 204 |
+
password: account.password,
|
| 205 |
+
token: account.token,
|
| 206 |
+
expires: account.expires
|
| 207 |
+
}))
|
| 208 |
+
|
| 209 |
+
await fs.writeFile(this.dataFilePath, JSON.stringify(data, null, 2), 'utf-8')
|
| 210 |
+
return true
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
/**
|
| 214 |
+
* 确保数据文件存在
|
| 215 |
+
* @private
|
| 216 |
+
*/
|
| 217 |
+
async _ensureDataFileExists() {
|
| 218 |
+
try {
|
| 219 |
+
await fs.access(this.dataFilePath)
|
| 220 |
+
} catch (error) {
|
| 221 |
+
logger.info('数据文件不存在,正在创建默认文件...', 'FILE', '📁')
|
| 222 |
+
|
| 223 |
+
// 确保目录存在
|
| 224 |
+
const dirPath = path.dirname(this.dataFilePath)
|
| 225 |
+
await fs.mkdir(dirPath, { recursive: true })
|
| 226 |
+
|
| 227 |
+
// 创建默认数据结构
|
| 228 |
+
const defaultData = {
|
| 229 |
+
defaultHeaders: null,
|
| 230 |
+
defaultCookie: null,
|
| 231 |
+
accounts: []
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
await fs.writeFile(this.dataFilePath, JSON.stringify(defaultData, null, 2), 'utf-8')
|
| 235 |
+
logger.success('默认数据文件创建成功', 'FILE')
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
module.exports = DataPersistence
|
src/utils/fingerprint.js
ADDED
|
Binary file (7.56 kB). View file
|
|
|
src/utils/img-caches.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require('fs')
|
| 2 |
+
const path = require('path')
|
| 3 |
+
const config = require('../config')
|
| 4 |
+
const { logger } = require('./logger')
|
| 5 |
+
|
| 6 |
+
class imgCacheManager {
|
| 7 |
+
constructor() {
|
| 8 |
+
this.cacheMap = new Map()
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
getCachePath(signature) {
|
| 12 |
+
return path.join(config.cacheDir, `${signature}.txt`)
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
cacheIsExist(signature) {
|
| 16 |
+
try {
|
| 17 |
+
if (config.cacheMode === 'default') {
|
| 18 |
+
return this.cacheMap.has(signature)
|
| 19 |
+
} else {
|
| 20 |
+
const cachePath = this.getCachePath(signature)
|
| 21 |
+
return fs.existsSync(cachePath)
|
| 22 |
+
}
|
| 23 |
+
} catch (e) {
|
| 24 |
+
logger.error('缓存检查失败', 'CACHE', '', e)
|
| 25 |
+
return false
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
addCache(signature, url) {
|
| 30 |
+
try {
|
| 31 |
+
const isExist = this.cacheIsExist(signature)
|
| 32 |
+
|
| 33 |
+
if (isExist) {
|
| 34 |
+
return false
|
| 35 |
+
} else {
|
| 36 |
+
|
| 37 |
+
if (config.cacheMode === 'default') {
|
| 38 |
+
this.cacheMap.set(signature, url)
|
| 39 |
+
} else {
|
| 40 |
+
const cachePath = this.getCachePath(signature)
|
| 41 |
+
fs.mkdirSync(config.cacheDir, { recursive: true })
|
| 42 |
+
fs.writeFileSync(cachePath, url)
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
return true
|
| 46 |
+
|
| 47 |
+
}
|
| 48 |
+
} catch (e) {
|
| 49 |
+
logger.error('添加缓存失败', 'CACHE', '', e)
|
| 50 |
+
return false
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
getCache(signature) {
|
| 55 |
+
try {
|
| 56 |
+
const cachePath = this.getCachePath(signature)
|
| 57 |
+
const isExist = this.cacheIsExist(signature)
|
| 58 |
+
|
| 59 |
+
if (isExist) {
|
| 60 |
+
if (config.cacheMode === 'default') {
|
| 61 |
+
return {
|
| 62 |
+
status: 200,
|
| 63 |
+
url: this.cacheMap.get(signature)
|
| 64 |
+
}
|
| 65 |
+
} else {
|
| 66 |
+
const data = fs.readFileSync(cachePath, 'utf-8')
|
| 67 |
+
return {
|
| 68 |
+
status: 200,
|
| 69 |
+
url: data
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
} else {
|
| 73 |
+
return {
|
| 74 |
+
status: 404,
|
| 75 |
+
url: null
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
} catch (e) {
|
| 79 |
+
logger.error('获取缓存失败', 'CACHE', '', e)
|
| 80 |
+
return {
|
| 81 |
+
status: 500,
|
| 82 |
+
url: null
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
module.exports = imgCacheManager
|