merge: 合并 main 分支到 docker

This commit is contained in:
root
2026-02-07 10:28:18 +08:00
41 changed files with 4508 additions and 1631 deletions

View File

@@ -0,0 +1,30 @@
---
description: 执行带有请求节流的任务,可以控制工具调用的频率
---
# 节流任务执行
你现在进入了节流模式。在这个模式下,你需要:
## 节流规则
1. **工具调用间隔**:每次调用工具后,等待至少 2 秒再调用下一个工具
2. **并行限制**:同时最多只能并行调用 2 个工具(原本可能更多)
3. **批处理优化**:优先将相关的操作合并到一个工具调用中
4. **进度提示**:每次等待时向用户说明正在节流等待
## 执行方式
- 对于读取操作Read, Glob, Grep可以适当放宽并行限制
- 对于写入操作Write, Edit严格遵守间隔要求
- 对于 API 调用Bash 中的 API 请求),必须串行执行
## 用户任务
请按照上述节流规则执行以下任务:
{{PROMPT}}
---
**注意**:完成任务后,你将自动退出节流模式,恢复正常的工具调用频率。

View File

@@ -0,0 +1,407 @@
# 心跳配置功能重构计划
## 概述
将心跳配置从"选择curl命令"模式重构为"基于URL的可配置心跳"模式。
**核心变化**
- ✅ 支持多个URL同时发送心跳
- ✅ 每个URL独立配置间隔时间
- ✅ curl命令作为导入模板可解析为URL配置
- ✅ 完全替换旧的基于curl选择的方式
## 架构设计
### 数据模型
**新表heartbeat_url_configs**
```typescript
{
id: number;
name: string; // URL配置名称
url: string; // 目标URL
method: string; // HTTP方法GET/POST/PUT/DELETE
headers: Record<string, string>; // 请求头JSONB
body: string | null; // 请求体
intervalSeconds: number; // 独立的心跳间隔10-3600秒
isEnabled: boolean; // 是否启用此配置
lastSuccessAt: Date | null; // 统计:上次成功时间
lastErrorAt: Date | null; // 统计:上次失败时间
lastErrorMessage: string | null; // 统计:上次错误信息
successCount: number; // 统计:成功次数
failureCount: number; // 统计:失败次数
providerId: number | null; // 关联的供应商ID可选
model: string | null; // 模型名称(展示用)
endpoint: string | null; // 端点路径(展示用)
createdAt: Date;
updatedAt: Date;
}
```
**修改表heartbeat_settings**
```typescript
{
id: number;
enabled: boolean; // 全局开关(保留)
// 删除intervalSeconds, savedCurls, selectedCurlIndex
createdAt: Date;
updatedAt: Date;
}
```
### 心跳执行逻辑
**ProviderHeartbeat类重构**
```typescript
class ProviderHeartbeat {
// 多定时器管理Map<configId, timer>
private static timers: Map<number, NodeJS.Timeout> = new Map();
// 启动为每个启用的URL配置创建独立定时器
static async start() {
const configs = await findEnabledHeartbeatUrlConfigs();
for (const config of configs) {
this.startConfigTimer(config);
}
}
// 停止:清除所有定时器
static stop() {
for (const timer of this.timers.values()) {
clearInterval(timer);
}
this.timers.clear();
}
// 单个配置的定时器
private static startConfigTimer(config: HeartbeatUrlConfig) {
const interval = setInterval(() => {
this.sendHeartbeat(config);
}, config.intervalSeconds * 1000);
this.timers.set(config.id, interval);
}
// 发送心跳并记录成功/失败
private static async sendHeartbeat(config: HeartbeatUrlConfig) {
const response = await fetch(config.url, {
method: config.method,
headers: config.headers,
body: config.body,
signal: AbortSignal.timeout(10000),
});
if (response.ok) {
await recordHeartbeatSuccess(config.id);
} else {
await recordHeartbeatFailure(config.id, errorMessage);
}
}
}
```
### 前端UI设计
**页面布局**
```
/settings/heartbeat/page.tsx
├── GlobalSettingsCard全局开关
├── CurlHistorySectioncurl历史记录 + 导入按钮)
└── UrlConfigsSectionURL配置列表 + 新建/编辑/删除)
```
**组件拆分**
- `global-settings-card.tsx` - 全局开关卡片
- `curl-history-section.tsx` - curl历史记录区域
- `curl-history-card.tsx` - 单个curl历史卡片
- `url-configs-section.tsx` - URL配置列表区域
- `url-config-card.tsx` - 单个URL配置卡片
- `url-config-dialog.tsx` - 新建/编辑对话框
- `_lib/hooks.ts` - 自定义hooksuseHeartbeatPageData
**curl导入流程**
1. 用户点击curl历史卡片上的"导入"按钮
2. 使用`parseCurlCommand()`解析curl命令
3. 自动打开新建对话框,表单预填充解析后的数据
4. 用户修改后保存创建URL配置
## 实施步骤
### 阶段1数据库和Repository层
1. **修改schema.ts**
- 添加`heartbeatUrlConfigs`表定义
- 修改`heartbeatSettings`表定义删除3个字段
2. **生成和审查迁移**
```bash
bun run db:generate
# 检查生成的 drizzle/0061_*.sql
# 确保数据迁移逻辑正确将选中的curl转为第一个URL配置
```
3. **创建repository/heartbeat-url-configs.ts**
- 接口:`HeartbeatUrlConfig`、`CreateHeartbeatUrlConfigInput`、`UpdateHeartbeatUrlConfigInput`
- 函数:
- `findAllHeartbeatUrlConfigs()` - 获取所有配置
- `findEnabledHeartbeatUrlConfigs()` - 获取启用的配置
- `findHeartbeatUrlConfigById(id)` - 根据ID获取
- `createHeartbeatUrlConfig(input)` - 创建配置
- `updateHeartbeatUrlConfig(id, input)` - 更新配置
- `deleteHeartbeatUrlConfig(id)` - 删除配置
- `recordHeartbeatSuccess(id)` - 记录成功
- `recordHeartbeatFailure(id, errorMessage)` - 记录失败
4. **修改repository/heartbeat-settings.ts**
- 简化为只管理全局开关
- 删除`savedCurls`和`selectedCurlIndex`相关逻辑
- 保持`getHeartbeatSettings()`和`updateHeartbeatSettings()`接口
5. **运行迁移**
```bash
bun run db:migrate
```
### 阶段2Action层
6. **创建actions/heartbeat-url-configs.ts**
- `fetchHeartbeatUrlConfigs()` - 获取所有配置
- `createHeartbeatUrlConfigAction(input)` - 创建配置
- `updateHeartbeatUrlConfigAction(id, input)` - 更新配置
- `deleteHeartbeatUrlConfigAction(id)` - 删除配置
- 验证规则:
- 名称不能为空
- URL不能为空
- 间隔时间范围10-3600秒
- 权限检查仅admin可操作
- 副作用:修改配置后重启心跳任务
7. **修改actions/heartbeat-settings.ts**
- 简化为只管理全局开关
- 保持`fetchHeartbeatSettings()`和`saveHeartbeatSettings()`
- 开关变化时重启心跳任务
### 阶段3心跳执行逻辑
8. **重构lib/provider-heartbeat.ts**
- 添加`timers: Map<number, NodeJS.Timeout>`
- 修改`start()`:为每个启用的配置创建定时器
- 修改`stop()`:清除所有定时器
- 新增`startConfigTimer(config)`:创建单个配置的定时器
- 新增`stopConfigTimer(configId)`:停止单个配置的定时器
- 修改`sendHeartbeat(config)`:发送请求并记录结果
- 删除curl解析逻辑不再需要
9. **修改app/v1/_lib/proxy/forwarder.ts**
- 删除或注释掉`addSuccessfulCurl()`调用第357-367行
- curl历史功能迁移到独立模块可选
### 阶段4i18n文案
10. **更新翻译文件**
- `messages/zh-CN/settings/heartbeat.json`
- `messages/zh-TW/settings/heartbeat.json`
- `messages/en/settings/heartbeat.json`
- `messages/ja/settings/heartbeat.json`
- `messages/ru/settings/heartbeat.json`
新增key
- `section.global.*` - 全局设置区域
- `section.curlHistory.*` - curl历史区域
- `section.urlConfigs.*` - URL配置区域
- `form.name.*` - 配置名称字段
- `form.url.*` - URL字段
- `form.method.*` - HTTP方法字段
- `form.headers.*` - 请求头字段
- `form.body.*` - 请求体字段
- `form.isEnabled.*` - 启用开关
- `form.stats.*` - 统计信息
- `form.createButton`、`importButton`等
### 阶段5前端UI
11. **创建组件**
- `app/[locale]/settings/heartbeat/_components/global-settings-card.tsx`
- Switch组件全局开关
- 说明文字
- `app/[locale]/settings/heartbeat/_components/curl-history-section.tsx`
- 区域标题和描述
- curl历史卡片列表
- 空状态提示
- `app/[locale]/settings/heartbeat/_components/curl-history-card.tsx`
- 显示:供应商名、端点、模型、时间
- 导入按钮
- `app/[locale]/settings/heartbeat/_components/url-configs-section.tsx`
- 区域标题和描述
- 新建按钮
- URL配置卡片列表
- 空状态提示
- `app/[locale]/settings/heartbeat/_components/url-config-card.tsx`
- 显示名称、URL、方法、间隔、启用状态
- 统计信息:成功次数、失败次数、最后成功/失败时间
- 编辑按钮、删除按钮
- Switch组件快速启用/禁用
- `app/[locale]/settings/heartbeat/_components/url-config-dialog.tsx`
- Dialog表单名称、URL、方法、headers、body、间隔
- 支持新建和编辑模式
- headers使用TextareaJSON格式
- body使用Textarea可选
- 验证和错误提示
- `app/[locale]/settings/heartbeat/_components/heartbeat-skeleton.tsx`
- 骨架屏加载状态
12. **创建hooks**
- `app/[locale]/settings/heartbeat/_lib/hooks.ts`
- `useHeartbeatPageData()`
- 加载settings、configs、savedCurls
- 提供CRUD操作函数
- 提供importFromCurl函数
- 统一错误处理和toast提示
13. **重写page.tsx**
- 使用`useHeartbeatPageData()`
- 组合所有子组件
- 加载状态和错误处理
### 阶段6测试和验证
14. **类型检查和格式化**
```bash
bun run typecheck
bun run lint:fix
```
15. **手动测试流程**
- [ ] 访问 `/settings/heartbeat` 页面
- [ ] 创建新的URL配置
- [ ] 从curl历史导入配置
- [ ] 编辑配置修改URL、间隔等
- [ ] 启用/禁用单个配置
- [ ] 启用/禁用全局开关
- [ ] 删除配置
- [ ] 检查多个URL同时发送心跳
- [ ] 检查失败记录和统计信息
- [ ] 检查国际化(切换语言)
16. **日志验证**
```bash
# 检查心跳日志
tail -f logs/app.log | grep "ProviderHeartbeat"
# 应该看到:
# - "Timer started" - 定时器启动
# - "Heartbeat sent successfully" - 成功日志
# - "Heartbeat failed" - 失败日志
```
17. **数据库验证**
```bash
bun run db:studio
# 检查 heartbeat_url_configs 表
# 确认配置已保存
# 确认成功/失败统计更新
```
## 关键文件清单
### 新建文件
- `src/repository/heartbeat-url-configs.ts` - URL配置Repository
- `src/actions/heartbeat-url-configs.ts` - URL配置Actions
- `src/app/[locale]/settings/heartbeat/_components/global-settings-card.tsx`
- `src/app/[locale]/settings/heartbeat/_components/curl-history-section.tsx`
- `src/app/[locale]/settings/heartbeat/_components/curl-history-card.tsx`
- `src/app/[locale]/settings/heartbeat/_components/url-configs-section.tsx`
- `src/app/[locale]/settings/heartbeat/_components/url-config-card.tsx`
- `src/app/[locale]/settings/heartbeat/_components/url-config-dialog.tsx`
- `src/app/[locale]/settings/heartbeat/_lib/hooks.ts`
- `drizzle/0061_*.sql` - 数据库迁移文件(自动生成)
### 修改文件
- `src/drizzle/schema.ts` - 添加新表,修改旧表
- `src/repository/heartbeat-settings.ts` - 简化逻辑
- `src/actions/heartbeat-settings.ts` - 简化Action
- `src/lib/provider-heartbeat.ts` - 重构心跳执行逻辑
- `src/app/v1/_lib/proxy/forwarder.ts` - 删除curl保存逻辑
- `src/app/[locale]/settings/heartbeat/page.tsx` - 重写UI
- `messages/*/settings/heartbeat.json` - 更新翻译5种语言
### 删除文件
- `src/app/[locale]/settings/heartbeat/_components/heartbeat-form.tsx` - 旧表单组件
## 数据迁移策略
**迁移逻辑在0061_*.sql中**
```sql
-- 创建新表
CREATE TABLE heartbeat_url_configs (...);
-- 迁移现有数据
DO $$
DECLARE
settings_row RECORD;
selected_curl JSONB;
BEGIN
SELECT * INTO settings_row FROM heartbeat_settings LIMIT 1;
IF settings_row.selected_curl_index IS NOT NULL THEN
selected_curl := settings_row.saved_curls->settings_row.selected_curl_index;
INSERT INTO heartbeat_url_configs (
name, url, interval_seconds, is_enabled, ...
) VALUES (
selected_curl->>'providerName',
selected_curl->>'url',
settings_row.interval_seconds,
settings_row.enabled,
...
);
END IF;
END $$;
-- 删除旧字段
ALTER TABLE heartbeat_settings
DROP COLUMN interval_seconds,
DROP COLUMN saved_curls,
DROP COLUMN selected_curl_index;
```
**回滚能力**:保留旧数据在迁移文件中,可以通过反向迁移恢复。
## 风险和缓解
| 风险 | 缓解措施 |
|------|----------|
| 数据迁移失败 | 1. 迁移前备份数据库<br>2. 在测试环境验证<br>3. 编写回滚脚本 |
| curl解析不完整 | 1. 复用现有`parseCurlCommand`<br>2. 添加解析错误提示<br>3. 允许手动编辑 |
| 多定时器性能问题 | 1. 限制最大配置数量如20个<br>2. 添加禁用功能<br>3. 监控日志 |
| 心跳发送失败 | 1. 记录失败日志<br>2. UI显示失败状态<br>3. 支持手动禁用 |
## 验证清单
- [ ] 数据库迁移成功,旧数据已转移
- [ ] 类型检查通过 (`bun run typecheck`)
- [ ] Lint检查通过 (`bun run lint`)
- [ ] 构建成功 (`bun run build`)
- [ ] 可以创建URL配置
- [ ] 可以从curl导入配置
- [ ] 可以编辑和删除配置
- [ ] 全局开关控制所有心跳
- [ ] 多个URL同时发送心跳检查日志
- [ ] 失败统计正确记录
- [ ] 所有5种语言显示正常
- [ ] 页面加载和交互流畅
## 预估工作量
- 数据库和Repository层1-2小时
- Action层30分钟
- 心跳执行逻辑1小时
- i18n文案30分钟
- 前端UI2-3小时
- 测试和验证1小时
- **总计6-8小时**

View File

@@ -0,0 +1,172 @@
#!/bin/bash
echo "=========================================="
echo "Claude Code Root Check 移除工具"
echo "=========================================="
echo ""
# 通过 which 命令找到 claude 可执行文件
echo "正在查找 claude 命令..."
CLAUDE_PATH=$(which claude)
if [ -z "$CLAUDE_PATH" ]; then
echo "❌ 错误: 未找到 claude 命令"
exit 1
fi
echo "找到 claude 位置: $CLAUDE_PATH"
# 如果是软链接,获取实际文件路径
if [ -L "$CLAUDE_PATH" ]; then
REAL_PATH=$(readlink -f "$CLAUDE_PATH")
echo "这是一个软链接,实际路径: $REAL_PATH"
else
REAL_PATH="$CLAUDE_PATH"
fi
# 获取 claude 所在的目录
CLAUDE_DIR=$(dirname "$CLAUDE_PATH")
echo "claude 目录: $CLAUDE_DIR"
echo ""
# 检查是否已经是包装脚本
if grep -q "Claude Code Wrapper" "$CLAUDE_PATH" 2>/dev/null; then
echo "✓ 检测到已安装包装脚本"
echo "正在更新包装脚本..."
else
echo "正在创建包装脚本..."
fi
# 创建 claude-wrapper.sh
WRAPPER_PATH="$CLAUDE_DIR/claude-wrapper.sh"
cat > "$WRAPPER_PATH" << 'EOF'
#!/bin/bash
# Claude Code Wrapper - 自动删除 root check 限制
# 此脚本会在每次执行 claude 前绕过 root 用户限制
#
# 新版本 (2.1.x+) 支持通过环境变量绕过检查:
# - IS_SANDBOX=1
# - CLAUDE_CODE_BUBBLEWRAP=1
#
# 旧版本需要修改 cli.js 文件删除检查代码
# 获取当前脚本的真实路径
SCRIPT_PATH="$(readlink -f "$0")"
SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
# 查找同目录下的 claude.bak原始软链接
CLAUDE_BAK="$SCRIPT_DIR/claude.bak"
# 如果 claude.bak 不存在,尝试通过 which 和目录搜索找到真实路径
if [ ! -L "$CLAUDE_BAK" ] && [ ! -f "$CLAUDE_BAK" ]; then
# 在当前目录查找指向 claude-code 的软链接或文件
for file in "$SCRIPT_DIR"/*; do
if [ -L "$file" ] || [ -f "$file" ]; then
target=$(readlink -f "$file" 2>/dev/null)
if [[ "$target" == *"@anthropic-ai/claude-code/cli.js" ]]; then
CLAUDE_REAL_PATH="$target"
break
fi
fi
done
# 如果还是没找到,尝试常见路径
if [ -z "$CLAUDE_REAL_PATH" ]; then
for path in \
"$SCRIPT_DIR/../lib/node_modules/@anthropic-ai/claude-code/cli.js" \
"/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js" \
"/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js"; do
if [ -f "$path" ]; then
CLAUDE_REAL_PATH="$path"
break
fi
done
fi
else
# 通过 claude.bak 获取真实的 cli.js 路径
CLAUDE_REAL_PATH="$(readlink -f "$CLAUDE_BAK")"
fi
if [ -z "$CLAUDE_REAL_PATH" ] || [ ! -f "$CLAUDE_REAL_PATH" ]; then
echo "错误: 未找到真实的 claude cli.js 文件" >&2
echo "请确保 claude 已正确安装" >&2
exit 1
fi
# 获取 claude 版本号(用于提示信息)
CLAUDE_VERSION=$(node "$CLAUDE_REAL_PATH" --version 2>/dev/null | head -1 || echo "unknown")
# 新版本 (2.1.x+) 直接使用环境变量绕过 root check
# 设置 IS_SANDBOX=1 或 CLAUDE_CODE_BUBBLEWRAP=1 即可
export IS_SANDBOX=1
export CLAUDE_CODE_BUBBLEWRAP=1
# 执行原始 claude 命令,传递所有参数
exec node "$CLAUDE_REAL_PATH" "$@"
EOF
# 给包装脚本添加执行权限
chmod +x "$WRAPPER_PATH"
echo "✓ 已创建包装脚本: $WRAPPER_PATH"
echo ""
# 备份原 claude 命令(如果尚未备份)
CLAUDE_BAK="$CLAUDE_DIR/claude.bak"
if [ ! -e "$CLAUDE_BAK" ]; then
if [ -L "$CLAUDE_PATH" ]; then
# 如果是软链接,复制软链接本身
cp -P "$CLAUDE_PATH" "$CLAUDE_BAK"
echo "✓ 已备份原 claude 软链接为: $CLAUDE_BAK"
else
# 如果是普通文件,复制文件
cp "$CLAUDE_PATH" "$CLAUDE_BAK"
echo "✓ 已备份原 claude 文件为: $CLAUDE_BAK"
fi
else
echo "✓ 检测到已存在备份: $CLAUDE_BAK"
fi
# 替换 claude 命令为包装脚本
echo ""
echo "正在替换 claude 命令..."
# 删除原有的 claude如果是软链接或文件
rm -f "$CLAUDE_PATH"
# 创建新的软链接指向包装脚本
ln -s "$WRAPPER_PATH" "$CLAUDE_PATH"
echo "✓ 已将 claude 命令替换为包装脚本"
echo ""
# 验证安装
echo "=========================================="
echo "验证安装..."
echo ""
if [ -L "$CLAUDE_PATH" ]; then
TARGET_PATH=$(readlink "$CLAUDE_PATH")
echo "✓ claude 现在指向: $TARGET_PATH"
fi
if [ -e "$CLAUDE_BAK" ]; then
echo "✓ 原始 claude 已备份为: $CLAUDE_BAK"
fi
if [ -x "$WRAPPER_PATH" ]; then
echo "✓ 包装脚本具有执行权限"
fi
echo ""
echo "=========================================="
echo "✓ 安装完成!"
echo ""
echo "现在你可以在 root 用户下使用:"
echo " claude --dangerously-skip-permissions"
echo ""
echo "如需恢复原始 claude 命令:"
echo " rm $CLAUDE_PATH"
echo " mv $CLAUDE_BAK $CLAUDE_PATH"
echo "=========================================="

View File

@@ -0,0 +1,24 @@
{
"env": {
"ANTHROPIC_API_KEY": "sk-d87ce5b80978df466c81378d798ca39f",
"ANTHROPIC_BASE_URL": "https://cc.ronghuaxueleng.top",
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0",
"DISABLE_AUTOUPDATER": 1,
"DISABLE_BUG_COMMAND": 1,
"DISABLE_ERROR_REPORTING": 1,
"DISABLE_TELEMETRY": 1,
"IS_SANDBOX": "1",
"USER_NAME": "腾讯云"
},
"permissions": {
"allow": [
"*"
],
"defaultMode": "bypassPermissions"
},
"statusLine": {
"command": "node \".claude/show-status.mjs\"",
"padding": 0,
"type": "command"
}
}

91
.claude/show-status.mjs Normal file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Claude Code 积分状态栏脚本
* 用途: 在状态栏显示配置信息
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
// 禁用SSL证书验证警告
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
function getDisplayUrl() {
const baseUrl = process.env.ANTHROPIC_BASE_URL || '';
if (baseUrl) {
const match = baseUrl.match(/https?:\/\/([^\/]+)/);
if (match) {
return match[1];
}
}
return '';
}
function getCurrentModel() {
// 优先使用环境变量
let model = process.env.ANTHROPIC_MODEL || '';
// 如果环境变量没有检查settings.json
if (!model) {
try {
const settingsFile = path.join(os.homedir(), '.claude', 'settings.json');
if (fs.existsSync(settingsFile)) {
const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
model = settings.model || '';
}
} catch (error) {
// 忽略错误
}
}
if (model) {
if (model.toLowerCase().includes('claude-3')) {
if (model.toLowerCase().includes('haiku')) {
return 'Claude 3 Haiku';
} else if (model.toLowerCase().includes('sonnet')) {
return 'Claude 3 Sonnet';
} else if (model.toLowerCase().includes('opus')) {
return 'Claude 3 Opus';
}
} else if (model.toLowerCase().includes('claude-4') || model.toLowerCase().includes('sonnet-4')) {
return 'Claude 4 Sonnet';
} else if (model.toLowerCase().includes('opus-4')) {
return 'Claude 4 Opus';
} else if (model.length > 20) {
return model.substring(0, 20) + '...';
}
return model;
}
return 'Claude (Auto)';
}
async function main() {
try {
const currentUrl = getDisplayUrl();
const currentModel = getCurrentModel();
const userName = process.env.USER_NAME || '';
const parts = [];
if (userName) parts.push(`👤 ${userName}`);
parts.push(currentModel);
parts.push(currentUrl);
console.log(parts.join(' | '));
} catch (error) {
// 即使出错也显示基本信息
const currentUrl = getDisplayUrl();
const currentModel = getCurrentModel();
const userName = process.env.USER_NAME || '';
const parts = ['🔴 错误'];
if (userName) parts.push(`👤 ${userName}`);
parts.push(currentModel);
parts.push(currentUrl);
console.log(parts.join(' | '));
}
}
// ES Module 中直接执行
main();

View File

@@ -1,50 +1,69 @@
# Git
.git
.gitignore
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
.venv
venv
ENV
# IDE
.idea
.vscode
*.swp
*.swo
# Node
webui/node_modules
webui/.vite
# Build artifacts (前端构建产物在 Docker 中重新生成)
static/admin
# 配置和敏感文件
.env
.env.*
config.json
# 日志和临时文件
*.log
logs/
tmp/
temp/
# 测试
tests/
*.test.py
# 文档和截图
*.md
截图/
docs/
# Claude Code
.claude/
CLAUDE*.md
# Git
.git
.gitignore
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# 虚拟环境
venv/
env/
ENV/
.venv
# 环境配置(通过 docker-compose 挂载或环境变量传递)
.env
.env.local
.env.*.local
config.json
# 开发工具
.vscode/
.idea/
*.swp
*.swo
*~
# 测试
tests/
.pytest_cache/
.coverage
htmlcov/
# Node.js / WebUI 开发依赖
node_modules/
webui/node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# 文档
*.md
!README*.md
# CI/CD
.github/
.releaserc.json
# 其他
.DS_Store
Thumbs.db

View File

@@ -1,76 +0,0 @@
# 自动构建 WebUI 并提交构建产物
# 触发条件webui 目录下的文件变更
name: Build WebUI
on:
push:
branches:
- main
paths:
- 'webui/**'
- '.github/workflows/build-webui.yml'
pull_request:
branches:
- main
paths:
- 'webui/**'
# 允许手动触发
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
# 只在主仓库运行,避免 fork 仓库运行
if: github.repository == 'CJackHwang/ds2api'
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: webui/package-lock.json
- name: Install dependencies
working-directory: webui
run: npm ci
- name: Build WebUI
working-directory: webui
run: npm run build
- name: Check for changes
id: check_changes
run: |
git add static/admin
if git diff --staged --quiet; then
echo "changed=false" >> $GITHUB_OUTPUT
else
echo "changed=true" >> $GITHUB_OUTPUT
fi
- name: Commit and push changes
if: steps.check_changes.outputs.changed == 'true' && github.event_name == 'push'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git commit -m "chore: auto-build WebUI [skip ci]"
git push
- name: Upload build artifacts (for PR review)
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v4
with:
name: webui-build
path: static/admin
retention-days: 7

127
.github/workflows/release-dockerhub.yml vendored Normal file
View File

@@ -0,0 +1,127 @@
name: Release to Docker Hub
on:
workflow_dispatch:
inputs:
version_type:
description: '版本类型'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get current version
id: get_version
run: |
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
TAG_VERSION=${LATEST_TAG#v}
if [ -f VERSION ]; then
FILE_VERSION=$(cat VERSION | tr -d '[:space:]')
else
FILE_VERSION="0.0.0"
fi
function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; }
if version_gt "$FILE_VERSION" "$TAG_VERSION"; then
VERSION="$FILE_VERSION"
else
VERSION="$TAG_VERSION"
fi
echo "Current version: $VERSION"
echo "current_version=$VERSION" >> $GITHUB_OUTPUT
- name: Calculate next version
id: next_version
env:
VERSION_TYPE: ${{ github.event.inputs.version_type }}
run: |
VERSION="${{ steps.get_version.outputs.current_version }}"
BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//')
IFS='.' read -r -a version_parts <<< "$BASE_VERSION"
MAJOR="${version_parts[0]:-0}"
MINOR="${version_parts[1]:-0}"
PATCH="${version_parts[2]:-0}"
case "$VERSION_TYPE" in
major)
NEW_VERSION="$((MAJOR + 1)).0.0"
;;
minor)
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
;;
*)
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
;;
esac
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Update VERSION file
run: |
echo "${{ steps.next_version.outputs.new_version }}" > VERSION
- name: Commit VERSION and create tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add VERSION
if ! git diff --cached --quiet; then
git commit -m "chore: bump version to ${{ steps.next_version.outputs.new_tag }} [skip ci]"
fi
NEW_TAG="${{ steps.next_version.outputs.new_tag }}"
git tag -a "$NEW_TAG" -m "Release $NEW_TAG"
git push origin HEAD:main "$NEW_TAG"
# Docker 构建并推送到 Docker Hub
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/ds2api:${{ steps.next_version.outputs.new_tag }}
${{ secrets.DOCKERHUB_USERNAME }}/ds2api:${{ steps.next_version.outputs.new_version }}
${{ secrets.DOCKERHUB_USERNAME }}/ds2api:latest
labels: |
org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }}
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

165
.gitignore vendored
View File

@@ -1,81 +1,84 @@
*.bak
config.json
.env
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
venv/
ENV/
env/
.venv
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Logs
*.log
logs/
uvicorn.log
# Vercel
.vercel
# Node.js / Frontend
node_modules/
webui/node_modules/
webui/dist/
.npm
.pnpm-store/
package-lock.json
yarn.lock
pnpm-lock.yaml
# Build artifacts
*.tsbuildinfo
.cache/
.parcel-cache/
# Environment
.env.local
.env.*.local
# Testing
.coverage
htmlcov/
.pytest_cache/
.tox/
# Misc
*.pyc
*.pyo
.git/
Thumbs.db
*.bak
config.json
.env
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
venv/
ENV/
env/
.venv
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Logs
*.log
logs/
uvicorn.log
# Vercel
.vercel
# Node.js / Frontend
node_modules/
webui/node_modules/
webui/dist/
.npm
.pnpm-store/
# 保留 webui/package-lock.json 用于 CI 缓存
# package-lock.json # 如果有根目录的可以忽略
yarn.lock
pnpm-lock.yaml
# Build artifacts
*.tsbuildinfo
.cache/
.parcel-cache/
static/admin/*
!static/admin/.gitkeep
# Environment
.env.local
.env.*.local
# Testing
.coverage
htmlcov/
.pytest_cache/
.tox/
# Misc
*.pyc
*.pyo
.git/
Thumbs.db

701
API.en.md Normal file
View File

@@ -0,0 +1,701 @@
# DS2API API Reference
Language: [中文](API.md) | [English](API.en.md)
This document describes all DS2API API endpoints.
---
## Table of Contents
- [Basics](#basics)
- [OpenAI-Compatible API](#openai-compatible-api)
- [List Models](#list-models)
- [Chat Completions](#chat-completions)
- [Claude-Compatible API](#claude-compatible-api)
- [Claude Model List](#claude-model-list)
- [Claude Messages](#claude-messages)
- [Token Counting](#token-counting)
- [Admin API](#admin-api)
- [Login](#login)
- [Configuration](#configuration)
- [Account Management](#account-management)
- [Vercel Sync](#vercel-sync)
- [Error Handling](#error-handling)
- [Examples](#examples)
---
## Basics
| Item | Description |
|-----|------|
| **Base URL** | `https://your-domain.com` or `http://localhost:5001` |
| **OpenAI auth** | `Authorization: Bearer <api-key>` |
| **Claude auth** | `x-api-key: <api-key>` |
| **Response format** | JSON |
---
## OpenAI-Compatible API
### List Models
```http
GET /v1/models
```
**Response example**:
```json
{
"object": "list",
"data": [
{"id": "deepseek-chat", "object": "model", "owned_by": "deepseek"},
{"id": "deepseek-reasoner", "object": "model", "owned_by": "deepseek"},
{"id": "deepseek-chat-search", "object": "model", "owned_by": "deepseek"},
{"id": "deepseek-reasoner-search", "object": "model", "owned_by": "deepseek"}
]
}
```
---
### Chat Completions
```http
POST /v1/chat/completions
Authorization: Bearer your-api-key
Content-Type: application/json
```
**Parameters**:
| Parameter | Type | Required | Description |
|-----|------|:----:|------|
| `model` | string | ✅ | Model name (see below) |
| `messages` | array | ✅ | Chat messages |
| `stream` | boolean | ❌ | Stream responses (default `false`) |
| `temperature` | number | ❌ | Temperature (0-2) |
| `max_tokens` | number | ❌ | Max output tokens |
| `tools` | array | ❌ | Tool definitions (Function Calling) |
| `tool_choice` | string | ❌ | Tool selection strategy |
**Supported models**:
| Model | Reasoning | Search | Notes |
|-----|:--------:|:------:|------|
| `deepseek-chat` | ❌ | ❌ | Standard chat |
| `deepseek-reasoner` | ✅ | ❌ | Reasoning mode with trace |
| `deepseek-chat-search` | ❌ | ✅ | Search enhanced |
| `deepseek-reasoner-search` | ✅ | ✅ | Reasoning + search |
**Basic request example**:
```json
{
"model": "deepseek-chat",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello"}
]
}
```
**Streaming request example**:
```json
{
"model": "deepseek-reasoner-search",
"messages": [
{"role": "user", "content": "What's in the news today?"}
],
"stream": true
}
```
**Streaming response format** (`stream: true`):
```
data: {"id":"...","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"},"index":0}]}
data: {"id":"...","object":"chat.completion.chunk","choices":[{"delta":{"reasoning_content":"Let me think..."},"index":0}]}
data: {"id":"...","object":"chat.completion.chunk","choices":[{"delta":{"content":"Based on search results..."},"index":0}]}
data: {"id":"...","object":"chat.completion.chunk","choices":[{"index":0,"finish_reason":"stop"}]}
data: [DONE]
```
> **Note**: Reasoning models emit `reasoning_content` with the trace.
**Non-streaming response format** (`stream: false`):
```json
{
"id": "chatcmpl-xxx",
"object": "chat.completion",
"created": 1738400000,
"model": "deepseek-reasoner",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Response text",
"reasoning_content": "Reasoning trace (reasoner only)"
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 50,
"total_tokens": 60,
"completion_tokens_details": {
"reasoning_tokens": 20
}
}
}
```
#### Tool Calling (Function Calling)
**Request example**:
```json
{
"model": "deepseek-chat",
"messages": [{"role": "user", "content": "What's the weather in Beijing?"}],
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather for a city",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "City name"}
},
"required": ["location"]
}
}
}]
}
```
**Response example**:
```json
{
"id": "chatcmpl-xxx",
"object": "chat.completion",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_xxx",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\": \"Beijing\"}"
}
}]
},
"finish_reason": "tool_calls"
}]
}
```
---
## Claude-Compatible API
### Claude Model List
```http
GET /anthropic/v1/models
```
**Response example**:
```json
{
"object": "list",
"data": [
{"id": "claude-sonnet-4-20250514", "object": "model", "owned_by": "anthropic"},
{"id": "claude-sonnet-4-20250514-fast", "object": "model", "owned_by": "anthropic"},
{"id": "claude-sonnet-4-20250514-slow", "object": "model", "owned_by": "anthropic"}
]
}
```
**Model mapping**:
| Claude Model | Actual | Notes |
|------------|--------|------|
| `claude-sonnet-4-20250514` | deepseek-chat | Standard mode |
| `claude-sonnet-4-20250514-fast` | deepseek-chat | Fast mode |
| `claude-sonnet-4-20250514-slow` | deepseek-reasoner | Reasoning mode |
---
### Claude Messages
```http
POST /anthropic/v1/messages
x-api-key: your-api-key
Content-Type: application/json
anthropic-version: 2023-06-01
```
**Parameters**:
| Parameter | Type | Required | Description |
|-----|------|:----:|------|
| `model` | string | ✅ | Model name |
| `max_tokens` | integer | ✅ | Max output tokens |
| `messages` | array | ✅ | Chat messages |
| `stream` | boolean | ❌ | Stream responses (default `false`) |
| `system` | string | ❌ | System prompt |
| `temperature` | number | ❌ | Temperature |
**Request example**:
```json
{
"model": "claude-sonnet-4-20250514",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "Hello, please introduce yourself."}
]
}
```
**Non-streaming response**:
```json
{
"id": "msg_xxx",
"type": "message",
"role": "assistant",
"content": [{
"type": "text",
"text": "Hello! I'm an AI assistant..."
}],
"model": "claude-sonnet-4-20250514",
"stop_reason": "end_turn",
"usage": {
"input_tokens": 10,
"output_tokens": 50
}
}
```
**Streaming response** (SSE):
```
event: message_start
data: {"type":"message_start","message":{"id":"msg_xxx","type":"message","role":"assistant","model":"claude-sonnet-4-20250514"}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":50}}
event: message_stop
data: {"type":"message_stop"}
```
---
### Token Counting
```http
POST /anthropic/v1/messages/count_tokens
x-api-key: your-api-key
Content-Type: application/json
```
**Request example**:
```json
{
"model": "claude-sonnet-4-20250514",
"messages": [
{"role": "user", "content": "Hello"}
]
}
```
**Response example**:
```json
{
"input_tokens": 5
}
```
---
## Admin API
All admin endpoints (except login) require `Authorization: Bearer <jwt-token>`.
### Login
```http
POST /admin/login
Content-Type: application/json
```
**Request body**:
```json
{
"key": "your-admin-key"
}
```
**Response**:
```json
{
"success": true,
"token": "jwt-token-string",
"expires_in": 86400
}
```
> Tokens are valid for 24 hours by default.
---
### Configuration
#### Get configuration
```http
GET /admin/config
Authorization: Bearer <jwt-token>
```
**Response**:
```json
{
"keys": ["api-key-1", "api-key-2"],
"accounts": [
{
"email": "user@example.com",
"password": "***",
"token": "session-token"
}
]
}
```
#### Update configuration
```http
POST /admin/config
Authorization: Bearer <jwt-token>
Content-Type: application/json
```
**Request body**:
```json
{
"keys": ["new-api-key"],
"accounts": [...]
}
```
---
### Account Management
#### Add account
```http
POST /admin/accounts
Authorization: Bearer <jwt-token>
Content-Type: application/json
```
**Request body**:
```json
{
"email": "user@example.com",
"password": "password123"
}
```
#### Batch import accounts
```http
POST /admin/accounts/batch
Authorization: Bearer <jwt-token>
Content-Type: application/json
```
**Request body**:
```json
{
"accounts": [
{"email": "user1@example.com", "password": "pass1"},
{"email": "user2@example.com", "password": "pass2"}
]
}
```
#### Test one account
```http
POST /admin/accounts/test
Authorization: Bearer <jwt-token>
Content-Type: application/json
```
**Request body**:
```json
{
"email": "user@example.com"
}
```
#### Test all accounts
```http
POST /admin/accounts/test-all
Authorization: Bearer <jwt-token>
```
#### Queue status
```http
GET /admin/queue/status
Authorization: Bearer <jwt-token>
```
**Response**:
```json
{
"total_accounts": 5,
"healthy_accounts": 4,
"queue_size": 10,
"accounts": [
{
"email": "user@example.com",
"status": "healthy",
"last_used": "2026-02-01T12:00:00Z"
}
]
}
```
---
### Vercel Sync
```http
POST /admin/vercel/sync
Authorization: Bearer <jwt-token>
Content-Type: application/json
```
**Request body** (first sync only):
```json
{
"vercel_token": "your-vercel-token",
"project_id": "your-project-id"
}
```
> After a successful first sync, credentials are stored for future syncs.
**Response**:
```json
{
"success": true,
"message": "Configuration synced to Vercel"
}
```
---
## Error Handling
All error responses follow this structure:
```json
{
"error": {
"message": "Error description",
"type": "error_type",
"code": "error_code"
}
}
```
**Common error codes**:
| HTTP Status | Error Type | Description |
|:----------:|---------|------|
| 400 | `invalid_request_error` | Invalid request parameters |
| 401 | `authentication_error` | Missing or invalid API key |
| 403 | `permission_denied` | Insufficient permissions |
| 429 | `rate_limit_error` | Too many requests |
| 500 | `internal_error` | Internal server error |
| 503 | `service_unavailable` | No available accounts |
---
## Examples
### Python (OpenAI SDK)
```python
from openai import OpenAI
client = OpenAI(
api_key="your-api-key",
base_url="https://your-domain.com/v1"
)
# Basic chat
response = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": "Hello"}]
)
print(response.choices[0].message.content)
# Streaming + reasoning
for chunk in client.chat.completions.create(
model="deepseek-reasoner",
messages=[{"role": "user", "content": "Explain relativity"}],
stream=True
):
delta = chunk.choices[0].delta
if hasattr(delta, 'reasoning_content') and delta.reasoning_content:
print(f"[Reasoning] {delta.reasoning_content}", end="")
if delta.content:
print(delta.content, end="")
```
### Python (Anthropic SDK)
```python
import anthropic
client = anthropic.Anthropic(
api_key="your-api-key",
base_url="https://your-domain.com/anthropic"
)
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello"}]
)
print(response.content[0].text)
```
### cURL
```bash
# OpenAI format
curl https://your-domain.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-api-key" \
-d '{
"model": "deepseek-chat",
"messages": [{"role": "user", "content": "Hello"}]
}'
# Claude format
curl https://your-domain.com/anthropic/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: your-api-key" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-sonnet-4-20250514",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
}'
```
### JavaScript / TypeScript
```javascript
// OpenAI format - streaming request
const response = await fetch('https://your-domain.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer your-api-key'
},
body: JSON.stringify({
model: 'deepseek-chat-search',
messages: [{ role: 'user', content: 'What is in the news today?' }],
stream: true
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n').filter(line => line.startsWith('data: '));
for (const line of lines) {
const data = line.slice(6);
if (data === '[DONE]') continue;
const json = JSON.parse(data);
const content = json.choices?.[0]?.delta?.content;
if (content) process.stdout.write(content);
}
}
```
### Node.js (OpenAI SDK)
```javascript
import OpenAI from 'openai';
const client = new OpenAI({
apiKey: 'your-api-key',
baseURL: 'https://your-domain.com/v1'
});
const stream = await client.chat.completions.create({
model: 'deepseek-reasoner',
messages: [{ role: 'user', content: 'Explain black holes' }],
stream: true
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content) process.stdout.write(content);
}
```

2
API.md
View File

@@ -1,5 +1,7 @@
# DS2API 接口文档
语言 / Language: [中文](API.md) | [English](API.en.md)
本文档详细介绍 DS2API 提供的所有 API 端点。
---

261
CLAUDE.local.md Normal file
View File

@@ -0,0 +1,261 @@
# Claude Code 行为准则
> 本文件定义 Claude Code 在本项目中的强制执行规则。所有规则均为**必须执行**,不可跳过。
---
## 一、核心原则(九荣九耻)
| 耻 | 荣 |
|---|---|
| ❌ 瞎猜接口 | ✅ 认真查询源码 |
| ❌ 模糊执行 | ✅ 寻求用户确认 |
| ❌ 臆想业务 | ✅ 复用现有实现 |
| ❌ 创造接口 | ✅ 主动测试验证 |
| ❌ 跳过验证 | ✅ 等待人类确认 |
| ❌ 破坏架构 | ✅ 遵循项目规范 |
| ❌ 假装理解 | ✅ 诚实说"不确定" |
| ❌ 盲目修改 | ✅ 谨慎重构 |
| ❌ 画蛇添足 | ✅ 按需实现 |
---
## 二、代码生成前置检查
### 【强制】生成代码前必须完成的 4 项检查
在生成**任何代码**之前,必须逐条确认以下检查项,**缺一不可**
| # | 检查项 | 未通过则 |
|---|--------|---------|
| 1 | 是否已读取 CLAUDE.md 中的编码规范? | ❌ 禁止生成代码 |
| 2 | 是否已搜索项目中类似实现作为参考? | ❌ 禁止生成代码 |
| 3 | 是否有不确定的地方需要询问用户? | ⚠️ 先询问再继续 |
| 4 | 是否复用了现有的实体类/工具类? | ❌ 禁止新建已存在的类 |
---
## 三、"参照 XX 写"执行规则
当用户说"参照XX写"、"仿照XX实现"、"按照XX的方式"时,**必须严格执行**以下步骤:
### 步骤清单
| # | 步骤 | 必须完成的动作 |
|---|------|--------------|
| 1 | 完整阅读参照对象 | 读取 Controller、Service、Mapper、Entity **所有相关文件**,不能只看部分 |
| 2 | 列出关键对照点 | 向用户列出接口路径、参数格式、返回值格式、Service 调用方式、业务逻辑 |
| 3 | 严格对照实现 | ❌ 禁止"优化"或"改进"参照对象,❌ 禁止偏离参照对象的风格 |
### 完成后自检
| # | 自检问题 | 答案必须是"是" |
|---|---------|--------------|
| 1 | 我的实现和参照对象的实现方式是否一致? | 否则必须修正 |
| 2 | 有没有任何地方是我"自作主张"改的? | 有则必须告知用户 |
**如果有任何偏离,必须告知用户并说明原因,由用户决定是否采用。**
---
## 四、批量保存接口设计规范
### 【强制】设计前必须列出用户操作场景
| 用户操作 | 数据特征 | 处理方式 |
|---------|---------|---------|
| 新增一条数据 | 传入的数据没有 id | INSERT |
| 修改一条数据 | 传入的数据有 id | UPDATE |
| 删除一条数据 | **数据库有但传入列表中没有** ← 容易遗漏! | DELETE |
| 不做任何改动 | 原样传回 | 不处理 |
### 正确实现步骤
```
1. 查询数据库中该主体已有的所有数据 ID
2. 对比传入列表中的 ID找出需要删除的数据库有但传入没有
3. 删除不在传入列表中的数据
4. 新增或更新传入列表中的数据
```
### 完成后自检
| # | 自检问题 | 答案必须是"是" |
|---|---------|--------------|
| 1 | 新增、更新、删除——三种情况都覆盖了吗? | |
| 2 | 如果用户删除了一条已有数据,保存后这条数据会消失吗? | |
---
## 五、文档解析规则
### 【强制】解析步骤(按顺序执行,不可跳过)
| # | 步骤 | 必须完成的动作 | 中断条件 |
|---|------|--------------|---------|
| 1 | 多方式解析 | Word/PDF 必须尝试 ≥2 种解析方式段落、表格、文本框、XML 等) | |
| 2 | 完整性检查 | 检查是否只看到类名而没有属性定义? | ⚠️ **是则停止,询问用户** |
| 3 | 列出清单 | 向用户列出:类数量+名称、每个类的属性数量+名称、方法数量+签名 | ⚠️ **等待用户确认** |
| 4 | 生成代码 | 只有用户明确确认后才能继续 | |
### 绝对禁止
| # | 禁止行为 |
|---|---------|
| 1 | ❌ 禁止在用户确认前生成任何代码 |
| 2 | ❌ 禁止自行补充或猜测文档中未明确写出的内容 |
| 3 | ❌ 禁止只用一种方式解析就认为解析完成 |
| 4 | ❌ 禁止看到类名/接口名却没有属性定义时继续执行 |
---
## 六、接口与参数分析规则
### 触发条件
- 分析接口映射关系(标准接口 → 内部接口)
- 分析参数映射关系
- 编写 DTO/Entity 字段定义
### 【强制】执行步骤
| # | 步骤 | 必须完成的动作 |
|---|------|--------------|
| 1 | 确认接口映射 | 阅读标准接口功能 → 搜索后端代码找**功能匹配**的内部接口(不是名称匹配!)→ 读 Controller 确认功能 |
| 2 | 确认参数映射 | 找到 @RequestBody 的类 → 读源码(含父类)→ 逐一列出字段 → 对比建立映射 |
### 映射可信度标注(必须标注)
| 标注 | 含义 |
|-----|------|
| ✅ 已验证 | 已阅读源码确认 |
| ⚠️ 待验证 | 需要进一步确认 |
| ❌ 需新建接口 | 需要编写复杂业务逻辑(组合调用多个接口等) |
### 绝对禁止
| # | 禁止行为 |
|---|---------|
| 1 | ❌ 禁止凭接口名称相似就认为可以映射 |
| 2 | ❌ 禁止直接使用 Postman/Swagger 参数定义,必须与源码核对 |
| 3 | ❌ 禁止凭"合理推测"编写参数映射 |
| 4 | ❌ 禁止使用模糊表述如"需要扩展"、"可能需要调用额外接口" |
---
## 七、Postman 文档规范
### 核心原则
| 位置 | 内容 |
|-----|------|
| `description` 字段 | Markdown 格式,展示完整参数说明(带注释的 JSON 代码块) |
| `body.raw` 字段 | 纯净 JSON无注释可直接发送请求 |
### description 格式模板
```json
{
"description": "接口功能说明。\n\n**请求参数示例:**\n```json\n{\n \"字段名\": \"示例值\", // 字段说明\n}\n```\n\n**响应示例:**\n```json\n{\n \"code\": 0,\n \"data\": {}\n}\n```"
}
```
### 自检清单
| # | 检查项 | 要求 |
|---|--------|-----|
| 1 | body.raw 是否有注释? | ❌ 禁止,会导致 JSON 格式错误 |
| 2 | description 是否展示了参数格式? | ✅ 必须有带注释的 JSON 示例 |
| 3 | 是否包含响应示例? | ✅ 每个接口都必须有 |
| 4 | Long 类型 ID 是否展示为 String | ✅ 如 `"id": "123456789"` |
---
## 八、设计文档编写规范
### 核心原则
设计文档的目标是:**开发人员可以直接照着写代码**,不是概念性说明。
### 【强制】文档必须包含的内容
| # | 内容 | 要求 |
|---|------|-----|
| 1 | 数据库表 DDL | 可直接执行的 CREATE TABLE |
| 2 | 枚举类代码 | 可直接复制使用 |
| 3 | 实体类代码 | 包括所有字段和注解 |
| 4 | Mapper 代码 | 包括 Provider 中的完整 SQL |
| 5 | Service 代码 | 接口定义和实现类 |
| 6 | Controller 代码 | 接口路径、请求体、响应格式 |
| 7 | 实现清单 | 新模块接入时的检查表 |
| 8 | 常见问题 FAQ | 解答可能的疑惑 |
### 代码示例要求
| # | 要求 |
|---|------|
| 1 | 代码必须**完整可用**,不是片段或伪代码 |
| 2 | 必须包含**完整的 import 语句** |
| 3 | SQL 必须**完整可执行**,不能用 `...` 省略 |
### 完成后自检
| # | 自检问题 | 答案必须是"是" |
|---|---------|--------------|
| 1 | 新人开发者能否只看这份文档就完成开发? | |
| 2 | 文档中的代码能否直接复制到项目中使用? | |
| 3 | 是否有"等"、"..."、"类似"等模糊表述? | 有则删除 |
---
## 九、方法重载规范
### 规则
| # | 规则 | 说明 |
|---|------|-----|
| 1 | 全量参数方法承载所有逻辑 | 是唯一的实现体 |
| 2 | 少参数方法只做委托调用 | 传 `null` 给新增参数,方法体只有一行 `return` |
| 3 | ❌ 禁止两个重载方法各写一份逻辑 | 即使逻辑相同也不行 |
| 4 | ❌ 禁止反向委托 | 全量方法不能调用少参数方法 |
### 正确示例
```java
// ✅ 少参数方法委托全量方法
public Object foo(Req req, Request request, Response response) {
return foo(req, request, response, null);
}
public Object foo(Req req, Request request, Response response, Function<String, String> lineConverter) {
// 所有逻辑在这里
if (lineConverter != null) {
// 有转换器时的处理
}
}
```
---
## 十、工作偏好
| # | 偏好 |
|---|------|
| 1 | 始终使用**简体中文**回复 |
| 2 | 长任务必须记录详细进度 |
| 3 | 提交代码时**不要**附带 `Co-Authored-By: Claude` |
| 4 | 对所有工具操作自动同意,无需额外确认 |
| 5 | 不用执行编译和测试 |
| 6 | 编写构建脚本时尽量使用 mjs 编写带菜单的脚本 |
| 7 | 尽量使用 Python 连接数据库 |
| 8 | 联网搜索时**禁止**使用 csdn.net、阿里云/腾讯云/华为云社区等内容农场 |
---
## 十一、代码生成规则
| # | 规则 |
|---|------|
| 1 | 提供实体类/模板/文档时,必须**完整复制所有属性和方法**,禁止省略 |
| 2 | 生成代码前,先列出文档中所有属性数量和名称,确认无遗漏后再生成 |
| 3 | 属性超过 20 个时,分批列出确认 |
| 4 | 禁止因为"优化"或"简化"而删减任何属性 |
| 5 | 生成完成后,对比源文档属性数量是否一致 |

94
CONTRIBUTING.en.md Normal file
View File

@@ -0,0 +1,94 @@
# Contributing Guide
Language: [中文](CONTRIBUTING.md) | [English](CONTRIBUTING.en.md)
Thank you for contributing to DS2API!
## Development Setup
### Backend
```bash
# 1. Clone the repo
git clone https://github.com/CJackHwang/ds2api.git
cd ds2api
# 2. Create a virtual environment (recommended)
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 3. Install dependencies
pip install -r requirements.txt
# 4. Configure
cp config.example.json config.json
# Edit config.json
# 5. Run
python dev.py
```
### Frontend (WebUI)
```bash
cd webui
npm install
npm run dev
```
WebUI language packs live in `webui/src/locales/`. Add new locale JSON files there.
## Code Standards
- **Python**: Follow PEP 8, use 4-space indentation
- **JavaScript/React**: Use 4-space indentation and function components
- **Commit messages**: Use semantic prefixes (e.g. `feat:`, `fix:`, `docs:`)
## Submitting a PR
1. Fork this repo
2. Create a feature branch (`git checkout -b feature/xxx`)
3. Commit your changes (`git commit -m 'feat: add xxx'`)
4. Push your branch (`git push origin feature/xxx`)
5. Open a Pull Request
## WebUI Build
> **Important**: After modifying `webui/`, **no manual build is required**.
When a PR is merged into `main`, GitHub Actions will automatically:
1. Build the WebUI
2. Commit build artifacts to `static/admin/`
If you need a local build (for testing):
```bash
./scripts/build-webui.sh
```
## Project Structure
```
ds2api/
├── app.py # FastAPI entrypoint
├── dev.py # Development server
├── core/ # Core modules
│ ├── auth.py # Account auth & rotation
│ ├── config.py # Configuration management
│ ├── deepseek.py # DeepSeek API calls
│ ├── models.py # Model definitions
│ ├── pow.py # PoW calculations
│ └── sse_parser.py # SSE parsing
├── routes/ # API routes
│ ├── openai.py # OpenAI-compatible endpoints
│ ├── claude.py # Claude-compatible endpoints
│ ├── home.py # Landing page routes
│ └── admin/ # Admin endpoints
├── webui/ # React WebUI source
├── static/admin/ # WebUI build output (auto-generated)
└── scripts/ # Helper scripts
```
## Reporting Issues
- Use [GitHub Issues](https://github.com/CJackHwang/ds2api/issues)
- Provide detailed reproduction steps and logs

View File

@@ -1,5 +1,7 @@
# 贡献指南
语言 / Language: [中文](CONTRIBUTING.md) | [English](CONTRIBUTING.en.md)
感谢你对 DS2API 的贡献!
## 开发环境设置
@@ -34,6 +36,8 @@ npm install
npm run dev
```
WebUI 语言包位于 `webui/src/locales/`,新增语言请在此处添加对应 JSON 文件。
## 代码规范
- **Python**: 遵循 PEP 8使用 4 空格缩进

410
DEPLOY.en.md Normal file
View File

@@ -0,0 +1,410 @@
# DS2API Deployment Guide
Language: [中文](DEPLOY.md) | [English](DEPLOY.en.md)
This document covers all supported DS2API deployment methods.
---
## Table of Contents
- [Vercel Deployment (Recommended)](#vercel-deployment-recommended)
- [Docker Deployment (Recommended)](#docker-deployment-recommended)
- [Local Development](#local-development)
- [Production Deployment](#production-deployment)
- [FAQ](#faq)
---
## Vercel Deployment (Recommended)
### One-click deployment
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api&env=DS2API_ADMIN_KEY&envDescription=Admin%20console%20access%20key%20%28required%29&envLink=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api%23environment-variables&project-name=ds2api&repository-name=ds2api)
### Steps
1. **Click the deploy button**
- Sign in to GitHub
- Authorize Vercel access
2. **Set environment variables**
- `DS2API_ADMIN_KEY`: Admin console password (**required**)
3. **Wait for deployment**
- Vercel builds and deploys automatically
- You will receive a deployment URL
4. **Configure accounts**
- Visit `https://your-project.vercel.app/admin`
- Log in with the admin key
- Add DeepSeek accounts
- Set custom API keys
5. **Sync configuration**
- Click "Sync to Vercel"
- The first sync requires a Vercel token and project ID
- After sync, the configuration is persisted
### Get Vercel credentials
**Vercel token**:
1. Visit https://vercel.com/account/tokens
2. Click "Create Token"
3. Set a name and expiration
4. Copy the token
**Project ID**:
1. Open your Vercel project
2. Go to Settings → General
3. Copy the "Project ID"
---
## Local Development
### Requirements
- Python 3.9+
- Node.js 18+ (WebUI development)
- pip
### Quick start
```bash
# 1. Clone the repo
git clone https://github.com/CJackHwang/ds2api.git
cd ds2api
# 2. Install Python dependencies
pip install -r requirements.txt
# 3. Configure accounts
cp config.example.json config.json
# Edit config.json and fill in DeepSeek account info
# 4. Start the service
python dev.py
```
### Config example
```json
{
"keys": ["my-api-key-1", "my-api-key-2"],
"accounts": [
{
"email": "your-email@example.com",
"password": "your-password",
"token": ""
},
{
"mobile": "12345678901",
"password": "your-password",
"token": ""
}
]
}
```
**Notes**:
- `keys`: Custom API keys for calling the service
- `accounts`: DeepSeek Web accounts
- Supports `email` or `mobile` login
- Leave `token` blank; it will be fetched automatically
### WebUI development
```bash
# Enter the WebUI directory
cd webui
# Install dependencies
npm install
# Start the dev server
npm run dev
```
The WebUI dev server runs on `http://localhost:5173` and proxies API requests to `http://localhost:5001`.
### WebUI build
Build artifacts are located in `static/admin/`.
**Automatic build (recommended)**:
- Vercel builds the WebUI during deployment (see `vercel.json` `buildCommand`)
- The GitHub Actions WebUI build workflow is disabled
- `static/admin/` build artifacts are no longer committed
**Manual build**:
```bash
# Option 1: use script
./scripts/build-webui.sh
# Option 2: run directly
cd webui
npm install
npm run build
```
> **Contributor note**: No manual build is required after modifying WebUI; Vercel deploys will build it automatically.
---
## Docker Deployment (Recommended)
Docker uses a **non-invasive, decoupled design**:
- Dockerfile executes standard Python steps and avoids hardcoded project configs
- WebUI is built during image build (for non-Vercel deployments)
- Configuration lives in environment variables and `.env`
- **Rebuild the image to update code without touching Docker config**
### Quick start (Docker Compose)
```bash
# 1. Copy the environment template
cp .env.example .env
# Edit .env with DS2API_ADMIN_KEY and DS2API_CONFIG_JSON
# 2. Start the service
docker-compose up -d
# 3. Check logs
docker-compose logs -f
# 4. Rebuild after code updates
docker-compose up -d --build
```
### Mount a config file
To use `config.json` instead of environment variables:
```yaml
# docker-compose.yml
services:
ds2api:
build: .
ports:
- "5001:5001"
environment:
- DS2API_ADMIN_KEY=your-admin-key
volumes:
- ./config.json:/app/config.json:ro
restart: unless-stopped
```
### Docker CLI deployment
```bash
# Build the image
docker build -t ds2api:latest .
# Run with env variables
docker run -d \
--name ds2api \
-p 5001:5001 \
-e DS2API_ADMIN_KEY=your-admin-key \
-e DS2API_CONFIG_JSON='{"keys":["api-key"],"accounts":[...]}' \
--restart unless-stopped \
ds2api:latest
# Or mount a config file
docker run -d \
--name ds2api \
-p 5001:5001 \
-e DS2API_ADMIN_KEY=your-admin-key \
-v $(pwd)/config.json:/app/config.json:ro \
--restart unless-stopped \
ds2api:latest
```
### Development mode (hot reload)
```bash
# Use the dev compose file to enable hot reload
docker-compose -f docker-compose.dev.yml up
```
Development mode:
- Source code is mounted into the container
- Log level set to DEBUG
- Reads local `config.json`
### Maintenance commands
```bash
# Check container status
docker-compose ps
# View logs
docker-compose logs -f ds2api
# Restart
docker-compose restart
# Stop
docker-compose down
# Full rebuild (clear cache)
docker-compose down
docker-compose build --no-cache
docker-compose up -d
```
---
## Production Deployment
### Using systemd (Linux)
1. **Create the service file**
```bash
sudo nano /etc/systemd/system/ds2api.service
```
```ini
[Unit]
Description=DS2API Service
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/ds2api
ExecStart=/usr/bin/python3 app.py
Restart=always
RestartSec=10
Environment=PORT=5001
Environment=DS2API_ADMIN_KEY=your-admin-key
[Install]
WantedBy=multi-user.target
```
2. **Start the service**
```bash
sudo systemctl daemon-reload
sudo systemctl enable ds2api
sudo systemctl start ds2api
```
3. **Check status**
```bash
sudo systemctl status ds2api
sudo journalctl -u ds2api -f
```
### Nginx reverse proxy
```nginx
server {
listen 80;
server_name api.yourdomain.com;
# SSL configuration (recommended)
# listen 443 ssl http2;
# ssl_certificate /path/to/cert.pem;
# ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
# Disable buffering for SSE
proxy_buffering off;
proxy_cache off;
# Connection settings
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE timeouts
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# Chunked transfer
chunked_transfer_encoding on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 120;
}
}
```
---
## FAQ
### Q: What if account validation fails?
**A**: Check the following:
1. Confirm the DeepSeek account password is correct
2. Ensure the account is not banned or requires verification
3. Log in once in a browser
4. Check logs for detailed errors
### Q: Streaming responses disconnect?
**A**:
1. Check Nginx / reverse proxy config and ensure `proxy_buffering` is off
2. Increase `proxy_read_timeout`
3. Verify network stability
### Q: Configuration lost after Vercel deploy?
**A**:
1. Ensure you clicked "Sync to Vercel"
2. Verify the Vercel token is valid and unexpired
3. Ensure the project ID is correct
### Q: How to update to the latest version?
**Local deployment**:
```bash
git pull origin main
pip install -r requirements.txt
# Restart the service
```
**Docker deployment**:
```bash
# Pull the latest code
git pull origin main
# Rebuild and start (Docker config unchanged)
docker-compose up -d --build
```
**Vercel deployment**:
- The project auto-syncs from GitHub
- Or trigger a redeploy in the Vercel console
### Q: How do I view logs?
**Local dev**:
```bash
# Set log level
export LOG_LEVEL=DEBUG
python dev.py
```
**Vercel**:
- Vercel console → Project → Deployments → Logs
### Q: Token counting is inaccurate?
**A**: DS2API uses a heuristic estimate (characters / 4). The official OpenAI tokenizer may differ, so treat it as a reference only.
---
## Get Help
- **GitHub Issues**: https://github.com/CJackHwang/ds2api/issues
- **Docs**: https://github.com/CJackHwang/ds2api

729
DEPLOY.md
View File

@@ -1,319 +1,410 @@
# DS2API 部署指南
本文档详细介绍 DS2API 的各种部署方式。
---
## 目录
- [Vercel 部署(推荐)](#vercel-部署推荐)
- [本地开发](#本地开发)
- [生产环境部署](#生产环境部署)
- [常见问题](#常见问题)
---
## Vercel 部署(推荐)
### 一键部署
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api&env=DS2API_ADMIN_KEY&envDescription=管理面板访问密码(必填)&envLink=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api%23环境变量&project-name=ds2api&repository-name=ds2api)
### 部署步骤
1. **点击部署按钮**
- 登录你的 GitHub 账号
- 授权 Vercel 访问
2. **设置环境变量**
- `DS2API_ADMIN_KEY`: 管理面板密码(**必填**
3. **等待部署完成**
- Vercel 会自动构建并部署项目
- 部署完成后获得访问 URL
4. **配置账号**
- 访问 `https://your-project.vercel.app/admin`
- 输入管理密码登录
- 添加 DeepSeek 账号
- 设置自定义 API Key
5. **同步配置**
- 点击「同步到 Vercel」按钮
- 首次需要输入 Vercel Token 和 Project ID
- 同步成功后配置会持久化
### 获取 Vercel 凭证
**Vercel Token**:
1. 访问 https://vercel.com/account/tokens
2. 点击 "Create Token"
3. 设置名称和有效期
4. 复制生成的 Token
**Project ID**:
1. 进入 Vercel 项目页面
2. 点击 Settings -> General
3. 复制 "Project ID"
---
## 本地开发
### 环境要求
- Python 3.9+
- Node.js 18+ (WebUI 开发)
- pip
### 快速开始
```bash
# 1. 克隆项目
git clone https://github.com/CJackHwang/ds2api.git
cd ds2api
# 2. 安装 Python 依赖
pip install -r requirements.txt
# 3. 配置账号
cp config.example.json config.json
# 编辑 config.json填入 DeepSeek 账号信息
# 4. 启动服务
python dev.py
```
### 配置文件示例
```json
{
"keys": ["my-api-key-1", "my-api-key-2"],
"accounts": [
{
"email": "your-email@example.com",
"password": "your-password",
"token": ""
},
{
"mobile": "12345678901",
"password": "your-password",
"token": ""
}
]
}
```
**说明**
- `keys`: 自定义 API Key用于调用本服务的接口
- `accounts`: DeepSeek 网页版账号
- 支持 `email``mobile` 登录
- `token` 留空,系统会自动获取
### WebUI 开发
```bash
# 进入 WebUI 目录
cd webui
# 安装依赖
npm install
# 启动开发服务器
npm run dev
```
WebUI 开发服务器会启动在 `http://localhost:5173`,并自动代理 API 请求到后端 `http://localhost:5001`
---
## 生产环境部署
### 使用 systemd (Linux)
1. **创建服务文件**
```bash
sudo nano /etc/systemd/system/ds2api.service
```
```ini
[Unit]
Description=DS2API Service
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/ds2api
ExecStart=/usr/bin/python3 app.py
Restart=always
RestartSec=10
Environment=PORT=5001
Environment=DS2API_ADMIN_KEY=your-admin-key
[Install]
WantedBy=multi-user.target
```
2. **启动服务**
```bash
sudo systemctl daemon-reload
sudo systemctl enable ds2api
sudo systemctl start ds2api
```
3. **查看状态**
```bash
sudo systemctl status ds2api
sudo journalctl -u ds2api -f
```
### Nginx 反向代理
```nginx
server {
listen 80;
server_name api.yourdomain.com;
# SSL 配置(推荐)
# listen 443 ssl http2;
# ssl_certificate /path/to/cert.pem;
# ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
# 关闭缓冲,支持 SSE
proxy_buffering off;
proxy_cache off;
# 连接设置
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE 超时设置
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# 分块传输
chunked_transfer_encoding on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 120;
}
}
```
### Docker 部署(可选)
```dockerfile
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5001
CMD ["python", "app.py"]
```
```bash
# 构建镜像
docker build -t ds2api .
# 运行容器
docker run -d \
--name ds2api \
-p 5001:5001 \
-e DS2API_ADMIN_KEY=your-admin-key \
-e DS2API_CONFIG_JSON='{"keys":["api-key"],"accounts":[...]}' \
ds2api
```
### Docker Compose
```yaml
# docker-compose.yml
version: '3.8'
services:
ds2api:
build: .
ports:
- "5001:5001"
environment:
- DS2API_ADMIN_KEY=${DS2API_ADMIN_KEY}
- DS2API_CONFIG_JSON=${DS2API_CONFIG_JSON}
restart: unless-stopped
```
---
## 常见问题
### Q: 账号验证失败怎么办?
**A**: 检查以下几点:
1. 确认 DeepSeek 账号密码正确
2. 检查账号是否被封禁或需要验证
3. 尝试在浏览器中手动登录一次
4. 查看日志获取详细错误信息
### Q: 流式响应断开怎么办?
**A**:
1. 检查 Nginx/反向代理配置,确保关闭了 `proxy_buffering`
2. 增加 `proxy_read_timeout` 超时时间
3. 检查网络连接稳定性
### Q: Vercel 部署后配置丢失?
**A**:
1. 确保点击了「同步到 Vercel」按钮
2. 检查 Vercel Token 是否正确且未过期
3. 确认 Project ID 正确
### Q: 如何更新到新版本?
**本地部署**:
```bash
git pull origin main
pip install -r requirements.txt
# 重启服务
```
**Vercel 部署**:
- 项目会自动从 GitHub 同步更新
- 或在 Vercel 控制台手动触发重新部署
### Q: 如何查看日志?
**本地开发**:
```bash
# 设置日志级别
export LOG_LEVEL=DEBUG
python dev.py
```
**Vercel**:
- 访问 Vercel 控制台 -> 项目 -> Deployments -> Logs
### Q: Token 计数不准确?
**A**: DS2API 使用估算方式计算 token 数量(字符数 / 4与 OpenAI 官方的 tokenizer 可能有差异,仅供参考。
---
## 获取帮助
- **GitHub Issues**: https://github.com/CJackHwang/ds2api/issues
- **文档**: https://github.com/CJackHwang/ds2api
# DS2API 部署指南
语言 / Language: [中文](DEPLOY.md) | [English](DEPLOY.en.md)
本文档详细介绍 DS2API 的各种部署方式。
---
## 目录
- [Vercel 部署(推荐)](#vercel-部署推荐)
- [Docker 部署(推荐)](#docker-部署推荐)
- [本地开发](#本地开发)
- [生产环境部署](#生产环境部署)
- [常见问题](#常见问题)
---
## Vercel 部署(推荐)
### 一键部署
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api&env=DS2API_ADMIN_KEY&envDescription=管理面板访问密码(必填)&envLink=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api%23环境变量&project-name=ds2api&repository-name=ds2api)
### 部署步骤
1. **点击部署按钮**
- 登录你的 GitHub 账号
- 授权 Vercel 访问
2. **设置环境变量**
- `DS2API_ADMIN_KEY`: 管理面板密码(**必填**
3. **等待部署完成**
- Vercel 会自动构建并部署项目
- 部署完成后获得访问 URL
4. **配置账号**
- 访问 `https://your-project.vercel.app/admin`
- 输入管理密码登录
- 添加 DeepSeek 账号
- 设置自定义 API Key
5. **同步配置**
- 点击「同步到 Vercel」按钮
- 首次需要输入 Vercel Token 和 Project ID
- 同步成功后配置会持久化
### 获取 Vercel 凭证
**Vercel Token**:
1. 访问 https://vercel.com/account/tokens
2. 点击 "Create Token"
3. 设置名称和有效期
4. 复制生成的 Token
**Project ID**:
1. 进入 Vercel 项目页面
2. 点击 Settings -> General
3. 复制 "Project ID"
---
## 本地开发
### 环境要求
- Python 3.9+
- Node.js 18+ (WebUI 开发)
- pip
### 快速开始
```bash
# 1. 克隆项目
git clone https://github.com/CJackHwang/ds2api.git
cd ds2api
# 2. 安装 Python 依赖
pip install -r requirements.txt
# 3. 配置账号
cp config.example.json config.json
# 编辑 config.json填入 DeepSeek 账号信息
# 4. 启动服务
python dev.py
```
### 配置文件示例
```json
{
"keys": ["my-api-key-1", "my-api-key-2"],
"accounts": [
{
"email": "your-email@example.com",
"password": "your-password",
"token": ""
},
{
"mobile": "12345678901",
"password": "your-password",
"token": ""
}
]
}
```
**说明**
- `keys`: 自定义 API Key用于调用本服务的接口
- `accounts`: DeepSeek 网页版账号
- 支持 `email``mobile` 登录
- `token` 留空,系统会自动获取
### WebUI 开发
```bash
# 进入 WebUI 目录
cd webui
# 安装依赖
npm install
# 启动开发服务器
npm run dev
```
WebUI 开发服务器会启动在 `http://localhost:5173`,并自动代理 API 请求到后端 `http://localhost:5001`
### WebUI 构建
WebUI 构建产物位于 `static/admin/` 目录。
**自动构建(推荐)**
- 当前由 Vercel 在部署时执行 WebUI 构建(见 `vercel.json``buildCommand`
- GitHub Actions 的 WebUI 自动构建流程已关闭
- `static/admin/` 构建产物不再提交到仓库
**手动构建**
```bash
# 方式1使用脚本
./scripts/build-webui.sh
# 方式2直接执行
cd webui
npm install
npm run build
```
> **贡献者注意**:修改 WebUI 后无需手动构建Vercel 部署会自动构建。
---
## Docker 部署(推荐)
Docker 部署采用**零侵入、解耦设计**
- Dockerfile 仅执行标准 Python 项目操作,不硬编码任何项目特定配置
- 构建镜像时会一并构建 WebUI便于非 Vercel 部署直接访问管理面板)
- 所有配置通过环境变量和 `.env` 文件管理
- **主代码更新时只需重新构建镜像,无需修改 Docker 配置**
### 快速开始Docker Compose
```bash
# 1. 复制环境变量模板
cp .env.example .env
# 编辑 .env填写 DS2API_ADMIN_KEY 和 DS2API_CONFIG_JSON
# 2. 启动服务
docker-compose up -d
# 3. 查看日志
docker-compose logs -f
# 4. 主代码更新后重新构建
docker-compose up -d --build
```
### 配置文件挂载方式
如需使用 `config.json` 而非环境变量:
```yaml
# docker-compose.yml
services:
ds2api:
build: .
ports:
- "5001:5001"
environment:
- DS2API_ADMIN_KEY=your-admin-key
volumes:
- ./config.json:/app/config.json:ro
restart: unless-stopped
```
### Docker 命令行部署
```bash
# 构建镜像
docker build -t ds2api:latest .
# 使用环境变量运行
docker run -d \
--name ds2api \
-p 5001:5001 \
-e DS2API_ADMIN_KEY=your-admin-key \
-e DS2API_CONFIG_JSON='{"keys":["api-key"],"accounts":[...]}' \
--restart unless-stopped \
ds2api:latest
# 或使用配置文件挂载
docker run -d \
--name ds2api \
-p 5001:5001 \
-e DS2API_ADMIN_KEY=your-admin-key \
-v $(pwd)/config.json:/app/config.json:ro \
--restart unless-stopped \
ds2api:latest
```
### 开发模式(热重载)
```bash
# 使用开发配置启动,代码修改实时生效
docker-compose -f docker-compose.dev.yml up
```
开发模式特性:
- 源代码挂载到容器,修改即时生效
- 日志级别设为 DEBUG
- 自动读取本地 `config.json`
### 维护命令
```bash
# 查看容器状态
docker-compose ps
# 查看日志
docker-compose logs -f ds2api
# 重启服务
docker-compose restart
# 停止服务
docker-compose down
# 完全重建(清除缓存)
docker-compose down
docker-compose build --no-cache
docker-compose up -d
```
---
## 生产环境部署
### 使用 systemd (Linux)
1. **创建服务文件**
```bash
sudo nano /etc/systemd/system/ds2api.service
```
```ini
[Unit]
Description=DS2API Service
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/ds2api
ExecStart=/usr/bin/python3 app.py
Restart=always
RestartSec=10
Environment=PORT=5001
Environment=DS2API_ADMIN_KEY=your-admin-key
[Install]
WantedBy=multi-user.target
```
2. **启动服务**
```bash
sudo systemctl daemon-reload
sudo systemctl enable ds2api
sudo systemctl start ds2api
```
3. **查看状态**
```bash
sudo systemctl status ds2api
sudo journalctl -u ds2api -f
```
### Nginx 反向代理
```nginx
server {
listen 80;
server_name api.yourdomain.com;
# SSL 配置(推荐)
# listen 443 ssl http2;
# ssl_certificate /path/to/cert.pem;
# ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
# 关闭缓冲,支持 SSE
proxy_buffering off;
proxy_cache off;
# 连接设置
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE 超时设置
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# 分块传输
chunked_transfer_encoding on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 120;
}
}
```
---
## 常见问题
### Q: 账号验证失败怎么办?
**A**: 检查以下几点:
1. 确认 DeepSeek 账号密码正确
2. 检查账号是否被封禁或需要验证
3. 尝试在浏览器中手动登录一次
4. 查看日志获取详细错误信息
### Q: 流式响应断开怎么办?
**A**:
1. 检查 Nginx/反向代理配置,确保关闭了 `proxy_buffering`
2. 增加 `proxy_read_timeout` 超时时间
3. 检查网络连接稳定性
### Q: Vercel 部署后配置丢失?
**A**:
1. 确保点击了「同步到 Vercel」按钮
2. 检查 Vercel Token 是否正确且未过期
3. 确认 Project ID 正确
### Q: 如何更新到新版本?
**本地部署**:
```bash
git pull origin main
pip install -r requirements.txt
# 重启服务
```
**Docker 部署**:
```bash
# 拉取最新代码
git pull origin main
# 重新构建并启动(无需修改 Docker 配置)
docker-compose up -d --build
```
**Vercel 部署**:
- 项目会自动从 GitHub 同步更新
- 或在 Vercel 控制台手动触发重新部署
### Q: 如何查看日志?
**本地开发**:
```bash
# 设置日志级别
export LOG_LEVEL=DEBUG
python dev.py
```
**Vercel**:
- 访问 Vercel 控制台 -> 项目 -> Deployments -> Logs
### Q: Token 计数不准确?
**A**: DS2API 使用估算方式计算 token 数量(字符数 / 4与 OpenAI 官方的 tokenizer 可能有差异,仅供参考。
---
## 获取帮助
- **GitHub Issues**: https://github.com/CJackHwang/ds2api/issues
- **文档**: https://github.com/CJackHwang/ds2api

33
Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# DS2API Docker 镜像
# 采用极简、零侵入设计,所有配置通过环境变量传递
# 主代码更新时只需重新构建镜像,无需修改 Dockerfile
FROM node:20 AS webui-builder
WORKDIR /app/webui
COPY webui/package.json webui/package-lock.json ./
RUN npm ci
COPY webui ./
RUN npm run build
FROM python:3.11-slim
WORKDIR /app
# 安装依赖(利用 Docker 缓存层)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 复制整个项目(保留原始目录结构)
COPY . .
# 拷贝 WebUI 构建产物(非 Vercel / Docker 部署可直接使用)
COPY --from=webui-builder /app/static/admin /app/static/admin
# 暴露服务端口
EXPOSE 5001
# 启动命令(依赖项目自身的启动逻辑)
CMD ["python", "app.py"]

View File

@@ -4,15 +4,26 @@
![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg)
![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg)
[![Version](https://img.shields.io/badge/version-1.6.11-blue.svg)](version.txt)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.md#docker-部署推荐)
语言 / Language: [中文](README.MD) | [English](README.en.md)
将 DeepSeek 免费对话版转换为 **OpenAI & Claude 兼容 API**,支持多账号轮询、自动 Token 刷新、可视化管理界面。
![p1](https://github.com/user-attachments/assets/07296a50-50d4-4f05-a9e5-280df14e9532)
![p2](https://github.com/user-attachments/assets/03b4a763-766f-4050-aea8-1a183e70ae6a)
![p3](https://github.com/user-attachments/assets/fc8b9836-11e3-4c38-a684-eb2c79b80fe9)
![p4](https://github.com/user-attachments/assets/513e9ca7-aa9e-45a6-8f7e-f362b1650675)
## ✨ 特性
- 🔄 **双协议兼容** - 同时支持 OpenAI 和 Claude (Anthropic) API 格式
- 🚀 **多账号轮询** - Round-Robin 负载均衡,支持高并发场景
- 🔐 **Token 自动刷新** - 过期自动重新登录,无需手动维护
- 🌐 **WebUI 管理** - 可视化添加账号、测试 API、同步 Vercel 配置
- 🌍 **多语言切换** - WebUI 内置中英双语,可随时切换
- 🔍 **联网搜索** - 支持 DeepSeek 原生搜索增强模式
- 🧠 **深度思考** - 支持推理模式,输出思考过程
- 🛠️ **工具调用** - 兼容 OpenAI Function Calling 格式
@@ -184,17 +195,26 @@ location / {
}
```
### Docker 部署(可选)
### 方式三:Docker 部署
```bash
# 使用环境变量配置
docker run -d \
-p 5001:5001 \
-e DS2API_ADMIN_KEY=your-admin-key \
-e DS2API_CONFIG_JSON='{"keys":["api-key"],"accounts":[...]}' \
ds2api
# 1. 克隆仓库并进入目录
git clone https://github.com/CJackHwang/ds2api.git
cd ds2api
# 2. 配置环境变量
cp .env.example .env
# 编辑 .env填写 DS2API_ADMIN_KEY 和 DS2API_CONFIG_JSON
# 3. 启动服务
docker-compose up -d
# 4. 查看日志
docker-compose logs -f
```
> **Docker 优势**:零侵入设计,主代码更新只需 `docker-compose up -d --build`,无需修改 Docker 配置。详见 [DEPLOY.md](DEPLOY.md#docker-部署推荐)。
## ⚠️ 免责声明
**本项目基于逆向工程实现,服务稳定性无法保证。**

233
README.en.md Normal file
View File

@@ -0,0 +1,233 @@
# DS2API
[![License](https://img.shields.io/github/license/CJackHwang/ds2api.svg)](LICENSE)
![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg)
![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg)
[![Version](https://img.shields.io/badge/version-1.6.11-blue.svg)](version.txt)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.md#docker-deployment-recommended)
Language: [中文](README.MD) | [English](README.en.md)
Convert DeepSeek Web into an **OpenAI & Claude compatible API**, with multi-account rotation, automatic token refresh, and a visual admin console.
![p1](https://github.com/user-attachments/assets/07296a50-50d4-4f05-a9e5-280df14e9532)
![p2](https://github.com/user-attachments/assets/03b4a763-766f-4050-aea8-1a183e70ae6a)
![p3](https://github.com/user-attachments/assets/fc8b9836-11e3-4c38-a684-eb2c79b80fe9)
![p4](https://github.com/user-attachments/assets/513e9ca7-aa9e-45a6-8f7e-f362b1650675)
## ✨ Features
- 🔄 **Dual-protocol support** - OpenAI and Claude (Anthropic) compatible APIs
- 🚀 **Multi-account rotation** - Round-robin load balancing for high concurrency
- 🔐 **Automatic token refresh** - Re-auth on expiry without manual maintenance
- 🌐 **WebUI management** - Add accounts, test APIs, and sync Vercel settings visually
- 🌍 **Language toggle** - Built-in Chinese and English UI switcher
- 🔍 **Web search** - DeepSeek native search enhancement mode
- 🧠 **Deep reasoning** - Reasoning mode with trace output
- 🛠️ **Tool calling** - OpenAI Function Calling compatible
- ☁️ **One-click Vercel deploy** - No server required
## 📋 Model Support
### OpenAI compatible endpoint (`/v1/chat/completions`)
| Model | Reasoning | Search | Notes |
|-----|:--------:|:------:|------|
| `deepseek-chat` | ❌ | ❌ | Standard chat |
| `deepseek-reasoner` | ✅ | ❌ | Reasoning (shows trace) |
| `deepseek-chat-search` | ❌ | ✅ | Web search mode |
| `deepseek-reasoner-search` | ✅ | ✅ | Reasoning + search |
### Claude compatible endpoint (`/anthropic/v1/messages`)
| Model | Notes |
|-----|------|
| `claude-sonnet-4-20250514` | Maps to deepseek-chat (standard) |
| `claude-sonnet-4-20250514-fast` | Maps to deepseek-chat (fast) |
| `claude-sonnet-4-20250514-slow` | Maps to deepseek-reasoner (reasoning) |
> **Tip**: The Claude endpoint actually calls DeepSeek and returns Anthropic-format responses.
## 🚀 Quick Start
### Option 1: Vercel deployment (recommended)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api&env=DS2API_ADMIN_KEY&envDescription=Admin%20console%20access%20key%20%28required%29&envLink=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api%23environment-variables&project-name=ds2api&repository-name=ds2api)
1. Click the button above and set `DS2API_ADMIN_KEY`
2. After deployment, visit `/admin`
3. Add DeepSeek accounts and custom API keys
4. Click "Sync to Vercel" to persist configuration
> **First sync validates accounts and stores tokens automatically.**
### Option 2: Local development
```bash
# 1. Clone the repo
git clone https://github.com/CJackHwang/ds2api.git
cd ds2api
# 2. Install dependencies
pip install -r requirements.txt
# 3. Configure accounts
cp config.example.json config.json
# Edit config.json to add DeepSeek account info
# 4. Start the service
python dev.py
```
Visit `http://localhost:5001` after startup.
## ⚙️ Configuration
### Environment variables
| Variable | Description | Required |
|-----|------|:----:|
| `DS2API_ADMIN_KEY` | Admin console password | Required on Vercel |
| `DS2API_CONFIG_JSON` | Config JSON or Base64 | Optional |
| `VERCEL_TOKEN` | Vercel API token (for sync) | Optional |
| `VERCEL_PROJECT_ID` | Vercel project ID | Optional |
| `PORT` | Service port (default 5001) | Optional |
### Config file format (`config.json`)
```json
{
"keys": ["your-api-key-1", "your-api-key-2"],
"accounts": [
{
"email": "user@example.com",
"password": "your-password",
"token": ""
},
{
"mobile": "12345678901",
"password": "your-password",
"token": ""
}
]
}
```
> **Notes**:
> - `keys`: Custom API keys for calling this service
> - `accounts`: DeepSeek Web accounts (email or mobile)
> - `token`: Leave blank; DS2API will fetch and refresh automatically
## 📡 API Usage
See **[API.md](API.md)** for full API documentation.
### Quick examples
**List models**:
```bash
curl http://localhost:5001/v1/models
```
**OpenAI-compatible call**:
```bash
curl http://localhost:5001/v1/chat/completions \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-chat",
"messages": [{"role": "user", "content": "Hello"}],
"stream": true
}'
```
**Claude-compatible call**:
```bash
curl http://localhost:5001/anthropic/v1/messages \
-H "x-api-key: your-api-key" \
-H "Content-Type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-sonnet-4-20250514",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
}'
```
### Python SDK usage
```python
from openai import OpenAI
client = OpenAI(
api_key="your-api-key",
base_url="http://localhost:5001/v1"
)
response = client.chat.completions.create(
model="deepseek-reasoner",
messages=[{"role": "user", "content": "Explain quantum entanglement"}],
stream=True
)
for chunk in response:
if chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="")
```
## 🔧 Deployment Notes
### Nginx reverse proxy
```nginx
location / {
proxy_pass http://localhost:5001;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 120;
}
```
### Option 3: Docker deployment
```bash
# 1. Clone the repo and enter the directory
git clone https://github.com/CJackHwang/ds2api.git
cd ds2api
# 2. Configure environment variables
cp .env.example .env
# Edit .env and fill in DS2API_ADMIN_KEY and DS2API_CONFIG_JSON
# 3. Start the service
docker-compose up -d
# 4. Check logs
docker-compose logs -f
```
> **Docker advantage**: Zero-intrusion design; update the main code with `docker-compose up -d --build` without changing Docker configuration. See [DEPLOY.md](DEPLOY.md#docker-deployment-recommended).
## ⚠️ Disclaimer
**This project is based on reverse engineering and stability is not guaranteed.**
- For learning and research only. **No commercial use or public service is allowed.**
- For production, use the official [DeepSeek API](https://platform.deepseek.com/)
- You assume all risks from using this project
## 📜 Acknowledgements
This project is based on the following open-source projects:
- [iidamie/deepseek2api](https://github.com/iidamie/deepseek2api)
- [LLM-Red-Team/deepseek-free-api](https://github.com/LLM-Red-Team/deepseek-free-api)
## 📊 Star History
[![Star History Chart](https://api.star-history.com/svg?repos=CJackHwang/ds2api&type=Date)](https://star-history.com/#CJackHwang/ds2api&Date)

45
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,45 @@
# DS2API 开发环境配置
# 特性:
# - 源代码挂载(热重载)
# - 调试日志级别
# - 自动重启
#
# 使用说明:
# docker-compose -f docker-compose.dev.yml up
services:
ds2api:
build: .
image: ds2api:dev
container_name: ds2api-dev
command: [
"uvicorn",
"app:app",
"--host",
"0.0.0.0",
"--port",
"5001",
"--reload",
"--reload-dir",
"/app",
"--log-level",
"debug"
]
ports:
- "${PORT:-5001}:5001"
env_file:
- .env
environment:
- HOST=0.0.0.0
- LOG_LEVEL=DEBUG
volumes:
# 源代码挂载(开发时实时生效)
- ./app.py:/app/app.py:ro
- ./core:/app/core:ro
- ./routes:/app/routes:ro
- ./static:/app/static:ro
# 配置文件挂载(便于本地修改)
- ./config.json:/app/config.json
restart: "no"
stdin_open: true
tty: true

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Admin 账号管理模块 - 账号验证和测试"""
"""Admin 账号管理模块 - 账号测试与导入"""
import asyncio
import json
import base64
@@ -24,93 +24,6 @@ from .auth import verify_admin
router = APIRouter()
# ----------------------------------------------------------------------
# 账号验证
# ----------------------------------------------------------------------
async def validate_single_account(account: dict) -> dict:
"""验证单个账号的有效性"""
acc_id = get_account_identifier(account)
result = {
"account": acc_id,
"valid": False,
"has_token": bool(account.get("token", "").strip()),
"message": "",
}
try:
if result["has_token"]:
result["valid"] = True
result["message"] = "已有有效 token"
else:
try:
login_deepseek_via_account(account)
result["valid"] = True
result["has_token"] = True
result["message"] = "登录成功"
except Exception as e:
result["valid"] = False
result["message"] = f"登录失败: {str(e)}"
except Exception as e:
result["message"] = f"验证出错: {str(e)}"
return result
@router.post("/accounts/validate")
async def validate_account(request: Request, _: bool = Depends(verify_admin)):
"""验证单个账号"""
data = await request.json()
identifier = data.get("identifier", "").strip()
if not identifier:
raise HTTPException(status_code=400, detail="需要账号标识email 或 mobile")
account = None
for acc in CONFIG.get("accounts", []):
if acc.get("email") == identifier or acc.get("mobile") == identifier:
account = acc
break
if not account:
raise HTTPException(status_code=404, detail="账号不存在")
result = await validate_single_account(account)
if result["valid"] and result["has_token"]:
save_config(CONFIG)
return JSONResponse(content=result)
@router.post("/accounts/validate-all")
async def validate_all_accounts(_: bool = Depends(verify_admin)):
"""批量验证所有账号"""
accounts = CONFIG.get("accounts", [])
if not accounts:
return JSONResponse(content={
"total": 0, "valid": 0, "invalid": 0, "results": [],
})
results = []
valid_count = 0
for acc in accounts:
result = await validate_single_account(acc)
results.append(result)
if result["valid"]:
valid_count += 1
await asyncio.sleep(0.5)
save_config(CONFIG)
return JSONResponse(content={
"total": len(accounts),
"valid": valid_count,
"invalid": len(accounts) - valid_count,
"results": results,
})
# ----------------------------------------------------------------------
# 账号 API 测试
# ----------------------------------------------------------------------
@@ -134,38 +47,68 @@ async def test_account_api(account: dict, model: str = "deepseek-chat", message:
start_time = time.time()
def _is_token_invalid(status_code: int, data: dict) -> bool:
msg = (data.get("msg") or data.get("message") or "").lower()
code = data.get("code")
return status_code in {401, 403} or code in {40001, 40002, 40003} or "token" in msg or "unauthorized" in msg
def _create_session(token: str) -> dict:
headers = {**BASE_HEADERS, "authorization": f"Bearer {token}"}
try:
session_resp = cffi_requests.post(
DEEPSEEK_CREATE_SESSION_URL,
headers=headers,
json={"agent": "chat"},
impersonate="safari15_3",
timeout=15,
)
except Exception as e:
return {"success": False, "message": f"请求异常: {e}", "status_code": 0, "data": {}}
try:
session_data = session_resp.json()
except Exception:
session_data = {}
finally:
session_resp.close()
if session_resp.status_code == 200 and session_data.get("code") == 0:
return {
"success": True,
"session_id": session_data.get("data", {}).get("biz_data", {}).get("id"),
"status_code": session_resp.status_code,
"data": session_data,
}
return {
"success": False,
"message": session_data.get("msg") or f"HTTP {session_resp.status_code}",
"status_code": session_resp.status_code,
"data": session_data,
}
try:
token = account.get("token", "").strip()
if not token:
session_result = None
if token:
session_result = _create_session(token)
if not token or (session_result and not session_result["success"] and _is_token_invalid(session_result["status_code"], session_result["data"])):
try:
account["token"] = ""
login_deepseek_via_account(account)
token = account.get("token", "")
session_result = _create_session(token)
except Exception as e:
result["message"] = f"登录失败: {str(e)}"
return result
if not session_result or not session_result["success"]:
result["message"] = f"创建会话失败: {session_result['message'] if session_result else 'Unknown error'}"
return result
session_id = session_result["session_id"]
headers = {**BASE_HEADERS, "authorization": f"Bearer {token}"}
session_resp = cffi_requests.post(
DEEPSEEK_CREATE_SESSION_URL,
headers=headers,
json={"agent": "chat"},
impersonate="safari15_3",
timeout=15,
)
if session_resp.status_code != 200:
result["message"] = f"创建会话失败: HTTP {session_resp.status_code}"
return result
session_data = session_resp.json()
if session_data.get("code") != 0:
result["message"] = f"创建会话失败: {session_data.get('msg', 'Unknown error')}"
account["token"] = ""
return result
session_id = session_data.get("data", {}).get("biz_data", {}).get("id")
if not message.strip():
result["success"] = True
result["message"] = "API 测试成功(仅会话创建)"

View File

@@ -1,301 +1,308 @@
# -*- coding: utf-8 -*-
"""首页和 WebUI 路由"""
import os
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, FileResponse
from core.config import STATIC_ADMIN_DIR
router = APIRouter()
# 首页 HTML内嵌避免依赖模板目录
WELCOME_HTML = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DS2API - DeepSeek to OpenAI API</title>
<meta name="description" content="DS2API - 将 DeepSeek 网页版转换为 OpenAI 兼容 API">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%23f59e0b'/%3E%3Cstop offset='100%25' stop-color='%23ef4444'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect rx='20' width='100' height='100' fill='url(%23g)'/%3E%3Ctext x='50' y='68' font-family='Arial,sans-serif' font-size='48' font-weight='bold' fill='white' text-anchor='middle'%3EDS%3C/text%3E%3C/svg%3E">
<style>
:root {
--primary: #f59e0b;
--primary-glow: rgba(245, 158, 11, 0.4);
--secondary: #ef4444;
--bg: #030712;
--card-bg: rgba(255, 255, 255, 0.03);
--card-border: rgba(255, 255, 255, 0.08);
--text-main: #f9fafb;
--text-dim: #9ca3af;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background-color: var(--bg);
color: var(--text-main);
min-height: 100vh;
overflow-x: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
/* Animated Background */
.bg-glow {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
background:
radial-gradient(circle at 20% 30%, rgba(245, 158, 11, 0.05) 0%, transparent 40%),
radial-gradient(circle at 80% 70%, rgba(239, 68, 68, 0.05) 0%, transparent 40%);
}
.blob {
position: absolute;
width: 400px;
height: 400px;
background: linear-gradient(135deg, var(--primary), var(--secondary));
filter: blur(80px);
opacity: 0.15;
border-radius: 50%;
z-index: -1;
animation: move 20s infinite alternate;
}
@keyframes move {
from { transform: translate(-10%, -10%) scale(1); }
to { transform: translate(10%, 10%) scale(1.1); }
}
.container {
width: 100%;
max-width: 900px;
padding: 2rem;
text-align: center;
}
.logo-section {
margin-bottom: 3rem;
animation: fadeInUp 0.8s ease-out;
}
.logo {
font-family: 'Orbitron', sans-serif;
font-size: clamp(3rem, 10vw, 5rem);
font-weight: 700;
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -2px;
margin-bottom: 0.5rem;
display: inline-block;
}
.subtitle {
color: var(--text-dim);
font-size: 1.25rem;
max-width: 600px;
margin: 0 auto;
line-height: 1.6;
}
.actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-bottom: 4rem;
flex-wrap: wrap;
animation: fadeInUp 0.8s ease-out 0.2s backwards;
}
.btn {
padding: 0.8rem 2rem;
border-radius: 12px;
text-decoration: none;
font-weight: 600;
font-size: 1rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), var(--secondary));
color: white;
box-shadow: 0 4px 15px var(--primary-glow);
}
.btn-primary:hover {
transform: translateY(-3px) scale(1.02);
box-shadow: 0 8px 25px var(--primary-glow);
}
.btn-secondary {
background: var(--card-bg);
color: var(--text-main);
border: 1px solid var(--card-border);
backdrop-filter: blur(10px);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-top: 1rem;
animation: fadeInUp 0.8s ease-out 0.4s backwards;
}
.feature-card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 16px;
padding: 1.5rem;
text-align: left;
transition: all 0.3s ease;
backdrop-filter: blur(8px);
}
.feature-card:hover {
border-color: rgba(245, 158, 11, 0.3);
background: rgba(255, 255, 255, 0.05);
transform: translateY(-5px);
}
.feature-icon {
font-size: 1.5rem;
margin-bottom: 1rem;
display: block;
}
.feature-card h3 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.feature-card p {
color: var(--text-dim);
font-size: 0.9rem;
line-height: 1.5;
}
footer {
margin-top: 4rem;
padding: 2rem;
color: var(--text-dim);
font-size: 0.875rem;
animation: fadeInUp 0.8s ease-out 0.6s backwards;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 640px) {
.logo { font-size: 3.5rem; }
.container { padding: 1.5rem; }
}
</style>
</head>
<body>
<div class="bg-glow"></div>
<div class="blob" style="top: 10%; left: 15%;"></div>
<div class="blob" style="bottom: 10%; right: 15%; animation-delay: -5s;"></div>
<div class="container">
<header class="logo-section">
<div class="logo">DS2API</div>
<p class="subtitle">DeepSeek to OpenAI & Claude Compatible API Interface</p>
</header>
<div class="actions">
<a href="/admin" class="btn btn-primary">
<span>🎛️</span> 管理面板
</a>
<a href="/v1/models" class="btn btn-secondary">
<span>📡</span> API 状态
</a>
<a href="https://github.com/CJackHwang/ds2api" class="btn btn-secondary" target="_blank">
<span>📦</span> GitHub
</a>
</div>
<div class="features-grid">
<div class="feature-card">
<span class="feature-icon">🚀</span>
<h3>全面兼容</h3>
<p>完美适配 OpenAI 与 Claude API 格式,无缝集成现有工具。</p>
</div>
<div class="feature-card">
<span class="feature-icon">⚖️</span>
<h3>负载均衡</h3>
<p>内置智能轮询机制,支持多账号并发,稳定高效。</p>
</div>
<div class="feature-card">
<span class="feature-icon">🧠</span>
<h3>深度思考</h3>
<p>完整支持 推理过程输出,让思考可见。</p>
</div>
<div class="feature-card">
<span class="feature-icon">🔍</span>
<h3>联网搜索</h3>
<p>集成 DeepSeek 原生搜索能力,获取最新实时资讯。</p>
</div>
</div>
<footer>
<p>&copy; 2026 DS2API Project. Designed for flexibility & performance.</p>
</footer>
</div>
</body>
</html>"""
@router.get("/")
def index(request: Request):
return HTMLResponse(content=WELCOME_HTML)
@router.get("/admin")
@router.get("/admin/{path:path}")
async def webui(request: Request, path: str = ""):
"""提供 WebUI 静态文件"""
# 检查 static/admin 目录是否存在
if not os.path.isdir(STATIC_ADMIN_DIR):
return HTMLResponse(
content="<h1>WebUI not built</h1><p>Run <code>cd webui && npm run build</code> first.</p>",
status_code=404
)
# 如果请求的是具体文件(如 js, css
if path and "." in path:
file_path = os.path.join(STATIC_ADMIN_DIR, path)
if os.path.isfile(file_path):
return FileResponse(file_path)
return HTMLResponse(content="Not Found", status_code=404)
# 否则返回 index.htmlSPA 路由)
index_path = os.path.join(STATIC_ADMIN_DIR, "index.html")
if os.path.isfile(index_path):
return FileResponse(index_path)
return HTMLResponse(content="index.html not found", status_code=404)
# -*- coding: utf-8 -*-
"""首页和 WebUI 路由"""
import os
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, FileResponse
from core.config import STATIC_ADMIN_DIR
router = APIRouter()
# 首页 HTML内嵌避免依赖模板目录
WELCOME_HTML = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DS2API - DeepSeek to OpenAI API</title>
<meta name="description" content="DS2API - 将 DeepSeek 网页版转换为 OpenAI 兼容 API">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%23f59e0b'/%3E%3Cstop offset='100%25' stop-color='%23ef4444'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect rx='20' width='100' height='100' fill='url(%23g)'/%3E%3Ctext x='50' y='68' font-family='Arial,sans-serif' font-size='48' font-weight='bold' fill='white' text-anchor='middle'%3EDS%3C/text%3E%3C/svg%3E">
<style>
:root {
--primary: #f59e0b;
--primary-glow: rgba(245, 158, 11, 0.4);
--secondary: #ef4444;
--bg: #030712;
--card-bg: rgba(255, 255, 255, 0.03);
--card-border: rgba(255, 255, 255, 0.08);
--text-main: #f9fafb;
--text-dim: #9ca3af;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background-color: var(--bg);
color: var(--text-main);
min-height: 100vh;
overflow-x: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
/* Animated Background */
.bg-glow {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
background:
radial-gradient(circle at 20% 30%, rgba(245, 158, 11, 0.05) 0%, transparent 40%),
radial-gradient(circle at 80% 70%, rgba(239, 68, 68, 0.05) 0%, transparent 40%);
}
.blob {
position: absolute;
width: 400px;
height: 400px;
background: linear-gradient(135deg, var(--primary), var(--secondary));
filter: blur(80px);
opacity: 0.15;
border-radius: 50%;
z-index: -1;
animation: move 20s infinite alternate;
}
@keyframes move {
from { transform: translate(-10%, -10%) scale(1); }
to { transform: translate(10%, 10%) scale(1.1); }
}
.container {
width: 100%;
max-width: 900px;
padding: 2rem;
text-align: center;
}
.logo-section {
margin-bottom: 3rem;
animation: fadeInUp 0.8s ease-out;
}
.logo {
font-family: 'Orbitron', sans-serif;
font-size: clamp(3rem, 10vw, 5rem);
font-weight: 700;
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -2px;
margin-bottom: 0.5rem;
display: inline-block;
}
.subtitle {
color: var(--text-dim);
font-size: 1.25rem;
max-width: 600px;
margin: 0 auto;
line-height: 1.6;
}
.actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-bottom: 4rem;
flex-wrap: wrap;
animation: fadeInUp 0.8s ease-out 0.2s backwards;
}
.btn {
padding: 0.8rem 2rem;
border-radius: 12px;
text-decoration: none;
font-weight: 600;
font-size: 1rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), var(--secondary));
color: white;
box-shadow: 0 4px 15px var(--primary-glow);
}
.btn-primary:hover {
transform: translateY(-3px) scale(1.02);
box-shadow: 0 8px 25px var(--primary-glow);
}
.btn-secondary {
background: var(--card-bg);
color: var(--text-main);
border: 1px solid var(--card-border);
backdrop-filter: blur(10px);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-top: 1rem;
animation: fadeInUp 0.8s ease-out 0.4s backwards;
}
.feature-card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 16px;
padding: 1.5rem;
text-align: left;
transition: all 0.3s ease;
backdrop-filter: blur(8px);
}
.feature-card:hover {
border-color: rgba(245, 158, 11, 0.3);
background: rgba(255, 255, 255, 0.05);
transform: translateY(-5px);
}
.feature-icon {
font-size: 1.5rem;
margin-bottom: 1rem;
display: block;
}
.feature-card h3 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.feature-card p {
color: var(--text-dim);
font-size: 0.9rem;
line-height: 1.5;
}
footer {
margin-top: 4rem;
padding: 2rem;
color: var(--text-dim);
font-size: 0.875rem;
animation: fadeInUp 0.8s ease-out 0.6s backwards;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 640px) {
.logo { font-size: 3.5rem; }
.container { padding: 1.5rem; }
}
</style>
</head>
<body>
<div class="bg-glow"></div>
<div class="blob" style="top: 10%; left: 15%;"></div>
<div class="blob" style="bottom: 10%; right: 15%; animation-delay: -5s;"></div>
<div class="container">
<header class="logo-section">
<div class="logo">DS2API</div>
<p class="subtitle">DeepSeek to OpenAI & Claude Compatible API Interface</p>
</header>
<div class="actions">
<a href="/admin" class="btn btn-primary">
<span>🎛️</span> 管理面板
</a>
<a href="/v1/models" class="btn btn-secondary">
<span>📡</span> API 状态
</a>
<a href="https://github.com/CJackHwang/ds2api" class="btn btn-secondary" target="_blank">
<span>📦</span> GitHub
</a>
</div>
<div class="features-grid">
<div class="feature-card">
<span class="feature-icon">🚀</span>
<h3>全面兼容</h3>
<p>完美适配 OpenAI 与 Claude API 格式,无缝集成现有工具。</p>
</div>
<div class="feature-card">
<span class="feature-icon">⚖️</span>
<h3>负载均衡</h3>
<p>内置智能轮询机制,支持多账号并发,稳定高效。</p>
</div>
<div class="feature-card">
<span class="feature-icon">🧠</span>
<h3>深度思考</h3>
<p>完整支持 推理过程输出,让思考可见。</p>
</div>
<div class="feature-card">
<span class="feature-icon">🔍</span>
<h3>联网搜索</h3>
<p>集成 DeepSeek 原生搜索能力,获取最新实时资讯。</p>
</div>
</div>
<footer>
<p>&copy; 2026 DS2API Project. Designed for flexibility & performance.</p>
</footer>
</div>
</body>
</html>"""
@router.get("/")
def index(request: Request):
return HTMLResponse(content=WELCOME_HTML)
@router.get("/admin")
@router.get("/admin/{path:path}")
async def webui(request: Request, path: str = ""):
"""提供 WebUI 静态文件"""
# 检查 static/admin 目录是否存在
if not os.path.isdir(STATIC_ADMIN_DIR):
return HTMLResponse(
content="<h1>WebUI not built</h1><p>Run <code>cd webui && npm run build</code> first.</p>",
status_code=404
)
# 如果请求的是具体文件(如 js, css
if path and "." in path:
file_path = os.path.join(STATIC_ADMIN_DIR, path)
if os.path.isfile(file_path):
cache_control = "public, max-age=31536000, immutable"
if path.startswith("assets/"):
headers = {"Cache-Control": cache_control}
else:
headers = {"Cache-Control": "no-store, must-revalidate"}
return FileResponse(file_path, headers=headers)
return HTMLResponse(content="Not Found", status_code=404)
# 否则返回 index.htmlSPA 路由)
index_path = os.path.join(STATIC_ADMIN_DIR, "index.html")
if os.path.isfile(index_path):
headers = {"Cache-Control": "no-store, must-revalidate"}
return FileResponse(index_path, headers=headers)
return HTMLResponse(content="index.html not found", status_code=404)

View File

@@ -453,107 +453,84 @@ IMPORTANT: If calling tools, output ONLY the JSON. The response must start with
def collect_data():
nonlocal result
ptype = "text"
current_fragment_type = "thinking" if thinking_enabled else "text"
try:
for raw_line in deepseek_resp.iter_lines():
try:
line = raw_line.decode("utf-8")
except Exception as e:
logger.warning(f"[chat_completions] 解码失败: {e}")
if ptype == "thinking":
think_list.append("解码失败,请稍候再试")
else:
text_list.append("解码失败,请稍候再试")
chunk = parse_deepseek_sse_line(raw_line)
if not chunk:
continue
if chunk.get("type") == "done":
data_queue.put(None)
break
if not line:
continue
if line.startswith("data:"):
data_str = line[5:].strip()
if data_str == "[DONE]":
data_queue.put(None)
break
try:
chunk = json.loads(data_str)
if "v" in chunk:
v_value = chunk["v"]
if "p" in chunk and chunk.get("p") == "response/search_status":
continue
if "p" in chunk and chunk.get("p") == "response/thinking_content":
ptype = "thinking"
elif "p" in chunk and chunk.get("p") == "response/content":
ptype = "text"
if isinstance(v_value, str):
if search_enabled and v_value.startswith("[citation:"):
continue
if ptype == "thinking":
think_list.append(v_value)
else:
text_list.append(v_value)
elif isinstance(v_value, list):
for item in v_value:
if item.get("p") == "status" and item.get("v") == "FINISHED":
final_reasoning = "".join(think_list)
final_content = "".join(text_list)
prompt_tokens = len(final_prompt) // 4
reasoning_tokens = len(final_reasoning) // 4
completion_tokens = len(final_content) // 4
# 检测工具调用
detected_tools = []
finish_reason = "stop"
if has_tools:
detected_tools = parse_tool_calls(final_content, [{"name": t.get("function", t).get("name")} for t in tools_requested])
if detected_tools:
finish_reason = "tool_calls"
# 构建 message 对象
message_obj = {
"role": "assistant",
"content": final_content if not detected_tools else None,
}
# 只有启用思考模式时才包含 reasoning_content
if thinking_enabled and final_reasoning:
message_obj["reasoning_content"] = final_reasoning
# 添加工具调用
if detected_tools:
tool_calls_data = format_openai_tool_calls(detected_tools)
message_obj["tool_calls"] = tool_calls_data
message_obj["content"] = None
result = {
"id": completion_id,
"object": "chat.completion",
"created": created_time,
"model": model,
"choices": [{
"index": 0,
"message": message_obj,
"finish_reason": finish_reason,
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": reasoning_tokens + completion_tokens,
"total_tokens": prompt_tokens + reasoning_tokens + completion_tokens,
"completion_tokens_details": {"reasoning_tokens": reasoning_tokens},
},
}
data_queue.put("DONE")
return
except Exception as e:
logger.warning(f"[collect_data] 无法解析: {data_str}, 错误: {e}")
if ptype == "thinking":
think_list.append("解析失败,请稍候再试")
try:
contents, is_finished, new_fragment_type = parse_sse_chunk_for_content(
chunk, thinking_enabled, current_fragment_type
)
current_fragment_type = new_fragment_type
if is_finished:
final_reasoning = "".join(think_list)
final_content = "".join(text_list)
prompt_tokens = len(final_prompt) // 4
reasoning_tokens = len(final_reasoning) // 4
completion_tokens = len(final_content) // 4
# 检测工具调用
detected_tools = []
finish_reason = "stop"
if has_tools:
detected_tools = parse_tool_calls(final_content, [{"name": t.get("function", t).get("name")} for t in tools_requested])
if detected_tools:
finish_reason = "tool_calls"
# 构建 message 对象
message_obj = {
"role": "assistant",
"content": final_content if not detected_tools else None,
}
# 只有启用思考模式时才包含 reasoning_content
if thinking_enabled and final_reasoning:
message_obj["reasoning_content"] = final_reasoning
# 添加工具调用
if detected_tools:
tool_calls_data = format_openai_tool_calls(detected_tools)
message_obj["tool_calls"] = tool_calls_data
message_obj["content"] = None
result = {
"id": completion_id,
"object": "chat.completion",
"created": created_time,
"model": model,
"choices": [{
"index": 0,
"message": message_obj,
"finish_reason": finish_reason,
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": reasoning_tokens + completion_tokens,
"total_tokens": prompt_tokens + reasoning_tokens + completion_tokens,
"completion_tokens_details": {"reasoning_tokens": reasoning_tokens},
},
}
data_queue.put("DONE")
return
for content_text, content_type in contents:
if should_filter_citation(content_text, search_enabled):
continue
if content_type == "thinking":
think_list.append(content_text)
else:
text_list.append("解析失败,请稍候再试")
data_queue.put(None)
break
text_list.append(content_text)
except Exception as e:
logger.warning(f"[collect_data] 无法解析: {chunk}, 错误: {e}")
text_list.append("解析失败,请稍候再试")
data_queue.put(None)
break
except Exception as e:
logger.warning(f"[collect_data] 错误: {e}")
if ptype == "thinking":
think_list.append("处理失败,请稍候再试")
else:
text_list.append("处理失败,请稍候再试")
text_list.append("处理失败,请稍候再试")
data_queue.put(None)
finally:
deepseek_resp.close()

View File

@@ -18,5 +18,10 @@ fi
echo "🏗️ Running build..."
npm run build
if [ ! -f "../static/admin/index.html" ]; then
echo "❌ WebUI build failed: static/admin/index.html not found"
exit 1
fi
echo "✅ WebUI built successfully!"
echo "📁 Output: static/admin/"

1
static/admin/.gitkeep Normal file
View File

@@ -0,0 +1 @@

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +1,30 @@
{
"version": 2,
"builds": [
"buildCommand": "bash scripts/build-webui.sh",
"rewrites": [
{
"src": "app.py",
"use": "@vercel/python"
"source": "/(.*)",
"destination": "/app.py"
}
],
"routes": [
"headers": [
{
"src": "/(.*)",
"dest": "app.py"
"source": "/admin/assets/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
},
{
"source": "/admin/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "no-store, must-revalidate"
}
]
}
]
}
}

View File

@@ -6,16 +6,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- SEO Meta Tags -->
<title>DS2API - DeepSeek API 管理面板</title>
<meta name="description" content="DS2API 管理面板 - 轻松管理 DeepSeek 账号、测试 API、同步 Vercel 配置" />
<meta name="keywords" content="DeepSeek, API, OpenAI, 管理面板, DS2API" />
<title>DS2API - 管理面板 / Admin Console</title>
<meta name="description" content="DS2API 管理面板管理 DeepSeek 账号、测试 API、同步 Vercel 配置 / Admin console for accounts, API tests, and Vercel sync." />
<meta name="keywords" content="DeepSeek, API, OpenAI, 管理面板, admin console, DS2API" />
<meta name="author" content="CJackHwang" />
<meta name="robots" content="noindex, nofollow" />
<!-- Open Graph / Social -->
<meta property="og:type" content="website" />
<meta property="og:title" content="DS2API - DeepSeek API 管理面板" />
<meta property="og:description" content="轻松管理 DeepSeek 账号、测试 API、同步 Vercel 配置" />
<meta property="og:title" content="DS2API - 管理面板 / Admin Console" />
<meta property="og:description" content="Manage DeepSeek accounts, test the API, and sync Vercel configuration." />
<meta property="og:site_name" content="DS2API" />
<!-- PWA / Mobile -->
@@ -39,4 +39,4 @@
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
</html>

View File

@@ -25,19 +25,22 @@ import BatchImport from './components/BatchImport'
import VercelSync from './components/VercelSync'
import Login from './components/Login'
import LandingPage from './components/LandingPage'
const NAV_ITEMS = [
{ id: 'accounts', label: '账号管理', icon: Users, description: '管理 DeepSeek 账号池' },
{ id: 'test', label: 'API 测试', icon: Server, description: '测试 API 连接与响应' },
{ id: 'import', label: '批量导入', icon: Upload, description: '批量导入账号配置' },
{ id: 'vercel', label: 'Vercel 同步', icon: Cloud, description: '同步配置到 Vercel' },
]
import LanguageToggle from './components/LanguageToggle'
import { useI18n } from './i18n'
function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message }) {
const { t } = useI18n()
const [activeTab, setActiveTab] = useState('accounts')
const [sidebarOpen, setSidebarOpen] = useState(false)
const [loading, setLoading] = useState(false)
const navItems = [
{ id: 'accounts', label: t('nav.accounts.label'), icon: Users, description: t('nav.accounts.desc') },
{ id: 'test', label: t('nav.test.label'), icon: Server, description: t('nav.test.desc') },
{ id: 'import', label: t('nav.import.label'), icon: Upload, description: t('nav.import.desc') },
{ id: 'vercel', label: t('nav.vercel.label'), icon: Cloud, description: t('nav.vercel.desc') },
]
const authFetch = async (url, options = {}) => {
const headers = {
...options.headers,
@@ -47,7 +50,7 @@ function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message
if (res.status === 401) {
onLogout()
throw new Error('认证已过期,请重新登录')
throw new Error(t('auth.expired'))
}
return res
}
@@ -87,11 +90,14 @@ function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message
</div>
<span>DS2API</span>
</div>
<p className="text-[10px] text-muted-foreground mt-2 font-semibold tracking-[0.1em] uppercase opacity-60 px-1">在线管理面板</p>
<div className="flex items-center justify-between mt-2">
<p className="text-[10px] text-muted-foreground font-semibold tracking-[0.1em] uppercase opacity-60 px-1">{t('sidebar.onlineAdminConsole')}</p>
<LanguageToggle />
</div>
</div>
<nav className="flex-1 px-3 space-y-1 overflow-y-auto pt-2">
{NAV_ITEMS.map((item) => {
{navItems.map((item) => {
const Icon = item.icon
const isActive = activeTab === item.id
return (
@@ -119,19 +125,19 @@ function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message
<div className="p-4 border-t border-border bg-card">
<div className="space-y-4">
<div className="flex items-center justify-between text-sm px-1">
<span className="text-muted-foreground font-semibold text-[10px] uppercase tracking-wider">系统状态</span>
<span className="text-muted-foreground font-semibold text-[10px] uppercase tracking-wider">{t('sidebar.systemStatus')}</span>
<span className="flex items-center gap-1.5 text-[10px] font-bold text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
在线
{t('sidebar.statusOnline')}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="bg-background rounded-lg p-3 border border-border shadow-sm">
<div className="text-[9px] text-muted-foreground font-bold uppercase tracking-wider mb-0.5 opacity-70">账号</div>
<div className="text-[9px] text-muted-foreground font-bold uppercase tracking-wider mb-0.5 opacity-70">{t('sidebar.accounts')}</div>
<div className="text-lg font-bold text-foreground leading-tight">{config.accounts?.length || 0}</div>
</div>
<div className="bg-background rounded-lg p-3 border border-border shadow-sm">
<div className="text-[9px] text-muted-foreground font-bold uppercase tracking-wider mb-0.5 opacity-70">密钥</div>
<div className="text-[9px] text-muted-foreground font-bold uppercase tracking-wider mb-0.5 opacity-70">{t('sidebar.keys')}</div>
<div className="text-lg font-bold text-foreground">{config.keys?.length || 0}</div>
</div>
</div>
@@ -140,7 +146,7 @@ function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message
className="w-full h-10 flex items-center justify-center gap-2 rounded-lg border border-border text-xs font-medium text-muted-foreground hover:bg-destructive/10 hover:text-destructive hover:border-destructive/20 transition-all"
>
<LogOut className="w-3.5 h-3.5" />
退出登录
{t('sidebar.signOut')}
</button>
</div>
</div>
@@ -154,22 +160,25 @@ function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message
</div>
<span className="font-semibold text-sm">DS2API</span>
</div>
<button
onClick={() => setSidebarOpen(true)}
className="p-2 -mr-2 text-muted-foreground hover:text-foreground"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center gap-2">
<LanguageToggle />
<button
onClick={() => setSidebarOpen(true)}
className="p-2 -mr-2 text-muted-foreground hover:text-foreground"
>
<Menu className="w-5 h-5" />
</button>
</div>
</header>
<div className="flex-1 overflow-auto bg-background p-4 lg:p-10">
<div className="max-w-6xl mx-auto space-y-4 lg:space-y-6">
<div className="hidden lg:block mb-8">
<h1 className="text-3xl font-bold tracking-tight mb-2">
{NAV_ITEMS.find(n => n.id === activeTab)?.label}
{navItems.find(n => n.id === activeTab)?.label}
</h1>
<p className="text-muted-foreground">
{NAV_ITEMS.find(n => n.id === activeTab)?.description}
{navItems.find(n => n.id === activeTab)?.description}
</p>
</div>
@@ -195,6 +204,7 @@ function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message
}
export default function App() {
const { t } = useI18n()
const navigate = useNavigate()
const location = useLocation()
const [config, setConfig] = useState({ keys: [], accounts: [] })
@@ -207,7 +217,7 @@ export default function App() {
const isAdminRoute = location.pathname.startsWith('/admin') || isProduction
useEffect(() => {
// 只在 admin 路由时检查登录状态
// Only check auth status on admin routes.
if (!isAdminRoute) {
setAuthChecking(false)
return
@@ -248,8 +258,8 @@ export default function App() {
setConfig(data)
}
} catch (e) {
console.error('获取配置失败:', e)
showMessage('error', e.message)
console.error('Failed to fetch config:', e)
showMessage('error', t('errors.fetchConfig', { error: e.message }))
} finally {
setLoading(false)
}
@@ -278,13 +288,13 @@ export default function App() {
sessionStorage.removeItem('ds2api_token_expires')
}
// 在 admin 路由时,等待认证检查完成
// Wait for auth checks on admin routes.
if (isAdminRoute && authChecking) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
<p className="text-muted-foreground animate-pulse">正在检查登录状态...</p>
<p className="text-muted-foreground animate-pulse">{t('auth.checking')}</p>
</div>
</div>
)

View File

@@ -2,12 +2,8 @@ import { useState, useEffect } from 'react'
import {
Plus,
Trash2,
RefreshCw,
CheckCircle2,
AlertCircle,
Search,
Play,
MoreHorizontal,
X,
Server,
ShieldCheck,
@@ -15,16 +11,16 @@ import {
Check
} from 'lucide-react'
import clsx from 'clsx'
import { useI18n } from '../i18n'
export default function AccountManager({ config, onRefresh, onMessage, authFetch }) {
const { t } = useI18n()
const [showAddKey, setShowAddKey] = useState(false)
const [showAddAccount, setShowAddAccount] = useState(false)
const [newKey, setNewKey] = useState('')
const [copiedKey, setCopiedKey] = useState(null)
const [newAccount, setNewAccount] = useState({ email: '', mobile: '', password: '' })
const [loading, setLoading] = useState(false)
const [validating, setValidating] = useState({})
const [validatingAll, setValidatingAll] = useState(false)
const [testing, setTesting] = useState({})
const [testingAll, setTestingAll] = useState(false)
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] })
@@ -60,39 +56,39 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
body: JSON.stringify({ key: newKey.trim() }),
})
if (res.ok) {
onMessage('success', 'API 密钥添加成功')
onMessage('success', t('accountManager.addKeySuccess'))
setNewKey('')
setShowAddKey(false)
onRefresh()
} else {
const data = await res.json()
onMessage('error', data.detail || 'Failed to add')
onMessage('error', data.detail || t('messages.failedToAdd'))
}
} catch (e) {
onMessage('error', '网络错误')
onMessage('error', t('messages.networkError'))
} finally {
setLoading(false)
}
}
const deleteKey = async (key) => {
if (!confirm('确定要删除此 API 密钥吗?')) return
if (!confirm(t('accountManager.deleteKeyConfirm'))) return
try {
const res = await apiFetch(`/admin/keys/${encodeURIComponent(key)}`, { method: 'DELETE' })
if (res.ok) {
onMessage('success', 'Deleted successfully')
onMessage('success', t('messages.deleted'))
onRefresh()
} else {
onMessage('error', 'Delete failed')
onMessage('error', t('messages.deleteFailed'))
}
} catch (e) {
onMessage('error', 'Network error')
onMessage('error', t('messages.networkError'))
}
}
const addAccount = async () => {
if (!newAccount.password || (!newAccount.email && !newAccount.mobile)) {
onMessage('error', 'Password and Email/Mobile are required')
onMessage('error', t('accountManager.requiredFields'))
return
}
setLoading(true)
@@ -103,90 +99,36 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
body: JSON.stringify(newAccount),
})
if (res.ok) {
onMessage('success', '账号添加成功')
onMessage('success', t('accountManager.addAccountSuccess'))
setNewAccount({ email: '', mobile: '', password: '' })
setShowAddAccount(false)
onRefresh()
} else {
const data = await res.json()
onMessage('error', data.detail || 'Failed to add')
onMessage('error', data.detail || t('messages.failedToAdd'))
}
} catch (e) {
onMessage('error', '网络错误')
onMessage('error', t('messages.networkError'))
} finally {
setLoading(false)
}
}
const deleteAccount = async (id) => {
if (!confirm('确定要删除此账号吗?')) return
if (!confirm(t('accountManager.deleteAccountConfirm'))) return
try {
const res = await apiFetch(`/admin/accounts/${encodeURIComponent(id)}`, { method: 'DELETE' })
if (res.ok) {
onMessage('success', 'Deleted successfully')
onMessage('success', t('messages.deleted'))
onRefresh()
} else {
onMessage('error', 'Delete failed')
onMessage('error', t('messages.deleteFailed'))
}
} catch (e) {
onMessage('error', 'Network error')
onMessage('error', t('messages.networkError'))
}
}
const validateAccount = async (identifier) => {
setValidating(prev => ({ ...prev, [identifier]: true }))
try {
const res = await apiFetch('/admin/accounts/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier }),
})
const data = await res.json()
onMessage(data.valid ? 'success' : 'error', `${identifier}: ${data.message}`)
onRefresh()
} catch (e) {
onMessage('error', 'Validation failed: ' + e.message)
} finally {
setValidating(prev => ({ ...prev, [identifier]: false }))
}
}
const validateAllAccounts = async () => {
if (!confirm('校验所有账号?这可能需要一些时间。')) return
const accounts = config.accounts || []
if (accounts.length === 0) return
setValidatingAll(true)
setBatchProgress({ current: 0, total: accounts.length, results: [] })
let validCount = 0
const results = []
for (let i = 0; i < accounts.length; i++) {
const acc = accounts[i]
const id = acc.email || acc.mobile
try {
const res = await apiFetch('/admin/accounts/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: id }),
})
const data = await res.json()
results.push({ id, success: data.valid, message: data.message })
if (data.valid) validCount++
} catch (e) {
results.push({ id, success: false, message: e.message })
}
setBatchProgress({ current: i + 1, total: accounts.length, results: [...results] })
}
onMessage('success', `Completed: ${validCount}/${accounts.length} valid`)
onRefresh()
setValidatingAll(false)
}
const testAccount = async (identifier) => {
setTesting(prev => ({ ...prev, [identifier]: true }))
try {
@@ -196,17 +138,20 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
body: JSON.stringify({ identifier }),
})
const data = await res.json()
onMessage(data.success ? 'success' : 'error', `${identifier}: ${data.success ? `Success (${data.response_time}ms)` : data.message}`)
const statusMessage = data.success
? t('apiTester.testSuccess', { account: identifier, time: data.response_time })
: `${identifier}: ${data.message}`
onMessage(data.success ? 'success' : 'error', statusMessage)
onRefresh()
} catch (e) {
onMessage('error', 'Test failed: ' + e.message)
onMessage('error', t('accountManager.testFailed', { error: e.message }))
} finally {
setTesting(prev => ({ ...prev, [identifier]: false }))
}
}
const testAllAccounts = async () => {
if (!confirm('测试所有账号的 API 连通性?')) return
if (!confirm(t('accountManager.testAllConfirm'))) return
const accounts = config.accounts || []
if (accounts.length === 0) return
@@ -236,7 +181,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
setBatchProgress({ current: i + 1, total: accounts.length, results: [...results] })
}
onMessage('success', `Completed: ${successCount}/${accounts.length} available`)
onMessage('success', t('accountManager.testAllCompleted', { success: successCount, total: accounts.length }))
onRefresh()
setTestingAll(false)
}
@@ -251,30 +196,30 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<CheckCircle2 className="w-16 h-16" />
</div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">可用</p>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">{t('accountManager.available')}</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">{queueStatus.available}</span>
<span className="text-xs text-muted-foreground">个账号</span>
<span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Server className="w-16 h-16" />
</div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">正在使用</p>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">{t('accountManager.inUse')}</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">{queueStatus.in_use}</span>
<span className="text-xs text-muted-foreground">线程</span>
<span className="text-xs text-muted-foreground">{t('accountManager.threadsUnit')}</span>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<ShieldCheck className="w-16 h-16" />
</div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">账号池总数</p>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">{t('accountManager.totalPool')}</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">{queueStatus.total}</span>
<span className="text-xs text-muted-foreground">个账号</span>
<span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
</div>
</div>
</div>
@@ -285,15 +230,15 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold">API 密钥</h2>
<p className="text-sm text-muted-foreground">管理 API 访问密钥池</p>
<h2 className="text-lg font-semibold">{t('accountManager.apiKeysTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('accountManager.apiKeysDesc')}</p>
</div>
<button
onClick={() => setShowAddKey(true)}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
>
<Plus className="w-4 h-4" />
添加密钥
{t('accountManager.addKey')}
</button>
</div>
@@ -306,7 +251,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
{key.slice(0, 16)}****
</div>
{copiedKey === key && (
<span className="text-xs text-green-500 animate-pulse">已复制</span>
<span className="text-xs text-green-500 animate-pulse">{t('accountManager.copied')}</span>
)}
</div>
<div className="flex items-center gap-1">
@@ -317,14 +262,14 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
setTimeout(() => setCopiedKey(null), 2000)
}}
className="p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-md transition-colors opacity-0 group-hover:opacity-100"
title="复制密钥"
title={t('accountManager.copyKeyTitle')}
>
{copiedKey === key ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</button>
<button
onClick={() => deleteKey(key)}
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors opacity-0 group-hover:opacity-100"
title="删除密钥"
title={t('accountManager.deleteKeyTitle')}
>
<Trash2 className="w-4 h-4" />
</button>
@@ -332,7 +277,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
</div>
))
) : (
<div className="p-8 text-center text-muted-foreground">未找到 API 密钥</div>
<div className="p-8 text-center text-muted-foreground">{t('accountManager.noApiKeys')}</div>
)}
</div>
</div>
@@ -341,41 +286,33 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold">DeepSeek 账号</h2>
<p className="text-sm text-muted-foreground">管理 DeepSeek 账号池</p>
<h2 className="text-lg font-semibold">{t('accountManager.accountsTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('accountManager.accountsDesc')}</p>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={testAllAccounts}
disabled={testingAll || validatingAll || !config.accounts?.length}
disabled={testingAll || !config.accounts?.length}
className="flex items-center px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-xs font-medium border border-border disabled:opacity-50"
>
{testingAll ? <span className="animate-spin mr-2"></span> : <Play className="w-3 h-3 mr-2" />}
测试全部
</button>
<button
onClick={validateAllAccounts}
disabled={validatingAll || testingAll || !config.accounts?.length}
className="flex items-center px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-xs font-medium border border-border disabled:opacity-50"
>
{validatingAll ? <span className="animate-spin mr-2"></span> : <CheckCircle2 className="w-3 h-3 mr-2" />}
校验全部
{t('accountManager.testAll')}
</button>
<button
onClick={() => setShowAddAccount(true)}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
>
<Plus className="w-4 h-4" />
添加账号
{t('accountManager.addAccount')}
</button>
</div>
</div>
{/* Batch Progress */}
{(testingAll || validatingAll) && batchProgress.total > 0 && (
{testingAll && batchProgress.total > 0 && (
<div className="p-4 border-b border-border bg-muted/30">
<div className="flex items-center justify-between text-sm mb-2">
<span className="font-medium">{testingAll ? '正在测试所有账号...' : '正在校验所有账号...'}</span>
<span className="font-medium">{t('accountManager.testingAllAccounts')}</span>
<span className="text-muted-foreground">{batchProgress.current} / {batchProgress.total}</span>
</div>
<div className="w-full bg-muted rounded-full h-2 overflow-hidden mb-4">
@@ -413,7 +350,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
<div className="min-w-0">
<div className="font-medium truncate">{id}</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
<span>{acc.has_token ? '已建立会话' : '需重新登录'}</span>
<span>{acc.has_token ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</span>
{acc.token_preview && (
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
{acc.token_preview}
@@ -428,14 +365,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
disabled={testing[id]}
className="px-2 lg:px-3 py-1 lg:py-1.5 text-[10px] lg:text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50"
>
{testing[id] ? '正在测试...' : '测试'}
</button>
<button
onClick={() => validateAccount(id)}
disabled={validating[id]}
className="px-2 lg:px-3 py-1 lg:py-1.5 text-[10px] lg:text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50"
>
{validating[id] ? '正在校验...' : '校验'}
{testing[id] ? t('actions.testing') : t('actions.test')}
</button>
<button
onClick={() => deleteAccount(id)}
@@ -448,7 +378,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
)
})
) : (
<div className="p-8 text-center text-muted-foreground">未找到任何账号</div>
<div className="p-8 text-center text-muted-foreground">{t('accountManager.noAccounts')}</div>
)}
</div>
</div>
@@ -459,19 +389,19 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in">
<div className="bg-card w-full max-w-md rounded-xl border border-border shadow-2xl overflow-hidden animate-in zoom-in-95">
<div className="p-4 border-b border-border flex justify-between items-center">
<h3 className="font-semibold">添加 API 密钥</h3>
<h3 className="font-semibold">{t('accountManager.modalAddKeyTitle')}</h3>
<button onClick={() => setShowAddKey(false)} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium mb-1.5">新密钥值</label>
<label className="block text-sm font-medium mb-1.5">{t('accountManager.newKeyLabel')}</label>
<div className="flex gap-2">
<input
type="text"
className="input-field bg-[#09090b] flex-1"
placeholder="输入自定义 API 密钥"
placeholder={t('accountManager.newKeyPlaceholder')}
value={newKey}
onChange={e => setNewKey(e.target.value)}
autoFocus
@@ -481,15 +411,15 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
onClick={() => setNewKey('sk-' + crypto.randomUUID().replace(/-/g, ''))}
className="px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm font-medium border border-border whitespace-nowrap"
>
生成
{t('accountManager.generate')}
</button>
</div>
<p className="text-xs text-muted-foreground mt-1.5">点击生成自动创建随机密钥</p>
<p className="text-xs text-muted-foreground mt-1.5">{t('accountManager.generateHint')}</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setShowAddKey(false)} className="px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors text-sm font-medium">取消</button>
<button onClick={() => setShowAddKey(false)} className="px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors text-sm font-medium">{t('actions.cancel')}</button>
<button onClick={addKey} disabled={loading} className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50">
{loading ? '添加中...' : '添加密钥'}
{loading ? t('accountManager.addKeyLoading') : t('accountManager.addKeyAction')}
</button>
</div>
</div>
@@ -503,14 +433,14 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in">
<div className="bg-card w-full max-w-md rounded-xl border border-border shadow-2xl overflow-hidden animate-in zoom-in-95">
<div className="p-4 border-b border-border flex justify-between items-center">
<h3 className="font-semibold">添加 DeepSeek 账号</h3>
<h3 className="font-semibold">{t('accountManager.modalAddAccountTitle')}</h3>
<button onClick={() => setShowAddAccount(false)} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium mb-1.5">邮箱 (可选)</label>
<label className="block text-sm font-medium mb-1.5">{t('accountManager.emailOptional')}</label>
<input
type="email"
className="input-field"
@@ -520,7 +450,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">手机号 (可选)</label>
<label className="block text-sm font-medium mb-1.5">{t('accountManager.mobileOptional')}</label>
<input
type="text"
className="input-field"
@@ -530,19 +460,19 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">密码 <span className="text-destructive">*</span></label>
<label className="block text-sm font-medium mb-1.5">{t('accountManager.passwordLabel')} <span className="text-destructive">*</span></label>
<input
type="password"
className="input-field bg-[#09090b]"
placeholder="账号密码"
placeholder={t('accountManager.passwordPlaceholder')}
value={newAccount.password}
onChange={e => setNewAccount({ ...newAccount, password: e.target.value })}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setShowAddAccount(false)} className="px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors text-sm font-medium">取消</button>
<button onClick={() => setShowAddAccount(false)} className="px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors text-sm font-medium">{t('actions.cancel')}</button>
<button onClick={addAccount} disabled={loading} className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50">
{loading ? '添加中...' : '添加账号'}
{loading ? t('accountManager.addAccountLoading') : t('accountManager.addAccountAction')}
</button>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
Send,
Square,
@@ -17,17 +17,13 @@ import {
Zap
} from 'lucide-react'
import clsx from 'clsx'
const MODELS = [
{ id: "deepseek-chat", name: "deepseek-chat", icon: MessageSquare, desc: "非思考模型", color: "text-amber-500" },
{ id: "deepseek-reasoner", name: "deepseek-reasoner", icon: Cpu, desc: "思考模型", color: "text-amber-600" },
{ id: "deepseek-chat-search", name: "deepseek-chat-search", icon: SearchIcon, desc: "非思考模型 (带搜索)", color: "text-cyan-500" },
{ id: "deepseek-reasoner-search", name: "deepseek-reasoner-search", icon: SearchIcon, desc: "思考模型 (带搜索)", color: "text-cyan-600" },
];
import { useI18n } from '../i18n'
export default function ApiTester({ config, onMessage, authFetch }) {
const { t } = useI18n()
const [model, setModel] = useState('deepseek-chat')
const [message, setMessage] = useState('Hello, please introduce yourself in one sentence.')
const defaultMessage = t('apiTester.defaultMessage')
const [message, setMessage] = useState(defaultMessage)
const [apiKey, setApiKey] = useState('')
const [selectedAccount, setSelectedAccount] = useState('')
const [response, setResponse] = useState(null)
@@ -36,12 +32,19 @@ export default function ApiTester({ config, onMessage, authFetch }) {
const [streamingThinking, setStreamingThinking] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const abortControllerRef = useRef(null)
const defaultMessageRef = useRef(defaultMessage)
const [sidebarOpen, setSidebarOpen] = useState(false)
const [configExpanded, setConfigExpanded] = useState(false)
const apiFetch = authFetch || fetch
const accounts = config.accounts || []
const models = [
{ id: "deepseek-chat", name: "deepseek-chat", icon: MessageSquare, desc: t('apiTester.models.chat'), color: "text-amber-500" },
{ id: "deepseek-reasoner", name: "deepseek-reasoner", icon: Cpu, desc: t('apiTester.models.reasoner'), color: "text-amber-600" },
{ id: "deepseek-chat-search", name: "deepseek-chat-search", icon: SearchIcon, desc: t('apiTester.models.chatSearch'), color: "text-cyan-500" },
{ id: "deepseek-reasoner-search", name: "deepseek-reasoner-search", icon: SearchIcon, desc: t('apiTester.models.reasonerSearch'), color: "text-cyan-600" },
]
const stopGeneration = () => {
if (abortControllerRef.current) {
@@ -66,7 +69,7 @@ export default function ApiTester({ config, onMessage, authFetch }) {
try {
const key = apiKey || (config.keys?.[0] || '')
if (!key) {
onMessage('error', '请提供 API 密钥')
onMessage('error', t('apiTester.missingApiKey'))
setLoading(false)
setIsStreaming(false)
return
@@ -88,8 +91,8 @@ export default function ApiTester({ config, onMessage, authFetch }) {
if (!res.ok) {
const data = await res.json()
setResponse({ success: false, error: data.error?.message || '请求失败' })
onMessage('error', data.error?.message || '请求失败')
setResponse({ success: false, error: data.error?.message || t('apiTester.requestFailed') })
onMessage('error', data.error?.message || t('apiTester.requestFailed'))
setLoading(false)
setIsStreaming(false)
return
@@ -138,9 +141,9 @@ export default function ApiTester({ config, onMessage, authFetch }) {
}
} catch (e) {
if (e.name === 'AbortError') {
onMessage('info', '已停止生成')
onMessage('info', t('messages.generationStopped'))
} else {
onMessage('error', '网络错误: ' + e.message)
onMessage('error', t('apiTester.networkError', { error: e.message }))
setResponse({ error: e.message, success: false })
}
} finally {
@@ -172,12 +175,12 @@ export default function ApiTester({ config, onMessage, authFetch }) {
account: selectedAccount,
})
if (data.success) {
onMessage('success', `${selectedAccount}: 测试成功 (${data.response_time}ms)`)
onMessage('success', t('apiTester.testSuccess', { account: selectedAccount, time: data.response_time }))
} else {
onMessage('error', `${selectedAccount}: ${data.message}`)
}
} catch (e) {
onMessage('error', '网络错误: ' + e.message)
onMessage('error', t('apiTester.networkError', { error: e.message }))
setResponse({ error: e.message })
} finally {
setLoading(false)
@@ -188,6 +191,11 @@ export default function ApiTester({ config, onMessage, authFetch }) {
directTest()
}
useEffect(() => {
setMessage((prev) => (prev === defaultMessageRef.current ? defaultMessage : prev))
defaultMessageRef.current = defaultMessage
}, [defaultMessage])
return (
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-6 h-[calc(100vh-140px)]">
{/* Configuration Panel */}
@@ -201,12 +209,12 @@ export default function ApiTester({ config, onMessage, authFetch }) {
onClick={() => setConfigExpanded(!configExpanded)}
className="lg:hidden flex items-center justify-between p-4 w-full bg-muted/20 hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2.5 font-medium text-sm text-foreground">
<div className="p-1.5 rounded-md bg-transparent text-foreground">
<Terminal className="w-4 h-4" />
<div className="flex items-center gap-2.5 font-medium text-sm text-foreground">
<div className="p-1.5 rounded-md bg-transparent text-foreground">
<Terminal className="w-4 h-4" />
</div>
<span>{t('apiTester.config')}</span>
</div>
<span>配置</span>
</div>
<div className={clsx("transition-transform duration-300 text-muted-foreground", configExpanded ? "rotate-180" : "")}>
<ChevronDown className="w-4 h-4" />
</div>
@@ -217,9 +225,9 @@ export default function ApiTester({ config, onMessage, authFetch }) {
!configExpanded && "hidden lg:block"
)}>
<div className="space-y-3">
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">模型</label>
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.modelLabel')}</label>
<div className="grid grid-cols-1 gap-2">
{MODELS.map(m => {
{models.map(m => {
const Icon = m.icon
return (
<button
@@ -256,14 +264,14 @@ export default function ApiTester({ config, onMessage, authFetch }) {
</div>
<div className="space-y-2">
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">账号策略</label>
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.accountStrategy')}</label>
<div className="relative">
<select
className="w-full h-10 pl-3 pr-8 bg-secondary border border-border rounded-lg text-sm appearance-none focus:outline-none focus:ring-1 focus:ring-ring focus:border-ring transition-all cursor-pointer hover:bg-muted"
value={selectedAccount}
onChange={e => setSelectedAccount(e.target.value)}
>
<option value="" className="bg-popover text-popover-foreground">🎲 随机切换 (支持流式预览)</option>
<option value="" className="bg-popover text-popover-foreground">{t('apiTester.randomRotation')}</option>
{accounts.map((acc, i) => (
<option key={i} value={acc.email || acc.mobile} className="bg-popover text-popover-foreground">
👤 {acc.email || acc.mobile}
@@ -275,11 +283,11 @@ export default function ApiTester({ config, onMessage, authFetch }) {
</div>
<div className="space-y-2">
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">API 密钥 (可选)</label>
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.apiKeyOptional')}</label>
<input
type="password"
className="w-full h-10 px-3 bg-muted/30 border border-border rounded-lg text-sm font-mono placeholder:text-muted-foreground/40 focus:outline-none focus:ring-1 focus:ring-ring focus:border-ring transition-all"
placeholder={config.keys?.[0] ? `默认: ...${config.keys[0].slice(-6)}` : '输入自定义密钥'}
placeholder={config.keys?.[0] ? t('apiTester.apiKeyDefault', { suffix: config.keys[0].slice(-6) }) : t('apiTester.apiKeyPlaceholder')}
value={apiKey}
onChange={e => setApiKey(e.target.value)}
/>
@@ -324,7 +332,7 @@ export default function ApiTester({ config, onMessage, authFetch }) {
"text-[10px] px-1.5 py-0.5 rounded-sm border uppercase font-medium tracking-wider",
response.success ? "border-emerald-500/20 text-emerald-500 bg-emerald-500/10" : "border-destructive/20 text-destructive bg-destructive/10"
)}>
{response.status_code || '错误'}
{response.status_code || t('apiTester.statusError')}
</span>
)}
</div>
@@ -333,7 +341,7 @@ export default function ApiTester({ config, onMessage, authFetch }) {
<div className="text-xs bg-secondary/50 border border-border rounded-lg p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Zap className="w-3.5 h-3.5" />
<span className="font-medium">思维链过程</span>
<span className="font-medium">{t('apiTester.reasoningTrace')}</span>
</div>
<div className="whitespace-pre-wrap leading-relaxed text-muted-foreground font-mono text-[11px] max-h-60 overflow-y-auto custom-scrollbar pl-5 border-l-2 border-border/50">
{streamingThinking || response?.response?.thinking}
@@ -345,7 +353,7 @@ export default function ApiTester({ config, onMessage, authFetch }) {
{!selectedAccount ? (
streamingContent || (response?.error && <span className="text-destructive font-medium">{response.error}</span>)
) : (
response?.response?.message || <span className="text-muted-foreground italic">正在生成响应...</span>
response?.response?.message || <span className="text-muted-foreground italic">{t('apiTester.generating')}</span>
)}
{isStreaming && <span className="inline-block w-1.5 h-4 bg-primary ml-1 align-middle animate-pulse" />}
</div>
@@ -357,9 +365,9 @@ export default function ApiTester({ config, onMessage, authFetch }) {
{/* Input Area */}
<div className="p-4 lg:p-6 border-t border-border bg-card">
<div className="max-w-4xl mx-auto relative group">
<textarea
className="w-full bg-[#09090b] border border-border rounded-xl pl-4 pr-12 py-3 text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all resize-none custom-scrollbar placeholder:text-muted-foreground/50 text-foreground shadow-inner"
placeholder="输入消息..."
<textarea
className="w-full bg-[#09090b] border border-border rounded-xl pl-4 pr-12 py-3 text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all resize-none custom-scrollbar placeholder:text-muted-foreground/50 text-foreground shadow-inner"
placeholder={t('apiTester.enterMessage')}
rows={1}
style={{ minHeight: '52px' }}
value={message}
@@ -391,7 +399,7 @@ export default function ApiTester({ config, onMessage, authFetch }) {
</div>
</div>
<div className="max-w-4xl mx-auto mt-3 flex justify-center">
<span className="text-[10px] text-muted-foreground/40 font-medium">DeepSeek 管理员界面</span>
<span className="text-[10px] text-muted-foreground/40 font-medium">{t('apiTester.adminConsoleLabel')}</span>
</div>
</div>
</div>

View File

@@ -1,68 +1,69 @@
import { useState } from 'react'
import { FileCode, Download, Upload, Copy, Check, AlertTriangle } from 'lucide-react'
import clsx from 'clsx'
const TEMPLATES = {
full: {
name: '全量配置模板',
desc: '包含密钥、账号及模型映射',
config: {
keys: ["your-api-key-1", "your-api-key-2"],
accounts: [
{ email: "user1@example.com", password: "password1", token: "" },
{ email: "user2@example.com", password: "password2", token: "" },
{ mobile: "+8613800138001", password: "password3", token: "" }
],
claude_model_mapping: {
fast: "deepseek-chat",
slow: "deepseek-reasoner"
}
}
},
email_only: {
name: '仅邮箱账号',
desc: '批量导入邮箱格式账号',
config: {
keys: ["your-api-key"],
accounts: [
{ email: "account1@example.com", password: "pass1", token: "" },
{ email: "account2@example.com", password: "pass2", token: "" },
{ email: "account3@example.com", password: "pass3", token: "" }
]
}
},
mobile_only: {
name: '仅手机号账号',
desc: '批量导入手机号格式账号',
config: {
keys: ["your-api-key"],
accounts: [
{ mobile: "+8613800000001", password: "pass1", token: "" },
{ mobile: "+8613800000002", password: "pass2", token: "" },
{ mobile: "+8613800000003", password: "pass3", token: "" }
]
}
},
keys_only: {
name: '仅 API 密钥',
desc: '仅添加 API 访问密钥',
config: {
keys: ["key-1", "key-2", "key-3"]
}
}
}
import { useI18n } from '../i18n'
export default function BatchImport({ onRefresh, onMessage, authFetch }) {
const { t } = useI18n()
const [jsonInput, setJsonInput] = useState('')
const [loading, setLoading] = useState(false)
const [result, setResult] = useState(null)
const [copied, setCopied] = useState(false)
const apiFetch = authFetch || fetch
const templates = {
full: {
name: t('batchImport.templates.full.name'),
desc: t('batchImport.templates.full.desc'),
config: {
keys: ["your-api-key-1", "your-api-key-2"],
accounts: [
{ email: "user1@example.com", password: "password1", token: "" },
{ email: "user2@example.com", password: "password2", token: "" },
{ mobile: "+8613800138001", password: "password3", token: "" }
],
claude_model_mapping: {
fast: "deepseek-chat",
slow: "deepseek-reasoner"
}
}
},
email_only: {
name: t('batchImport.templates.emailOnly.name'),
desc: t('batchImport.templates.emailOnly.desc'),
config: {
keys: ["your-api-key"],
accounts: [
{ email: "account1@example.com", password: "pass1", token: "" },
{ email: "account2@example.com", password: "pass2", token: "" },
{ email: "account3@example.com", password: "pass3", token: "" }
]
}
},
mobile_only: {
name: t('batchImport.templates.mobileOnly.name'),
desc: t('batchImport.templates.mobileOnly.desc'),
config: {
keys: ["your-api-key"],
accounts: [
{ mobile: "+8613800000001", password: "pass1", token: "" },
{ mobile: "+8613800000002", password: "pass2", token: "" },
{ mobile: "+8613800000003", password: "pass3", token: "" }
]
}
},
keys_only: {
name: t('batchImport.templates.keysOnly.name'),
desc: t('batchImport.templates.keysOnly.desc'),
config: {
keys: ["key-1", "key-2", "key-3"]
}
}
}
const handleImport = async () => {
if (!jsonInput.trim()) {
onMessage('error', '请输入 JSON 配置内容')
onMessage('error', t('batchImport.enterJson'))
return
}
@@ -70,7 +71,7 @@ export default function BatchImport({ onRefresh, onMessage, authFetch }) {
try {
config = JSON.parse(jsonInput)
} catch (e) {
onMessage('error', '无效的 JSON 格式')
onMessage('error', t('messages.invalidJson'))
return
}
@@ -85,23 +86,23 @@ export default function BatchImport({ onRefresh, onMessage, authFetch }) {
const data = await res.json()
if (res.ok) {
setResult(data)
onMessage('success', `导入成功: ${data.imported_keys} 个密钥, ${data.imported_accounts} 个账号`)
onMessage('success', t('batchImport.importSuccess', { keys: data.imported_keys, accounts: data.imported_accounts }))
onRefresh()
} else {
onMessage('error', data.detail || '导入失败')
onMessage('error', data.detail || t('messages.importFailed'))
}
} catch (e) {
onMessage('error', '网络错误')
onMessage('error', t('messages.networkError'))
} finally {
setLoading(false)
}
}
const loadTemplate = (key) => {
const tpl = TEMPLATES[key]
const tpl = templates[key]
if (tpl) {
setJsonInput(JSON.stringify(tpl.config, null, 2))
onMessage('info', `已加载模板: ${tpl.name}`)
onMessage('info', t('batchImport.templateLoaded', { name: tpl.name }))
}
}
@@ -111,10 +112,10 @@ export default function BatchImport({ onRefresh, onMessage, authFetch }) {
if (res.ok) {
const data = await res.json()
setJsonInput(JSON.stringify(JSON.parse(data.json), null, 2))
onMessage('success', '当前配置已加载')
onMessage('success', t('batchImport.currentConfigLoaded'))
}
} catch (e) {
onMessage('error', '获取配置失败')
onMessage('error', t('batchImport.fetchConfigFailed'))
}
}
@@ -126,10 +127,10 @@ export default function BatchImport({ onRefresh, onMessage, authFetch }) {
await navigator.clipboard.writeText(data.base64)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
onMessage('success', 'Base64 配置已复制到剪贴板')
onMessage('success', t('batchImport.copySuccess'))
}
} catch (e) {
onMessage('error', '复制失败')
onMessage('error', t('messages.copyFailed'))
}
}
@@ -140,10 +141,10 @@ export default function BatchImport({ onRefresh, onMessage, authFetch }) {
<div className="bg-card border border-border rounded-xl p-5 shadow-sm">
<h3 className="font-semibold flex items-center gap-2 mb-4">
<FileCode className="w-4 h-4 text-primary" />
快速模板
{t('batchImport.quickTemplates')}
</h3>
<div className="space-y-3">
{Object.entries(TEMPLATES).map(([key, tpl]) => (
{Object.entries(templates).map(([key, tpl]) => (
<button
key={key}
onClick={() => loadTemplate(key)}
@@ -159,20 +160,20 @@ export default function BatchImport({ onRefresh, onMessage, authFetch }) {
<div className="bg-linear-to-br from-primary/10 to-transparent border border-primary/20 rounded-xl p-5 shadow-sm">
<h3 className="font-semibold flex items-center gap-2 mb-2 text-primary">
<Download className="w-4 h-4" />
数据导出
{t('batchImport.dataExport')}
</h3>
<p className="text-sm text-muted-foreground mb-4">
获取配置的 Base64 字符串用于 Vercel 环境变量
{t('batchImport.dataExportDesc')}
</p>
<button
onClick={copyBase64}
className="w-full flex items-center justify-center gap-2 py-2.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-all font-medium text-sm shadow-sm"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? '已复制' : '复制 Base64 配置'}
{copied ? t('batchImport.copied') : t('batchImport.copyBase64')}
</button>
<p className="text-[10px] text-muted-foreground mt-2 text-center">
变量名: <code className="bg-background px-1 py-0.5 rounded border border-border">DS2API_CONFIG_JSON</code>
{t('batchImport.variableName')}: <code className="bg-background px-1 py-0.5 rounded border border-border">DS2API_CONFIG_JSON</code>
</p>
</div>
</div>
@@ -182,14 +183,14 @@ export default function BatchImport({ onRefresh, onMessage, authFetch }) {
<div className="p-4 border-b border-border flex items-center justify-between bg-muted/20">
<h3 className="font-semibold flex items-center gap-2">
<Upload className="w-4 h-4 text-primary" />
JSON 编辑器
{t('batchImport.jsonEditor')}
</h3>
<div className="flex gap-2">
<button onClick={handleExport} className="px-3 py-1.5 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-xs font-medium border border-border">
加载当前配置
{t('batchImport.loadCurrentConfig')}
</button>
<button onClick={handleImport} disabled={loading} className="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-xs font-medium disabled:opacity-50">
{loading ? '正在导入...' : '应用配置'}
{loading ? t('batchImport.importing') : t('batchImport.applyConfig')}
</button>
</div>
</div>
@@ -217,10 +218,10 @@ export default function BatchImport({ onRefresh, onMessage, authFetch }) {
)}
<div>
<h4 className={clsx("font-medium", result.imported_keys || result.imported_accounts ? "text-emerald-500" : "text-destructive")}>
导入操作已完成
{t('batchImport.importComplete')}
</h4>
<p className="text-sm opacity-80 mt-1">
成功导入了 {result.imported_keys} API 密钥并更新了 {result.imported_accounts} 个账号
{t('batchImport.importSummary', { keys: result.imported_keys, accounts: result.imported_accounts })}
</p>
</div>
</div>

View File

@@ -1,6 +1,9 @@
import React from 'react'
import { useI18n } from '../i18n'
import LanguageToggle from './LanguageToggle'
const LandingPage = ({ onEnter }) => {
const { t } = useI18n()
return (
<div className="landing-container min-h-screen relative overflow-hidden flex flex-col items-center justify-center p-6 text-center">
{/* Animated Background Elements - using Tailwind with some custom CSS in styles.css if needed,
@@ -84,6 +87,10 @@ const LandingPage = ({ onEnter }) => {
<div className="blob" style={{ top: '10%', left: '15%' }} />
<div className="blob" style={{ bottom: '10%', right: '15%', animationDelay: '-5s' }} />
<div className="absolute top-6 right-6 z-20">
<LanguageToggle />
</div>
<div className="landing-content">
<header className="mb-12">
<h1 className="logo-text">DS2API</h1>
@@ -97,14 +104,14 @@ const LandingPage = ({ onEnter }) => {
onClick={onEnter}
className="btn-premium text-white px-8 py-3 rounded-xl font-bold transition-all flex items-center gap-2"
>
<span>🎛</span> 管理面板
<span>🎛</span> {t('landing.adminConsole')}
</button>
<a
href="/v1/models"
target="_blank"
className="glass-card text-white px-8 py-3 rounded-xl font-semibold transition-all flex items-center gap-2"
>
<span>📡</span> API 状态
<span>📡</span> {t('landing.apiStatus')}
</a>
<a
href="https://github.com/CJackHwang/ds2api"
@@ -117,10 +124,10 @@ const LandingPage = ({ onEnter }) => {
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6 text-left">
{[
{ icon: '🚀', title: '全面兼容', desc: '适配 OpenAI 与 Claude 格式' },
{ icon: '⚖️', title: '负载均衡', desc: '智能轮询,稳定高效' },
{ icon: '🧠', title: '深度思考', desc: '支持推理过程输出' },
{ icon: '🔍', title: '联网搜索', desc: '集成原生网页搜索能力' },
{ icon: '🚀', title: t('landing.features.compatibility.title'), desc: t('landing.features.compatibility.desc') },
{ icon: '⚖️', title: t('landing.features.loadBalancing.title'), desc: t('landing.features.loadBalancing.desc') },
{ icon: '🧠', title: t('landing.features.reasoning.title'), desc: t('landing.features.reasoning.desc') },
{ icon: '🔍', title: t('landing.features.search.title'), desc: t('landing.features.search.desc') },
].map((feature, idx) => (
<div key={idx} className="glass-card p-6 rounded-2xl">
<span className="text-2xl mb-4 block">{feature.icon}</span>

View File

@@ -0,0 +1,18 @@
import { useI18n } from '../i18n'
export default function LanguageToggle({ className = '' }) {
const { lang, setLang, t } = useI18n()
const nextLang = lang === 'zh' ? 'en' : 'zh'
const label = nextLang === 'zh' ? t('language.chinese') : t('language.english')
return (
<button
type="button"
onClick={() => setLang(nextLang)}
className={`text-xs font-semibold px-2 py-1 rounded-md border border-border bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors ${className}`}
title={t('language.label')}
>
{label}
</button>
)
}

View File

@@ -1,8 +1,11 @@
import { useState } from 'react'
import { Key, ArrowRight, ShieldCheck, Lock, Check } from 'lucide-react'
import clsx from 'clsx'
import { useI18n } from '../i18n'
import LanguageToggle from './LanguageToggle'
export default function Login({ onLogin, onMessage }) {
const { t } = useI18n()
const [adminKey, setAdminKey] = useState('')
const [loading, setLoading] = useState(false)
const [remember, setRemember] = useState(true)
@@ -32,10 +35,10 @@ export default function Login({ onLogin, onMessage }) {
onMessage('warning', data.message)
}
} else {
onMessage('error', data.detail || '登录失败')
onMessage('error', data.detail || t('login.signInFailed'))
}
} catch (e) {
onMessage('error', '网络错误: ' + e.message)
onMessage('error', t('login.networkError', { error: e.message }))
} finally {
setLoading(false)
}
@@ -43,6 +46,9 @@ export default function Login({ onLogin, onMessage }) {
return (
<div className="min-h-screen w-full flex flex-col items-center justify-center p-4 bg-background text-foreground">
<div className="absolute top-6 right-6">
<LanguageToggle />
</div>
<div className="w-full max-w-[400px] relative z-10 animate-in fade-in zoom-in-95 duration-200">
<div className="w-full bg-card border border-border rounded-xl p-8 shadow-sm">
@@ -50,13 +56,13 @@ export default function Login({ onLogin, onMessage }) {
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 text-primary mb-2">
<Lock className="w-6 h-6" />
</div>
<h1 className="text-3xl font-bold tracking-tight text-foreground">欢迎回来</h1>
<p className="text-sm text-muted-foreground/80">请输入管理员密钥以继续</p>
<h1 className="text-3xl font-bold tracking-tight text-foreground">{t('login.welcome')}</h1>
<p className="text-sm text-muted-foreground/80">{t('login.subtitle')}</p>
</div>
<form onSubmit={handleLogin} className="space-y-5 animate-in fade-in slide-in-from-bottom-4 duration-700 delay-150">
<div className="space-y-2">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-widest ml-1">管理员密钥</label>
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-widest ml-1">{t('login.adminKeyLabel')}</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-muted-foreground group-focus-within:text-primary transition-colors">
<Key className="w-4 h-4" />
@@ -64,7 +70,7 @@ export default function Login({ onLogin, onMessage }) {
<input
type="password"
className="w-full bg-[#09090b] border border-border rounded-xl pl-10 pr-4 py-3 text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all placeholder:text-muted-foreground/30 text-foreground"
placeholder="输入您的管理员密钥..."
placeholder={t('login.adminKeyPlaceholder')}
value={adminKey}
onChange={e => setAdminKey(e.target.value)}
autoFocus
@@ -84,7 +90,7 @@ export default function Login({ onLogin, onMessage }) {
<div className="w-4.5 h-4.5 bg-secondary border border-border rounded-md peer-checked:bg-primary peer-checked:border-primary transition-all shadow-sm"></div>
<Check className="absolute w-3 h-3 text-primary-foreground opacity-0 peer-checked:opacity-100 left-0.5 transition-opacity" />
</div>
<span className="text-xs font-medium text-muted-foreground group-hover:text-foreground transition-colors">记住登录状态</span>
<span className="text-xs font-medium text-muted-foreground group-hover:text-foreground transition-colors">{t('login.rememberSession')}</span>
</label>
</div>
@@ -97,7 +103,7 @@ export default function Login({ onLogin, onMessage }) {
<div className="w-5 h-5 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
) : (
<div className="flex items-center gap-2">
<span>登录</span>
<span>{t('login.signIn')}</span>
<ArrowRight className="w-4 h-4" />
</div>
)}
@@ -107,13 +113,13 @@ export default function Login({ onLogin, onMessage }) {
<div className="mt-6 pt-6 border-t border-border flex justify-center">
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/60 font-medium tracking-wide uppercase">
<ShieldCheck className="w-3 h-3" />
<span>安全连接</span>
<span>{t('login.secureConnection')}</span>
</div>
</div>
</div>
<div className="mt-8 text-center">
<p className="text-[10px] text-muted-foreground/30 font-mono text-center">DS2API 管理员门户</p>
<p className="text-[10px] text-muted-foreground/30 font-mono text-center">{t('login.adminPortal')}</p>
</div>
</div>
</div>

View File

@@ -1,7 +1,9 @@
import { useState, useEffect } from 'react'
import { Cloud, ArrowRight, ExternalLink, Info, CheckCircle2, XCircle } from 'lucide-react'
import { useI18n } from '../i18n'
export default function VercelSync({ onMessage, authFetch }) {
const { t } = useI18n()
const [vercelToken, setVercelToken] = useState('')
const [projectId, setProjectId] = useState('')
const [teamId, setTeamId] = useState('')
@@ -32,11 +34,11 @@ export default function VercelSync({ onMessage, authFetch }) {
const tokenToUse = preconfig?.has_token && !vercelToken ? '__USE_PRECONFIG__' : vercelToken
if (!tokenToUse && !preconfig?.has_token) {
onMessage('error', '需要 Vercel 访问令牌')
onMessage('error', t('vercel.tokenRequired'))
return
}
if (!projectId) {
onMessage('error', '需要项目 ID')
onMessage('error', t('vercel.projectRequired'))
return
}
@@ -47,7 +49,7 @@ export default function VercelSync({ onMessage, authFetch }) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
vercel_token: vercelToken,
vercel_token: tokenToUse,
project_id: projectId,
team_id: teamId || undefined,
}),
@@ -58,10 +60,10 @@ export default function VercelSync({ onMessage, authFetch }) {
onMessage('success', data.message)
} else {
setResult({ ...data, success: false })
onMessage('error', data.detail || '同步失败')
onMessage('error', data.detail || t('vercel.syncFailed'))
}
} catch (e) {
onMessage('error', '网络错误')
onMessage('error', t('vercel.networkError'))
} finally {
setLoading(false)
}
@@ -74,26 +76,26 @@ export default function VercelSync({ onMessage, authFetch }) {
<div className="border-b border-border pb-6">
<h2 className="text-xl font-semibold flex items-center gap-2">
<Cloud className="w-6 h-6 text-primary" />
Vercel 部署
{t('vercel.title')}
</h2>
<p className="text-muted-foreground text-sm mt-1">
将当前密钥和账号配置直接同步到 Vercel 环境变量中
{t('vercel.description')}
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium flex items-center justify-between">
Vercel 访问令牌
{t('vercel.tokenLabel')}
<a href="https://vercel.com/account/tokens" target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline flex items-center gap-1">
获取令牌 <ExternalLink className="w-3 h-3" />
{t('vercel.getToken')} <ExternalLink className="w-3 h-3" />
</a>
</label>
<div className="relative">
<input
type="password"
className="w-full h-10 px-3 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-all pr-10"
placeholder={preconfig?.has_token ? "正在使用预配置的令牌" : "输入 Vercel 访问令牌"}
placeholder={preconfig?.has_token ? t('vercel.tokenPlaceholderPreconfig') : t('vercel.tokenPlaceholder')}
value={vercelToken}
onChange={e => setVercelToken(e.target.value)}
/>
@@ -106,7 +108,7 @@ export default function VercelSync({ onMessage, authFetch }) {
</div>
<div className="space-y-2">
<label className="text-sm font-medium">项目 ID</label>
<label className="text-sm font-medium">{t('vercel.projectIdLabel')}</label>
<input
type="text"
className="w-full h-10 px-3 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-all"
@@ -114,12 +116,12 @@ export default function VercelSync({ onMessage, authFetch }) {
value={projectId}
onChange={e => setProjectId(e.target.value)}
/>
<p className="text-xs text-muted-foreground">可在项目设置 (Project Settings) 常规 (General) 中找到</p>
<p className="text-xs text-muted-foreground">{t('vercel.projectIdHint')}</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
团队 ID <span className="text-xs text-muted-foreground font-normal">(可选)</span>
{t('vercel.teamIdLabel')} <span className="text-xs text-muted-foreground font-normal">({t('vercel.optional')})</span>
</label>
<input
type="text"
@@ -140,16 +142,16 @@ export default function VercelSync({ onMessage, authFetch }) {
{loading ? (
<span className="flex items-center gap-2">
<span className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
正在同步...
{t('vercel.syncing')}
</span>
) : (
<span className="flex items-center gap-2">
同步并重新部署 <ArrowRight className="w-4 h-4" />
{t('vercel.syncRedeploy')} <ArrowRight className="w-4 h-4" />
</span>
)}
</button>
<p className="text-xs text-center text-muted-foreground mt-4">
这将触发 Vercel 的重新部署大约需要 30-60
{t('vercel.redeployHint')}
</p>
</div>
</div>
@@ -170,14 +172,14 @@ export default function VercelSync({ onMessage, authFetch }) {
)}
<div className="space-y-1">
<h3 className={`font-semibold text-lg ${result.success ? 'text-emerald-500' : 'text-destructive'}`}>
{result.success ? '同步成功' : '同步失败'}
{result.success ? t('vercel.syncSucceeded') : t('vercel.syncFailedLabel')}
</h3>
<p className="text-sm opacity-90">{result.message}</p>
{result.deployment_url && (
<div className="pt-3 mt-3 border-t border-emerald-500/20">
<a href={`https://${result.deployment_url}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-sm font-medium hover:underline">
访问部署地址 <ExternalLink className="w-3 h-3" />
{t('vercel.openDeployment')} <ExternalLink className="w-3 h-3" />
</a>
</div>
)}
@@ -189,24 +191,26 @@ export default function VercelSync({ onMessage, authFetch }) {
<div className="bg-secondary/20 border border-border rounded-xl p-6">
<h3 className="font-semibold flex items-center gap-2 mb-4">
<Info className="w-5 h-5 text-primary" />
工作原理
{t('vercel.howItWorks')}
</h3>
<ul className="space-y-4">
<li className="flex gap-3">
<span className="shrink-0 w-6 h-6 rounded-full bg-background border border-border flex items-center justify-center text-xs font-bold text-muted-foreground">1</span>
<p className="text-sm text-muted-foreground">当前配置 (密钥和账号) 被导出为 JSON 字符串</p>
<p className="text-sm text-muted-foreground">{t('vercel.steps.one')}</p>
</li>
<li className="flex gap-3">
<span className="shrink-0 w-6 h-6 rounded-full bg-background border border-border flex items-center justify-center text-xs font-bold text-muted-foreground">2</span>
<p className="text-sm text-muted-foreground">JSON 被编码为 Base64 以确保格式兼容性</p>
<p className="text-sm text-muted-foreground">{t('vercel.steps.two')}</p>
</li>
<li className="flex gap-3">
<span className="shrink-0 w-6 h-6 rounded-full bg-background border border-border flex items-center justify-center text-xs font-bold text-muted-foreground">3</span>
<p className="text-sm text-muted-foreground">更新 Vercel 项目中的 <code className="bg-background px-1 py-0.5 rounded border border-border text-xs">DS2API_CONFIG_JSON</code> 环境变量</p>
<p className="text-sm text-muted-foreground">
{t('vercel.steps.three')} <code className="bg-background px-1 py-0.5 rounded border border-border text-xs">DS2API_CONFIG_JSON</code>
</p>
</li>
<li className="flex gap-3">
<span className="shrink-0 w-6 h-6 rounded-full bg-background border border-border flex items-center justify-center text-xs font-bold text-muted-foreground">4</span>
<p className="text-sm text-muted-foreground">触发重新部署以应用新的环境变量</p>
<p className="text-sm text-muted-foreground">{t('vercel.steps.four')}</p>
</li>
</ul>
</div>

62
webui/src/i18n.jsx Normal file
View File

@@ -0,0 +1,62 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import en from './locales/en.json'
import zh from './locales/zh.json'
const STORAGE_KEY = 'ds2api_lang'
const translations = { en, zh }
const I18nContext = createContext({
lang: 'zh',
setLang: () => {},
t: (key) => key,
})
const getBrowserLang = () => {
if (typeof navigator === 'undefined') return 'zh'
return navigator.language?.toLowerCase().startsWith('zh') ? 'zh' : 'en'
}
const getValue = (obj, key) => {
if (!obj) return undefined
return key.split('.').reduce((acc, part) => (acc ? acc[part] : undefined), obj)
}
const formatMessage = (message, vars) => {
if (!vars) return message
return message.replace(/\{(\w+)\}/g, (match, key) => {
if (Object.prototype.hasOwnProperty.call(vars, key)) {
return vars[key]
}
return match
})
}
export const I18nProvider = ({ children }) => {
const [lang, setLang] = useState(() => {
if (typeof localStorage === 'undefined') return getBrowserLang()
return localStorage.getItem(STORAGE_KEY) || getBrowserLang()
})
useEffect(() => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEY, lang)
}
if (typeof document !== 'undefined') {
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'
}
}, [lang])
const t = useMemo(() => {
return (key, vars) => {
const value = getValue(translations[lang], key) ?? getValue(translations.en, key) ?? key
if (typeof value !== 'string') return value
return formatMessage(value, vars)
}
}, [lang])
const contextValue = useMemo(() => ({ lang, setLang, t }), [lang, t])
return <I18nContext.Provider value={contextValue}>{children}</I18nContext.Provider>
}
export const useI18n = () => useContext(I18nContext)

231
webui/src/locales/en.json Normal file
View File

@@ -0,0 +1,231 @@
{
"language": {
"label": "Language",
"english": "English",
"chinese": "中文"
},
"nav": {
"accounts": {
"label": "Account Management",
"desc": "Manage the DeepSeek account pool"
},
"test": {
"label": "API Test",
"desc": "Test API connectivity and responses"
},
"import": {
"label": "Batch Import",
"desc": "Bulk import account configuration"
},
"vercel": {
"label": "Vercel Sync",
"desc": "Sync configuration to Vercel"
}
},
"sidebar": {
"onlineAdminConsole": "Online Admin Console",
"systemStatus": "System Status",
"statusOnline": "Online",
"accounts": "Accounts",
"keys": "Keys",
"signOut": "Sign out"
},
"auth": {
"expired": "Authentication expired. Please sign in again.",
"checking": "Checking authentication status..."
},
"errors": {
"fetchConfig": "Failed to fetch configuration: {error}"
},
"actions": {
"cancel": "Cancel",
"add": "Add",
"delete": "Delete",
"copy": "Copy",
"generate": "Generate",
"test": "Test",
"testing": "Testing...",
"loading": "Loading..."
},
"messages": {
"deleted": "Deleted successfully",
"deleteFailed": "Delete failed",
"failedToAdd": "Failed to add",
"networkError": "Network error.",
"requestFailed": "Request failed.",
"generationStopped": "Generation stopped.",
"invalidJson": "Invalid JSON format.",
"importFailed": "Import failed.",
"copyFailed": "Copy failed."
},
"landing": {
"adminConsole": "Admin Console",
"apiStatus": "API Status",
"features": {
"compatibility": {
"title": "Full Compatibility",
"desc": "OpenAI & Claude format support"
},
"loadBalancing": {
"title": "Load Balancing",
"desc": "Smart rotation with stable throughput"
},
"reasoning": {
"title": "Deep Reasoning",
"desc": "Expose reasoning traces when enabled"
},
"search": {
"title": "Web Search",
"desc": "Integrated native web search"
}
}
},
"accountManager": {
"addKeySuccess": "API key added successfully.",
"addAccountSuccess": "Account added successfully.",
"requiredFields": "Password and email/mobile are required.",
"deleteKeyConfirm": "Are you sure you want to delete this API key?",
"deleteAccountConfirm": "Are you sure you want to delete this account?",
"testAllConfirm": "Test API connectivity for all accounts?",
"testAllCompleted": "Completed: {success}/{total} available",
"testFailed": "Test failed: {error}",
"available": "Available",
"inUse": "In use",
"totalPool": "Total pool",
"accountsUnit": "accounts",
"threadsUnit": "threads",
"apiKeysTitle": "API Keys",
"apiKeysDesc": "Manage the API access key pool",
"addKey": "Add key",
"copied": "Copied",
"copyKeyTitle": "Copy key",
"deleteKeyTitle": "Delete key",
"noApiKeys": "No API keys found.",
"accountsTitle": "DeepSeek Accounts",
"accountsDesc": "Manage the DeepSeek account pool",
"testAll": "Test all",
"addAccount": "Add account",
"testingAllAccounts": "Testing all accounts...",
"sessionActive": "Session active",
"reauthRequired": "Re-auth required",
"noAccounts": "No accounts found.",
"modalAddKeyTitle": "Add API key",
"newKeyLabel": "New key value",
"newKeyPlaceholder": "Enter a custom API key",
"generate": "Generate",
"generateHint": "Click Generate to create a random key.",
"addKeyLoading": "Adding...",
"addKeyAction": "Add key",
"modalAddAccountTitle": "Add DeepSeek account",
"emailOptional": "Email (optional)",
"mobileOptional": "Mobile (optional)",
"passwordLabel": "Password",
"passwordPlaceholder": "Account password",
"addAccountLoading": "Adding...",
"addAccountAction": "Add account"
},
"apiTester": {
"defaultMessage": "Hello, please introduce yourself in one sentence.",
"models": {
"chat": "Non-reasoning model",
"reasoner": "Reasoning model",
"chatSearch": "Non-reasoning model (with search)",
"reasonerSearch": "Reasoning model (with search)"
},
"missingApiKey": "Please provide an API key.",
"requestFailed": "Request failed.",
"networkError": "Network error: {error}",
"testSuccess": "{account}: Test successful ({time}ms)",
"config": "Configuration",
"modelLabel": "Model",
"accountStrategy": "Account Strategy",
"randomRotation": "🎲 Random rotation (streaming preview supported)",
"apiKeyOptional": "API Key (optional)",
"apiKeyDefault": "Default: ...{suffix}",
"apiKeyPlaceholder": "Enter a custom key",
"statusError": "Error",
"reasoningTrace": "Reasoning Trace",
"generating": "Generating response...",
"enterMessage": "Enter a message...",
"adminConsoleLabel": "DeepSeek admin console"
},
"batchImport": {
"templates": {
"full": {
"name": "Full configuration template",
"desc": "Includes keys, accounts, and model mapping"
},
"emailOnly": {
"name": "Email-only accounts",
"desc": "Batch import accounts using email login"
},
"mobileOnly": {
"name": "Mobile-only accounts",
"desc": "Batch import accounts using mobile login"
},
"keysOnly": {
"name": "API keys only",
"desc": "Add API access keys only"
}
},
"enterJson": "Please provide JSON configuration content.",
"importSuccess": "Import successful: {keys} keys, {accounts} accounts",
"templateLoaded": "Template loaded: {name}",
"currentConfigLoaded": "Current configuration loaded.",
"fetchConfigFailed": "Failed to fetch configuration.",
"copySuccess": "Base64 configuration copied to clipboard.",
"quickTemplates": "Quick Templates",
"dataExport": "Data Export",
"dataExportDesc": "Copy the Base64-encoded configuration for Vercel environment variables.",
"copyBase64": "Copy Base64 config",
"copied": "Copied",
"variableName": "Variable name",
"jsonEditor": "JSON Editor",
"loadCurrentConfig": "Load current config",
"applyConfig": "Apply config",
"importing": "Importing...",
"importComplete": "Import complete",
"importSummary": "Imported {keys} API keys and updated {accounts} accounts."
},
"login": {
"welcome": "Welcome back",
"subtitle": "Enter your admin key to continue",
"adminKeyLabel": "Admin key",
"adminKeyPlaceholder": "Enter your admin key...",
"rememberSession": "Remember this session",
"signIn": "Sign in",
"secureConnection": "Secure connection",
"adminPortal": "DS2API admin portal",
"signInFailed": "Sign-in failed.",
"networkError": "Network error: {error}"
},
"vercel": {
"tokenRequired": "Vercel access token is required.",
"projectRequired": "Project ID is required.",
"syncFailed": "Sync failed.",
"networkError": "Network error.",
"title": "Vercel Deployment",
"description": "Sync the current keys and accounts directly to Vercel environment variables.",
"tokenLabel": "Vercel Access Token",
"getToken": "Get token",
"tokenPlaceholderPreconfig": "Using preconfigured token",
"tokenPlaceholder": "Enter Vercel access token",
"projectIdLabel": "Project ID",
"projectIdHint": "Find it in Project Settings → General.",
"teamIdLabel": "Team ID",
"optional": "optional",
"syncing": "Syncing...",
"syncRedeploy": "Sync & redeploy",
"redeployHint": "This triggers a Vercel redeploy and usually takes 3060 seconds.",
"syncSucceeded": "Sync succeeded",
"syncFailedLabel": "Sync failed",
"openDeployment": "Open deployment",
"howItWorks": "How it works",
"steps": {
"one": "The current configuration (keys and accounts) is exported as JSON.",
"two": "The JSON is Base64-encoded for safe formatting.",
"three": "Update the env var in Vercel:",
"four": "Trigger a redeploy to apply the updated environment variables."
}
}
}

231
webui/src/locales/zh.json Normal file
View File

@@ -0,0 +1,231 @@
{
"language": {
"label": "语言",
"english": "English",
"chinese": "中文"
},
"nav": {
"accounts": {
"label": "账号管理",
"desc": "管理 DeepSeek 账号池"
},
"test": {
"label": "API 测试",
"desc": "测试 API 连接与响应"
},
"import": {
"label": "批量导入",
"desc": "批量导入账号配置"
},
"vercel": {
"label": "Vercel 同步",
"desc": "同步配置到 Vercel"
}
},
"sidebar": {
"onlineAdminConsole": "在线管理面板",
"systemStatus": "系统状态",
"statusOnline": "在线",
"accounts": "账号",
"keys": "密钥",
"signOut": "退出登录"
},
"auth": {
"expired": "认证已过期,请重新登录",
"checking": "正在检查登录状态..."
},
"errors": {
"fetchConfig": "获取配置失败: {error}"
},
"actions": {
"cancel": "取消",
"add": "添加",
"delete": "删除",
"copy": "复制",
"generate": "生成",
"test": "测试",
"testing": "正在测试...",
"loading": "加载中..."
},
"messages": {
"deleted": "删除成功",
"deleteFailed": "删除失败",
"failedToAdd": "添加失败",
"networkError": "网络错误",
"requestFailed": "请求失败",
"generationStopped": "已停止生成",
"invalidJson": "无效的 JSON 格式",
"importFailed": "导入失败",
"copyFailed": "复制失败"
},
"landing": {
"adminConsole": "管理面板",
"apiStatus": "API 状态",
"features": {
"compatibility": {
"title": "全面兼容",
"desc": "适配 OpenAI 与 Claude 格式"
},
"loadBalancing": {
"title": "负载均衡",
"desc": "智能轮询,稳定高效"
},
"reasoning": {
"title": "深度思考",
"desc": "支持推理过程输出"
},
"search": {
"title": "联网搜索",
"desc": "集成原生网页搜索能力"
}
}
},
"accountManager": {
"addKeySuccess": "API 密钥添加成功",
"addAccountSuccess": "账号添加成功",
"requiredFields": "需要填写密码以及邮箱或手机号",
"deleteKeyConfirm": "确定要删除此 API 密钥吗?",
"deleteAccountConfirm": "确定要删除此账号吗?",
"testAllConfirm": "测试所有账号的 API 连通性?",
"testAllCompleted": "完成:{success}/{total} 可用",
"testFailed": "测试失败: {error}",
"available": "可用",
"inUse": "正在使用",
"totalPool": "账号池总数",
"accountsUnit": "个账号",
"threadsUnit": "线程",
"apiKeysTitle": "API 密钥",
"apiKeysDesc": "管理 API 访问密钥池",
"addKey": "添加密钥",
"copied": "已复制",
"copyKeyTitle": "复制密钥",
"deleteKeyTitle": "删除密钥",
"noApiKeys": "未找到 API 密钥",
"accountsTitle": "DeepSeek 账号",
"accountsDesc": "管理 DeepSeek 账号池",
"testAll": "测试全部",
"addAccount": "添加账号",
"testingAllAccounts": "正在测试所有账号...",
"sessionActive": "已建立会话",
"reauthRequired": "需重新登录",
"noAccounts": "未找到任何账号",
"modalAddKeyTitle": "添加 API 密钥",
"newKeyLabel": "新密钥值",
"newKeyPlaceholder": "输入自定义 API 密钥",
"generate": "生成",
"generateHint": "点击「生成」自动创建随机密钥",
"addKeyLoading": "添加中...",
"addKeyAction": "添加密钥",
"modalAddAccountTitle": "添加 DeepSeek 账号",
"emailOptional": "邮箱 (可选)",
"mobileOptional": "手机号 (可选)",
"passwordLabel": "密码",
"passwordPlaceholder": "账号密码",
"addAccountLoading": "添加中...",
"addAccountAction": "添加账号"
},
"apiTester": {
"defaultMessage": "你好,请用一句话介绍你自己。",
"models": {
"chat": "非思考模型",
"reasoner": "思考模型",
"chatSearch": "非思考模型 (带搜索)",
"reasonerSearch": "思考模型 (带搜索)"
},
"missingApiKey": "请提供 API 密钥",
"requestFailed": "请求失败",
"networkError": "网络错误: {error}",
"testSuccess": "{account}: 测试成功 ({time}ms)",
"config": "配置",
"modelLabel": "模型",
"accountStrategy": "账号策略",
"randomRotation": "🎲 随机切换 (支持流式预览)",
"apiKeyOptional": "API 密钥 (可选)",
"apiKeyDefault": "默认: ...{suffix}",
"apiKeyPlaceholder": "输入自定义密钥",
"statusError": "错误",
"reasoningTrace": "思维链过程",
"generating": "正在生成响应...",
"enterMessage": "输入消息...",
"adminConsoleLabel": "DeepSeek 管理员界面"
},
"batchImport": {
"templates": {
"full": {
"name": "全量配置模板",
"desc": "包含密钥、账号及模型映射"
},
"emailOnly": {
"name": "仅邮箱账号",
"desc": "批量导入邮箱格式账号"
},
"mobileOnly": {
"name": "仅手机号账号",
"desc": "批量导入手机号格式账号"
},
"keysOnly": {
"name": "仅 API 密钥",
"desc": "仅添加 API 访问密钥"
}
},
"enterJson": "请输入 JSON 配置内容",
"importSuccess": "导入成功: {keys} 个密钥, {accounts} 个账号",
"templateLoaded": "已加载模板: {name}",
"currentConfigLoaded": "当前配置已加载",
"fetchConfigFailed": "获取配置失败",
"copySuccess": "Base64 配置已复制到剪贴板",
"quickTemplates": "快速模板",
"dataExport": "数据导出",
"dataExportDesc": "获取配置的 Base64 字符串,用于 Vercel 环境变量。",
"copyBase64": "复制 Base64 配置",
"copied": "已复制",
"variableName": "变量名",
"jsonEditor": "JSON 编辑器",
"loadCurrentConfig": "加载当前配置",
"applyConfig": "应用配置",
"importing": "正在导入...",
"importComplete": "导入操作已完成",
"importSummary": "成功导入了 {keys} 个 API 密钥,并更新了 {accounts} 个账号。"
},
"login": {
"welcome": "欢迎回来",
"subtitle": "请输入管理员密钥以继续",
"adminKeyLabel": "管理员密钥",
"adminKeyPlaceholder": "输入您的管理员密钥...",
"rememberSession": "记住登录状态",
"signIn": "登录",
"secureConnection": "安全连接",
"adminPortal": "DS2API 管理员门户",
"signInFailed": "登录失败",
"networkError": "网络错误: {error}"
},
"vercel": {
"tokenRequired": "需要 Vercel 访问令牌",
"projectRequired": "需要项目 ID",
"syncFailed": "同步失败",
"networkError": "网络错误",
"title": "Vercel 部署",
"description": "将当前密钥和账号配置直接同步到 Vercel 环境变量中。",
"tokenLabel": "Vercel 访问令牌",
"getToken": "获取令牌",
"tokenPlaceholderPreconfig": "正在使用预配置的令牌",
"tokenPlaceholder": "输入 Vercel 访问令牌",
"projectIdLabel": "项目 ID",
"projectIdHint": "可在项目设置 (Project Settings) → 常规 (General) 中找到",
"teamIdLabel": "团队 ID",
"optional": "可选",
"syncing": "正在同步...",
"syncRedeploy": "同步并重新部署",
"redeployHint": "这将触发 Vercel 的重新部署,大约需要 30-60 秒。",
"syncSucceeded": "同步成功",
"syncFailedLabel": "同步失败",
"openDeployment": "访问部署地址",
"howItWorks": "工作原理",
"steps": {
"one": "当前配置 (密钥和账号) 被导出为 JSON 字符串。",
"two": "JSON 被编码为 Base64 以确保格式兼容性。",
"three": "更新 Vercel 项目中的环境变量:",
"four": "触发重新部署以应用新的环境变量。"
}
}
}

View File

@@ -2,14 +2,17 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { I18nProvider } from './i18n'
import './styles.css'
const basename = import.meta.env.MODE === 'production' ? '/admin' : '/'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter basename={basename}>
<App />
</BrowserRouter>
<I18nProvider>
<BrowserRouter basename={basename}>
<App />
</BrowserRouter>
</I18nProvider>
</React.StrictMode>,
)