diff --git a/README-JA.md b/README-JA.md index e00e5ea..aacae95 100644 --- a/README-JA.md +++ b/README-JA.md @@ -30,10 +30,11 @@ > > **📅 バージョン更新ログ** > +> - **2025.11.11** - Web UI管理コントロールコンソールの追加、リアルタイム設定管理与健康状態モニタリングをサポート > - **2025.11.06** - Gemini 3 プレビュー版のサポートを追加、モデル互換性とパフォーマンス最適化を向上 > - **2025.10.18** - Kiroオープン登録、新規アカウントに500クレジット付与、Claude Sonnet 4.5を完全サポート > - **2025.09.01** - Qwen Code CLIを統合、`qwen3-coder-plus`モデルサポートを追加 -> - **2025.08.29** - アカウントプール管理機能をリリース、マルチアカウントポーリング、インテリジェントフェイルオーバー、自動ダウングレード戦略をサポート +> - **2025.08.29** - アカウントプール管理機能をリリース、マルチアカウントポーリング、自動フェイルオーバー、自動ダウングレード戦略をサポート > - 設定方法:config.jsonに`PROVIDER_POOLS_FILE_PATH`パラメータを追加 > - 参考設定:[provider_pools.json](./provider_pools.json.example) @@ -85,8 +86,8 @@ 本プロジェクトは異なるプロトコルを通じて複数のモデルプロバイダーをサポートします。以下はそれらの関係の概要です: -* **OpenAIプロトコル (P_OPENAI)**:`openai-custom`、`gemini-cli-oauth`、`claude-custom`、`claude-kiro-oauth`、`openai-qwen-oauth`モデルプロバイダーによって実装。 -* **Claudeプロトコル (P_CLAUDE)**:`claude-custom`、`claude-kiro-oauth`、`gemini-cli-oauth`、`openai-custom`、`openai-qwen-oauth`モデルプロバイダーによって実装。 +* **OpenAIプロトコル (P_OPENAI)**:`openai-custom`、`gemini-cli-oauth`、`claude-custom`、`claude-kiro-oauth`、`openai-qwen-oauth`、`openaiResponses-custom`モデルプロバイダーによって実装。 +* **Claudeプロトコル (P_CLAUDE)**:`claude-custom`、`claude-kiro-oauth`、`gemini-cli-oauth`、`openai-custom`、`openai-qwen-oauth`、`openaiResponses-custom`モデルプロバイダーによって実装。 * **Geminiプロトコル (P_GEMINI)**:`gemini-cli-oauth`モデルプロバイダーによって実装。 詳細な関係図: @@ -106,6 +107,7 @@ MP_CLAUDE_C[claude-custom] MP_CLAUDE_K[claude-kiro-oauth] MP_QWEN[openai-qwen-oauth] + MP_OPENAI_RESP[openaiResponses-custom] end P_OPENAI ---|サポート| MP_OPENAI @@ -113,14 +115,16 @@ P_OPENAI ---|サポート| MP_GEMINI P_OPENAI ---|サポート| MP_CLAUDE_C P_OPENAI ---|サポート| MP_CLAUDE_K - + P_OPENAI ---|サポート| MP_OPENAI_RESP + P_GEMINI ---|サポート| MP_GEMINI - + P_CLAUDE ---|サポート| MP_CLAUDE_C P_CLAUDE ---|サポート| MP_CLAUDE_K P_CLAUDE ---|サポート| MP_GEMINI P_CLAUDE ---|サポート| MP_OPENAI P_CLAUDE ---|サポート| MP_QWEN + P_CLAUDE ---|サポート| MP_OPENAI_RESP style P_OPENAI fill:#f9f,stroke:#333,stroke-width:2px style P_GEMINI fill:#ccf,stroke:#333,stroke-width:2px @@ -132,8 +136,80 @@ ## 🔧 使用方法 +### 🚀 install-and-run スクリプトでクイックスタート + +AIClient-2-APIを使い始める最も簡単な方法は、自動的にインストールと起動を実行するスクリプトを使用することです。Linux/macOS版とWindows版の2つのバージョンを提供: + +#### Linux/macOS ユーザー向け +```bash +# スクリプトに実行権限を付与して実行 +chmod +x install-and-run.sh +./install-and-run.sh +``` + +#### Windows ユーザー向け +```cmd +# バッチファイルを実行 +install-and-run.bat +``` + +#### スクリプトの機能 + +`install-and-run` スクリプトは自動的に以下の操作を実行します: + +1. **Node.js インストール確認**:Node.js がインストールされているかを確認し、不足の場合はダウンロードリンクを提供 +2. **依存関係管理**:`node_modules` が存在しない場合、npm 依存関係を自動インストール +3. **ファイル検証**:すべての必要なプロジェクトファイルが存在することを確認 +4. **サーバー起動**:`http://localhost:3000` で API サーバーを起動 +5. **Web UI アクセス**:管理コンソールへの直接アクセスを提供 + +#### スクリプト実行例 +``` +======================================== + AI Client 2 API 快速インストール起動スクリプト +======================================== + +[確認] Node.js がインストールされているかを確認中... +✅ Node.js がインストールされています、バージョン: v20.10.0 +✅ package.json ファイルが見つかりました +✅ node_modules ディレクトリが既に存在しています +✅ プロジェクトファイルの確認が完了しました + +======================================== + AI Client 2 API サーバーを起動中... +======================================== + +🌐 サーバーは http://localhost:3000 で起動します +📖 管理インターフェースを表示するには http://localhost:3000 にアクセス +⏹️ サーバーを停止するには Ctrl+C を押してください +``` + +> **💡 ヒント**:スクリプトは自動的に依存関係をインストールし、サーバーを起動します。問題が発生した場合、スクリプトは明確なエラーメッセージと解決案を提供します。 + +--- + ### 📋 コア機能 +#### Web UI管理コントロールコンソール + +![Web UI](src/img/web.png) + +以下の機能モジュールを備えたWeb管理インターフェース: + +**📊 ダッシュボード**:システム概要、インタラクティブなルーティング例、クライアント設定ガイド + +**⚙️ 設定管理**:全プロバイダー(Gemini、OpenAI、Claude、Kiro、Qwen)のリアルタイムパラメータ修正、高度設定、ファイルアップロード対応 + +**🔗 プロバイダープール**:アクティブ接続監視、プロバイダー健全性統計、有効化/無効化管理 + +**📁 設定ファイル**:OAuth資格情報の集中管理、検索フィルタリング、ファイル操作機能 + +**📜 リアルタイムログ**:システムログとリクエストログのライブ表示、管理コントロール付き + +**🔐 ログイン**:認証が必要(デフォルト:`admin123`、`pwd`ファイルで変更可能) + +アクセス:`http://localhost:3000` → ログイン → サイドバーナビゲーション → 即座有効 + #### MCPプロトコルサポート 本プロジェクトは**Model Context Protocol (MCP)**と完全互換で、MCPをサポートするクライアントとシームレスに統合し、強力な機能拡張を実現します。 @@ -145,6 +221,8 @@ * **Kimi K2** - 月之暗面の最新フラッグシップモデル * **GLM-4.5** - 智譜AIの最新バージョン * **Qwen Code** - アリババ通義千問のコード専用モデル +* **Gemini 3** - Googleの最新プレビューモデル +* **Claude Sonnet 4.5** - Anthropicの最新フラッグシップモデル --- @@ -187,31 +265,6 @@ 本プロジェクトは2つの柔軟なモデル切り替え方法を提供し、異なる使用シナリオのニーズに対応します。 -#### 方法1:起動パラメータ切り替え - -コマンドラインパラメータでデフォルトのモデルプロバイダーを指定: - -```bash -# Geminiプロバイダーを使用 -node src/api-server.js --model-provider gemini-cli-oauth --project-id your-project-id - -# Claude Kiroプロバイダーを使用 -node src/api-server.js --model-provider claude-kiro-oauth - -# Qwenプロバイダーを使用 -node src/api-server.js --model-provider openai-qwen-oauth -``` - -**利用可能なモデルプロバイダー識別子**: -- `openai-custom` - 標準OpenAI API -- `claude-custom` - 公式Claude API -- `gemini-cli-oauth` - Gemini CLI OAuth -- `claude-kiro-oauth` - Kiro Claude OAuth -- `openai-qwen-oauth` - Qwen Code OAuth -- `openaiResponses-custom` - OpenAI Responses API - -#### 方法2:Pathルーティング切り替え(推奨) - APIリクエストパスでプロバイダー識別子を指定して即座に切り替え: | ルートパス | 説明 | 使用ケース | @@ -268,7 +321,7 @@ curl http://localhost:3000/gemini-cli-oauth/v1/chat/completions \ | パラメータ | タイプ | デフォルト値 | 説明 | |------|------|--------|------| -| `--model-provider` | string | gemini-cli-oauth | AIモデルプロバイダー、選択可能値:openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth | +| `--model-provider` | string | gemini-cli-oauth | AIモデルプロバイダー、選択可能値:openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth, openaiResponses-custom | ### 🧠 OpenAI互換プロバイダーパラメータ diff --git a/README-ZH.md b/README-ZH.md index 42a47cc..0c4455e 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -30,6 +30,7 @@ > > **📅 版本更新日志** > +> - **2025.11.11** - 新增 Web UI 管理控制台,支持实时配置管理和健康状态监控 > - **2025.11.06** - 新增对 Gemini 3 预览版的支持,增强模型兼容性和性能优化 > - **2025.10.18** - Kiro 开放注册,新用户赠送 500 额度,已完整支持 Claude Sonnet 4.5 > - **2025.09.01** - 集成 Qwen Code CLI,新增 `qwen3-coder-plus` 模型支持 @@ -62,6 +63,7 @@ * **系统提示词管理**:支持覆盖和追加两种模式,实现统一基础指令与个性化扩展的完美结合 ### 🔧 开发友好,易于扩展 +* **Web UI 管理控制台**:实时配置管理、健康状态监控、API 测试和日志查看 * **模块化架构**:基于策略模式和适配器模式,新增模型提供商仅需 3 步 * **完整测试保障**:集成测试和单元测试覆盖率 90%+,确保代码质量 * **容器化部署**:提供 Docker 支持,一键部署,跨平台运行 @@ -83,7 +85,6 @@ ## 🎨 模型协议与提供商关系图 - 本项目通过不同的协议(Protocol)支持多种模型提供商(Model Provider)。以下是它们之间的关系概述: * **OpenAI 协议 (P_OPENAI)**:由 `openai-custom`, `gemini-cli-oauth`, `claude-custom`, `claude-kiro-oauth` 和 `openai-qwen-oauth` 等模型提供商实现。 @@ -94,7 +95,7 @@ ```mermaid - + graph TD subgraph Core_Protocols["核心协议"] P_OPENAI[OpenAI Protocol] @@ -108,6 +109,7 @@ MP_CLAUDE_C[claude-custom] MP_CLAUDE_K[claude-kiro-oauth] MP_QWEN[openai-qwen-oauth] + MP_OPENAI_RESP[openaiResponses-custom] end P_OPENAI ---|支持| MP_OPENAI @@ -115,6 +117,7 @@ P_OPENAI ---|支持| MP_GEMINI P_OPENAI ---|支持| MP_CLAUDE_C P_OPENAI ---|支持| MP_CLAUDE_K + P_OPENAI ---|支持| MP_OPENAI_RESP P_GEMINI ---|支持| MP_GEMINI @@ -123,6 +126,7 @@ P_CLAUDE ---|支持| MP_GEMINI P_CLAUDE ---|支持| MP_OPENAI P_CLAUDE ---|支持| MP_QWEN + P_CLAUDE ---|支持| MP_OPENAI_RESP style P_OPENAI fill:#f9f,stroke:#333,stroke-width:2px style P_GEMINI fill:#ccf,stroke:#333,stroke-width:2px @@ -134,8 +138,80 @@ ## 🔧 使用说明 +### 🚀 install-and-run 脚本快速启动 + +使用 AIClient-2-API 最简单的方式是使用我们的自动化安装启动脚本。我们提供了 Linux/macOS 和 Windows 两个版本: + +#### Linux/macOS 用户 +```bash +# 给脚本添加执行权限并运行 +chmod +x install-and-run.sh +./install-and-run.sh +``` + +#### Windows 用户 +```cmd +# 运行批处理文件 +install-and-run.bat +``` + +#### 脚本功能说明 + +`install-and-run` 脚本会自动执行以下操作: + +1. **检查 Node.js 安装**:验证 Node.js 是否已安装,如缺失则提供下载链接 +2. **依赖管理**:如果 `node_modules` 不存在,自动安装 npm 依赖包 +3. **文件验证**:确保所有必需的项目文件都存在 +4. **服务器启动**:在 `http://localhost:3000` 启动 API 服务器 +5. **Web UI 访问**:直接提供管理控制台的访问地址 + +#### 脚本执行示例 +``` +======================================== + AI Client 2 API 快速安装启动脚本 +======================================== + +[检查] 正在检查Node.js是否已安装... +✅ Node.js已安装,版本: v20.10.0 +✅ 找到package.json文件 +✅ node_modules目录已存在 +✅ 项目文件检查完成 + +======================================== + 启动AI Client 2 API服务器... +======================================== + +🌐 服务器将在 http://localhost:3000 启动 +📖 访问 http://localhost:3000 查看管理界面 +⏹️ 按 Ctrl+C 停止服务器 +``` + +> **💡 提示**:脚本会自动安装依赖并启动服务器。如果遇到任何问题,脚本会提供清晰的错误信息和解决建议。 + +--- + ### 📋 核心功能 +#### Web UI 管理控制台 + +![Web UI](src/img/web.png) + +功能完善的 Web 管理界面,包含: + +**📊 仪表盘**:系统概览、交互式路由示例、客户端配置指南 + +**⚙️ 配置管理**:实时参数修改,支持所有提供商(Gemini、OpenAI、Claude、Kiro、Qwen),包含高级设置和文件上传 + +**🔗 供应商池**:监控活动连接、提供商健康统计、启用/禁用管理 + +**📁 配置文件**:OAuth 凭据集中管理,支持搜索过滤和文件操作 + +**📜 实时日志**:系统日志和请求日志实时显示,带管理控制 + +**🔐 登录验证**:默认密码 `admin123`,可通过 `pwd` 文件修改 + +访问:`http://localhost:3000` → 登录 → 侧边栏导航 → 立即生效 + #### MCP 协议支持 本项目完全兼容 **Model Context Protocol (MCP)**,可与支持 MCP 的客户端无缝集成,实现强大的功能扩展。 @@ -147,6 +223,8 @@ * **Kimi K2** - 月之暗面最新旗舰模型 * **GLM-4.5** - 智谱 AI 最新版本 * **Qwen Code** - 阿里通义千问代码专用模型 +* **Gemini 3** - Google 最新预览版模型 +* **Claude Sonnet 4.5** - Anthropic 最新旗舰模型 --- @@ -175,13 +253,11 @@ 3. **最佳实践**:推荐配合 **Claude Code** 使用,可获得最优体验 4. **重要提示**:Kiro 服务使用政策已更新,请访问官方网站查看最新使用限制和条款 -#### OpenAI Responses API -* **应用场景**:适用于需要使用 OpenAI Responses API 进行结构化对话的场景,如Codex -* **配置方法**: - * 方式一:在 [`config.json`](./config.json) 中设置 `MODEL_PROVIDER` 为 `openaiResponses-custom` - * 方式二:使用启动参数 `--model-provider openaiResponses-custom` - * 方式三:使用路径路由 `/openaiResponses-custom` -* **必需参数**:提供有效的 API 密钥和基础 URL +#### 账号池管理配置 +1. **创建号池配置文件**:参考 [provider_pools.json.example](./provider_pools.json.example) 创建配置文件 +2. **配置号池参数**:在 config.json 中设置 `PROVIDER_POOLS_FILE_PATH` 指向号池配置文件 +3. **启动参数配置**:使用 `--provider-pools-file ` 参数指定号池配置文件路径 +4. **健康检查**:系统会定期自动执行健康检查,移除不健康的提供商 --- @@ -189,31 +265,6 @@ 本项目提供两种灵活的模型切换方式,满足不同使用场景的需求。 -#### 方式一:启动参数切换 - -通过命令行参数指定默认使用的模型提供商: - -```bash -# 使用Gemini提供商 -node src/api-server.js --model-provider gemini-cli-oauth --project-id your-project-id - -# 使用Claude Kiro提供商 -node src/api-server.js --model-provider claude-kiro-oauth - -# 使用Qwen提供商 -node src/api-server.js --model-provider openai-qwen-oauth -``` - -**可用的模型提供商标识**: -- `openai-custom` - 标准OpenAI API -- `claude-custom` - 官方Claude API -- `gemini-cli-oauth` - Gemini CLI OAuth -- `claude-kiro-oauth` - Kiro Claude OAuth -- `openai-qwen-oauth` - Qwen Code OAuth -- `openaiResponses-custom` - OpenAI Responses API - -#### 方式二:Path 路由切换(推荐) - 通过在 API 请求路径中指定供应商标识,实现即时切换: | 路由路径 | 说明 | 适用场景 | @@ -270,7 +321,7 @@ curl http://localhost:3000/gemini-cli-oauth/v1/chat/completions \ | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| -| `--model-provider` | string | gemini-cli-oauth | AI 模型提供商,可选值:openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth | +| `--model-provider` | string | gemini-cli-oauth | AI 模型提供商,可选值:openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth, openaiResponses-custom | ### 🧠 OpenAI 兼容提供商参数 @@ -380,6 +431,9 @@ node src/api-server.js --system-prompt-file custom-prompt.txt --system-prompt-mo node src/api-server.js --log-prompts console node src/api-server.js --log-prompts file --prompt-log-base-name my-logs +# 配置号池 +node src/api-server.js --provider-pools-file ./provider_pools.json + # 完整示例 node src/api-server.js \ --host 0.0.0.0 \ @@ -391,8 +445,10 @@ node src/api-server.js \ --system-prompt-file ./custom-system-prompt.txt \ --system-prompt-mode overwrite \ --log-prompts file \ - --prompt-log-base-name api-logs + --prompt-log-base-name api-logs \ + --provider-pools-file ./provider_pools.json ``` + --- ## 📄 开源许可 diff --git a/README.md b/README.md index f4070ae..49de594 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ > > **📅 Version Update Log** > +> - **2025.11.11** - Added Web UI management console, supporting real-time configuration management and health status monitoring > - **2025.11.06** - Added support for Gemini 3 Preview, enhanced model compatibility and performance optimization > - **2025.10.18** - Kiro open registration, new accounts get 500 credits, full support for Claude Sonnet 4.5 > - **2025.09.01** - Integrated Qwen Code CLI, added `qwen3-coder-plus` model support @@ -85,8 +86,8 @@ This project supports multiple model providers through different protocols. The following is an overview of their relationships: -* **OpenAI Protocol (P_OPENAI)**: Implemented by `openai-custom`, `gemini-cli-oauth`, `claude-custom`, `claude-kiro-oauth`, and `openai-qwen-oauth` model providers. -* **Claude Protocol (P_CLAUDE)**: Implemented by `claude-custom`, `claude-kiro-oauth`, `gemini-cli-oauth`, `openai-custom`, and `openai-qwen-oauth` model providers. +* **OpenAI Protocol (P_OPENAI)**: Implemented by `openai-custom`, `gemini-cli-oauth`, `claude-custom`, `claude-kiro-oauth`, `openai-qwen-oauth`, and `openaiResponses-custom` model providers. +* **Claude Protocol (P_CLAUDE)**: Implemented by `claude-custom`, `claude-kiro-oauth`, `gemini-cli-oauth`, `openai-custom`, `openai-qwen-oauth`, and `openaiResponses-custom` model providers. * **Gemini Protocol (P_GEMINI)**: Implemented by `gemini-cli-oauth` model provider. Detailed relationship diagram: @@ -106,6 +107,7 @@ Detailed relationship diagram: MP_CLAUDE_C[claude-custom] MP_CLAUDE_K[claude-kiro-oauth] MP_QWEN[openai-qwen-oauth] + MP_OPENAI_RESP[openaiResponses-custom] end P_OPENAI ---|Support| MP_OPENAI @@ -113,6 +115,7 @@ Detailed relationship diagram: P_OPENAI ---|Support| MP_GEMINI P_OPENAI ---|Support| MP_CLAUDE_C P_OPENAI ---|Support| MP_CLAUDE_K + P_OPENAI ---|Support| MP_OPENAI_RESP P_GEMINI ---|Support| MP_GEMINI @@ -121,6 +124,7 @@ Detailed relationship diagram: P_CLAUDE ---|Support| MP_GEMINI P_CLAUDE ---|Support| MP_OPENAI P_CLAUDE ---|Support| MP_QWEN + P_CLAUDE ---|Support| MP_OPENAI_RESP style P_OPENAI fill:#f9f,stroke:#333,stroke-width:2px style P_GEMINI fill:#ccf,stroke:#333,stroke-width:2px @@ -132,8 +136,79 @@ Detailed relationship diagram: ## 🔧 Usage Instructions +### 🚀 Quick Start with install-and-run Script + +The easiest way to get started with AIClient-2-API is to use our automated installation and startup scripts. We provide both Linux/macOS and Windows versions: + +#### For Linux/macOS Users +```bash +# Make the script executable and run it +chmod +x install-and-run.sh +./install-and-run.sh +``` + +#### For Windows Users +```cmd +# Run the batch file +install-and-run.bat +``` + +#### What the Script Does + +The `install-and-run` script automatically: +1. **Checks Node.js Installation**: Verifies Node.js is installed and provides download link if missing +2. **Dependency Management**: Automatically installs npm dependencies if `node_modules` doesn't exist +3. **File Validation**: Ensures all required project files are present +4. **Server Startup**: Launches the API server on `http://localhost:3000` +5. **Web UI Access**: Provides direct access to the management console + +#### Script Output Example +``` +======================================== + AI Client 2 API Quick Install Script +======================================== + +[Check] Checking if Node.js is installed... +✅ Node.js is installed, version: v20.10.0 +✅ Found package.json file +✅ node_modules directory already exists +✅ Project file check completed + +======================================== + Starting AI Client 2 API Server... +======================================== + +🌐 Server will start on http://localhost:3000 +📖 Visit http://localhost:3000 to view management interface +⏹️ Press Ctrl+C to stop server +``` + +> **💡 Tip**: The script will automatically install dependencies and start the server. If you encounter any issues, the script provides clear error messages and suggested solutions. + +--- + ### 📋 Core Features +#### Web UI Management Console + +![Web UI](src/img/web.png) + +A comprehensive web-based management interface offering: + +**📊 Dashboard**: System overview, interactive routing examples, and client configuration guides + +**⚙️ Configuration**: Real-time parameter modification for all providers (Gemini, OpenAI, Claude, Kiro, Qwen) with advanced settings and file upload support + +**🔗 Provider Pools**: Monitor active connections, provider health statistics, and enable/disable providers + +**📁 Config Files**: Centralized OAuth credential management with search, filtering, and file operations + +**📜 Logs**: Real-time system and request logs with management controls + +**🔐 Login**: Authentication required (default: `admin123`, modify via `pwd` file) + +Access: `http://localhost:3000` → Login → Sidebar navigation → Immediate effect changes + #### MCP Protocol Support This project is fully compatible with **Model Context Protocol (MCP)**, enabling seamless integration with MCP-supporting clients for powerful functional extensions. @@ -145,6 +220,8 @@ Seamlessly supports the following latest large models, simply configure the corr * **Kimi K2** - Moonshot AI's latest flagship model * **GLM-4.5** - Zhipu AI's latest version * **Qwen Code** - Alibaba Tongyi Qianwen code-specific model +* **Gemini 3** - Google's latest preview model +* **Claude Sonnet 4.5** - Anthropic's latest flagship model --- @@ -187,31 +264,6 @@ Seamlessly supports the following latest large models, simply configure the corr This project provides two flexible model switching methods to meet different usage scenario requirements. -#### Method 1: Startup Parameter Switching - -Specify the default model provider via command line parameters: - -```bash -# Use Gemini provider -node src/api-server.js --model-provider gemini-cli-oauth --project-id your-project-id - -# Use Claude Kiro provider -node src/api-server.js --model-provider claude-kiro-oauth - -# Use Qwen provider -node src/api-server.js --model-provider openai-qwen-oauth -``` - -**Available Model Provider Identifiers**: -- `openai-custom` - Standard OpenAI API -- `claude-custom` - Official Claude API -- `gemini-cli-oauth` - Gemini CLI OAuth -- `claude-kiro-oauth` - Kiro Claude OAuth -- `openai-qwen-oauth` - Qwen Code OAuth -- `openaiResponses-custom` - OpenAI Responses API - -#### Method 2: Path Routing Switching (Recommended) - Achieve instant switching by specifying provider identifier in API request path: | Route Path | Description | Use Case | @@ -268,7 +320,7 @@ This project supports rich command-line parameter configuration, allowing flexib | Parameter | Type | Default Value | Description | |------|------|--------|------| -| `--model-provider` | string | gemini-cli-oauth | AI model provider, optional values: openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth | +| `--model-provider` | string | gemini-cli-oauth | AI model provider, optional values: openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth, openaiResponses-custom | ### 🧠 OpenAI Compatible Provider Parameters diff --git a/install-and-run.bat b/install-and-run.bat new file mode 100644 index 0000000..a0b9356 --- /dev/null +++ b/install-and-run.bat @@ -0,0 +1,101 @@ +@echo off +chcp 65001 >nul +setlocal enabledelayedexpansion + +echo ======================================== +echo AI Client 2 API 快速安装启动脚本 +echo ======================================== +echo. + +:: 检查Node.js是否已安装 +echo [检查] 正在检查Node.js是否已安装... +node --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo ❌ 错误:未检测到Node.js,请先安装Node.js + echo 📥 下载地址:https://nodejs.org/ + echo 💡 推荐安装LTS版本 + pause + exit /b 1 +) + +:: 获取Node.js版本 +for /f "tokens=*" %%i in ('node --version') do set NODE_VERSION=%%i +echo ✅ Node.js已安装,版本: !NODE_VERSION! + +:: 检查package.json是否存在 +if not exist "package.json" ( + echo ❌ 错误:未找到package.json文件 + echo 请确保在项目根目录下运行此脚本 + pause + exit /b 1 +) + +echo ✅ 找到package.json文件 + +:: 检查node_modules目录是否存在 +if not exist "node_modules" ( + echo [安装] node_modules目录不存在,正在安装依赖... + echo 这可能需要几分钟时间,请耐心等待... + echo 正在执行: npm install... + :: 使用npm install并设置超时机制 + npm install --timeout=300000 + if !errorlevel! neq 0 ( + echo ❌ 依赖安装失败 + echo 请检查网络连接或手动运行 'npm install' + pause + exit /b 1 + ) + echo ✅ 依赖安装完成 +) else ( + echo ✅ node_modules目录已存在 +) + +:: 检查package-lock.json是否存在 +if not exist "package-lock.json" ( + echo [更新] package-lock.json不存在,正在更新依赖... + echo 正在执行: npm install... + :: 使用npm install并设置超时机制 + npm install --timeout=300000 + if !errorlevel! neq 0 ( + echo ❌ 依赖更新失败 + echo 请检查网络连接或手动运行 'npm install' + pause + exit /b 1 + ) + echo ✅ 依赖更新完成 +) else ( + echo ✅ package-lock.json文件存在 +) + +:: 检查src目录和api-server.js是否存在 +if not exist "src\api-server.js" ( + echo ❌ 错误:未找到src\api-server.js文件 + pause + exit /b 1 +) + +echo ✅ 项目文件检查完成 + +:: 启动应用程序 +echo. +echo ======================================== +echo 启动AI Client 2 API服务器... +echo ======================================== +echo. +echo 🌐 服务器将在 http://localhost:3000 启动 +echo 📖 访问 http://localhost:3000 查看管理界面 +echo ⏹️ 按 Ctrl+C 停止服务器 +echo. + +:: 启动服务器 +node src\api-server.js + +:: 如果启动失败 +if !errorlevel! neq 0 ( + echo. + echo ❌ 服务器异常 + pause + exit /b 1 +) + +pause \ No newline at end of file diff --git a/install-and-run.sh b/install-and-run.sh new file mode 100644 index 0000000..d3a41c5 --- /dev/null +++ b/install-and-run.sh @@ -0,0 +1,102 @@ +#!/bin/bash + +# 设置中文环境 +export LC_ALL=zh_CN.UTF-8 +export LANG=zh_CN.UTF-8 + +echo "========================================" +echo " AI Client 2 API 快速安装启动脚本" +echo "========================================" +echo + +# 检查Node.js是否已安装 +echo "[检查] 正在检查Node.js是否已安装..." +node --version > /dev/null 2>&1 +if [ $? -ne 0 ]; then + echo "❌ 错误:未检测到Node.js,请先安装Node.js" + echo "📥 下载地址:https://nodejs.org/" + echo "💡 推荐安装LTS版本" + exit 1 +fi + +# 获取Node.js版本 +NODE_VERSION=$(node --version 2>/dev/null) +echo "✅ Node.js已安装,版本: $NODE_VERSION" + +# 检查npm是否可用 +echo "[检查] 正在检查npm是否可用..." +npm --version > /dev/null 2>&1 +if [ $? -ne 0 ]; then + echo "❌ 错误:npm不可用,请重新安装Node.js" + exit 1 +fi + +# 检查package.json是否存在 +if [ ! -f "package.json" ]; then + echo "❌ 错误:未找到package.json文件" + echo "请确保在项目根目录下运行此脚本" + exit 1 +fi + +echo "✅ 找到package.json文件" + +# 检查node_modules目录是否存在 +if [ ! -d "node_modules" ]; then + echo "[安装] node_modules目录不存在,正在安装依赖..." + echo "这可能需要几分钟时间,请耐心等待..." + echo "正在执行: npm install..." + npm install + if [ $? -ne 0 ]; then + echo "❌ 依赖安装失败" + echo "请检查网络连接或运行 'npm install' 手动安装" + exit 1 + fi + echo "✅ 依赖安装完成" +else + echo "✅ node_modules目录已存在" +fi + +# 检查package-lock.json是否存在 +if [ ! -f "package-lock.json" ]; then + echo "[更新] package-lock.json不存在,正在更新依赖..." + echo "正在执行: npm install..." + npm install + if [ $? -ne 0 ]; then + echo "❌ 依赖更新失败" + echo "请检查网络连接或运行 'npm install' 手动安装" + exit 1 + fi + echo "✅ 依赖更新完成" +else + echo "✅ package-lock.json文件存在" +fi + +# 检查src目录和api-server.js是否存在 +if [ ! -f "src/api-server.js" ]; then + echo "❌ 错误:未找到src/api-server.js文件" + exit 1 +fi + +echo "✅ 项目文件检查完成" + +# 启动应用程序 +echo +echo "========================================" +echo " 启动AI Client 2 API服务器..." +echo "========================================" +echo +echo "🌐 服务器将在 http://localhost:3000 启动" +echo "📖 访问 http://localhost:3000 查看管理界面" +echo "⏹️ 按 Ctrl+C 停止服务器" +echo + +# 启动服务器 +node src/api-server.js + +# 如果启动失败 +if [ $? -ne 0 ]; then + echo + echo "❌ 服务器异常" + echo "请检查错误信息并重试" + exit 1 +fi \ No newline at end of file diff --git a/provider_pools.json.example b/provider_pools.json.example index d10c48d..e80f201 100644 --- a/provider_pools.json.example +++ b/provider_pools.json.example @@ -7,6 +7,7 @@ "checkHealth": true, "uuid": "2f579c65-d3c5-41b1-9985-9f6e3d7bf39c", "isHealthy": true, + "isDisabled": false, "lastUsed": null, "usageCount": 0, "errorCount": 0, @@ -19,6 +20,7 @@ "checkHealth": true, "uuid": "e284628d-302f-456d-91f3-6095386fb3b8", "isHealthy": true, + "isDisabled": true, "lastUsed": null, "usageCount": 0, "errorCount": 0, @@ -33,6 +35,7 @@ "checkHealth": true, "uuid": "e284628d-302f-456d-91f3-609538678968", "isHealthy": true, + "isDisabled": false, "lastUsed": null, "usageCount": 0, "errorCount": 0, @@ -47,6 +50,7 @@ "checkHealth": true, "uuid": "ac200154-26b8-4f5f-8650-e8cc738b06e3", "isHealthy": true, + "isDisabled": false, "lastUsed": null, "usageCount": 0, "errorCount": 0, @@ -59,6 +63,7 @@ "checkHealth": true, "uuid": "4f8afcc2-a9bb-4b96-bb50-3b9667a71f54", "isHealthy": true, + "isDisabled": false, "lastUsed": null, "usageCount": 0, "errorCount": 0, @@ -73,6 +78,7 @@ "checkHealth": true, "uuid": "bb87047a-3b1d-4249-adbb-1087ecd58128", "isHealthy": true, + "isDisabled": false, "lastUsed": null, "usageCount": 0, "errorCount": 0, @@ -85,6 +91,7 @@ "checkHealth": true, "uuid": "7c2002c6-122a-4db0-af06-8a0ff433801a", "isHealthy": true, + "isDisabled": false, "lastUsed": null, "usageCount": 0, "errorCount": 0, @@ -98,6 +105,7 @@ "checkModelName": null, "checkHealth": true, "isHealthy": true, + "isDisabled": false, "lastUsed": null, "usageCount": 0, "errorCount": 0, @@ -109,6 +117,7 @@ "checkModelName": null, "checkHealth": true, "isHealthy": true, + "isDisabled": false, "lastUsed": null, "usageCount": 0, "errorCount": 0, @@ -122,6 +131,7 @@ "checkModelName": null, "checkHealth": true, "isHealthy": true, + "isDisabled": false, "lastUsed": null, "usageCount": 0, "errorCount": 0, diff --git a/pwd b/pwd new file mode 100644 index 0000000..32e9c62 --- /dev/null +++ b/pwd @@ -0,0 +1 @@ +admin123 \ No newline at end of file diff --git a/src/api-server.js b/src/api-server.js index becc8db..de42630 100644 --- a/src/api-server.js +++ b/src/api-server.js @@ -166,16 +166,16 @@ async function startServer() { try { const open = (await import('open')).default; setTimeout(() => { - open(`http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/`) + open(`http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/login.html`) .then(() => { - console.log('[UI] Opened management console in default browser'); + console.log('[UI] Opened login page in default browser'); }) .catch(err => { - console.log('[UI] Please open manually: http://' + CONFIG.HOST + ':' + CONFIG.SERVER_PORT + '/'); + console.log('[UI] Please open manually: http://' + CONFIG.HOST + ':' + CONFIG.SERVER_PORT + '/login.html'); }); }, 1000); } catch (err) { - console.log(`[UI] Management console available at: http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/`); + console.log(`[UI] Login page available at: http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/login.html`); } // } diff --git a/src/config-manager.js b/src/config-manager.js index 7e6f25a..01a57dd 100644 --- a/src/config-manager.js +++ b/src/config-manager.js @@ -78,14 +78,14 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP QWEN_OAUTH_CREDS_FILE_PATH: null, PROJECT_ID: null, SYSTEM_PROMPT_FILE_PATH: INPUT_SYSTEM_PROMPT_FILE, // Default value - SYSTEM_PROMPT_MODE: 'overwrite', + SYSTEM_PROMPT_MODE: 'append', PROMPT_LOG_BASE_NAME: "prompt_log", PROMPT_LOG_MODE: "none", REQUEST_MAX_RETRIES: 3, REQUEST_BASE_DELAY: 1000, CRON_NEAR_MINUTES: 15, - CRON_REFRESH_TOKEN: true, - PROVIDER_POOLS_FILE_PATH: null // 新增号池配置文件路径 + CRON_REFRESH_TOKEN: false, + PROVIDER_POOLS_FILE_PATH: '' // 新增号池配置文件路径 }; console.log('[Config] Using default configuration.'); } diff --git a/src/img/web.png b/src/img/web.png new file mode 100644 index 0000000..7f9f0d1 Binary files /dev/null and b/src/img/web.png differ diff --git a/src/provider-pool-manager.js b/src/provider-pool-manager.js index 512b423..092759d 100644 --- a/src/provider-pool-manager.js +++ b/src/provider-pool-manager.js @@ -33,6 +33,7 @@ export class ProviderPoolManager { this.providerPools[providerType].forEach((providerConfig) => { // Ensure initial health and usage stats are present in the config providerConfig.isHealthy = providerConfig.isHealthy !== undefined ? providerConfig.isHealthy : true; + providerConfig.isDisabled = providerConfig.isDisabled !== undefined ? providerConfig.isDisabled : false; providerConfig.lastUsed = providerConfig.lastUsed !== undefined ? providerConfig.lastUsed : null; providerConfig.usageCount = providerConfig.usageCount !== undefined ? providerConfig.usageCount : 0; providerConfig.errorCount = providerConfig.errorCount !== undefined ? providerConfig.errorCount : 0; @@ -59,20 +60,22 @@ export class ProviderPoolManager { */ selectProvider(providerType) { const availableProviders = this.providerStatus[providerType] || []; - const healthyProviders = availableProviders.filter(p => p.config.isHealthy); + const availableAndHealthyProviders = availableProviders.filter(p => + p.config.isHealthy && !p.config.isDisabled + ); - if (healthyProviders.length === 0) { - console.warn(`[ProviderPoolManager] No healthy providers available for type: ${providerType}`); + if (availableAndHealthyProviders.length === 0) { + console.warn(`[ProviderPoolManager] No available and healthy providers for type: ${providerType}`); return null; } // 优化3: 简化轮询逻辑,移除不必要的循环 const currentIndex = this.roundRobinIndex[providerType] || 0; - const providerIndex = currentIndex % healthyProviders.length; - const selected = healthyProviders[providerIndex]; + const providerIndex = currentIndex % availableAndHealthyProviders.length; + const selected = availableAndHealthyProviders[providerIndex]; // 更新下次轮询的索引 - this.roundRobinIndex[providerType] = (providerIndex + 1) % healthyProviders.length; + this.roundRobinIndex[providerType] = (providerIndex + 1) % availableAndHealthyProviders.length; // 更新使用信息 selected.config.lastUsed = new Date().toISOString(); @@ -136,6 +139,44 @@ export class ProviderPoolManager { } } + /** + * 禁用指定供应商 + * @param {string} providerType - 供应商类型 + * @param {object} providerConfig - 供应商配置 + */ + disableProvider(providerType, providerConfig) { + const pool = this.providerStatus[providerType]; + if (pool) { + const provider = pool.find(p => p.uuid === providerConfig.uuid); + if (provider) { + provider.config.isDisabled = true; + console.log(`[ProviderPoolManager] Disabled provider: ${providerConfig.uuid} for type ${providerType}`); + + // 使用防抖保存 + this._debouncedSave(providerType); + } + } + } + + /** + * 启用指定供应商 + * @param {string} providerType - 供应商类型 + * @param {object} providerConfig - 供应商配置 + */ + enableProvider(providerType, providerConfig) { + const pool = this.providerStatus[providerType]; + if (pool) { + const provider = pool.find(p => p.uuid === providerConfig.uuid); + if (provider) { + provider.config.isDisabled = false; + console.log(`[ProviderPoolManager] Enabled provider: ${providerConfig.uuid} for type ${providerType}`); + + // 使用防抖保存 + this._debouncedSave(providerType); + } + } + } + /** * Performs health checks on all providers in the pool. * This method would typically be called periodically (e.g., via cron job). diff --git a/src/request-handler.js b/src/request-handler.js index 438b894..4afdff5 100644 --- a/src/request-handler.js +++ b/src/request-handler.js @@ -6,7 +6,6 @@ import { getApiService } from './service-manager.js'; import { getProviderPoolManager } from './service-manager.js'; import { MODEL_PROVIDER } from './common.js'; import { PROMPT_LOG_FILENAME } from './config-manager.js'; - /** * Main request handler. It authenticates the request, determines the endpoint type, * and delegates to the appropriate specialized handler function. @@ -32,13 +31,12 @@ export function createRequestHandler(config, providerPoolManager) { return; } - // Serve static files for UI - if (path.startsWith('/static/') || path === '/' || path === '/index.html' || path.startsWith('/app/')) { + // Serve static files for UI (除了登录页面需要认证) + if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path === '/login.html') { const served = await serveStaticFiles(path, res); if (served) return; } - // Handle UI management API requests const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager); if (uiHandled) return; @@ -51,7 +49,7 @@ export function createRequestHandler(config, providerPoolManager) { currentConfig.MODEL_PROVIDER = modelProviderHeader; console.log(`[Config] MODEL_PROVIDER overridden by header to: ${currentConfig.MODEL_PROVIDER}`); } - + // Check if the first path segment matches a MODEL_PROVIDER and switch if it does const pathSegments = path.split('/').filter(segment => segment.length > 0); if (pathSegments.length > 0) { diff --git a/src/ui-manager.js b/src/ui-manager.js index 0ae9e3d..7dd83d6 100644 --- a/src/ui-manager.js +++ b/src/ui-manager.js @@ -2,9 +2,182 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { promises as fs } from 'fs'; import path from 'path'; import multer from 'multer'; +import crypto from 'crypto'; import { getRequestBody } from './common.js'; import { CONFIG } from './config-manager.js'; +// Token存储在内存中(生产环境建议使用Redis) +const tokenStore = new Map(); + +/** + * 生成简单的token + */ +function generateToken() { + return crypto.randomBytes(32).toString('hex'); +} + +/** + * 生成token过期时间 + */ +function getExpiryTime() { + const now = Date.now(); + const expiry = 60 * 60 * 1000; // 1小时 + return now + expiry; +} + +/** + * 验证简单token + */ +function verifyToken(token) { + const tokenInfo = tokenStore.get(token); + if (!tokenInfo) { + return null; + } + + // 检查是否过期 + if (Date.now() > tokenInfo.expiryTime) { + tokenStore.delete(token); + return null; + } + + return tokenInfo; +} + +/** + * 清理过期的token + */ +function cleanupExpiredTokens() { + const now = Date.now(); + for (const [token, info] of tokenStore.entries()) { + if (now > info.expiryTime) { + tokenStore.delete(token); + } + } +} + +/** + * 读取密码文件内容 + */ +async function readPasswordFile() { + try { + const password = await fs.readFile('./pwd', 'utf8'); + return password.trim(); + } catch (error) { + console.error('读取密码文件失败:', error); + return null; + } +} + +/** + * 验证登录凭据 + */ +async function validateCredentials(password) { + const storedPassword = await readPasswordFile(); + return storedPassword && password === storedPassword; +} + +/** + * 解析请求体JSON + */ +function parseRequestBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', chunk => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + if (!body.trim()) { + resolve({}); + } else { + resolve(JSON.parse(body)); + } + } catch (error) { + reject(new Error('无效的JSON格式')); + } + }); + req.on('error', reject); + }); +} + +/** + * 检查token验证 + */ +function checkAuth(req) { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return false; + } + + const token = authHeader.substring(7); + const tokenInfo = verifyToken(token); + + return tokenInfo !== null; +} + +/** + * 处理登录请求 + */ +async function handleLoginRequest(req, res) { + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, message: '仅支持POST请求' })); + return true; + } + + try { + const requestData = await parseRequestBody(req); + const { password } = requestData; + + if (!password) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, message: '密码不能为空' })); + return true; + } + + const isValid = await validateCredentials(password); + + if (isValid) { + // 生成简单token + const token = generateToken(); + const expiryTime = getExpiryTime(); + + // 存储token信息 + tokenStore.set(token, { + username: 'admin', + loginTime: Date.now(), + expiryTime + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: '登录成功', + token, + expiresIn: '1小时' + })); + } else { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + message: '密码错误,请重试' + })); + } + } catch (error) { + console.error('登录处理错误:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + message: error.message || '服务器错误' + })); + } + return true; +} + +// 定时清理过期token +setInterval(cleanupExpiredTokens, 5 * 60 * 1000); // 每5分钟清理一次 + // 配置multer中间件 const storage = multer.diskStorage({ destination: async (req, file, cb) => { @@ -80,6 +253,38 @@ export async function serveStaticFiles(pathParam, res) { * @returns {Promise} - True if the request was handled by UI API */ export async function handleUIApiRequests(method, pathParam, req, res, currentConfig, providerPoolManager) { + // 处理登录接口 + if (method === 'POST' && pathParam === '/api/login') { + const handled = await handleLoginRequest(req, res); + if (handled) return true; + } + + // 健康检查接口(用于前端token验证) + if (method === 'GET' && pathParam === '/api/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', timestamp: Date.now() })); + return true; + } + + // Handle UI management API requests (需要token验证,除了登录接口、健康检查和Events接口) + if (pathParam.startsWith('/api/') && pathParam !== '/api/login' && pathParam !== '/api/health' && pathParam !== '/api/events') { + // 检查token验证 + if (!checkAuth(req)) { + res.writeHead(401, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + }); + res.end(JSON.stringify({ + error: { + message: '未授权访问,请先登录', + code: 'UNAUTHORIZED' + } + })); + return true; + } + } + // 文件上传API if (method === 'POST' && pathParam === '/api/upload-oauth-credentials') { const uploadMiddleware = upload.single('file'); @@ -612,6 +817,85 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo } } + // Disable/Enable specific provider configuration + const disableEnableProviderMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/([^\/]+)\/(disable|enable)$/); + if (disableEnableProviderMatch) { + const providerType = decodeURIComponent(disableEnableProviderMatch[1]); + const providerUuid = disableEnableProviderMatch[2]; + const action = disableEnableProviderMatch[3]; + + try { + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; + let providerPools = {}; + + // Load existing pools + if (existsSync(filePath)) { + try { + const fileContent = readFileSync(filePath, 'utf8'); + providerPools = JSON.parse(fileContent); + } catch (readError) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); + return true; + } + } + + // Find and update the provider + const providers = providerPools[providerType] || []; + const providerIndex = providers.findIndex(p => p.uuid === providerUuid); + + if (providerIndex === -1) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider not found' } })); + return true; + } + + // Update isDisabled field + const provider = providers[providerIndex]; + provider.isDisabled = action === 'disable'; + + // Save to file + writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf8'); + console.log(`[UI API] ${action === 'disable' ? 'Disabled' : 'Enabled'} provider ${providerUuid} in ${providerType}`); + + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = providerPools; + + // Call the appropriate method + if (action === 'disable') { + providerPoolManager.disableProvider(providerType, provider); + } else { + providerPoolManager.enableProvider(providerType, provider); + } + } + + // Update CONFIG cache to maintain consistency + CONFIG.providerPools = providerPools; + + // 广播更新事件 + broadcastEvent('config_update', { + action: action, + filePath: filePath, + providerType, + providerConfig: provider, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: `Provider ${action}d successfully`, + provider: provider + })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } + } + // Server-Sent Events for real-time updates if (method === 'GET' && pathParam === '/api/events') { res.writeHead(200, { @@ -900,7 +1184,7 @@ async function scanConfigFiles(currentConfig, providerPoolManager) { const configsPath = path.join(process.cwd(), 'configs'); if (!existsSync(configsPath)) { - console.log('[Config Scanner] configs directory not found, creating empty result'); + // console.log('[Config Scanner] configs directory not found, creating empty result'); return configFiles; } diff --git a/static/app/auth.js b/static/app/auth.js new file mode 100644 index 0000000..00d733a --- /dev/null +++ b/static/app/auth.js @@ -0,0 +1,305 @@ +// 认证模块 - 处理token管理和API调用封装 +/** + * 认证管理类 + */ +class AuthManager { + constructor() { + this.tokenKey = 'authToken'; + this.expiryKey = 'authTokenExpiry'; + this.baseURL = window.location.origin; + } + + /** + * 获取存储的token + */ + getToken() { + return localStorage.getItem(this.tokenKey); + } + + /** + * 获取token过期时间 + */ + getTokenExpiry() { + const expiry = localStorage.getItem(this.expiryKey); + return expiry ? parseInt(expiry) : null; + } + + /** + * 检查token是否有效 + */ + isTokenValid() { + const token = this.getToken(); + const expiry = this.getTokenExpiry(); + + if (!token) return false; + + // 如果设置了过期时间,检查是否过期 + if (expiry && Date.now() > expiry) { + this.clearToken(); + return false; + } + + return true; + } + + /** + * 保存token到本地存储 + */ + saveToken(token, rememberMe = false) { + localStorage.setItem(this.tokenKey, token); + + if (rememberMe) { + const expiryTime = Date.now() + (7 * 24 * 60 * 60 * 1000); // 7天 + localStorage.setItem(this.expiryKey, expiryTime.toString()); + } + } + + /** + * 清除token + */ + clearToken() { + localStorage.removeItem(this.tokenKey); + localStorage.removeItem(this.expiryKey); + } + + /** + * 登出 + */ + async logout() { + this.clearToken(); + window.location.href = '/login.html'; + } +} + +/** + * API调用封装类 + */ +class ApiClient { + constructor() { + this.authManager = new AuthManager(); + this.baseURL = window.location.origin; + } + + /** + * 获取带认证的请求头 + */ + getAuthHeaders() { + const token = this.authManager.getToken(); + return token ? { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } : { + 'Content-Type': 'application/json' + }; + } + + /** + * 处理401错误重定向到登录页 + */ + handleUnauthorized() { + this.authManager.clearToken(); + window.location.href = '/login.html'; + } + + /** + * 通用API请求方法 + */ + async request(endpoint, options = {}) { + const url = `${this.baseURL}/api${endpoint}`; + const headers = { + ...this.getAuthHeaders(), + ...options.headers + }; + + const config = { + ...options, + headers + }; + + try { + const response = await fetch(url, config); + + // 如果是401错误,重定向到登录页 + if (response.status === 401) { + this.handleUnauthorized(); + throw new Error('未授权访问'); + } + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return await response.json(); + } else { + return await response.text(); + } + } catch (error) { + if (error.message === '未授权访问') { + // 已经在handleUnauthorized中处理了重定向 + throw error; + } + console.error('API请求错误:', error); + throw error; + } + } + + /** + * GET请求 + */ + async get(endpoint, params = {}) { + const queryString = new URLSearchParams(params).toString(); + const url = queryString ? `${endpoint}?${queryString}` : endpoint; + return this.request(url, { method: 'GET' }); + } + + /** + * POST请求 + */ + async post(endpoint, data = {}) { + return this.request(endpoint, { + method: 'POST', + body: JSON.stringify(data) + }); + } + + /** + * PUT请求 + */ + async put(endpoint, data = {}) { + return this.request(endpoint, { + method: 'PUT', + body: JSON.stringify(data) + }); + } + + /** + * DELETE请求 + */ + async delete(endpoint) { + return this.request(endpoint, { method: 'DELETE' }); + } + + /** + * POST请求(支持FormData上传) + */ + async upload(endpoint, formData) { + const url = `${this.baseURL}/api${endpoint}`; + + // 获取认证token + const token = this.authManager.getToken(); + const headers = {}; + + // 如果有token,添加Authorization头部 + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + // 对于FormData请求,不添加Content-Type头部,让浏览器自动设置 + const config = { + method: 'POST', + headers, + body: formData + }; + + try { + const response = await fetch(url, config); + + // 如果是401错误,重定向到登录页 + if (response.status === 401) { + this.handleUnauthorized(); + throw new Error('未授权访问'); + } + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return await response.json(); + } else { + return await response.text(); + } + } catch (error) { + if (error.message === '未授权访问') { + // 已经在handleUnauthorized中处理了重定向 + throw error; + } + console.error('API请求错误:', error); + throw error; + } + } +} + +/** + * 初始化认证检查 + */ +async function initAuth() { + const authManager = new AuthManager(); + + // 检查是否已经有有效的token + if (authManager.isTokenValid()) { + // 验证token是否仍然有效(发送一个测试请求) + try { + const apiClient = new ApiClient(); + await apiClient.get('/health'); + return true; + } catch (error) { + // Token无效,清除并重定向到登录页 + authManager.clearToken(); + window.location.href = '/login.html'; + return false; + } + } else { + // 没有有效token,重定向到登录页 + window.location.href = '/login.html'; + return false; + } +} + +/** + * 登出函数 + */ +async function logout() { + const authManager = new AuthManager(); + await authManager.logout(); +} + +/** + * 登录函数(供登录页面使用) + */ +async function login(password, rememberMe = false) { + try { + const response = await fetch('/api/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + password, + rememberMe + }) + }); + + const data = await response.json(); + + if (data.success) { + // 保存token + const authManager = new AuthManager(); + authManager.saveToken(data.token, rememberMe); + return { success: true }; + } else { + return { success: false, message: data.message }; + } + } catch (error) { + console.error('登录错误:', error); + return { success: false, message: '登录失败,请检查网络连接' }; + } +} + +// 导出实例 +window.authManager = new AuthManager(); +window.apiClient = new ApiClient(); +window.initAuth = initAuth; +window.logout = logout; +window.login = login; + +// 导出认证管理器类和API客户端类供其他模块使用 +window.AuthManager = AuthManager; +window.ApiClient = ApiClient; + +console.log('认证模块已加载'); \ No newline at end of file diff --git a/static/app/config-manager.js b/static/app/config-manager.js index da2b2a3..3f7e9a5 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -2,14 +2,14 @@ import { showToast, formatUptime } from './utils.js'; import { handleProviderChange, handleGeminiCredsTypeChange, handleKiroCredsTypeChange } from './event-handlers.js'; +import { loadProviders } from './provider-manager.js'; /** * 加载配置 */ async function loadConfiguration() { try { - const response = await fetch('/api/config'); - const data = await response.json(); + const data = await window.apiClient.get('/config'); // 基础配置 const apiKeyEl = document.getElementById('apiKey'); @@ -77,14 +77,14 @@ async function loadConfiguration() { const providerPoolsFilePathEl = document.getElementById('providerPoolsFilePath'); if (systemPromptFilePathEl) systemPromptFilePathEl.value = data.SYSTEM_PROMPT_FILE_PATH || 'input_system_prompt.txt'; - if (systemPromptModeEl) systemPromptModeEl.value = data.SYSTEM_PROMPT_MODE || 'overwrite'; + if (systemPromptModeEl) systemPromptModeEl.value = data.SYSTEM_PROMPT_MODE || 'append'; if (promptLogBaseNameEl) promptLogBaseNameEl.value = data.PROMPT_LOG_BASE_NAME || 'prompt_log'; if (promptLogModeEl) promptLogModeEl.value = data.PROMPT_LOG_MODE || 'none'; if (requestMaxRetriesEl) requestMaxRetriesEl.value = data.REQUEST_MAX_RETRIES || 3; if (requestBaseDelayEl) requestBaseDelayEl.value = data.REQUEST_BASE_DELAY || 1000; if (cronNearMinutesEl) cronNearMinutesEl.value = data.CRON_NEAR_MINUTES || 1; if (cronRefreshTokenEl) cronRefreshTokenEl.checked = data.CRON_REFRESH_TOKEN || false; - if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; + if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH || ''; // 触发供应商配置显示 handleProviderChange(); @@ -106,7 +106,7 @@ async function loadConfiguration() { } // 检查并设置供应商池菜单显示状态 - const providerPoolsFilePath = data.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; + const providerPoolsFilePath = data.PROVIDER_POOLS_FILE_PATH; const providersMenuItem = document.querySelector('.nav-item[data-section="providers"]'); if (providerPoolsFilePath && providerPoolsFilePath.trim() !== '') { if (providersMenuItem) providersMenuItem.style.display = 'flex'; @@ -179,8 +179,8 @@ async function saveConfiguration() { } // 保存高级配置参数 - config.SYSTEM_PROMPT_FILE_PATH = document.getElementById('systemPromptFilePath')?.value || ''; - config.SYSTEM_PROMPT_MODE = document.getElementById('systemPromptMode')?.value || ''; + config.SYSTEM_PROMPT_FILE_PATH = document.getElementById('systemPromptFilePath')?.value || 'input_system_prompt.txt'; + config.SYSTEM_PROMPT_MODE = document.getElementById('systemPromptMode')?.value || 'append'; config.PROMPT_LOG_BASE_NAME = document.getElementById('promptLogBaseName')?.value || ''; config.PROMPT_LOG_MODE = document.getElementById('promptLogMode')?.value || ''; config.REQUEST_MAX_RETRIES = parseInt(document.getElementById('requestMaxRetries')?.value || 3); @@ -190,18 +190,16 @@ async function saveConfiguration() { config.PROVIDER_POOLS_FILE_PATH = document.getElementById('providerPoolsFilePath')?.value || ''; try { - const response = await fetch('/api/config', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(config), - }); + await window.apiClient.post('/config', config); - if (response.ok) { - showToast('配置已保存', 'success'); - } else { - showToast('保存配置失败', 'error'); + showToast('配置已保存', 'success'); + + // 检查当前是否在供应商池管理页面,如果是则刷新数据 + const providersSection = document.getElementById('providers'); + if (providersSection && providersSection.classList.contains('active')) { + // 当前在供应商池页面,刷新数据 + await loadProviders(); + showToast('供应商池数据已刷新', 'success'); } } catch (error) { console.error('Failed to save configuration:', error); diff --git a/static/app/file-upload.js b/static/app/file-upload.js index 3ca445b..61005dd 100644 --- a/static/app/file-upload.js +++ b/static/app/file-upload.js @@ -125,18 +125,8 @@ class FileUploadHandler { formData.append('provider', this.currentProvider); formData.append('targetInputId', targetInputId); - // 发送上传请求 - const response = await fetch('/api/upload-oauth-credentials', { - method: 'POST', - body: formData - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error?.message || '上传失败'); - } - - const result = await response.json(); + // 使用封装接口发送上传请求 + const result = await window.apiClient.upload('/upload-oauth-credentials', formData); // 成功上传,设置文件路径到输入框 this.setFilePathToInput(targetInputId, result.filePath); diff --git a/static/app/mobile.css b/static/app/mobile.css index dad0850..b30061e 100644 --- a/static/app/mobile.css +++ b/static/app/mobile.css @@ -80,6 +80,10 @@ .config-form { max-width: 100%; } + + .logout-btn { + line-height: 0; + } } /* ======================================== @@ -592,6 +596,10 @@ .system-prompt-section textarea { min-height: 120px; } + + .logout-btn { + line-height: 0; + } } /* ======================================== @@ -704,6 +712,10 @@ font-size: 0.8125rem; padding: 0.625rem 0.875rem; } + + .logout-btn { + line-height: 0; + } } /* ======================================== @@ -735,6 +747,10 @@ .provider-modal-body { max-height: calc(85vh - 120px); } + + .logout-btn { + line-height: 0; + } } /* ======================================== @@ -779,6 +795,10 @@ .provider-modal-body { -webkit-overflow-scrolling: touch; } + + .logout-btn { + line-height: 0; + } } /* ======================================== diff --git a/static/app/modal.js b/static/app/modal.js index 5cf8f5f..923a1bd 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -156,13 +156,20 @@ function closeProviderModal(button) { function renderProviderList(providers) { return providers.map(provider => { const isHealthy = provider.isHealthy; + const isDisabled = provider.isDisabled || false; const lastUsed = provider.lastUsed ? new Date(provider.lastUsed).toLocaleString() : '从未使用'; const healthClass = isHealthy ? 'healthy' : 'unhealthy'; + const disabledClass = isDisabled ? 'disabled' : ''; const healthIcon = isHealthy ? 'fas fa-check-circle text-success' : 'fas fa-exclamation-triangle text-warning'; const healthText = isHealthy ? '正常' : '异常'; + const disabledText = isDisabled ? '已禁用' : '已启用'; + const disabledIcon = isDisabled ? 'fas fa-ban text-muted' : 'fas fa-play text-success'; + const toggleButtonText = isDisabled ? '启用' : '禁用'; + const toggleButtonIcon = isDisabled ? 'fas fa-play' : 'fas fa-ban'; + const toggleButtonClass = isDisabled ? 'btn-success' : 'btn-warning'; return ` -
+
${provider.uuid}
@@ -171,11 +178,19 @@ function renderProviderList(providers) { 健康状态: ${healthText} | + + + 状态: ${disabledText} + | 使用次数: ${provider.usageCount || 0} | + 失败次数: ${provider.errorCount || 0} | 最后使用: ${lastUsed}
+ @@ -380,7 +395,7 @@ function getFieldOrder(provider) { const otherFields = Object.keys(provider).filter(key => key !== 'isHealthy' && key !== 'lastUsed' && key !== 'usageCount' && key !== 'errorCount' && key !== 'lastErrorTime' && key !== 'uuid' && - !orderedFields.includes(key) + key !== 'isDisabled' && !orderedFields.includes(key) ); // 按字母顺序排序其他字段 @@ -440,9 +455,19 @@ function editProvider(uuid, event) { select.disabled = false; }); - // 替换编辑按钮为保存和取消按钮 + // 替换编辑按钮为保存和取消按钮,但保留禁用/启用按钮 const actionsGroup = providerDetail.querySelector('.provider-actions-group'); + const toggleButton = actionsGroup.querySelector('[onclick*="toggleProviderStatus"]'); + const currentProvider = providerDetail.closest('.provider-modal').querySelector(`[data-uuid="${uuid}"]`); + const isCurrentlyDisabled = currentProvider.classList.contains('disabled'); + const toggleButtonText = isCurrentlyDisabled ? '启用' : '禁用'; + const toggleButtonIcon = isCurrentlyDisabled ? 'fas fa-play' : 'fas fa-ban'; + const toggleButtonClass = isCurrentlyDisabled ? 'btn-success' : 'btn-warning'; + actionsGroup.innerHTML = ` + @@ -489,9 +514,18 @@ function cancelEdit(uuid, event) { select.value = originalValue || ''; }); - // 恢复原来的编辑和删除按钮 + // 恢复原来的编辑和删除按钮,但保留禁用/启用按钮 const actionsGroup = providerDetail.querySelector('.provider-actions-group'); + const currentProvider = providerDetail.closest('.provider-modal').querySelector(`[data-uuid="${uuid}"]`); + const isCurrentlyDisabled = currentProvider.classList.contains('disabled'); + const toggleButtonText = isCurrentlyDisabled ? '启用' : '禁用'; + const toggleButtonIcon = isCurrentlyDisabled ? 'fas fa-play' : 'fas fa-ban'; + const toggleButtonClass = isCurrentlyDisabled ? 'btn-success' : 'btn-warning'; + actionsGroup.innerHTML = ` + @@ -529,22 +563,10 @@ async function saveProvider(uuid, event) { }); try { - const response = await fetch(`/api/providers/${encodeURIComponent(providerType)}/${uuid}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ providerConfig }), - }); - - if (response.ok) { - showToast('供应商配置更新成功', 'success'); - // 重新获取该供应商类型的最新配置 - await refreshProviderConfig(providerType); - } else { - const error = await response.json(); - showToast('更新失败: ' + error.error.message, 'error'); - } + await window.apiClient.put(`/providers/${encodeURIComponent(providerType)}/${uuid}`, { providerConfig }); + showToast('供应商配置更新成功', 'success'); + // 重新获取该供应商类型的最新配置 + await refreshProviderConfig(providerType); } catch (error) { console.error('Failed to update provider:', error); showToast('更新失败: ' + error.message, 'error'); @@ -567,18 +589,10 @@ async function deleteProvider(uuid, event) { const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type'); try { - const response = await fetch(`/api/providers/${encodeURIComponent(providerType)}/${uuid}`, { - method: 'DELETE', - }); - - if (response.ok) { - showToast('供应商配置删除成功', 'success'); - // 重新获取最新配置 - await refreshProviderConfig(providerType); - } else { - const error = await response.json(); - showToast('删除失败: ' + error.error.message, 'error'); - } + await window.apiClient.delete(`/providers/${encodeURIComponent(providerType)}/${uuid}`); + showToast('供应商配置删除成功', 'success'); + // 重新获取最新配置 + await refreshProviderConfig(providerType); } catch (error) { console.error('Failed to delete provider:', error); showToast('删除失败: ' + error.message, 'error'); @@ -592,29 +606,26 @@ async function deleteProvider(uuid, event) { async function refreshProviderConfig(providerType) { try { // 重新获取该供应商类型的最新数据 - const response = await fetch(`/api/providers/${encodeURIComponent(providerType)}`); - if (response.ok) { - const data = await response.json(); + const data = await window.apiClient.get(`/providers/${encodeURIComponent(providerType)}`); + + // 如果当前显示的是该供应商类型的模态框,则更新模态框 + const modal = document.querySelector('.provider-modal'); + if (modal && modal.getAttribute('data-provider-type') === providerType) { + // 更新统计信息 + const totalCountElement = modal.querySelector('.provider-summary-item .value'); + if (totalCountElement) { + totalCountElement.textContent = data.totalCount; + } - // 如果当前显示的是该供应商类型的模态框,则更新模态框 - const modal = document.querySelector('.provider-modal'); - if (modal && modal.getAttribute('data-provider-type') === providerType) { - // 更新统计信息 - const totalCountElement = modal.querySelector('.provider-summary-item .value'); - if (totalCountElement) { - totalCountElement.textContent = data.totalCount; - } - - const healthyCountElement = modal.querySelectorAll('.provider-summary-item .value')[1]; - if (healthyCountElement) { - healthyCountElement.textContent = data.healthyCount; - } - - // 重新渲染供应商列表 - const providerList = modal.querySelector('.provider-list'); - if (providerList) { - providerList.innerHTML = renderProviderList(data.providers); - } + const healthyCountElement = modal.querySelectorAll('.provider-summary-item .value')[1]; + if (healthyCountElement) { + healthyCountElement.textContent = data.healthyCount; + } + + // 重新渲染供应商列表 + const providerList = modal.querySelector('.provider-list'); + if (providerList) { + providerList.innerHTML = renderProviderList(data.providers); } } @@ -647,7 +658,7 @@ function showAddProviderForm(providerType) {

添加新供应商配置

- +
@@ -823,13 +834,8 @@ async function addProvider(providerType) { const checkModelName = document.getElementById('newCheckModelName')?.value; const checkHealth = document.getElementById('newCheckHealth')?.value === 'true'; - if (!checkModelName) { - showToast('请输入检查模型名称', 'error'); - return; - } - const providerConfig = { - checkModelName, + checkModelName: checkModelName || '', // 允许为空 checkHealth }; @@ -860,36 +866,58 @@ async function addProvider(providerType) { } try { - const response = await fetch('/api/providers', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - providerType, - providerConfig - }), + await window.apiClient.post('/providers', { + providerType, + providerConfig }); - - if (response.ok) { - showToast('供应商配置添加成功', 'success'); - // 移除添加表单 - const form = document.querySelector('.add-provider-form'); - if (form) { - form.remove(); - } - // 重新获取最新配置数据 - await refreshProviderConfig(providerType); - } else { - const error = await response.json(); - showToast('添加失败: ' + error.error.message, 'error'); + showToast('供应商配置添加成功', 'success'); + // 移除添加表单 + const form = document.querySelector('.add-provider-form'); + if (form) { + form.remove(); } + // 重新获取最新配置数据 + await refreshProviderConfig(providerType); } catch (error) { console.error('Failed to add provider:', error); showToast('添加失败: ' + error.message, 'error'); } } +/** + * 切换供应商禁用/启用状态 + * @param {string} uuid - 供应商UUID + * @param {Event} event - 事件对象 + */ +async function toggleProviderStatus(uuid, event) { + event.stopPropagation(); + + const providerDetail = event.target.closest('.provider-item-detail'); + const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type'); + const currentProvider = providerDetail.closest('.provider-modal').querySelector(`[data-uuid="${uuid}"]`); + + // 获取当前供应商信息 + const isCurrentlyDisabled = currentProvider.classList.contains('disabled'); + const action = isCurrentlyDisabled ? 'enable' : 'disable'; + const confirmMessage = isCurrentlyDisabled ? + `确定要启用这个供应商配置吗?` : + `确定要禁用这个供应商配置吗?禁用后该供应商将不会被选中使用。`; + + if (!confirm(confirmMessage)) { + return; + } + + try { + await window.apiClient.post(`/providers/${encodeURIComponent(providerType)}/${uuid}/${action}`, { action }); + showToast(`供应商${isCurrentlyDisabled ? '启用' : '禁用'}成功`, 'success'); + // 重新获取该供应商类型的最新配置 + await refreshProviderConfig(providerType); + } catch (error) { + console.error('Failed to toggle provider status:', error); + showToast(`操作失败: ${error.message}`, 'error'); + } +} + // 导出所有函数,并挂载到window对象供HTML调用 export { showProviderManagerModal, @@ -901,7 +929,8 @@ export { deleteProvider, refreshProviderConfig, showAddProviderForm, - addProvider + addProvider, + toggleProviderStatus }; // 将函数挂载到window对象 @@ -912,4 +941,5 @@ window.cancelEdit = cancelEdit; window.saveProvider = saveProvider; window.deleteProvider = deleteProvider; window.showAddProviderForm = showAddProviderForm; -window.addProvider = addProvider; \ No newline at end of file +window.addProvider = addProvider; +window.toggleProviderStatus = toggleProviderStatus; \ No newline at end of file diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index a50adaf..426aca8 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -13,8 +13,7 @@ let initialLoadTime = null; */ async function loadSystemInfo() { try { - const response = await fetch('/api/system'); - const data = await response.json(); + const data = await window.apiClient.get('/system'); const nodeVersionEl = document.getElementById('nodeVersion'); const serverTimeEl = document.getElementById('serverTime'); @@ -80,8 +79,7 @@ function updateTimeDisplay() { */ async function loadProviders() { try { - const response = await fetch('/api/providers'); - const data = await response.json(); + const data = await window.apiClient.get('/providers'); renderProviders(data); } catch (error) { console.error('Failed to load providers:', error); @@ -102,112 +100,123 @@ function renderProviders(providers) { const hasProviders = Object.keys(providers).length > 0; const statsGrid = document.querySelector('#providers .stats-grid'); + // 始终显示统计卡片 + if (statsGrid) statsGrid.style.display = 'grid'; + + // 定义所有支持的提供商显示顺序 + const providerDisplayOrder = [ + 'gemini-cli-oauth', + 'openai-custom', + 'claude-custom', + 'claude-kiro-oauth', + 'openai-qwen-oauth', + 'openaiResponses-custom' + ]; + + // 获取所有提供商类型并按指定顺序排序 + // 优先显示预定义的所有供应商类型,即使某些供应商没有数据也要显示 + let allProviderTypes; if (hasProviders) { - // 显示统计卡片 - if (statsGrid) statsGrid.style.display = 'grid'; - - // 计算总统计 - let totalAccounts = 0; - let totalHealthy = 0; - - // 定义提供商显示顺序 - const providerDisplayOrder = [ - 'gemini-cli-oauth', - 'openai-custom', - 'claude-custom', - 'claude-kiro-oauth', - 'openai-qwen-oauth', - 'openaiResponses-custom' - ]; - - // 获取所有提供商类型并按指定顺序排序 - const allProviderTypes = Object.keys(providers); - const sortedProviderTypes = providerDisplayOrder.filter(type => allProviderTypes.includes(type)) - .concat(allProviderTypes.filter(type => !providerDisplayOrder.includes(type))); - - // 按照排序后的提供商类型渲染 - sortedProviderTypes.forEach((providerType) => { - const accounts = providers[providerType]; - const providerDiv = document.createElement('div'); - providerDiv.className = 'provider-item'; - providerDiv.dataset.providerType = providerType; - providerDiv.style.cursor = 'pointer'; - - const healthyCount = accounts.filter(acc => acc.isHealthy).length; - const totalCount = accounts.length; - const usageCount = accounts.reduce((sum, acc) => sum + (acc.usageCount || 0), 0); - const errorCount = accounts.reduce((sum, acc) => sum + (acc.errorCount || 0), 0); - - totalAccounts += totalCount; - totalHealthy += healthyCount; - - // 更新全局统计变量 - if (!providerStats.providerTypeStats[providerType]) { - providerStats.providerTypeStats[providerType] = { - totalAccounts: 0, - healthyAccounts: 0, - totalUsage: 0, - totalErrors: 0, - lastUpdate: null - }; - } - - const typeStats = providerStats.providerTypeStats[providerType]; - typeStats.totalAccounts = totalCount; - typeStats.healthyAccounts = healthyCount; - typeStats.totalUsage = usageCount; - typeStats.totalErrors = errorCount; - typeStats.lastUpdate = new Date().toISOString(); - - providerDiv.innerHTML = ` -
-
- ${providerType} -
-
- - ${healthyCount}/${totalCount} 健康 -
-
-
-
- 总账户 - ${totalCount} -
-
- 健康账户 - ${healthyCount} -
-
- 使用次数 - ${usageCount} -
-
- 错误次数 - ${errorCount} -
-
- `; - - // 添加点击事件 - 整个供应商组都可以点击 - providerDiv.addEventListener('click', (e) => { - e.preventDefault(); - openProviderManager(providerType); - }); - - container.appendChild(providerDiv); - }); - - // 更新统计卡片数据 - const activeProviders = Object.keys(providers).length; - updateProviderStatsDisplay(activeProviders, totalHealthy, totalAccounts); + // 合并预定义类型和实际存在的类型,确保显示所有预定义供应商 + const actualProviderTypes = Object.keys(providers); + allProviderTypes = [...new Set([...providerDisplayOrder, ...actualProviderTypes])]; } else { - // 隐藏统计卡片 - if (statsGrid) statsGrid.style.display = 'none'; - - // 显示无数据提示 - container.innerHTML = '

暂无供应商池配置

'; + allProviderTypes = providerDisplayOrder; } + const sortedProviderTypes = providerDisplayOrder.filter(type => allProviderTypes.includes(type)) + .concat(allProviderTypes.filter(type => !providerDisplayOrder.includes(type))); + + // 计算总统计 + let totalAccounts = 0; + let totalHealthy = 0; + + // 按照排序后的提供商类型渲染 + sortedProviderTypes.forEach((providerType) => { + const accounts = hasProviders ? providers[providerType] || [] : []; + const providerDiv = document.createElement('div'); + providerDiv.className = 'provider-item'; + providerDiv.dataset.providerType = providerType; + providerDiv.style.cursor = 'pointer'; + + const healthyCount = accounts.filter(acc => acc.isHealthy).length; + const totalCount = accounts.length; + const usageCount = accounts.reduce((sum, acc) => sum + (acc.usageCount || 0), 0); + const errorCount = accounts.reduce((sum, acc) => sum + (acc.errorCount || 0), 0); + + totalAccounts += totalCount; + totalHealthy += healthyCount; + + // 更新全局统计变量 + if (!providerStats.providerTypeStats[providerType]) { + providerStats.providerTypeStats[providerType] = { + totalAccounts: 0, + healthyAccounts: 0, + totalUsage: 0, + totalErrors: 0, + lastUpdate: null + }; + } + + const typeStats = providerStats.providerTypeStats[providerType]; + typeStats.totalAccounts = totalCount; + typeStats.healthyAccounts = healthyCount; + typeStats.totalUsage = usageCount; + typeStats.totalErrors = errorCount; + typeStats.lastUpdate = new Date().toISOString(); + + // 为无数据状态设置特殊样式 + const isEmptyState = !hasProviders || totalCount === 0; + const statusClass = isEmptyState ? 'status-empty' : (healthyCount === totalCount ? 'status-healthy' : 'status-unhealthy'); + const statusIcon = isEmptyState ? 'fa-info-circle' : (healthyCount === totalCount ? 'fa-check-circle' : 'fa-exclamation-triangle'); + const statusText = isEmptyState ? '0/0 节点' : `${healthyCount}/${totalCount} 健康`; + + providerDiv.innerHTML = ` +
+
+ ${providerType} +
+
+ + ${statusText} +
+
+
+
+ 总账户 + ${totalCount} +
+
+ 健康账户 + ${healthyCount} +
+
+ 使用次数 + ${usageCount} +
+
+ 错误次数 + ${errorCount} +
+
+ `; + + // 如果是空状态,添加特殊样式 + if (isEmptyState) { + providerDiv.classList.add('empty-provider'); + } + + // 添加点击事件 - 整个供应商组都可以点击 + providerDiv.addEventListener('click', (e) => { + e.preventDefault(); + openProviderManager(providerType); + }); + + container.appendChild(providerDiv); + }); + + // 更新统计卡片数据 + const activeProviders = hasProviders ? Object.keys(providers).length : 0; + updateProviderStatsDisplay(activeProviders, totalHealthy, totalAccounts); } /** @@ -282,8 +291,7 @@ function updateProviderStatsDisplay(activeProviders, healthyProviders, totalAcco */ async function openProviderManager(providerType) { try { - const response = await fetch(`/api/providers/${encodeURIComponent(providerType)}`); - const data = await response.json(); + const data = await window.apiClient.get(`/providers/${encodeURIComponent(providerType)}`); showProviderManagerModal(data); } catch (error) { diff --git a/static/app/styles.css b/static/app/styles.css index e83807c..80a68bc 100644 --- a/static/app/styles.css +++ b/static/app/styles.css @@ -1,7 +1,7 @@ /* CSS变量 */ :root { - --primary-color: #4f46e5; - --secondary-color: #818cf8; + --primary-color: #059669; + --secondary-color: #10b981; --success-color: #10b981; --danger-color: #ef4444; --warning-color: #f59e0b; @@ -92,6 +92,33 @@ body { .status-badge.error i { color: var(--danger-color); } +.logout-btn { + padding: 8px 16px; + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.logout-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(5, 150, 105, 0.4); +} + +.logout-btn:active { + transform: translateY(0); +} + +.logout-btn i { + font-size: 14px; +} @keyframes pulse { 0%, 100% { @@ -255,6 +282,13 @@ body { color: var(--text-primary); } +.form-group label .optional-mark { + font-size: 0.75rem; + color: var(--text-secondary); + font-weight: 400; + margin-left: 0.25rem; +} + .form-control { width: 100%; padding: 0.75rem; @@ -268,7 +302,7 @@ body { .form-control:focus { outline: none; border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); + box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1); } textarea.form-control { @@ -504,13 +538,33 @@ textarea.form-control { } .btn-primary { - background: var(--primary-color); - color: white; - line-height: 0; + padding: 8px 16px; + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + gap: 6px; } .btn-primary:hover { - background: #4338ca; + transform: translateY(-2px); + background: var(--bg-tertiary); + color: var(--primary-color); + border-color: var(--primary-color); +} + +.btn-primary:active { + transform: translateY(0); +} + +.btn-primary i { + font-size: 14px; } .btn-success { @@ -537,7 +591,7 @@ textarea.form-control { } .btn-danger:hover { - background: #dc2626; + background: #ef4444; } /* 提供商列表 */ @@ -1341,14 +1395,14 @@ input:checked + .toggle-slider:before { } .btn-edit { - background: linear-gradient(135deg, #007bff 0%, #6f42c1 100%); + background: linear-gradient(135deg, #059669 0%, #10b981 100%); color: white; - box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); + box-shadow: 0 2px 8px rgba(5, 150, 105, 0.3); } .btn-edit:hover { transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4); + box-shadow: 0 4px 12px rgba(5, 150, 105, 0.4); } .btn-delete { @@ -1417,8 +1471,8 @@ input:checked + .toggle-slider:before { .config-item input:focus, .config-item textarea:focus, .config-item select:focus { outline: none; - border-color: #007bff; - box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1); + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1); } .config-item input[readonly], .config-item select[disabled] { @@ -1486,8 +1540,8 @@ input:checked + .toggle-slider:before { .form-group input:focus, .form-group select:focus { outline: none; - border-color: #007bff; - box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1); + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1); } /* 无提供商提示 */ @@ -1704,7 +1758,7 @@ input:checked + .toggle-slider:before { } .search-input-group .btn:hover { - background: #4338ca; + background: #047857; transform: translateY(-1px); } @@ -1763,18 +1817,18 @@ input:checked + .toggle-slider:before { overflow-y: auto; } -.config-item { +.config-item-manager { padding: 1.5rem; border-bottom: 1px solid var(--border-color); transition: var(--transition); cursor: pointer; } -.config-item:hover { +.config-item-manager:hover { background: var(--bg-secondary); } -.config-item:last-child { +.config-item-manager:last-child { border-bottom: none; } @@ -1843,17 +1897,17 @@ input:checked + .toggle-slider:before { font-weight: 500; } -.config-item.used .config-item-status { +.config-item-manager.used .config-item-status { background: #d1fae5; color: #065f46; } -.config-item.unused .config-item-status { +.config-item-manager.unused .config-item-status { background: #fef3c7; color: #92400e; } -.config-item.invalid .config-item-status { +.config-item-manager.invalid .config-item-status { background: #fee2e2; color: #991b1b; } @@ -1865,7 +1919,7 @@ input:checked + .toggle-slider:before { display: none; } -.config-item.expanded .config-item-details { +.config-item-manager.expanded .config-item-details { display: block; } @@ -1923,7 +1977,7 @@ input:checked + .toggle-slider:before { } .btn-view:hover { - background: #4338ca; + background: #047857; } .btn-delete-small { @@ -1932,14 +1986,14 @@ input:checked + .toggle-slider:before { } .btn-delete-small:hover { - background: #dc2626; + background: #ef4444; } -.config-item.expanded { +.config-item-manager.expanded { background: var(--bg-secondary); } -.config-item.expanded:hover { +.config-item-manager.expanded:hover { background: #e5e7eb; } @@ -2218,19 +2272,19 @@ input:checked + .toggle-slider:before { } /* 针对不同使用类型的特殊样式 */ -.config-item.used .config-usage-info { +.config-item-manager.used .config-usage-info { border-left-color: var(--success-color); } -.config-item.used .config-usage-info .usage-info-header i { +.config-item-manager.used .config-usage-info .usage-info-header i { color: var(--success-color); } -.config-item.used .usage-detail-item i { +.config-item-manager.used .usage-detail-item i { color: var(--success-color); } -.config-item.used .usage-detail-type { +.config-item-manager.used .usage-detail-type { background: var(--success-color); } @@ -2363,7 +2417,7 @@ input:checked + .toggle-slider:before { } .delete-confirm-modal.used .delete-modal-header h3 { - color: #dc2626; + color: #ef4444; } .delete-confirm-modal.unused .delete-modal-header h3 { @@ -2390,7 +2444,7 @@ input:checked + .toggle-slider:before { .delete-warning.warning-used { background: linear-gradient(135deg, #fef2f2 0%, #ffffff 100%); border-color: #fca5a5; - color: #dc2626; + color: #ef4444; } .delete-warning.warning-unused { @@ -2486,7 +2540,7 @@ input:checked + .toggle-slider:before { .alert-icon { font-size: 1.25rem; - color: #dc2626; + color: #ef4444; margin-top: 0.125rem; flex-shrink: 0; } @@ -2495,7 +2549,7 @@ input:checked + .toggle-slider:before { margin: 0 0 0.75rem 0; font-size: 0.875rem; font-weight: 600; - color: #dc2626; + color: #ef4444; } .alert-content p { @@ -2517,7 +2571,7 @@ input:checked + .toggle-slider:before { } .alert-content strong { - color: #dc2626; + color: #ef4444; font-weight: 600; } @@ -2557,9 +2611,9 @@ input:checked + .toggle-slider:before { } .delete-confirm-modal.used .btn-confirm-delete { - background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; - box-shadow: 0 4px 15px rgba(220, 38, 38, 0.4); + box-shadow: 0 4px 15px rgba(245, 158, 11, 0.4); animation: pulseDanger 2s infinite; } @@ -2570,9 +2624,9 @@ input:checked + .toggle-slider:before { } .delete-confirm-modal.used .btn-confirm-delete:hover { - background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%); + background: linear-gradient(135deg, #d97706 0%, #b45309 100%); transform: translateY(-2px); - box-shadow: 0 6px 20px rgba(220, 38, 38, 0.5); + box-shadow: 0 6px 20px rgba(245, 158, 11, 0.5); } .delete-confirm-modal.unused .btn-confirm-delete:hover { @@ -2583,10 +2637,10 @@ input:checked + .toggle-slider:before { @keyframes pulseDanger { 0%, 100% { - box-shadow: 0 4px 15px rgba(220, 38, 38, 0.4); + box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4); } 50% { - box-shadow: 0 4px 15px rgba(220, 38, 38, 0.7); + box-shadow: 0 4px 15px rgba(239, 68, 68, 0.7); } } @@ -2645,3 +2699,144 @@ input:checked + .toggle-slider:before { gap: 0.75rem; } } + +/* 禁用供应商状态样式 */ +.provider-item-detail.disabled { + opacity: 0.6; + background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%); + border: 1px solid #dee2e6; + position: relative; +} + +.provider-item-detail.disabled::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: linear-gradient(135deg, #6c757d 0%, #495057 100%); + border-radius: 0 2px 2px 0; + z-index: 1; +} + +.provider-item-detail.disabled:hover { + opacity: 0.8; + border-color: #adb5bd; + box-shadow: 0 2px 8px rgba(108, 117, 125, 0.15); + transform: none; +} + +.provider-item-detail.disabled .provider-item-header { + background: linear-gradient(135deg, #f1f3f4 0%, #ffffff 100%); +} + +.provider-item-detail.disabled .provider-name { + color: #6c757d; + text-decoration: line-through; +} + +.provider-item-detail.disabled .provider-meta { + color: #9ca3af; +} + +.disabled-status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-weight: 500; + padding: 0.25rem 0.5rem; + border-radius: 6px; + transition: var(--transition); +} + +.provider-item-detail.disabled .disabled-status { + color: #6c757d; + background: rgba(108, 117, 125, 0.1); +} + +.provider-item-detail:not(.disabled) .disabled-status { + color: #059669; + background: rgba(16, 185, 129, 0.1); +} + +/* 禁用/启用按钮特殊样式 */ +.btn-warning { + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: white; + box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3); +} + +.btn-warning:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4); +} + +.btn-warning:active { + transform: translateY(0); +} + +/* 供应商状态指示器 */ +.provider-status .disabled-indicator { + position: relative; +} + +.provider-status .disabled-indicator::after { + content: ''; + position: absolute; + top: -2px; + right: -2px; + width: 8px; + height: 8px; + background: #6c757d; + border: 2px solid white; + border-radius: 50%; +} + +.provider-status .enabled-indicator::after { + background: var(--success-color); +} + +/* 禁用状态下的交互效果 */ +.provider-item-detail.disabled .provider-actions-group .btn-edit, +.provider-item-detail.disabled .provider-actions-group .btn-delete { + opacity: 0.5; + pointer-events: none; +} + +.provider-item-detail.disabled .provider-actions-group .btn-toggle { + opacity: 1; +} + +/* 统计信息中的禁用状态显示 */ +.provider-stat .disabled-count { + color: #6c757d; + font-style: italic; +} + +.provider-stat .enabled-count { + color: var(--success-color); + font-weight: 600; +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .provider-item-detail.disabled { + opacity: 0.5; + } + + .disabled-status { + font-size: 0.75rem; + padding: 0.2rem 0.4rem; + } + + .provider-actions-group { + flex-wrap: wrap; + gap: 0.5rem; + } + + .btn-small { + font-size: 0.7rem; + padding: 0.4rem 0.6rem; + } +} diff --git a/static/app/upload-config-manager.js b/static/app/upload-config-manager.js index 6732049..f49c9f9 100644 --- a/static/app/upload-config-manager.js +++ b/static/app/upload-config-manager.js @@ -4,6 +4,7 @@ import { showToast } from './utils.js'; let allConfigs = []; // 存储所有配置数据 let filteredConfigs = []; // 存储过滤后的配置数据 +let isLoadingConfigs = false; // 防止重复加载配置 /** * 搜索配置 @@ -64,7 +65,7 @@ function createConfigItemElement(config, index) { // 从布尔值 isUsed 转换为状态字符串用于显示 const configStatus = config.isUsed ? 'used' : 'unused'; const item = document.createElement('div'); - item.className = `config-item ${configStatus}`; + item.className = `config-item-manager ${configStatus}`; item.dataset.index = index; const statusIcon = config.isUsed ? 'fa-check-circle' : 'fa-circle'; @@ -250,17 +251,23 @@ function updateStats() { * 加载配置文件列表 */ async function loadConfigList() { + // 防止重复加载 + if (isLoadingConfigs) { + console.log('正在加载配置列表,跳过重复调用'); + return; + } + + isLoadingConfigs = true; + console.log('开始加载配置列表...'); + try { - const response = await fetch('/api/upload-configs'); - if (response.ok) { - allConfigs = await response.json(); - filteredConfigs = [...allConfigs]; - renderConfigList(); - updateStats(); - // showToast('配置文件列表已刷新', 'success'); - } else { - throw new Error('获取配置文件列表失败'); - } + const result = await window.apiClient.get('/upload-configs'); + allConfigs = result; + filteredConfigs = [...allConfigs]; + renderConfigList(); + updateStats(); + console.log('配置列表加载成功,共', allConfigs.length, '个项目'); + // showToast('配置文件列表已刷新', 'success'); } catch (error) { console.error('加载配置列表失败:', error); showToast('加载配置列表失败: ' + error.message, 'error'); @@ -270,6 +277,9 @@ async function loadConfigList() { filteredConfigs = [...allConfigs]; renderConfigList(); updateStats(); + } finally { + isLoadingConfigs = false; + console.log('配置列表加载完成'); } } @@ -343,14 +353,8 @@ function generateMockConfigData() { */ async function viewConfig(path) { try { - const response = await fetch(`/api/upload-configs/view/${encodeURIComponent(path)}`); - if (response.ok) { - const fileData = await response.json(); - showConfigModal(fileData); - } else { - const error = await response.json(); - showToast('查看配置失败: ' + error.error.message, 'error'); - } + const fileData = await window.apiClient.get(`/upload-configs/view/${encodeURIComponent(path)}`); + showConfigModal(fileData); } catch (error) { console.error('查看配置失败:', error); showToast('查看配置失败: ' + error.message, 'error'); @@ -450,39 +454,34 @@ function closeConfigModal() { */ async function copyConfigContent(path) { try { - const response = await fetch(`/api/upload-configs/view/${encodeURIComponent(path)}`); - if (response.ok) { - const fileData = await response.json(); - - // 尝试使用现代 Clipboard API - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(fileData.content); - showToast('内容已复制到剪贴板', 'success'); - } else { - // 降级方案:使用传统的 document.execCommand - const textarea = document.createElement('textarea'); - textarea.value = fileData.content; - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - document.body.appendChild(textarea); - textarea.select(); - - try { - const successful = document.execCommand('copy'); - if (successful) { - showToast('内容已复制到剪贴板', 'success'); - } else { - showToast('复制失败,请手动复制', 'error'); - } - } catch (err) { - console.error('复制失败:', err); - showToast('复制失败,请手动复制', 'error'); - } finally { - document.body.removeChild(textarea); - } - } + const fileData = await window.apiClient.get(`/upload-configs/view/${encodeURIComponent(path)}`); + + // 尝试使用现代 Clipboard API + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(fileData.content); + showToast('内容已复制到剪贴板', 'success'); } else { - showToast('获取配置内容失败', 'error'); + // 降级方案:使用传统的 document.execCommand + const textarea = document.createElement('textarea'); + textarea.value = fileData.content; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + + try { + const successful = document.execCommand('copy'); + if (successful) { + showToast('内容已复制到剪贴板', 'success'); + } else { + showToast('复制失败,请手动复制', 'error'); + } + } catch (err) { + console.error('复制失败:', err); + showToast('复制失败,请手动复制', 'error'); + } finally { + document.body.removeChild(textarea); + } } } catch (error) { console.error('复制失败:', error); @@ -640,23 +639,14 @@ function showDeleteConfirmModal(config) { */ async function performDelete(path) { try { - const response = await fetch(`/api/upload-configs/delete/${encodeURIComponent(path)}`, { - method: 'DELETE' - }); + const result = await window.apiClient.delete(`/upload-configs/delete/${encodeURIComponent(path)}`); + showToast(result.message, 'success'); - if (response.ok) { - const result = await response.json(); - showToast(result.message, 'success'); - - // 从本地列表中移除 - allConfigs = allConfigs.filter(c => c.path !== path); - filteredConfigs = filteredConfigs.filter(c => c.path !== path); - renderConfigList(); - updateStats(); - } else { - const error = await response.json(); - showToast('删除失败: ' + error.error.message, 'error'); - } + // 从本地列表中移除 + allConfigs = allConfigs.filter(c => c.path !== path); + filteredConfigs = filteredConfigs.filter(c => c.path !== path); + renderConfigList(); + updateStats(); } catch (error) { console.error('删除配置失败:', error); showToast('删除配置失败: ' + error.message, 'error'); @@ -724,30 +714,24 @@ function initUploadConfigManager() { * 重新加载配置文件 */ async function reloadConfig() { - try { - const response = await fetch('/api/reload-config', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); + // 防止重复重载 + if (isLoadingConfigs) { + console.log('正在重载配置,跳过重复调用'); + return; + } - if (response.ok) { - const result = await response.json(); - showToast(result.message, 'success'); - - // 重新加载配置列表以反映最新的关联状态 - await loadConfigList(); - - // 发送自定义事件通知其他组件配置已重新加载 - window.dispatchEvent(new CustomEvent('configReloaded', { - detail: result.details - })); - - } else { - const error = await response.json(); - throw new Error(error.error?.message || '重载配置失败'); - } + try { + const result = await window.apiClient.post('/reload-config'); + showToast(result.message, 'success'); + + // 重新加载配置列表以反映最新的关联状态 + await loadConfigList(); + + // 注意:不再发送 configReloaded 事件,避免重复调用 + // window.dispatchEvent(new CustomEvent('configReloaded', { + // detail: result.details + // })); + } catch (error) { console.error('重载配置失败:', error); showToast('重载配置失败: ' + error.message, 'error'); diff --git a/static/app/utils.js b/static/app/utils.js index 49554e9..e558a10 100644 --- a/static/app/utils.js +++ b/static/app/utils.js @@ -54,7 +54,7 @@ function showToast(message, type = 'info') { */ function getFieldLabel(key) { const labelMap = { - 'checkModelName': '检查模型名称', + 'checkModelName': '检查模型名称 (选填)', 'checkHealth': '健康检查', 'OPENAI_API_KEY': 'OpenAI API Key', 'OPENAI_BASE_URL': 'OpenAI Base URL', diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..3861e26 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/index.html b/static/index.html index 03dc69e..f2ceb84 100644 --- a/static/index.html +++ b/static/index.html @@ -3,7 +3,7 @@ - + AIClient2API - 管理控制台 @@ -20,7 +20,11 @@ 连接中... - + +
@@ -115,9 +119,6 @@
/gemini-cli-oauth/v1/chat/completions -
@@ -137,9 +138,6 @@
/gemini-cli-oauth/v1/messages -
@@ -174,9 +172,6 @@
/openai-qwen-oauth/v1/chat/completions -
@@ -196,9 +191,6 @@
/openai-qwen-oauth/v1/messages -
@@ -233,9 +225,6 @@
/claude-custom/v1/chat/completions -
@@ -255,9 +244,6 @@
/claude-custom/v1/messages -
@@ -292,9 +278,6 @@
/claude-kiro-oauth/v1/chat/completions -
@@ -314,9 +297,6 @@
/claude-kiro-oauth/v1/messages -
@@ -351,9 +331,6 @@
/openai-custom/v1/chat/completions -
@@ -373,9 +350,6 @@
/openai-custom/v1/messages -
@@ -576,13 +550,13 @@
- +
@@ -629,7 +603,7 @@
- + 配置了供应商池后,可在供应商池管理中查看详细信息
@@ -768,6 +742,30 @@
+ + diff --git a/static/login.html b/static/login.html new file mode 100644 index 0000000..edd1740 --- /dev/null +++ b/static/login.html @@ -0,0 +1,304 @@ + + + + + + 登录 - AIClient2API + + + + + + + + + \ No newline at end of file