diff --git a/.claude/commands/throttled-task.md b/.claude/commands/throttled-task.md new file mode 100644 index 0000000..72ba1a5 --- /dev/null +++ b/.claude/commands/throttled-task.md @@ -0,0 +1,30 @@ +--- +description: 执行带有请求节流的任务,可以控制工具调用的频率 +--- + +# 节流任务执行 + +你现在进入了节流模式。在这个模式下,你需要: + +## 节流规则 + +1. **工具调用间隔**:每次调用工具后,等待至少 2 秒再调用下一个工具 +2. **并行限制**:同时最多只能并行调用 2 个工具(原本可能更多) +3. **批处理优化**:优先将相关的操作合并到一个工具调用中 +4. **进度提示**:每次等待时向用户说明正在节流等待 + +## 执行方式 + +- 对于读取操作(Read, Glob, Grep),可以适当放宽并行限制 +- 对于写入操作(Write, Edit),严格遵守间隔要求 +- 对于 API 调用(Bash 中的 API 请求),必须串行执行 + +## 用户任务 + +请按照上述节流规则执行以下任务: + +{{PROMPT}} + +--- + +**注意**:完成任务后,你将自动退出节流模式,恢复正常的工具调用频率。 diff --git a/.claude/plans/compiled-discovering-sunset.md b/.claude/plans/compiled-discovering-sunset.md new file mode 100644 index 0000000..6769b63 --- /dev/null +++ b/.claude/plans/compiled-discovering-sunset.md @@ -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; // 请求头(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 + private static timers: Map = 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(全局开关) +├── CurlHistorySection(curl历史记录 + 导入按钮) +└── UrlConfigsSection(URL配置列表 + 新建/编辑/删除) +``` + +**组件拆分**: +- `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` - 自定义hooks(useHeartbeatPageData) + +**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 + ``` + +### 阶段2:Action层 + +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` + - 修改`start()`:为每个启用的配置创建定时器 + - 修改`stop()`:清除所有定时器 + - 新增`startConfigTimer(config)`:创建单个配置的定时器 + - 新增`stopConfigTimer(configId)`:停止单个配置的定时器 + - 修改`sendHeartbeat(config)`:发送请求并记录结果 + - 删除curl解析逻辑(不再需要) + +9. **修改app/v1/_lib/proxy/forwarder.ts** + - 删除或注释掉`addSuccessfulCurl()`调用(第357-367行) + - curl历史功能迁移到独立模块(可选) + +### 阶段4:i18n文案 + +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使用Textarea(JSON格式) + - 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. 迁移前备份数据库
2. 在测试环境验证
3. 编写回滚脚本 | +| curl解析不完整 | 1. 复用现有`parseCurlCommand`
2. 添加解析错误提示
3. 允许手动编辑 | +| 多定时器性能问题 | 1. 限制最大配置数量(如20个)
2. 添加禁用功能
3. 监控日志 | +| 心跳发送失败 | 1. 记录失败日志
2. UI显示失败状态
3. 支持手动禁用 | + +## 验证清单 + +- [ ] 数据库迁移成功,旧数据已转移 +- [ ] 类型检查通过 (`bun run typecheck`) +- [ ] Lint检查通过 (`bun run lint`) +- [ ] 构建成功 (`bun run build`) +- [ ] 可以创建URL配置 +- [ ] 可以从curl导入配置 +- [ ] 可以编辑和删除配置 +- [ ] 全局开关控制所有心跳 +- [ ] 多个URL同时发送心跳(检查日志) +- [ ] 失败统计正确记录 +- [ ] 所有5种语言显示正常 +- [ ] 页面加载和交互流畅 + +## 预估工作量 + +- 数据库和Repository层:1-2小时 +- Action层:30分钟 +- 心跳执行逻辑:1小时 +- i18n文案:30分钟 +- 前端UI:2-3小时 +- 测试和验证:1小时 +- **总计:6-8小时** diff --git a/.claude/remove-root-check.sh b/.claude/remove-root-check.sh new file mode 100644 index 0000000..6df65a0 --- /dev/null +++ b/.claude/remove-root-check.sh @@ -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 "==========================================" diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..24e6832 --- /dev/null +++ b/.claude/settings.local.json @@ -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" + } +} \ No newline at end of file diff --git a/.claude/show-status.mjs b/.claude/show-status.mjs new file mode 100644 index 0000000..772ab85 --- /dev/null +++ b/.claude/show-status.mjs @@ -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(); diff --git a/.dockerignore b/.dockerignore index 3fc3934..a33226a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/build-webui.yml b/.github/workflows/build-webui.yml deleted file mode 100644 index e9fdf45..0000000 --- a/.github/workflows/build-webui.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/release-dockerhub.yml b/.github/workflows/release-dockerhub.yml new file mode 100644 index 0000000..de86912 --- /dev/null +++ b/.github/workflows/release-dockerhub.yml @@ -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 diff --git a/.gitignore b/.gitignore index 7c56b63..2342ad0 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/API.en.md b/API.en.md new file mode 100644 index 0000000..6487f45 --- /dev/null +++ b/API.en.md @@ -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 ` | +| **Claude auth** | `x-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 `. + +### 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 +``` + +**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 +Content-Type: application/json +``` + +**Request body**: + +```json +{ + "keys": ["new-api-key"], + "accounts": [...] +} +``` + +--- + +### Account Management + +#### Add account + +```http +POST /admin/accounts +Authorization: Bearer +Content-Type: application/json +``` + +**Request body**: + +```json +{ + "email": "user@example.com", + "password": "password123" +} +``` + +#### Batch import accounts + +```http +POST /admin/accounts/batch +Authorization: Bearer +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 +Content-Type: application/json +``` + +**Request body**: + +```json +{ + "email": "user@example.com" +} +``` + +#### Test all accounts + +```http +POST /admin/accounts/test-all +Authorization: Bearer +``` + +#### Queue status + +```http +GET /admin/queue/status +Authorization: Bearer +``` + +**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 +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); +} +``` diff --git a/API.md b/API.md index a5e73db..58b22fa 100644 --- a/API.md +++ b/API.md @@ -1,5 +1,7 @@ # DS2API 接口文档 +语言 / Language: [中文](API.md) | [English](API.en.md) + 本文档详细介绍 DS2API 提供的所有 API 端点。 --- diff --git a/CLAUDE.local.md b/CLAUDE.local.md new file mode 100644 index 0000000..4fd51c0 --- /dev/null +++ b/CLAUDE.local.md @@ -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 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 | 生成完成后,对比源文档属性数量是否一致 | diff --git a/CONTRIBUTING.en.md b/CONTRIBUTING.en.md new file mode 100644 index 0000000..e8b8c7f --- /dev/null +++ b/CONTRIBUTING.en.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4cb7f71..f35dcc8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 空格缩进 diff --git a/DEPLOY.en.md b/DEPLOY.en.md new file mode 100644 index 0000000..69f3f54 --- /dev/null +++ b/DEPLOY.en.md @@ -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 diff --git a/DEPLOY.md b/DEPLOY.md index 09a6462..b5c7748 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..542feaa --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.MD b/README.MD index 300d956..98a28c3 100644 --- a/README.MD +++ b/README.MD @@ -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-部署推荐)。 + ## ⚠️ 免责声明 **本项目基于逆向工程实现,服务稳定性无法保证。** diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..f1b223c --- /dev/null +++ b/README.en.md @@ -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) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..a329cf8 --- /dev/null +++ b/docker-compose.dev.yml @@ -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 diff --git a/routes/admin/accounts.py b/routes/admin/accounts.py index f5b8b43..6603acd 100644 --- a/routes/admin/accounts.py +++ b/routes/admin/accounts.py @@ -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 测试成功(仅会话创建)" diff --git a/routes/home.py b/routes/home.py index 4105302..7199f33 100644 --- a/routes/home.py +++ b/routes/home.py @@ -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 = """ - - - - - DS2API - DeepSeek to OpenAI API - - - - - - - - -
-
-
- -
-
- -

DeepSeek to OpenAI & Claude Compatible API Interface

-
- - - -
-
- 🚀 -

全面兼容

-

完美适配 OpenAI 与 Claude API 格式,无缝集成现有工具。

-
-
- ⚖️ -

负载均衡

-

内置智能轮询机制,支持多账号并发,稳定高效。

-
-
- 🧠 -

深度思考

-

完整支持 推理过程输出,让思考可见。

-
-
- 🔍 -

联网搜索

-

集成 DeepSeek 原生搜索能力,获取最新实时资讯。

-
-
- -
-

© 2026 DS2API Project. Designed for flexibility & performance.

-
-
- -""" - - -@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="

WebUI not built

Run cd webui && npm run build first.

", - 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.html(SPA 路由) - 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 = """ + + + + + DS2API - DeepSeek to OpenAI API + + + + + + + + +
+
+
+ +
+
+ +

DeepSeek to OpenAI & Claude Compatible API Interface

+
+ + + +
+
+ 🚀 +

全面兼容

+

完美适配 OpenAI 与 Claude API 格式,无缝集成现有工具。

+
+
+ ⚖️ +

负载均衡

+

内置智能轮询机制,支持多账号并发,稳定高效。

+
+
+ 🧠 +

深度思考

+

完整支持 推理过程输出,让思考可见。

+
+
+ 🔍 +

联网搜索

+

集成 DeepSeek 原生搜索能力,获取最新实时资讯。

+
+
+ +
+

© 2026 DS2API Project. Designed for flexibility & performance.

+
+
+ +""" + + +@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="

WebUI not built

Run cd webui && npm run build first.

", + 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.html(SPA 路由) + 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) + diff --git a/routes/openai.py b/routes/openai.py index 5063ec9..5ba60f2 100644 --- a/routes/openai.py +++ b/routes/openai.py @@ -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() diff --git a/scripts/build-webui.sh b/scripts/build-webui.sh index dcf7717..485f4cb 100755 --- a/scripts/build-webui.sh +++ b/scripts/build-webui.sh @@ -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/" diff --git a/static/admin/.gitkeep b/static/admin/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/static/admin/.gitkeep @@ -0,0 +1 @@ + diff --git a/static/admin/assets/index-C7aw1GYL.js b/static/admin/assets/index-C7aw1GYL.js deleted file mode 100644 index 9b576a6..0000000 --- a/static/admin/assets/index-C7aw1GYL.js +++ /dev/null @@ -1,272 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const o of l)if(o.type==="childList")for(const s of o.addedNodes)s.tagName==="LINK"&&s.rel==="modulepreload"&&r(s)}).observe(document,{childList:!0,subtree:!0});function n(l){const o={};return l.integrity&&(o.integrity=l.integrity),l.referrerPolicy&&(o.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?o.credentials="include":l.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function r(l){if(l.ep)return;l.ep=!0;const o=n(l);fetch(l.href,o)}})();function $d(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var eu={exports:{}},bl={},tu={exports:{}},D={};/** - * @license React - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Nr=Symbol.for("react.element"),Fd=Symbol.for("react.portal"),Ad=Symbol.for("react.fragment"),Ud=Symbol.for("react.strict_mode"),Bd=Symbol.for("react.profiler"),Vd=Symbol.for("react.provider"),Hd=Symbol.for("react.context"),Wd=Symbol.for("react.forward_ref"),Qd=Symbol.for("react.suspense"),Kd=Symbol.for("react.memo"),Jd=Symbol.for("react.lazy"),Ti=Symbol.iterator;function Yd(e){return e===null||typeof e!="object"?null:(e=Ti&&e[Ti]||e["@@iterator"],typeof e=="function"?e:null)}var nu={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},ru=Object.assign,lu={};function Ln(e,t,n){this.props=e,this.context=t,this.refs=lu,this.updater=n||nu}Ln.prototype.isReactComponent={};Ln.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};Ln.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function ou(){}ou.prototype=Ln.prototype;function Rs(e,t,n){this.props=e,this.context=t,this.refs=lu,this.updater=n||nu}var Ls=Rs.prototype=new ou;Ls.constructor=Rs;ru(Ls,Ln.prototype);Ls.isPureReactComponent=!0;var zi=Array.isArray,su=Object.prototype.hasOwnProperty,Ts={current:null},iu={key:!0,ref:!0,__self:!0,__source:!0};function au(e,t,n){var r,l={},o=null,s=null;if(t!=null)for(r in t.ref!==void 0&&(s=t.ref),t.key!==void 0&&(o=""+t.key),t)su.call(t,r)&&!iu.hasOwnProperty(r)&&(l[r]=t[r]);var i=arguments.length-2;if(i===1)l.children=n;else if(1>>1,W=L[U];if(0>>1;Ul(vt,z))Cl(I,vt)?(L[U]=I,L[C]=z,U=C):(L[U]=vt,L[me]=z,U=me);else if(Cl(I,z))L[U]=I,L[C]=z,U=C;else break e}}return E}function l(L,E){var z=L.sortIndex-E.sortIndex;return z!==0?z:L.id-E.id}if(typeof performance=="object"&&typeof performance.now=="function"){var o=performance;e.unstable_now=function(){return o.now()}}else{var s=Date,i=s.now();e.unstable_now=function(){return s.now()-i}}var u=[],c=[],y=1,p=null,h=3,v=!1,x=!1,w=!1,N=typeof setTimeout=="function"?setTimeout:null,f=typeof clearTimeout=="function"?clearTimeout:null,d=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function m(L){for(var E=n(c);E!==null;){if(E.callback===null)r(c);else if(E.startTime<=L)r(c),E.sortIndex=E.expirationTime,t(u,E);else break;E=n(c)}}function k(L){if(w=!1,m(L),!x)if(n(u)!==null)x=!0,yt(S);else{var E=n(c);E!==null&>(k,E.startTime-L)}}function S(L,E){x=!1,w&&(w=!1,f(P),P=-1),v=!0;var z=h;try{for(m(E),p=n(u);p!==null&&(!(p.expirationTime>E)||L&&!pe());){var U=p.callback;if(typeof U=="function"){p.callback=null,h=p.priorityLevel;var W=U(p.expirationTime<=E);E=e.unstable_now(),typeof W=="function"?p.callback=W:p===n(u)&&r(u),m(E)}else r(u);p=n(u)}if(p!==null)var xe=!0;else{var me=n(c);me!==null&>(k,me.startTime-E),xe=!1}return xe}finally{p=null,h=z,v=!1}}var _=!1,R=null,P=-1,b=5,O=-1;function pe(){return!(e.unstable_now()-OL||125U?(L.sortIndex=z,t(c,L),n(u)===null&&L===n(c)&&(w?(f(P),P=-1):w=!0,gt(k,z-U))):(L.sortIndex=W,t(u,L),x||v||(x=!0,yt(S))),L},e.unstable_shouldYield=pe,e.unstable_wrapCallback=function(L){var E=h;return function(){var z=h;h=E;try{return L.apply(this,arguments)}finally{h=z}}}})(pu);fu.exports=pu;var af=fu.exports;/** - * @license React - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var uf=g,Pe=af;function j(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Oo=Object.prototype.hasOwnProperty,cf=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Ii={},Di={};function df(e){return Oo.call(Di,e)?!0:Oo.call(Ii,e)?!1:cf.test(e)?Di[e]=!0:(Ii[e]=!0,!1)}function ff(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function pf(e,t,n,r){if(t===null||typeof t>"u"||ff(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function ve(e,t,n,r,l,o,s){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=o,this.removeEmptyString=s}var ae={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){ae[e]=new ve(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];ae[t]=new ve(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){ae[e]=new ve(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){ae[e]=new ve(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){ae[e]=new ve(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){ae[e]=new ve(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){ae[e]=new ve(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){ae[e]=new ve(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){ae[e]=new ve(e,5,!1,e.toLowerCase(),null,!1,!1)});var Os=/[\-:]([a-z])/g;function Is(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Os,Is);ae[t]=new ve(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Os,Is);ae[t]=new ve(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Os,Is);ae[t]=new ve(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){ae[e]=new ve(e,1,!1,e.toLowerCase(),null,!1,!1)});ae.xlinkHref=new ve("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){ae[e]=new ve(e,1,!1,e.toLowerCase(),null,!0,!0)});function Ds(e,t,n,r){var l=ae.hasOwnProperty(t)?ae[t]:null;(l!==null?l.type!==0:r||!(2i||l[s]!==o[i]){var u=` -`+l[s].replace(" at new "," at ");return e.displayName&&u.includes("")&&(u=u.replace("",e.displayName)),u}while(1<=s&&0<=i);break}}}finally{ro=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?Wn(e):""}function mf(e){switch(e.tag){case 5:return Wn(e.type);case 16:return Wn("Lazy");case 13:return Wn("Suspense");case 19:return Wn("SuspenseList");case 0:case 2:case 15:return e=lo(e.type,!1),e;case 11:return e=lo(e.type.render,!1),e;case 1:return e=lo(e.type,!0),e;default:return""}}function Mo(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case on:return"Fragment";case ln:return"Portal";case Io:return"Profiler";case bs:return"StrictMode";case Do:return"Suspense";case bo:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case yu:return(e.displayName||"Context")+".Consumer";case hu:return(e._context.displayName||"Context")+".Provider";case Ms:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case $s:return t=e.displayName||null,t!==null?t:Mo(e.type)||"Memo";case kt:t=e._payload,e=e._init;try{return Mo(e(t))}catch{}}return null}function hf(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Mo(t);case 8:return t===bs?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function Dt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function vu(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function yf(e){var t=vu(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,o=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(s){r=""+s,o.call(this,s)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(s){r=""+s},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Or(e){e._valueTracker||(e._valueTracker=yf(e))}function xu(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=vu(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function ul(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function $o(e,t){var n=t.checked;return G({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Mi(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=Dt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function wu(e,t){t=t.checked,t!=null&&Ds(e,"checked",t,!1)}function Fo(e,t){wu(e,t);var n=Dt(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Ao(e,t.type,n):t.hasOwnProperty("defaultValue")&&Ao(e,t.type,Dt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function $i(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Ao(e,t,n){(t!=="number"||ul(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Qn=Array.isArray;function gn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=Ir.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function or(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Yn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},gf=["Webkit","ms","Moz","O"];Object.keys(Yn).forEach(function(e){gf.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Yn[t]=Yn[e]})});function ju(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Yn.hasOwnProperty(e)&&Yn[e]?(""+t).trim():t+"px"}function Eu(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=ju(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var vf=G({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Vo(e,t){if(t){if(vf[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(j(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(j(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(j(61))}if(t.style!=null&&typeof t.style!="object")throw Error(j(62))}}function Ho(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Wo=null;function Fs(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Qo=null,vn=null,xn=null;function Ui(e){if(e=Er(e)){if(typeof Qo!="function")throw Error(j(280));var t=e.stateNode;t&&(t=Ul(t),Qo(e.stateNode,e.type,t))}}function Cu(e){vn?xn?xn.push(e):xn=[e]:vn=e}function _u(){if(vn){var e=vn,t=xn;if(xn=vn=null,Ui(e),t)for(e=0;e>>=0,e===0?32:31-(Rf(e)/Lf|0)|0}var Dr=64,br=4194304;function Kn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function pl(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,o=e.pingedLanes,s=n&268435455;if(s!==0){var i=s&~l;i!==0?r=Kn(i):(o&=s,o!==0&&(r=Kn(o)))}else s=n&~l,s!==0?r=Kn(s):o!==0&&(r=Kn(o));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,o=t&-t,l>=o||l===16&&(o&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Sr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-He(t),e[t]=n}function If(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Xn),Gi=" ",Xi=!1;function Ku(e,t){switch(e){case"keyup":return ap.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Ju(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var sn=!1;function cp(e,t){switch(e){case"compositionend":return Ju(t);case"keypress":return t.which!==32?null:(Xi=!0,Gi);case"textInput":return e=t.data,e===Gi&&Xi?null:e;default:return null}}function dp(e,t){if(sn)return e==="compositionend"||!Ks&&Ku(e,t)?(e=Wu(),Zr=Hs=Et=null,sn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=ta(n)}}function Zu(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Zu(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function qu(){for(var e=window,t=ul();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=ul(e.document)}return t}function Js(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function wp(e){var t=qu(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Zu(n.ownerDocument.documentElement,n)){if(r!==null&&Js(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,o=Math.min(r.start,l);r=r.end===void 0?o:Math.min(r.end,l),!e.extend&&o>r&&(l=r,r=o,o=l),l=na(n,o);var s=na(n,r);l&&s&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==s.node||e.focusOffset!==s.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),o>r?(e.addRange(t),e.extend(s.node,s.offset)):(t.setEnd(s.node,s.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,an=null,Zo=null,qn=null,qo=!1;function ra(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;qo||an==null||an!==ul(r)||(r=an,"selectionStart"in r&&Js(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),qn&&dr(qn,r)||(qn=r,r=yl(Zo,"onSelect"),0dn||(e.current=os[dn],os[dn]=null,dn--)}function B(e,t){dn++,os[dn]=e.current,e.current=t}var bt={},fe=$t(bt),Ne=$t(!1),Jt=bt;function jn(e,t){var n=e.type.contextTypes;if(!n)return bt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},o;for(o in n)l[o]=t[o];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function Se(e){return e=e.childContextTypes,e!=null}function vl(){H(Ne),H(fe)}function ca(e,t,n){if(fe.current!==bt)throw Error(j(168));B(fe,t),B(Ne,n)}function ac(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(j(108,hf(e)||"Unknown",l));return G({},n,r)}function xl(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||bt,Jt=fe.current,B(fe,e),B(Ne,Ne.current),!0}function da(e,t,n){var r=e.stateNode;if(!r)throw Error(j(169));n?(e=ac(e,t,Jt),r.__reactInternalMemoizedMergedChildContext=e,H(Ne),H(fe),B(fe,e)):H(Ne),B(Ne,n)}var lt=null,Bl=!1,xo=!1;function uc(e){lt===null?lt=[e]:lt.push(e)}function zp(e){Bl=!0,uc(e)}function Ft(){if(!xo&<!==null){xo=!0;var e=0,t=A;try{var n=lt;for(A=1;e>=s,l-=s,ot=1<<32-He(t)+l|n<P?(b=R,R=null):b=R.sibling;var O=h(f,R,m[P],k);if(O===null){R===null&&(R=b);break}e&&R&&O.alternate===null&&t(f,R),d=o(O,d,P),_===null?S=O:_.sibling=O,_=O,R=b}if(P===m.length)return n(f,R),Q&&Ut(f,P),S;if(R===null){for(;PP?(b=R,R=null):b=R.sibling;var pe=h(f,R,O.value,k);if(pe===null){R===null&&(R=b);break}e&&R&&pe.alternate===null&&t(f,R),d=o(pe,d,P),_===null?S=pe:_.sibling=pe,_=pe,R=b}if(O.done)return n(f,R),Q&&Ut(f,P),S;if(R===null){for(;!O.done;P++,O=m.next())O=p(f,O.value,k),O!==null&&(d=o(O,d,P),_===null?S=O:_.sibling=O,_=O);return Q&&Ut(f,P),S}for(R=r(f,R);!O.done;P++,O=m.next())O=v(R,f,P,O.value,k),O!==null&&(e&&O.alternate!==null&&R.delete(O.key===null?P:O.key),d=o(O,d,P),_===null?S=O:_.sibling=O,_=O);return e&&R.forEach(function(oe){return t(f,oe)}),Q&&Ut(f,P),S}function N(f,d,m,k){if(typeof m=="object"&&m!==null&&m.type===on&&m.key===null&&(m=m.props.children),typeof m=="object"&&m!==null){switch(m.$$typeof){case zr:e:{for(var S=m.key,_=d;_!==null;){if(_.key===S){if(S=m.type,S===on){if(_.tag===7){n(f,_.sibling),d=l(_,m.props.children),d.return=f,f=d;break e}}else if(_.elementType===S||typeof S=="object"&&S!==null&&S.$$typeof===kt&&ma(S)===_.type){n(f,_.sibling),d=l(_,m.props),d.ref=Un(f,_,m),d.return=f,f=d;break e}n(f,_);break}else t(f,_);_=_.sibling}m.type===on?(d=Kt(m.props.children,f.mode,k,m.key),d.return=f,f=d):(k=sl(m.type,m.key,m.props,null,f.mode,k),k.ref=Un(f,d,m),k.return=f,f=k)}return s(f);case ln:e:{for(_=m.key;d!==null;){if(d.key===_)if(d.tag===4&&d.stateNode.containerInfo===m.containerInfo&&d.stateNode.implementation===m.implementation){n(f,d.sibling),d=l(d,m.children||[]),d.return=f,f=d;break e}else{n(f,d);break}else t(f,d);d=d.sibling}d=_o(m,f.mode,k),d.return=f,f=d}return s(f);case kt:return _=m._init,N(f,d,_(m._payload),k)}if(Qn(m))return x(f,d,m,k);if(bn(m))return w(f,d,m,k);Vr(f,m)}return typeof m=="string"&&m!==""||typeof m=="number"?(m=""+m,d!==null&&d.tag===6?(n(f,d.sibling),d=l(d,m),d.return=f,f=d):(n(f,d),d=Co(m,f.mode,k),d.return=f,f=d),s(f)):n(f,d)}return N}var Cn=pc(!0),mc=pc(!1),Nl=$t(null),Sl=null,mn=null,Zs=null;function qs(){Zs=mn=Sl=null}function ei(e){var t=Nl.current;H(Nl),e._currentValue=t}function as(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function kn(e,t){Sl=e,Zs=mn=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(ke=!0),e.firstContext=null)}function Me(e){var t=e._currentValue;if(Zs!==e)if(e={context:e,memoizedValue:t,next:null},mn===null){if(Sl===null)throw Error(j(308));mn=e,Sl.dependencies={lanes:0,firstContext:e}}else mn=mn.next=e;return t}var Ht=null;function ti(e){Ht===null?Ht=[e]:Ht.push(e)}function hc(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,ti(t)):(n.next=l.next,l.next=n),t.interleaved=n,dt(e,r)}function dt(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var Nt=!1;function ni(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function yc(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function it(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Tt(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,$&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,dt(e,n)}return l=r.interleaved,l===null?(t.next=t,ti(r)):(t.next=l.next,l.next=t),r.interleaved=t,dt(e,n)}function el(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Us(e,n)}}function ha(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,o=null;if(n=n.firstBaseUpdate,n!==null){do{var s={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};o===null?l=o=s:o=o.next=s,n=n.next}while(n!==null);o===null?l=o=t:o=o.next=t}else l=o=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:o,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function jl(e,t,n,r){var l=e.updateQueue;Nt=!1;var o=l.firstBaseUpdate,s=l.lastBaseUpdate,i=l.shared.pending;if(i!==null){l.shared.pending=null;var u=i,c=u.next;u.next=null,s===null?o=c:s.next=c,s=u;var y=e.alternate;y!==null&&(y=y.updateQueue,i=y.lastBaseUpdate,i!==s&&(i===null?y.firstBaseUpdate=c:i.next=c,y.lastBaseUpdate=u))}if(o!==null){var p=l.baseState;s=0,y=c=u=null,i=o;do{var h=i.lane,v=i.eventTime;if((r&h)===h){y!==null&&(y=y.next={eventTime:v,lane:0,tag:i.tag,payload:i.payload,callback:i.callback,next:null});e:{var x=e,w=i;switch(h=t,v=n,w.tag){case 1:if(x=w.payload,typeof x=="function"){p=x.call(v,p,h);break e}p=x;break e;case 3:x.flags=x.flags&-65537|128;case 0:if(x=w.payload,h=typeof x=="function"?x.call(v,p,h):x,h==null)break e;p=G({},p,h);break e;case 2:Nt=!0}}i.callback!==null&&i.lane!==0&&(e.flags|=64,h=l.effects,h===null?l.effects=[i]:h.push(i))}else v={eventTime:v,lane:h,tag:i.tag,payload:i.payload,callback:i.callback,next:null},y===null?(c=y=v,u=p):y=y.next=v,s|=h;if(i=i.next,i===null){if(i=l.shared.pending,i===null)break;h=i,i=h.next,h.next=null,l.lastBaseUpdate=h,l.shared.pending=null}}while(!0);if(y===null&&(u=p),l.baseState=u,l.firstBaseUpdate=c,l.lastBaseUpdate=y,t=l.shared.interleaved,t!==null){l=t;do s|=l.lane,l=l.next;while(l!==t)}else o===null&&(l.shared.lanes=0);Xt|=s,e.lanes=s,e.memoizedState=p}}function ya(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=ko.transition;ko.transition={};try{e(!1),t()}finally{A=n,ko.transition=r}}function Oc(){return $e().memoizedState}function bp(e,t,n){var r=Ot(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Ic(e))Dc(t,n);else if(n=hc(e,t,n,r),n!==null){var l=ye();We(n,e,r,l),bc(n,t,r)}}function Mp(e,t,n){var r=Ot(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Ic(e))Dc(t,l);else{var o=e.alternate;if(e.lanes===0&&(o===null||o.lanes===0)&&(o=t.lastRenderedReducer,o!==null))try{var s=t.lastRenderedState,i=o(s,n);if(l.hasEagerState=!0,l.eagerState=i,Qe(i,s)){var u=t.interleaved;u===null?(l.next=l,ti(t)):(l.next=u.next,u.next=l),t.interleaved=l;return}}catch{}finally{}n=hc(e,t,l,r),n!==null&&(l=ye(),We(n,e,r,l),bc(n,t,r))}}function Ic(e){var t=e.alternate;return e===Y||t!==null&&t===Y}function Dc(e,t){er=Cl=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function bc(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Us(e,n)}}var _l={readContext:Me,useCallback:ue,useContext:ue,useEffect:ue,useImperativeHandle:ue,useInsertionEffect:ue,useLayoutEffect:ue,useMemo:ue,useReducer:ue,useRef:ue,useState:ue,useDebugValue:ue,useDeferredValue:ue,useTransition:ue,useMutableSource:ue,useSyncExternalStore:ue,useId:ue,unstable_isNewReconciler:!1},$p={readContext:Me,useCallback:function(e,t){return Ge().memoizedState=[e,t===void 0?null:t],e},useContext:Me,useEffect:va,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,nl(4194308,4,Pc.bind(null,t,e),n)},useLayoutEffect:function(e,t){return nl(4194308,4,e,t)},useInsertionEffect:function(e,t){return nl(4,2,e,t)},useMemo:function(e,t){var n=Ge();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Ge();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=bp.bind(null,Y,e),[r.memoizedState,e]},useRef:function(e){var t=Ge();return e={current:e},t.memoizedState=e},useState:ga,useDebugValue:ci,useDeferredValue:function(e){return Ge().memoizedState=e},useTransition:function(){var e=ga(!1),t=e[0];return e=Dp.bind(null,e[1]),Ge().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=Y,l=Ge();if(Q){if(n===void 0)throw Error(j(407));n=n()}else{if(n=t(),le===null)throw Error(j(349));Gt&30||wc(r,t,n)}l.memoizedState=n;var o={value:n,getSnapshot:t};return l.queue=o,va(Nc.bind(null,r,o,e),[e]),r.flags|=2048,xr(9,kc.bind(null,r,o,n,t),void 0,null),n},useId:function(){var e=Ge(),t=le.identifierPrefix;if(Q){var n=st,r=ot;n=(r&~(1<<32-He(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=gr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=s.createElement(n,{is:r.is}):(e=s.createElement(n),n==="select"&&(s=e,r.multiple?s.multiple=!0:r.size&&(s.size=r.size))):e=s.createElementNS(e,n),e[Xe]=t,e[mr]=r,Qc(e,t,!1,!1),t.stateNode=e;e:{switch(s=Ho(n,r),n){case"dialog":V("cancel",e),V("close",e),l=r;break;case"iframe":case"object":case"embed":V("load",e),l=r;break;case"video":case"audio":for(l=0;lRn&&(t.flags|=128,r=!0,Bn(o,!1),t.lanes=4194304)}else{if(!r)if(e=El(s),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),Bn(o,!0),o.tail===null&&o.tailMode==="hidden"&&!s.alternate&&!Q)return ce(t),null}else 2*Z()-o.renderingStartTime>Rn&&n!==1073741824&&(t.flags|=128,r=!0,Bn(o,!1),t.lanes=4194304);o.isBackwards?(s.sibling=t.child,t.child=s):(n=o.last,n!==null?n.sibling=s:t.child=s,o.last=s)}return o.tail!==null?(t=o.tail,o.rendering=t,o.tail=t.sibling,o.renderingStartTime=Z(),t.sibling=null,n=J.current,B(J,r?n&1|2:n&1),t):(ce(t),null);case 22:case 23:return yi(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?Ee&1073741824&&(ce(t),t.subtreeFlags&6&&(t.flags|=8192)):ce(t),null;case 24:return null;case 25:return null}throw Error(j(156,t.tag))}function Qp(e,t){switch(Gs(t),t.tag){case 1:return Se(t.type)&&vl(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return _n(),H(Ne),H(fe),oi(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return li(t),null;case 13:if(H(J),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(j(340));En()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return H(J),null;case 4:return _n(),null;case 10:return ei(t.type._context),null;case 22:case 23:return yi(),null;case 24:return null;default:return null}}var Wr=!1,de=!1,Kp=typeof WeakSet=="function"?WeakSet:Set,T=null;function hn(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){X(e,t,r)}else n.current=null}function gs(e,t,n){try{n()}catch(r){X(e,t,r)}}var Ra=!1;function Jp(e,t){if(es=ml,e=qu(),Js(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break e}var s=0,i=-1,u=-1,c=0,y=0,p=e,h=null;t:for(;;){for(var v;p!==n||l!==0&&p.nodeType!==3||(i=s+l),p!==o||r!==0&&p.nodeType!==3||(u=s+r),p.nodeType===3&&(s+=p.nodeValue.length),(v=p.firstChild)!==null;)h=p,p=v;for(;;){if(p===e)break t;if(h===n&&++c===l&&(i=s),h===o&&++y===r&&(u=s),(v=p.nextSibling)!==null)break;p=h,h=p.parentNode}p=v}n=i===-1||u===-1?null:{start:i,end:u}}else n=null}n=n||{start:0,end:0}}else n=null;for(ts={focusedElem:e,selectionRange:n},ml=!1,T=t;T!==null;)if(t=T,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,T=e;else for(;T!==null;){t=T;try{var x=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(x!==null){var w=x.memoizedProps,N=x.memoizedState,f=t.stateNode,d=f.getSnapshotBeforeUpdate(t.elementType===t.type?w:Ue(t.type,w),N);f.__reactInternalSnapshotBeforeUpdate=d}break;case 3:var m=t.stateNode.containerInfo;m.nodeType===1?m.textContent="":m.nodeType===9&&m.documentElement&&m.removeChild(m.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(j(163))}}catch(k){X(t,t.return,k)}if(e=t.sibling,e!==null){e.return=t.return,T=e;break}T=t.return}return x=Ra,Ra=!1,x}function tr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var o=l.destroy;l.destroy=void 0,o!==void 0&&gs(t,n,o)}l=l.next}while(l!==r)}}function Wl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function vs(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Yc(e){var t=e.alternate;t!==null&&(e.alternate=null,Yc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Xe],delete t[mr],delete t[ls],delete t[Lp],delete t[Tp])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Gc(e){return e.tag===5||e.tag===3||e.tag===4}function La(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Gc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function xs(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=gl));else if(r!==4&&(e=e.child,e!==null))for(xs(e,t,n),e=e.sibling;e!==null;)xs(e,t,n),e=e.sibling}function ws(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(ws(e,t,n),e=e.sibling;e!==null;)ws(e,t,n),e=e.sibling}var se=null,Be=!1;function wt(e,t,n){for(n=n.child;n!==null;)Xc(e,t,n),n=n.sibling}function Xc(e,t,n){if(Ze&&typeof Ze.onCommitFiberUnmount=="function")try{Ze.onCommitFiberUnmount(Ml,n)}catch{}switch(n.tag){case 5:de||hn(n,t);case 6:var r=se,l=Be;se=null,wt(e,t,n),se=r,Be=l,se!==null&&(Be?(e=se,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):se.removeChild(n.stateNode));break;case 18:se!==null&&(Be?(e=se,n=n.stateNode,e.nodeType===8?vo(e.parentNode,n):e.nodeType===1&&vo(e,n),ur(e)):vo(se,n.stateNode));break;case 4:r=se,l=Be,se=n.stateNode.containerInfo,Be=!0,wt(e,t,n),se=r,Be=l;break;case 0:case 11:case 14:case 15:if(!de&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var o=l,s=o.destroy;o=o.tag,s!==void 0&&(o&2||o&4)&&gs(n,t,s),l=l.next}while(l!==r)}wt(e,t,n);break;case 1:if(!de&&(hn(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(i){X(n,t,i)}wt(e,t,n);break;case 21:wt(e,t,n);break;case 22:n.mode&1?(de=(r=de)||n.memoizedState!==null,wt(e,t,n),de=r):wt(e,t,n);break;default:wt(e,t,n)}}function Ta(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Kp),t.forEach(function(r){var l=rm.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function Ae(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=s),r&=~o}if(r=l,r=Z()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Gp(r/1960))-r,10e?16:e,Ct===null)var r=!1;else{if(e=Ct,Ct=null,Ll=0,$&6)throw Error(j(331));var l=$;for($|=4,T=e.current;T!==null;){var o=T,s=o.child;if(T.flags&16){var i=o.deletions;if(i!==null){for(var u=0;uZ()-mi?Qt(e,0):pi|=n),je(e,t)}function od(e,t){t===0&&(e.mode&1?(t=br,br<<=1,!(br&130023424)&&(br=4194304)):t=1);var n=ye();e=dt(e,t),e!==null&&(Sr(e,t,n),je(e,n))}function nm(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),od(e,n)}function rm(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(j(314))}r!==null&&r.delete(t),od(e,n)}var sd;sd=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||Ne.current)ke=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return ke=!1,Hp(e,t,n);ke=!!(e.flags&131072)}else ke=!1,Q&&t.flags&1048576&&cc(t,kl,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;rl(e,t),e=t.pendingProps;var l=jn(t,fe.current);kn(t,n),l=ii(null,t,r,e,l,n);var o=ai();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Se(r)?(o=!0,xl(t)):o=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,ni(t),l.updater=Hl,t.stateNode=l,l._reactInternals=t,cs(t,r,e,n),t=ps(null,t,r,!0,o,n)):(t.tag=0,Q&&o&&Ys(t),he(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(rl(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=om(r),e=Ue(r,e),l){case 0:t=fs(null,t,r,e,n);break e;case 1:t=Ca(null,t,r,e,n);break e;case 11:t=ja(null,t,r,e,n);break e;case 14:t=Ea(null,t,r,Ue(r.type,e),n);break e}throw Error(j(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Ue(r,l),fs(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Ue(r,l),Ca(e,t,r,l,n);case 3:e:{if(Vc(t),e===null)throw Error(j(387));r=t.pendingProps,o=t.memoizedState,l=o.element,yc(e,t),jl(t,r,null,n);var s=t.memoizedState;if(r=s.element,o.isDehydrated)if(o={element:r,isDehydrated:!1,cache:s.cache,pendingSuspenseBoundaries:s.pendingSuspenseBoundaries,transitions:s.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){l=Pn(Error(j(423)),t),t=_a(e,t,r,n,l);break e}else if(r!==l){l=Pn(Error(j(424)),t),t=_a(e,t,r,n,l);break e}else for(Ce=Lt(t.stateNode.containerInfo.firstChild),_e=t,Q=!0,Ve=null,n=mc(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(En(),r===l){t=ft(e,t,n);break e}he(e,t,r,n)}t=t.child}return t;case 5:return gc(t),e===null&&is(t),r=t.type,l=t.pendingProps,o=e!==null?e.memoizedProps:null,s=l.children,ns(r,l)?s=null:o!==null&&ns(r,o)&&(t.flags|=32),Bc(e,t),he(e,t,s,n),t.child;case 6:return e===null&&is(t),null;case 13:return Hc(e,t,n);case 4:return ri(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=Cn(t,null,r,n):he(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Ue(r,l),ja(e,t,r,l,n);case 7:return he(e,t,t.pendingProps,n),t.child;case 8:return he(e,t,t.pendingProps.children,n),t.child;case 12:return he(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,o=t.memoizedProps,s=l.value,B(Nl,r._currentValue),r._currentValue=s,o!==null)if(Qe(o.value,s)){if(o.children===l.children&&!Ne.current){t=ft(e,t,n);break e}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var i=o.dependencies;if(i!==null){s=o.child;for(var u=i.firstContext;u!==null;){if(u.context===r){if(o.tag===1){u=it(-1,n&-n),u.tag=2;var c=o.updateQueue;if(c!==null){c=c.shared;var y=c.pending;y===null?u.next=u:(u.next=y.next,y.next=u),c.pending=u}}o.lanes|=n,u=o.alternate,u!==null&&(u.lanes|=n),as(o.return,n,t),i.lanes|=n;break}u=u.next}}else if(o.tag===10)s=o.type===t.type?null:o.child;else if(o.tag===18){if(s=o.return,s===null)throw Error(j(341));s.lanes|=n,i=s.alternate,i!==null&&(i.lanes|=n),as(s,n,t),s=o.sibling}else s=o.child;if(s!==null)s.return=o;else for(s=o;s!==null;){if(s===t){s=null;break}if(o=s.sibling,o!==null){o.return=s.return,s=o;break}s=s.return}o=s}he(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,kn(t,n),l=Me(l),r=r(l),t.flags|=1,he(e,t,r,n),t.child;case 14:return r=t.type,l=Ue(r,t.pendingProps),l=Ue(r.type,l),Ea(e,t,r,l,n);case 15:return Ac(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Ue(r,l),rl(e,t),t.tag=1,Se(r)?(e=!0,xl(t)):e=!1,kn(t,n),Mc(t,r,l),cs(t,r,l,n),ps(null,t,r,!0,e,n);case 19:return Wc(e,t,n);case 22:return Uc(e,t,n)}throw Error(j(156,t.tag))};function id(e,t){return Iu(e,t)}function lm(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function De(e,t,n,r){return new lm(e,t,n,r)}function vi(e){return e=e.prototype,!(!e||!e.isReactComponent)}function om(e){if(typeof e=="function")return vi(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Ms)return 11;if(e===$s)return 14}return 2}function It(e,t){var n=e.alternate;return n===null?(n=De(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function sl(e,t,n,r,l,o){var s=2;if(r=e,typeof e=="function")vi(e)&&(s=1);else if(typeof e=="string")s=5;else e:switch(e){case on:return Kt(n.children,l,o,t);case bs:s=8,l|=8;break;case Io:return e=De(12,n,t,l|2),e.elementType=Io,e.lanes=o,e;case Do:return e=De(13,n,t,l),e.elementType=Do,e.lanes=o,e;case bo:return e=De(19,n,t,l),e.elementType=bo,e.lanes=o,e;case gu:return Kl(n,l,o,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case hu:s=10;break e;case yu:s=9;break e;case Ms:s=11;break e;case $s:s=14;break e;case kt:s=16,r=null;break e}throw Error(j(130,e==null?e:typeof e,""))}return t=De(s,n,t,l),t.elementType=e,t.type=r,t.lanes=o,t}function Kt(e,t,n,r){return e=De(7,e,r,t),e.lanes=n,e}function Kl(e,t,n,r){return e=De(22,e,r,t),e.elementType=gu,e.lanes=n,e.stateNode={isHidden:!1},e}function Co(e,t,n){return e=De(6,e,null,t),e.lanes=n,e}function _o(e,t,n){return t=De(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function sm(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=so(0),this.expirationTimes=so(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=so(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function xi(e,t,n,r,l,o,s,i,u){return e=new sm(e,t,n,i,u),t===1?(t=1,o===!0&&(t|=8)):t=0,o=De(3,null,null,t),e.current=o,o.stateNode=e,o.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},ni(o),e}function im(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(dd)}catch(e){console.error(e)}}dd(),du.exports=Re;var fm=du.exports,Fa=fm;zo.createRoot=Fa.createRoot,zo.hydrateRoot=Fa.hydrateRoot;/** - * react-router v7.13.0 - * - * Copyright (c) Remix Software Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE.md file in the root directory of this source tree. - * - * @license MIT - */var Aa="popstate";function pm(e={}){function t(r,l){let{pathname:o,search:s,hash:i}=r.location;return Es("",{pathname:o,search:s,hash:i},l.state&&l.state.usr||null,l.state&&l.state.key||"default")}function n(r,l){return typeof l=="string"?l:kr(l)}return hm(t,n,null,e)}function K(e,t){if(e===!1||e===null||typeof e>"u")throw new Error(t)}function Ke(e,t){if(!e){typeof console<"u"&&console.warn(t);try{throw new Error(t)}catch{}}}function mm(){return Math.random().toString(36).substring(2,10)}function Ua(e,t){return{usr:e.state,key:e.key,idx:t}}function Es(e,t,n=null,r){return{pathname:typeof e=="string"?e:e.pathname,search:"",hash:"",...typeof t=="string"?On(t):t,state:n,key:t&&t.key||r||mm()}}function kr({pathname:e="/",search:t="",hash:n=""}){return t&&t!=="?"&&(e+=t.charAt(0)==="?"?t:"?"+t),n&&n!=="#"&&(e+=n.charAt(0)==="#"?n:"#"+n),e}function On(e){let t={};if(e){let n=e.indexOf("#");n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf("?");r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function hm(e,t,n,r={}){let{window:l=document.defaultView,v5Compat:o=!1}=r,s=l.history,i="POP",u=null,c=y();c==null&&(c=0,s.replaceState({...s.state,idx:c},""));function y(){return(s.state||{idx:null}).idx}function p(){i="POP";let N=y(),f=N==null?null:N-c;c=N,u&&u({action:i,location:w.location,delta:f})}function h(N,f){i="PUSH";let d=Es(w.location,N,f);c=y()+1;let m=Ua(d,c),k=w.createHref(d);try{s.pushState(m,"",k)}catch(S){if(S instanceof DOMException&&S.name==="DataCloneError")throw S;l.location.assign(k)}o&&u&&u({action:i,location:w.location,delta:1})}function v(N,f){i="REPLACE";let d=Es(w.location,N,f);c=y();let m=Ua(d,c),k=w.createHref(d);s.replaceState(m,"",k),o&&u&&u({action:i,location:w.location,delta:0})}function x(N){return ym(N)}let w={get action(){return i},get location(){return e(l,s)},listen(N){if(u)throw new Error("A history only accepts one active listener");return l.addEventListener(Aa,p),u=N,()=>{l.removeEventListener(Aa,p),u=null}},createHref(N){return t(l,N)},createURL:x,encodeLocation(N){let f=x(N);return{pathname:f.pathname,search:f.search,hash:f.hash}},push:h,replace:v,go(N){return s.go(N)}};return w}function ym(e,t=!1){let n="http://localhost";typeof window<"u"&&(n=window.location.origin!=="null"?window.location.origin:window.location.href),K(n,"No window.location.(origin|href) available to create URL");let r=typeof e=="string"?e:kr(e);return r=r.replace(/ $/,"%20"),!t&&r.startsWith("//")&&(r=n+r),new URL(r,n)}function fd(e,t,n="/"){return gm(e,t,n,!1)}function gm(e,t,n,r){let l=typeof t=="string"?On(t):t,o=pt(l.pathname||"/",n);if(o==null)return null;let s=pd(e);vm(s);let i=null;for(let u=0;i==null&&u{let y={relativePath:c===void 0?s.path||"":c,caseSensitive:s.caseSensitive===!0,childrenIndex:i,route:s};if(y.relativePath.startsWith("/")){if(!y.relativePath.startsWith(r)&&u)return;K(y.relativePath.startsWith(r),`Absolute route path "${y.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),y.relativePath=y.relativePath.slice(r.length)}let p=at([r,y.relativePath]),h=n.concat(y);s.children&&s.children.length>0&&(K(s.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${p}".`),pd(s.children,t,h,p,u)),!(s.path==null&&!s.index)&&t.push({path:p,score:Em(p,s.index),routesMeta:h})};return e.forEach((s,i)=>{var u;if(s.path===""||!((u=s.path)!=null&&u.includes("?")))o(s,i);else for(let c of md(s.path))o(s,i,!0,c)}),t}function md(e){let t=e.split("/");if(t.length===0)return[];let[n,...r]=t,l=n.endsWith("?"),o=n.replace(/\?$/,"");if(r.length===0)return l?[o,""]:[o];let s=md(r.join("/")),i=[];return i.push(...s.map(u=>u===""?o:[o,u].join("/"))),l&&i.push(...s),i.map(u=>e.startsWith("/")&&u===""?"/":u)}function vm(e){e.sort((t,n)=>t.score!==n.score?n.score-t.score:Cm(t.routesMeta.map(r=>r.childrenIndex),n.routesMeta.map(r=>r.childrenIndex)))}var xm=/^:[\w-]+$/,wm=3,km=2,Nm=1,Sm=10,jm=-2,Ba=e=>e==="*";function Em(e,t){let n=e.split("/"),r=n.length;return n.some(Ba)&&(r+=jm),t&&(r+=km),n.filter(l=>!Ba(l)).reduce((l,o)=>l+(xm.test(o)?wm:o===""?Nm:Sm),r)}function Cm(e,t){return e.length===t.length&&e.slice(0,-1).every((r,l)=>r===t[l])?e[e.length-1]-t[t.length-1]:0}function _m(e,t,n=!1){let{routesMeta:r}=e,l={},o="/",s=[];for(let i=0;i{if(y==="*"){let x=i[h]||"";s=o.slice(0,o.length-x.length).replace(/(.)\/+$/,"$1")}const v=i[h];return p&&!v?c[y]=void 0:c[y]=(v||"").replace(/%2F/g,"/"),c},{}),pathname:o,pathnameBase:s,pattern:e}}function Pm(e,t=!1,n=!0){Ke(e==="*"||!e.endsWith("*")||e.endsWith("/*"),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,"/*")}".`);let r=[],l="^"+e.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(s,i,u)=>(r.push({paramName:i,isOptional:u!=null}),u?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return e.endsWith("*")?(r.push({paramName:"*"}),l+=e==="*"||e==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):n?l+="\\/*$":e!==""&&e!=="/"&&(l+="(?:(?=\\/|$))"),[new RegExp(l,t?void 0:"i"),r]}function Rm(e){try{return e.split("/").map(t=>decodeURIComponent(t).replace(/\//g,"%2F")).join("/")}catch(t){return Ke(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function pt(e,t){if(t==="/")return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith("/")?t.length-1:t.length,r=e.charAt(n);return r&&r!=="/"?null:e.slice(n)||"/"}var Lm=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function Tm(e,t="/"){let{pathname:n,search:r="",hash:l=""}=typeof e=="string"?On(e):e,o;return n?(n=n.replace(/\/\/+/g,"/"),n.startsWith("/")?o=Va(n.substring(1),"/"):o=Va(n,t)):o=t,{pathname:o,search:Im(r),hash:Dm(l)}}function Va(e,t){let n=t.replace(/\/+$/,"").split("/");return e.split("/").forEach(l=>{l===".."?n.length>1&&n.pop():l!=="."&&n.push(l)}),n.length>1?n.join("/"):"/"}function Po(e,t,n,r){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function zm(e){return e.filter((t,n)=>n===0||t.route.path&&t.route.path.length>0)}function Si(e){let t=zm(e);return t.map((n,r)=>r===t.length-1?n.pathname:n.pathnameBase)}function ji(e,t,n,r=!1){let l;typeof e=="string"?l=On(e):(l={...e},K(!l.pathname||!l.pathname.includes("?"),Po("?","pathname","search",l)),K(!l.pathname||!l.pathname.includes("#"),Po("#","pathname","hash",l)),K(!l.search||!l.search.includes("#"),Po("#","search","hash",l)));let o=e===""||l.pathname==="",s=o?"/":l.pathname,i;if(s==null)i=n;else{let p=t.length-1;if(!r&&s.startsWith("..")){let h=s.split("/");for(;h[0]==="..";)h.shift(),p-=1;l.pathname=h.join("/")}i=p>=0?t[p]:"/"}let u=Tm(l,i),c=s&&s!=="/"&&s.endsWith("/"),y=(o||s===".")&&n.endsWith("/");return!u.pathname.endsWith("/")&&(c||y)&&(u.pathname+="/"),u}var at=e=>e.join("/").replace(/\/\/+/g,"/"),Om=e=>e.replace(/\/+$/,"").replace(/^\/*/,"/"),Im=e=>!e||e==="?"?"":e.startsWith("?")?e:"?"+e,Dm=e=>!e||e==="#"?"":e.startsWith("#")?e:"#"+e,bm=class{constructor(e,t,n,r=!1){this.status=e,this.statusText=t||"",this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function Mm(e){return e!=null&&typeof e.status=="number"&&typeof e.statusText=="string"&&typeof e.internal=="boolean"&&"data"in e}function $m(e){return e.map(t=>t.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var hd=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function yd(e,t){let n=e;if(typeof n!="string"||!Lm.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,l=!1;if(hd)try{let o=new URL(window.location.href),s=n.startsWith("//")?new URL(o.protocol+n):new URL(n),i=pt(s.pathname,t);s.origin===o.origin&&i!=null?n=i+s.search+s.hash:l=!0}catch{Ke(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:l,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var gd=["POST","PUT","PATCH","DELETE"];new Set(gd);var Fm=["GET",...gd];new Set(Fm);var In=g.createContext(null);In.displayName="DataRouter";var Zl=g.createContext(null);Zl.displayName="DataRouterState";var Am=g.createContext(!1),vd=g.createContext({isTransitioning:!1});vd.displayName="ViewTransition";var Um=g.createContext(new Map);Um.displayName="Fetchers";var Bm=g.createContext(null);Bm.displayName="Await";var Te=g.createContext(null);Te.displayName="Navigation";var _r=g.createContext(null);_r.displayName="Location";var et=g.createContext({outlet:null,matches:[],isDataRoute:!1});et.displayName="Route";var Ei=g.createContext(null);Ei.displayName="RouteError";var xd="REACT_ROUTER_ERROR",Vm="REDIRECT",Hm="ROUTE_ERROR_RESPONSE";function Wm(e){if(e.startsWith(`${xd}:${Vm}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.location=="string"&&typeof t.reloadDocument=="boolean"&&typeof t.replace=="boolean")return t}catch{}}function Qm(e){if(e.startsWith(`${xd}:${Hm}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string")return new bm(t.status,t.statusText,t.data)}catch{}}function Km(e,{relative:t}={}){K(Dn(),"useHref() may be used only in the context of a component.");let{basename:n,navigator:r}=g.useContext(Te),{hash:l,pathname:o,search:s}=Pr(e,{relative:t}),i=o;return n!=="/"&&(i=o==="/"?n:at([n,o])),r.createHref({pathname:i,search:s,hash:l})}function Dn(){return g.useContext(_r)!=null}function ht(){return K(Dn(),"useLocation() may be used only in the context of a component."),g.useContext(_r).location}var wd="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function kd(e){g.useContext(Te).static||g.useLayoutEffect(e)}function Ci(){let{isDataRoute:e}=g.useContext(et);return e?sh():Jm()}function Jm(){K(Dn(),"useNavigate() may be used only in the context of a component.");let e=g.useContext(In),{basename:t,navigator:n}=g.useContext(Te),{matches:r}=g.useContext(et),{pathname:l}=ht(),o=JSON.stringify(Si(r)),s=g.useRef(!1);return kd(()=>{s.current=!0}),g.useCallback((u,c={})=>{if(Ke(s.current,wd),!s.current)return;if(typeof u=="number"){n.go(u);return}let y=ji(u,JSON.parse(o),l,c.relative==="path");e==null&&t!=="/"&&(y.pathname=y.pathname==="/"?t:at([t,y.pathname])),(c.replace?n.replace:n.push)(y,c.state,c)},[t,n,o,l,e])}g.createContext(null);function Pr(e,{relative:t}={}){let{matches:n}=g.useContext(et),{pathname:r}=ht(),l=JSON.stringify(Si(n));return g.useMemo(()=>ji(e,JSON.parse(l),r,t==="path"),[e,l,r,t])}function Ym(e,t){return Nd(e,t)}function Nd(e,t,n,r,l){var d;K(Dn(),"useRoutes() may be used only in the context of a component.");let{navigator:o}=g.useContext(Te),{matches:s}=g.useContext(et),i=s[s.length-1],u=i?i.params:{},c=i?i.pathname:"/",y=i?i.pathnameBase:"/",p=i&&i.route;{let m=p&&p.path||"";jd(c,!p||m.endsWith("*")||m.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${c}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. - -Please change the parent to .`)}let h=ht(),v;if(t){let m=typeof t=="string"?On(t):t;K(y==="/"||((d=m.pathname)==null?void 0:d.startsWith(y)),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${y}" but pathname "${m.pathname}" was given in the \`location\` prop.`),v=m}else v=h;let x=v.pathname||"/",w=x;if(y!=="/"){let m=y.replace(/^\//,"").split("/");w="/"+x.replace(/^\//,"").split("/").slice(m.length).join("/")}let N=fd(e,{pathname:w});Ke(p||N!=null,`No routes matched location "${v.pathname}${v.search}${v.hash}" `),Ke(N==null||N[N.length-1].route.element!==void 0||N[N.length-1].route.Component!==void 0||N[N.length-1].route.lazy!==void 0,`Matched leaf route at location "${v.pathname}${v.search}${v.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let f=eh(N&&N.map(m=>Object.assign({},m,{params:Object.assign({},u,m.params),pathname:at([y,o.encodeLocation?o.encodeLocation(m.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:m.pathname]),pathnameBase:m.pathnameBase==="/"?y:at([y,o.encodeLocation?o.encodeLocation(m.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:m.pathnameBase])})),s,n,r,l);return t&&f?g.createElement(_r.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...v},navigationType:"POP"}},f):f}function Gm(){let e=oh(),t=Mm(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r="rgba(200,200,200, 0.5)",l={padding:"0.5rem",backgroundColor:r},o={padding:"2px 4px",backgroundColor:r},s=null;return console.error("Error handled by React Router default ErrorBoundary:",e),s=g.createElement(g.Fragment,null,g.createElement("p",null,"💿 Hey developer 👋"),g.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",g.createElement("code",{style:o},"ErrorBoundary")," or"," ",g.createElement("code",{style:o},"errorElement")," prop on your route.")),g.createElement(g.Fragment,null,g.createElement("h2",null,"Unexpected Application Error!"),g.createElement("h3",{style:{fontStyle:"italic"}},t),n?g.createElement("pre",{style:l},n):null,s)}var Xm=g.createElement(Gm,null),Sd=class extends g.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!=="idle"&&e.revalidation==="idle"?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error!==void 0?e.error:t.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error("React Router caught the following error during render",e)}render(){let e=this.state.error;if(this.context&&typeof e=="object"&&e&&"digest"in e&&typeof e.digest=="string"){const n=Qm(e.digest);n&&(e=n)}let t=e!==void 0?g.createElement(et.Provider,{value:this.props.routeContext},g.createElement(Ei.Provider,{value:e,children:this.props.component})):this.props.children;return this.context?g.createElement(Zm,{error:e},t):t}};Sd.contextType=Am;var Ro=new WeakMap;function Zm({children:e,error:t}){let{basename:n}=g.useContext(Te);if(typeof t=="object"&&t&&"digest"in t&&typeof t.digest=="string"){let r=Wm(t.digest);if(r){let l=Ro.get(t);if(l)throw l;let o=yd(r.location,n);if(hd&&!Ro.get(t))if(o.isExternal||r.reloadDocument)window.location.href=o.absoluteURL||o.to;else{const s=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(o.to,{replace:r.replace}));throw Ro.set(t,s),s}return g.createElement("meta",{httpEquiv:"refresh",content:`0;url=${o.absoluteURL||o.to}`})}}return e}function qm({routeContext:e,match:t,children:n}){let r=g.useContext(In);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),g.createElement(et.Provider,{value:e},n)}function eh(e,t=[],n=null,r=null,l=null){if(e==null){if(!n)return null;if(n.errors)e=n.matches;else if(t.length===0&&!n.initialized&&n.matches.length>0)e=n.matches;else return null}let o=e,s=n==null?void 0:n.errors;if(s!=null){let y=o.findIndex(p=>p.route.id&&(s==null?void 0:s[p.route.id])!==void 0);K(y>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(s).join(",")}`),o=o.slice(0,Math.min(o.length,y+1))}let i=!1,u=-1;if(n)for(let y=0;y=0?o=o.slice(0,u+1):o=[o[0]];break}}}let c=n&&r?(y,p)=>{var h,v;r(y,{location:n.location,params:((v=(h=n.matches)==null?void 0:h[0])==null?void 0:v.params)??{},unstable_pattern:$m(n.matches),errorInfo:p})}:void 0;return o.reduceRight((y,p,h)=>{let v,x=!1,w=null,N=null;n&&(v=s&&p.route.id?s[p.route.id]:void 0,w=p.route.errorElement||Xm,i&&(u<0&&h===0?(jd("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),x=!0,N=null):u===h&&(x=!0,N=p.route.hydrateFallbackElement||null)));let f=t.concat(o.slice(0,h+1)),d=()=>{let m;return v?m=w:x?m=N:p.route.Component?m=g.createElement(p.route.Component,null):p.route.element?m=p.route.element:m=y,g.createElement(qm,{match:p,routeContext:{outlet:y,matches:f,isDataRoute:n!=null},children:m})};return n&&(p.route.ErrorBoundary||p.route.errorElement||h===0)?g.createElement(Sd,{location:n.location,revalidation:n.revalidation,component:w,error:v,children:d(),routeContext:{outlet:null,matches:f,isDataRoute:!0},onError:c}):d()},null)}function _i(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function th(e){let t=g.useContext(In);return K(t,_i(e)),t}function nh(e){let t=g.useContext(Zl);return K(t,_i(e)),t}function rh(e){let t=g.useContext(et);return K(t,_i(e)),t}function Pi(e){let t=rh(e),n=t.matches[t.matches.length-1];return K(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function lh(){return Pi("useRouteId")}function oh(){var r;let e=g.useContext(Ei),t=nh("useRouteError"),n=Pi("useRouteError");return e!==void 0?e:(r=t.errors)==null?void 0:r[n]}function sh(){let{router:e}=th("useNavigate"),t=Pi("useNavigate"),n=g.useRef(!1);return kd(()=>{n.current=!0}),g.useCallback(async(l,o={})=>{Ke(n.current,wd),n.current&&(typeof l=="number"?await e.navigate(l):await e.navigate(l,{fromRouteId:t,...o}))},[e,t])}var Ha={};function jd(e,t,n){!t&&!Ha[e]&&(Ha[e]=!0,Ke(!1,n))}g.memo(ih);function ih({routes:e,future:t,state:n,onError:r}){return Nd(e,void 0,n,r,t)}function ah({to:e,replace:t,state:n,relative:r}){K(Dn()," may be used only in the context of a component.");let{static:l}=g.useContext(Te);Ke(!l," must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.");let{matches:o}=g.useContext(et),{pathname:s}=ht(),i=Ci(),u=ji(e,Si(o),s,r==="path"),c=JSON.stringify(u);return g.useEffect(()=>{i(JSON.parse(c),{replace:t,state:n,relative:r})},[i,c,r,t,n]),null}function Cs(e){K(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function uh({basename:e="/",children:t=null,location:n,navigationType:r="POP",navigator:l,static:o=!1,unstable_useTransitions:s}){K(!Dn(),"You cannot render a inside another . You should never have more than one in your app.");let i=e.replace(/^\/*/,"/"),u=g.useMemo(()=>({basename:i,navigator:l,static:o,unstable_useTransitions:s,future:{}}),[i,l,o,s]);typeof n=="string"&&(n=On(n));let{pathname:c="/",search:y="",hash:p="",state:h=null,key:v="default"}=n,x=g.useMemo(()=>{let w=pt(c,i);return w==null?null:{location:{pathname:w,search:y,hash:p,state:h,key:v},navigationType:r}},[i,c,y,p,h,v,r]);return Ke(x!=null,` is not able to match the URL "${c}${y}${p}" because it does not start with the basename, so the won't render anything.`),x==null?null:g.createElement(Te.Provider,{value:u},g.createElement(_r.Provider,{children:t,value:x}))}function ch({children:e,location:t}){return Ym(_s(e),t)}function _s(e,t=[]){let n=[];return g.Children.forEach(e,(r,l)=>{if(!g.isValidElement(r))return;let o=[...t,l];if(r.type===g.Fragment){n.push.apply(n,_s(r.props.children,o));return}K(r.type===Cs,`[${typeof r.type=="string"?r.type:r.type.name}] is not a component. All component children of must be a or `),K(!r.props.index||!r.props.children,"An index route cannot have child routes.");let s={id:r.props.id||o.join("-"),caseSensitive:r.props.caseSensitive,element:r.props.element,Component:r.props.Component,index:r.props.index,path:r.props.path,middleware:r.props.middleware,loader:r.props.loader,action:r.props.action,hydrateFallbackElement:r.props.hydrateFallbackElement,HydrateFallback:r.props.HydrateFallback,errorElement:r.props.errorElement,ErrorBoundary:r.props.ErrorBoundary,hasErrorBoundary:r.props.hasErrorBoundary===!0||r.props.ErrorBoundary!=null||r.props.errorElement!=null,shouldRevalidate:r.props.shouldRevalidate,handle:r.props.handle,lazy:r.props.lazy};r.props.children&&(s.children=_s(r.props.children,o)),n.push(s)}),n}var il="get",al="application/x-www-form-urlencoded";function ql(e){return typeof HTMLElement<"u"&&e instanceof HTMLElement}function dh(e){return ql(e)&&e.tagName.toLowerCase()==="button"}function fh(e){return ql(e)&&e.tagName.toLowerCase()==="form"}function ph(e){return ql(e)&&e.tagName.toLowerCase()==="input"}function mh(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function hh(e,t){return e.button===0&&(!t||t==="_self")&&!mh(e)}var Jr=null;function yh(){if(Jr===null)try{new FormData(document.createElement("form"),0),Jr=!1}catch{Jr=!0}return Jr}var gh=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function Lo(e){return e!=null&&!gh.has(e)?(Ke(!1,`"${e}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${al}"`),null):e}function vh(e,t){let n,r,l,o,s;if(fh(e)){let i=e.getAttribute("action");r=i?pt(i,t):null,n=e.getAttribute("method")||il,l=Lo(e.getAttribute("enctype"))||al,o=new FormData(e)}else if(dh(e)||ph(e)&&(e.type==="submit"||e.type==="image")){let i=e.form;if(i==null)throw new Error('Cannot submit a @@ -154,22 +160,25 @@ function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message DS2API - +
+ + +

- {NAV_ITEMS.find(n => n.id === activeTab)?.label} + {navItems.find(n => n.id === activeTab)?.label}

- {NAV_ITEMS.find(n => n.id === activeTab)?.description} + {navItems.find(n => n.id === activeTab)?.description}

@@ -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 (
-

正在检查登录状态...

+

{t('auth.checking')}

) diff --git a/webui/src/components/AccountManager.jsx b/webui/src/components/AccountManager.jsx index 66658a0..25aa8c1 100644 --- a/webui/src/components/AccountManager.jsx +++ b/webui/src/components/AccountManager.jsx @@ -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
-

可用

+

{t('accountManager.available')}

{queueStatus.available} - 个账号 + {t('accountManager.accountsUnit')}
-

正在使用

+

{t('accountManager.inUse')}

{queueStatus.in_use} - 线程 + {t('accountManager.threadsUnit')}
-

账号池总数

+

{t('accountManager.totalPool')}

{queueStatus.total} - 个账号 + {t('accountManager.accountsUnit')}
@@ -285,15 +230,15 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
-

API 密钥

-

管理 API 访问密钥池

+

{t('accountManager.apiKeysTitle')}

+

{t('accountManager.apiKeysDesc')}

@@ -306,7 +251,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch {key.slice(0, 16)}****
{copiedKey === key && ( - 已复制 + {t('accountManager.copied')} )}
@@ -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 ? : } @@ -332,7 +277,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
)) ) : ( -
未找到 API 密钥
+
{t('accountManager.noApiKeys')}
)} @@ -341,41 +286,33 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
-

DeepSeek 账号

-

管理 DeepSeek 账号池

+

{t('accountManager.accountsTitle')}

+

{t('accountManager.accountsDesc')}

-
{/* Batch Progress */} - {(testingAll || validatingAll) && batchProgress.total > 0 && ( + {testingAll && batchProgress.total > 0 && (
- {testingAll ? '正在测试所有账号...' : '正在校验所有账号...'} + {t('accountManager.testingAllAccounts')} {batchProgress.current} / {batchProgress.total}
@@ -413,7 +350,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
{id}
- {acc.has_token ? '已建立会话' : '需重新登录'} + {acc.has_token ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')} {acc.token_preview && ( {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] ? '正在测试...' : '测试'} - -
@@ -459,19 +389,19 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
-

添加 API 密钥

+

{t('accountManager.modalAddKeyTitle')}

- +
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')}
-

点击「生成」自动创建随机密钥

+

{t('accountManager.generateHint')}

- +
@@ -503,14 +433,14 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
-

添加 DeepSeek 账号

+

{t('accountManager.modalAddAccountTitle')}

- +
- +
- + setNewAccount({ ...newAccount, password: e.target.value })} />
- +
diff --git a/webui/src/components/ApiTester.jsx b/webui/src/components/ApiTester.jsx index ba7ced7..87fc7d9 100644 --- a/webui/src/components/ApiTester.jsx +++ b/webui/src/components/ApiTester.jsx @@ -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 (
{/* 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" > -
-
- +
+
+ +
+ {t('apiTester.config')}
- 配置 -
@@ -217,9 +225,9 @@ export default function ApiTester({ config, onMessage, authFetch }) { !configExpanded && "hidden lg:block" )}>
- +
- {MODELS.map(m => { + {models.map(m => { const Icon = m.icon return (