diff --git a/Dockerfile b/Dockerfile index 517d0a4..13ef61e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,4 +35,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ # 设置启动命令 # 使用默认配置启动服务器,支持通过环境变量配置 # 通过环境变量传递参数,例如:docker run -e ARGS="--api-key mykey --port 8080" ... -CMD ["sh", "-c", "node src/api-server.js $ARGS"] \ No newline at end of file +CMD ["sh", "-c", "node src/master.js $ARGS"] \ No newline at end of file diff --git a/README-JA.md b/README-JA.md index 76b5842..9b79c29 100644 --- a/README-JA.md +++ b/README-JA.md @@ -78,9 +78,10 @@ - [🐳 Docker デプロイ](https://hub.docker.com/r/justlikemaki/aiclient-2-api) - [🔧 使用方法](#-使用方法) +- [❓ よくある質問](#-よくある質問) - [📄 オープンソースライセンス](#-オープンソースライセンス) - [🙏 謝辞](#-謝辞) -- [⚠️ 免責事項](#-免責事項) +- [⚠️ 免責事項](#️-免責事項) --- @@ -334,6 +335,128 @@ curl http://localhost:3000/ollama/api/chat \ --- +## ❓ よくある質問 + +### 1. OAuth認証失敗 + +**問題の説明**:「認証生成」をクリックした後、ブラウザで認証ページが開きますが、認証が失敗するか完了できません。 + +**解決策**: +- **ネットワーク接続を確認**:Google、アリババクラウドなどのサービスに正常にアクセスできることを確認 +- **ポート占有を確認**:OAuthコールバックには特定のポートが必要です(Gemini: 8085, Antigravity: 8086, Kiro: 19876-19880)、これらのポートが占有されていないことを確認 +- **ブラウザキャッシュをクリア**:シークレットモードを使用するか、ブラウザキャッシュをクリアして再試行 +- **ファイアウォール設定を確認**:ファイアウォールがローカルコールバックポートへのアクセスを許可していることを確認 +- **Dockerユーザー**:すべてのOAuthコールバックポートが正しくマッピングされていることを確認 + +### 2. ポートが使用中 + +**問題の説明**:サービス起動時にポートが既に使用中と表示されます(例:`EADDRINUSE`)。 + +**解決策**: +```bash +# Windows - ポートを占有しているプロセスを検索 +netstat -ano | findstr :3000 +# タスクマネージャーで対応するPIDのプロセスを終了 + +# Linux/macOS - ポートを占有しているプロセスを検索して終了 +lsof -i :3000 +kill -9 +``` + +または `configs/config.json` のポート設定を変更して別のポートを使用します。 + +### 3. Dockerコンテナが起動しない + +**問題の説明**:Dockerコンテナの起動に失敗するか、すぐに終了します。 + +**解決策**: +- **ログを確認**:`docker logs aiclient2api` でエラーメッセージを確認 +- **マウントパスを確認**:`-v` パラメータのローカルパスが存在し、読み書き権限があることを確認 +- **ポート競合を確認**:マッピングされたすべてのポートがホストで占有されていないことを確認 +- **イメージを再取得**:`docker pull justlikemaki/aiclient-2-api:latest` + +### 4. 認証情報ファイルが認識されない + +**問題の説明**:認証情報ファイルをアップロードまたは設定した後、システムが認識できないかフォーマットエラーと表示されます。 + +**解決策**: +- **ファイル形式を確認**:認証情報ファイルが有効なJSON形式であることを確認 +- **ファイルパスを確認**:ファイルパスが正しいことを確認、Dockerユーザーはファイルがマウントディレクトリ内にあることを確認 +- **ファイル権限を確認**:サービスが認証情報ファイルを読み取る権限があることを確認 +- **認証情報を再生成**:認証情報が期限切れの場合、OAuth認証を再度実行 + +### 5. リクエストが429エラーを返す + +**問題の説明**:APIリクエストが頻繁に429 Too Many Requestsエラーを返します。 + +**解決策**: +- **アカウントプールを設定**:`provider_pools.json` に複数のアカウントを追加し、ポーリングメカニズムを有効化 +- **フォールバックを設定**:`config.json` で `providerFallbackChain` を設定し、クロスタイプ降格を実現 +- **リクエスト頻度を下げる**:リクエスト間隔を適切に増やし、レート制限のトリガーを回避 +- **割り当てリセットを待つ**:無料割り当ては通常、毎日または毎分リセットされます + +### 6. モデルが利用できないかエラーを返す + +**問題の説明**:特定のモデルをリクエストするとエラーが返されるか、モデルが利用できないと表示されます。 + +**解決策**: +- **モデル名を確認**:正しいモデル名を使用していることを確認(大文字小文字を区別) +- **プロバイダーサポートを確認**:現在設定されているプロバイダーがそのモデルをサポートしていることを確認 +- **アカウント権限を確認**:一部の高度なモデルは特定のアカウント権限が必要な場合があります +- **モデルフィルタリングを設定**:`notSupportedModels` を使用してサポートされていないモデルを除外 + +### 7. Web UIにアクセスできない + +**問題の説明**:ブラウザで `http://localhost:3000` を開けません。 + +**解決策**: +- **サービス状態を確認**:サービスが正常に起動したことを確認、ターミナル出力を確認 +- **ポートマッピングを確認**:Dockerユーザーは `-p 3000:3000` パラメータが正しいことを確認 +- **別のアドレスを試す**:`http://127.0.0.1:3000` へのアクセスを試す +- **ファイアウォールを確認**:ファイアウォールがポート3000へのアクセスを許可していることを確認 + +### 8. ストリーミングレスポンスが中断される + +**問題の説明**:ストリーミング出力を使用すると、レスポンスが途中で中断されるか不完全になります。 + +**解決策**: +- **ネットワーク安定性を確認**:ネットワーク接続が安定していることを確認 +- **タイムアウトを増やす**:クライアント設定でリクエストタイムアウトを増やす +- **プロキシ設定を確認**:プロキシを使用している場合、プロキシが長時間接続をサポートしていることを確認 +- **サービスログを確認**:エラーメッセージがないか確認 + +### 9. 設定変更が反映されない + +**問題の説明**:Web UIで設定を変更した後、サービスの動作が変わりません。 + +**解決策**: +- **ページを更新**:変更後にWeb UIページを更新 +- **保存状態を確認**:設定が正常に保存されたことを確認(プロンプトメッセージを確認) +- **サービスを再起動**:一部の設定はサービスの再起動が必要な場合があります +- **設定ファイルを確認**:`configs/config.json` を直接確認して変更が書き込まれていることを確認 + +### 10. APIが404を返す + +**問題の説明**:APIエンドポイントを呼び出すと404 Not Foundエラーが返されます。 + +**解決策**: +- **エンドポイントパスを確認**:`/v1/chat/completions`、`/ollama/api/chat` などの正しいエンドポイントパスを使用していることを確認 +- **クライアントの自動補完を確認**:一部のクライアント(Cherry-Studio、NextChatなど)はBase URLの後にパス(`/v1/chat/completions` など)を自動的に追加し、パスの重複を引き起こします。コンソールで実際のリクエストURLを確認し、冗長なパス部分を削除してください +- **サービス状態を確認**:サービスが正常に起動していることを確認、`http://localhost:3000` にアクセスしてWeb UIを確認 +- **ポート設定を確認**:リクエストが正しいポート(デフォルト3000)に送信されていることを確認 +- **利用可能なルートを確認**:Web UIダッシュボードページの「インタラクティブルーティング例」ですべての利用可能なエンドポイントを確認 + +### 11. Unauthorized: API key is invalid or missing + +**問題の説明**:APIエンドポイントを呼び出すと `Unauthorized: API key is invalid or missing.` エラーが返されます。 + +**解決策**: +- **API Key設定を確認**:`configs/config.json` またはWeb UIでAPI Keyが正しく設定されていることを確認 +- **リクエストヘッダー形式を確認**:リクエストに正しい形式のAuthorizationヘッダーが含まれていることを確認、例:`Authorization: Bearer your-api-key` +- **サービスログを確認**:Web UIの「リアルタイムログ」ページで詳細なエラーメッセージを確認し、具体的な原因を特定 + +--- + ## 📄 オープンソースライセンス 本プロジェクトは [**GNU General Public License v3 (GPLv3)**](https://www.gnu.org/licenses/gpl-3.0) オープンソースライセンスに従います。詳細はルートディレクトリの `LICENSE` ファイルをご覧ください。 @@ -355,8 +478,9 @@ AIClient-2-APIプロジェクトに貢献してくれたすべての開発者に - [**Cigarliu**](https://github.com/Cigarliu "9.9") - [**xianengqi**](https://github.com/xianengqi "9.9") - [**3831143avl**](https://github.com/3831143avl "10") -- [**醉春风**](https://github.com/handsometong "28.8") +- [**醉春風**](https://github.com/handsometong "28.8") - [**crazy**](https://github.com/404 "88") +- [**清宵落了灯花**](https://github.com/Lanternmorning "16") ### 🌟 Star History diff --git a/README-ZH.md b/README-ZH.md index 14a703d..53d2b1c 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -77,9 +77,10 @@ - [🐳 Docker 部署](https://hub.docker.com/r/justlikemaki/aiclient-2-api) - [🔧 使用说明](#-使用说明) +- [❓ 常见问题](#-常见问题) - [📄 开源许可](#-开源许可) - [🙏 致谢](#-致谢) -- [⚠️ 免责声明](#-免责声明) +- [⚠️ 免责声明](#️-免责声明) --- @@ -333,6 +334,128 @@ curl http://localhost:3000/ollama/api/chat \ --- +## ❓ 常见问题 + +### 1. OAuth 授权失败 + +**问题描述**:点击"生成授权"后,浏览器打开授权页面但授权失败或无法完成。 + +**解决方案**: +- **检查网络连接**:确保能够正常访问 Google、阿里云等服务 +- **检查端口占用**:OAuth 回调需要特定端口(Gemini: 8085, Antigravity: 8086, Kiro: 19876-19880),确保这些端口未被占用 +- **清除浏览器缓存**:尝试使用无痕模式或清除浏览器缓存后重试 +- **检查防火墙设置**:确保防火墙允许本地回调端口的访问 +- **Docker 用户**:确保已正确映射所有 OAuth 回调端口 + +### 2. 端口被占用 + +**问题描述**:启动服务时提示端口已被占用(如 `EADDRINUSE`)。 + +**解决方案**: +```bash +# Windows - 查找占用端口的进程 +netstat -ano | findstr :3000 +# 然后使用任务管理器结束对应 PID 的进程 + +# Linux/macOS - 查找并结束占用端口的进程 +lsof -i :3000 +kill -9 +``` + +或者修改 `configs/config.json` 中的端口配置使用其他端口。 + +### 3. Docker 容器无法启动 + +**问题描述**:Docker 容器启动失败或立即退出。 + +**解决方案**: +- **检查日志**:`docker logs aiclient2api` 查看错误信息 +- **检查挂载路径**:确保 `-v` 参数中的本地路径存在且有读写权限 +- **检查端口冲突**:确保所有映射的端口在宿主机上未被占用 +- **重新拉取镜像**:`docker pull justlikemaki/aiclient-2-api:latest` + +### 4. 凭据文件无法识别 + +**问题描述**:上传或配置凭据文件后,系统提示无法识别或格式错误。 + +**解决方案**: +- **检查文件格式**:确保凭据文件是有效的 JSON 格式 +- **检查文件路径**:确保文件路径正确,Docker 用户需确保文件在挂载目录内 +- **检查文件权限**:确保服务有权限读取凭据文件 +- **重新生成凭据**:如果凭据已过期,尝试重新进行 OAuth 授权 + +### 5. 请求返回 429 错误 + +**问题描述**:API 请求频繁返回 429 Too Many Requests 错误。 + +**解决方案**: +- **配置账号池**:添加多个账号到 `provider_pools.json`,启用轮询机制 +- **配置 Fallback**:在 `config.json` 中配置 `providerFallbackChain`,实现跨类型降级 +- **降低请求频率**:适当增加请求间隔,避免触发速率限制 +- **等待配额重置**:免费配额通常每日或每分钟重置 + +### 6. 模型不可用或返回错误 + +**问题描述**:请求特定模型时返回错误或提示模型不可用。 + +**解决方案**: +- **检查模型名称**:确保使用正确的模型名称(区分大小写) +- **检查提供商支持**:确认当前配置的提供商支持该模型 +- **检查账号权限**:某些高级模型可能需要特定账号权限 +- **配置模型过滤**:使用 `notSupportedModels` 排除不支持的模型 + +### 7. Web UI 无法访问 + +**问题描述**:浏览器无法打开 `http://localhost:3000`。 + +**解决方案**: +- **检查服务状态**:确认服务已成功启动,查看终端输出 +- **检查端口映射**:Docker 用户确保 `-p 3000:3000` 参数正确 +- **尝试其他地址**:尝试访问 `http://127.0.0.1:3000` +- **检查防火墙**:确保防火墙允许 3000 端口的访问 + +### 8. 流式响应中断 + +**问题描述**:使用流式输出时,响应中途中断或不完整。 + +**解决方案**: +- **检查网络稳定性**:确保网络连接稳定 +- **增加超时时间**:在客户端配置中增加请求超时时间 +- **检查代理设置**:如使用代理,确保代理支持长连接 +- **查看服务日志**:检查是否有错误信息 + +### 9. 配置修改不生效 + +**问题描述**:在 Web UI 中修改配置后,服务行为未改变。 + +**解决方案**: +- **刷新页面**:修改后刷新 Web UI 页面 +- **检查保存状态**:确认配置已成功保存(查看提示信息) +- **重启服务**:某些配置可能需要重启服务才能生效 +- **检查配置文件**:直接查看 `configs/config.json` 确认修改已写入 + +### 10. 访问接口返回 404 + +**问题描述**:调用 API 接口时返回 404 Not Found 错误。 + +**解决方案**: +- **检查接口路径**:确保使用正确的接口路径,如 `/v1/chat/completions`、`/ollama/api/chat` 等 +- **检查客户端自动补全**:某些客户端(如 Cherry-Studio、NextChat)会自动在 Base URL 后追加路径(如 `/v1/chat/completions`),导致路径重复。请查看控制台中的实际请求 URL,移除多余的路径部分 +- **检查服务状态**:确认服务已正常启动,访问 `http://localhost:3000` 查看 Web UI +- **检查端口配置**:确保请求发送到正确的端口(默认 3000) +- **查看可用路由**:在 Web UI 仪表盘页面查看"交互式路由示例"了解所有可用接口 + +### 11. Unauthorized: API key is invalid or missing + +**问题描述**:调用 API 接口时返回 `Unauthorized: API key is invalid or missing.` 错误。 + +**解决方案**: +- **检查 API Key 配置**:确保在 `configs/config.json` 或 Web UI 中正确配置API Key +- **检查请求头格式**:确保请求中包含正确格式的 Authorization 头,如 `Authorization: Bearer your-api-key` +- **查看服务日志**:在 Web UI 的"实时日志"页面查看详细错误信息,定位具体原因 + +--- + ## 📄 开源许可 本项目遵循 [**GNU General Public License v3 (GPLv3)**](https://www.gnu.org/licenses/gpl-3.0) 开源许可。详情请查看根目录下的 `LICENSE` 文件。 @@ -354,6 +477,7 @@ curl http://localhost:3000/ollama/api/chat \ - [**3831143avl**](https://github.com/3831143avl "10") - [**醉春风**](https://github.com/handsometong "28.8") - [**crazy**](https://github.com/404 "88") +- [**清宵落了灯花**](https://github.com/Lanternmorning "16") ### 🌟 Star History diff --git a/README.md b/README.md index 4150d03..9ebdea7 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,10 @@ - [🐳 Docker Deployment](https://hub.docker.com/r/justlikemaki/aiclient-2-api) - [🔧 Usage Instructions](#-usage-instructions) +- [❓ FAQ](#-faq) - [📄 Open Source License](#-open-source-license) - [🙏 Acknowledgements](#-acknowledgements) -- [⚠️ Disclaimer](#-disclaimer) +- [⚠️ Disclaimer](#️-disclaimer) --- @@ -334,6 +335,128 @@ curl http://localhost:3000/ollama/api/chat \ --- +## ❓ FAQ + +### 1. OAuth Authorization Failed + +**Problem Description**: After clicking "Generate Authorization", the browser opens the authorization page but authorization fails or cannot be completed. + +**Solutions**: +- **Check Network Connection**: Ensure you can access Google, Alibaba Cloud, and other services normally +- **Check Port Occupation**: OAuth callbacks require specific ports (Gemini: 8085, Antigravity: 8086, Kiro: 19876-19880), ensure these ports are not occupied +- **Clear Browser Cache**: Try using incognito mode or clearing browser cache and retry +- **Check Firewall Settings**: Ensure the firewall allows access to local callback ports +- **Docker Users**: Ensure all OAuth callback ports are correctly mapped + +### 2. Port Already in Use + +**Problem Description**: When starting the service, it shows the port is already in use (e.g., `EADDRINUSE`). + +**Solutions**: +```bash +# Windows - Find the process occupying the port +netstat -ano | findstr :3000 +# Then use Task Manager to end the corresponding PID process + +# Linux/macOS - Find and end the process occupying the port +lsof -i :3000 +kill -9 +``` + +Or modify the port configuration in `configs/config.json` to use a different port. + +### 3. Docker Container Won't Start + +**Problem Description**: Docker container fails to start or exits immediately. + +**Solutions**: +- **Check Logs**: `docker logs aiclient2api` to view error messages +- **Check Mount Path**: Ensure the local path in the `-v` parameter exists and has read/write permissions +- **Check Port Conflicts**: Ensure all mapped ports are not occupied on the host +- **Re-pull Image**: `docker pull justlikemaki/aiclient-2-api:latest` + +### 4. Credential File Not Recognized + +**Problem Description**: After uploading or configuring credential files, the system shows it cannot be recognized or format error. + +**Solutions**: +- **Check File Format**: Ensure the credential file is valid JSON format +- **Check File Path**: Ensure the file path is correct, Docker users need to ensure the file is in the mounted directory +- **Check File Permissions**: Ensure the service has permission to read the credential file +- **Regenerate Credentials**: If credentials have expired, try re-authorizing via OAuth + +### 5. Request Returns 429 Error + +**Problem Description**: API requests frequently return 429 Too Many Requests error. + +**Solutions**: +- **Configure Account Pool**: Add multiple accounts to `provider_pools.json`, enable polling mechanism +- **Configure Fallback**: Configure `providerFallbackChain` in `config.json` for cross-type degradation +- **Reduce Request Frequency**: Appropriately increase request intervals to avoid triggering rate limits +- **Wait for Quota Reset**: Free quotas usually reset daily or per minute + +### 6. Model Unavailable or Returns Error + +**Problem Description**: When requesting a specific model, it returns an error or shows the model is unavailable. + +**Solutions**: +- **Check Model Name**: Ensure you're using the correct model name (case-sensitive) +- **Check Provider Support**: Confirm the currently configured provider supports that model +- **Check Account Permissions**: Some advanced models may require specific account permissions +- **Configure Model Filtering**: Use `notSupportedModels` to exclude unsupported models + +### 7. Web UI Cannot Be Accessed + +**Problem Description**: Browser cannot open `http://localhost:3000`. + +**Solutions**: +- **Check Service Status**: Confirm the service has started successfully, check terminal output +- **Check Port Mapping**: Docker users ensure `-p 3000:3000` parameter is correct +- **Try Other Address**: Try accessing `http://127.0.0.1:3000` +- **Check Firewall**: Ensure the firewall allows access to port 3000 + +### 8. Streaming Response Interrupted + +**Problem Description**: When using streaming output, the response is interrupted midway or incomplete. + +**Solutions**: +- **Check Network Stability**: Ensure network connection is stable +- **Increase Timeout**: Increase request timeout in client configuration +- **Check Proxy Settings**: If using a proxy, ensure the proxy supports long connections +- **Check Service Logs**: Check for error messages + +### 9. Configuration Changes Not Taking Effect + +**Problem Description**: After modifying configuration in Web UI, service behavior doesn't change. + +**Solutions**: +- **Refresh Page**: Refresh the Web UI page after modification +- **Check Save Status**: Confirm the configuration was saved successfully (check prompt messages) +- **Restart Service**: Some configurations may require service restart to take effect +- **Check Configuration File**: Directly check `configs/config.json` to confirm changes were written + +### 10. API Returns 404 + +**Problem Description**: When calling API endpoints, it returns 404 Not Found error. + +**Solutions**: +- **Check Endpoint Path**: Ensure you're using the correct endpoint path, such as `/v1/chat/completions`, `/ollama/api/chat`, etc. +- **Check Client Auto-completion**: Some clients (like Cherry-Studio, NextChat) automatically append paths (like `/v1/chat/completions`) after the Base URL, causing path duplication. Check the actual request URL in the console and remove redundant path parts +- **Check Service Status**: Confirm the service has started normally, visit `http://localhost:3000` to view Web UI +- **Check Port Configuration**: Ensure requests are sent to the correct port (default 3000) +- **View Available Routes**: Check "Interactive Routing Examples" on the Web UI dashboard page to see all available endpoints + +### 11. Unauthorized: API key is invalid or missing + +**Problem Description**: When calling API endpoints, it returns `Unauthorized: API key is invalid or missing.` error. + +**Solutions**: +- **Check API Key Configuration**: Ensure API Key is correctly configured in `configs/config.json` or Web UI +- **Check Request Header Format**: Ensure the request contains the correct Authorization header format, such as `Authorization: Bearer your-api-key` +- **Check Service Logs**: View detailed error messages on the "Real-time Logs" page in Web UI to locate the specific cause + +--- + ## 📄 Open Source License This project follows the [**GNU General Public License v3 (GPLv3)**](https://www.gnu.org/licenses/gpl-3.0) license. For details, please check the `LICENSE` file in the root directory. @@ -357,6 +480,7 @@ We are grateful for the support from our sponsors: - [**3831143avl**](https://github.com/3831143avl "10") - [**醉春风**](https://github.com/handsometong "28.8") - [**crazy**](https://github.com/404 "88") +- [**清宵落了灯花**](https://github.com/Lanternmorning "16") ### 🌟 Star History diff --git a/install-and-run.bat b/install-and-run.bat index 5b07253..d9922a3 100755 --- a/install-and-run.bat +++ b/install-and-run.bat @@ -68,9 +68,9 @@ if !errorlevel! neq 0 ( ) echo [成功] 依赖安装/更新完成 -:: 检查src目录和api-server.js是否存在 -if not exist "src\api-server.js" ( - echo [错误] 未找到src\api-server.js文件 +:: 检查src目录和master.js是否存在 +if not exist "src\master.js" ( + echo [错误] 未找到src\master.js文件 pause exit /b 1 ) @@ -89,4 +89,4 @@ echo 按 Ctrl+C 停止服务器 echo. :: 启动服务器 -node src\api-server.js \ No newline at end of file +node src\master.js \ No newline at end of file diff --git a/install-and-run.sh b/install-and-run.sh index d2acbeb..2aa7e18 100755 --- a/install-and-run.sh +++ b/install-and-run.sh @@ -75,9 +75,9 @@ if [ $? -ne 0 ]; then fi echo "[成功] 依赖安装/更新完成" -# 检查src目录和api-server.js是否存在 -if [ ! -f "src/api-server.js" ]; then - echo "[错误] 未找到src/api-server.js文件" +# 检查src目录和master.js是否存在 +if [ ! -f "src/master.js" ]; then + echo "[错误] 未找到src/master.js文件" exit 1 fi @@ -95,4 +95,4 @@ echo "按 Ctrl+C 停止服务器" echo # 启动服务器 -node src/api-server.js \ No newline at end of file +node src/master.js \ No newline at end of file diff --git a/package.json b/package.json index 4fe12f9..4a6368b 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,9 @@ "supertest": "^6.3.3" }, "scripts": { + "start": "node src/master.js", + "start:standalone": "node src/api-server.js", + "start:dev": "node src/master.js --dev", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", diff --git a/src/api-server.js b/src/api-server.js index 5b7051b..e7d8a66 100644 --- a/src/api-server.js +++ b/src/api-server.js @@ -114,6 +114,107 @@ import 'dotenv/config'; // Import dotenv and configure it import './converters/register-converters.js'; // 注册所有转换器 import { getProviderPoolManager } from './service-manager.js'; +// 检测是否作为子进程运行 +const IS_WORKER_PROCESS = process.env.IS_WORKER_PROCESS === 'true'; + +// 存储服务器实例,用于优雅关闭 +let serverInstance = null; + +/** + * 发送消息给主进程 + * @param {Object} message - 消息对象 + */ +function sendToMaster(message) { + if (IS_WORKER_PROCESS && process.send) { + process.send(message); + } +} + +/** + * 设置子进程通信处理 + */ +function setupWorkerCommunication() { + if (!IS_WORKER_PROCESS) return; + + // 监听来自主进程的消息 + process.on('message', (message) => { + if (!message || !message.type) return; + + console.log('[Worker] Received message from master:', message.type); + + switch (message.type) { + case 'shutdown': + console.log('[Worker] Shutdown requested by master'); + gracefulShutdown(); + break; + case 'status': + sendToMaster({ + type: 'status', + data: { + pid: process.pid, + uptime: process.uptime(), + memoryUsage: process.memoryUsage() + } + }); + break; + default: + console.log('[Worker] Unknown message type:', message.type); + } + }); + + // 监听断开连接 + process.on('disconnect', () => { + console.log('[Worker] Disconnected from master, shutting down...'); + gracefulShutdown(); + }); +} + +/** + * 优雅关闭服务器 + */ +async function gracefulShutdown() { + console.log('[Server] Initiating graceful shutdown...'); + + if (serverInstance) { + serverInstance.close(() => { + console.log('[Server] HTTP server closed'); + process.exit(0); + }); + + // 设置超时,防止无限等待 + setTimeout(() => { + console.log('[Server] Shutdown timeout, forcing exit...'); + process.exit(1); + }, 10000); + } else { + process.exit(0); + } +} + +/** + * 设置进程信号处理 + */ +function setupSignalHandlers() { + process.on('SIGTERM', () => { + console.log('[Server] Received SIGTERM'); + gracefulShutdown(); + }); + + process.on('SIGINT', () => { + console.log('[Server] Received SIGINT'); + gracefulShutdown(); + }); + + process.on('uncaughtException', (error) => { + console.error('[Server] Uncaught exception:', error); + gracefulShutdown(); + }); + + process.on('unhandledRejection', (reason, promise) => { + console.error('[Server] Unhandled rejection at:', promise, 'reason:', reason); + }); +} + // --- Server Initialization --- async function startServer() { // Initialize configuration @@ -135,8 +236,8 @@ async function startServer() { // Create request handler const requestHandlerInstance = createRequestHandler(CONFIG, getProviderPoolManager()); - const server = http.createServer(requestHandlerInstance); - server.listen(CONFIG.SERVER_PORT, CONFIG.HOST, async () => { + serverInstance = http.createServer(requestHandlerInstance); + serverInstance.listen(CONFIG.SERVER_PORT, CONFIG.HOST, async () => { console.log(`--- Unified API Server Configuration ---`); const configuredProviders = Array.isArray(CONFIG.DEFAULT_MODEL_PROVIDERS) && CONFIG.DEFAULT_MODEL_PROVIDERS.length > 0 ? CONFIG.DEFAULT_MODEL_PROVIDERS @@ -196,11 +297,25 @@ async function startServer() { console.log('[Initialization] Performing initial health checks for provider pools...'); poolManager.performHealthChecks(true); } + + // 如果是子进程,通知主进程已就绪 + if (IS_WORKER_PROCESS) { + sendToMaster({ type: 'ready', pid: process.pid }); + } }); - return server; // Return the server instance for testing purposes + return serverInstance; // Return the server instance for testing purposes } +// 设置信号处理 +setupSignalHandlers(); + +// 设置子进程通信 +setupWorkerCommunication(); + startServer().catch(err => { console.error("[Server] Failed to start server:", err.message); process.exit(1); }); + +// 导出用于外部调用 +export { gracefulShutdown, sendToMaster }; diff --git a/src/master.js b/src/master.js new file mode 100644 index 0000000..39e4a84 --- /dev/null +++ b/src/master.js @@ -0,0 +1,378 @@ +/** + * 主进程 (Master Process) + * + * 负责管理子进程的生命周期,包括: + * - 启动子进程 + * - 监控子进程状态 + * - 处理子进程重启请求 + * - 提供 IPC 通信 + * + * 使用方式: + * node src/master.js [原有的命令行参数] + */ + +import { fork } from 'child_process'; +import * as http from 'http'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// 子进程实例 +let workerProcess = null; + +// 子进程状态 +let workerStatus = { + pid: null, + startTime: null, + restartCount: 0, + lastRestartTime: null, + isRestarting: false +}; + +// 配置 +const config = { + workerScript: path.join(__dirname, 'api-server.js'), + maxRestartAttempts: 10, + restartDelay: 1000, // 重启延迟(毫秒) + masterPort: parseInt(process.env.MASTER_PORT) || 3100, // 主进程管理端口 + args: process.argv.slice(2) // 传递给子进程的参数 +}; + +/** + * 启动子进程 + */ +function startWorker() { + if (workerProcess) { + console.log('[Master] Worker process already running, PID:', workerProcess.pid); + return; + } + + console.log('[Master] Starting worker process...'); + console.log('[Master] Worker script:', config.workerScript); + console.log('[Master] Worker args:', config.args.join(' ')); + + workerProcess = fork(config.workerScript, config.args, { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + env: { + ...process.env, + IS_WORKER_PROCESS: 'true' + } + }); + + workerStatus.pid = workerProcess.pid; + workerStatus.startTime = new Date().toISOString(); + + console.log('[Master] Worker process started, PID:', workerProcess.pid); + + // 监听子进程消息 + workerProcess.on('message', (message) => { + console.log('[Master] Received message from worker:', message); + handleWorkerMessage(message); + }); + + // 监听子进程退出 + workerProcess.on('exit', (code, signal) => { + console.log(`[Master] Worker process exited with code ${code}, signal ${signal}`); + workerProcess = null; + workerStatus.pid = null; + + // 如果不是主动重启导致的退出,尝试自动重启 + if (!workerStatus.isRestarting && code !== 0) { + console.log('[Master] Worker crashed, attempting auto-restart...'); + scheduleRestart(); + } + }); + + // 监听子进程错误 + workerProcess.on('error', (error) => { + console.error('[Master] Worker process error:', error.message); + }); +} + +/** + * 停止子进程 + * @param {boolean} graceful - 是否优雅关闭 + * @returns {Promise} + */ +function stopWorker(graceful = true) { + return new Promise((resolve) => { + if (!workerProcess) { + console.log('[Master] No worker process to stop'); + resolve(); + return; + } + + console.log('[Master] Stopping worker process, PID:', workerProcess.pid); + + const timeout = setTimeout(() => { + if (workerProcess) { + console.log('[Master] Force killing worker process...'); + workerProcess.kill('SIGKILL'); + } + resolve(); + }, 5000); // 5秒超时后强制杀死 + + workerProcess.once('exit', () => { + clearTimeout(timeout); + workerProcess = null; + workerStatus.pid = null; + console.log('[Master] Worker process stopped'); + resolve(); + }); + + if (graceful) { + // 发送优雅关闭信号 + workerProcess.send({ type: 'shutdown' }); + workerProcess.kill('SIGTERM'); + } else { + workerProcess.kill('SIGKILL'); + } + }); +} + +/** + * 重启子进程 + * @returns {Promise} + */ +async function restartWorker() { + if (workerStatus.isRestarting) { + console.log('[Master] Restart already in progress'); + return { success: false, message: 'Restart already in progress' }; + } + + workerStatus.isRestarting = true; + workerStatus.restartCount++; + workerStatus.lastRestartTime = new Date().toISOString(); + + console.log('[Master] Restarting worker process...'); + + try { + await stopWorker(true); + + // 等待一小段时间确保端口释放 + await new Promise(resolve => setTimeout(resolve, config.restartDelay)); + + startWorker(); + workerStatus.isRestarting = false; + + return { + success: true, + message: 'Worker restarted successfully', + pid: workerStatus.pid, + restartCount: workerStatus.restartCount + }; + } catch (error) { + workerStatus.isRestarting = false; + console.error('[Master] Failed to restart worker:', error.message); + return { + success: false, + message: 'Failed to restart worker: ' + error.message + }; + } +} + +/** + * 计划重启(用于崩溃后自动重启) + */ +function scheduleRestart() { + if (workerStatus.restartCount >= config.maxRestartAttempts) { + console.error('[Master] Max restart attempts reached, giving up'); + return; + } + + const delay = Math.min(config.restartDelay * Math.pow(2, workerStatus.restartCount), 30000); + console.log(`[Master] Scheduling restart in ${delay}ms...`); + + setTimeout(() => { + restartWorker(); + }, delay); +} + +/** + * 处理来自子进程的消息 + * @param {Object} message - 消息对象 + */ +function handleWorkerMessage(message) { + if (!message || !message.type) return; + + switch (message.type) { + case 'ready': + console.log('[Master] Worker is ready'); + break; + case 'restart_request': + console.log('[Master] Worker requested restart'); + restartWorker(); + break; + case 'status': + console.log('[Master] Worker status:', message.data); + break; + default: + console.log('[Master] Unknown message type:', message.type); + } +} + +/** + * 获取状态信息 + * @returns {Object} + */ +function getStatus() { + return { + master: { + pid: process.pid, + uptime: process.uptime(), + memoryUsage: process.memoryUsage() + }, + worker: { + pid: workerStatus.pid, + startTime: workerStatus.startTime, + restartCount: workerStatus.restartCount, + lastRestartTime: workerStatus.lastRestartTime, + isRestarting: workerStatus.isRestarting, + isRunning: workerProcess !== null + } + }; +} + +/** + * 创建主进程管理 HTTP 服务器 + */ +function createMasterServer() { + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const path = url.pathname; + const method = req.method; + + // 设置 CORS 头 + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + // 状态端点 + if (method === 'GET' && path === '/master/status') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(getStatus())); + return; + } + + // 重启端点 + if (method === 'POST' && path === '/master/restart') { + console.log('[Master] Restart requested via API'); + const result = await restartWorker(); + res.writeHead(result.success ? 200 : 500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + return; + } + + // 停止端点 + if (method === 'POST' && path === '/master/stop') { + console.log('[Master] Stop requested via API'); + await stopWorker(true); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, message: 'Worker stopped' })); + return; + } + + // 启动端点 + if (method === 'POST' && path === '/master/start') { + console.log('[Master] Start requested via API'); + if (workerProcess) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, message: 'Worker already running' })); + return; + } + startWorker(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, message: 'Worker started', pid: workerStatus.pid })); + return; + } + + // 健康检查 + if (method === 'GET' && path === '/master/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'healthy', + workerRunning: workerProcess !== null, + timestamp: new Date().toISOString() + })); + return; + } + + // 404 + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not Found' })); + }); + + server.listen(config.masterPort, () => { + console.log(`[Master] Management server listening on port ${config.masterPort}`); + console.log(`[Master] Available endpoints:`); + console.log(` GET /master/status - Get master and worker status`); + console.log(` GET /master/health - Health check`); + console.log(` POST /master/restart - Restart worker process`); + console.log(` POST /master/stop - Stop worker process`); + console.log(` POST /master/start - Start worker process`); + }); + + return server; +} + +/** + * 处理进程信号 + */ +function setupSignalHandlers() { + // 优雅关闭 + process.on('SIGTERM', async () => { + console.log('[Master] Received SIGTERM, shutting down...'); + await stopWorker(true); + process.exit(0); + }); + + process.on('SIGINT', async () => { + console.log('[Master] Received SIGINT, shutting down...'); + await stopWorker(true); + process.exit(0); + }); + + // 未捕获的异常 + process.on('uncaughtException', (error) => { + console.error('[Master] Uncaught exception:', error); + }); + + process.on('unhandledRejection', (reason, promise) => { + console.error('[Master] Unhandled rejection at:', promise, 'reason:', reason); + }); +} + +/** + * 主函数 + */ +async function main() { + console.log('='.repeat(50)); + console.log('[Master] AIClient2API Master Process'); + console.log('[Master] PID:', process.pid); + console.log('[Master] Node version:', process.version); + console.log('[Master] Working directory:', process.cwd()); + console.log('='.repeat(50)); + + // 设置信号处理 + setupSignalHandlers(); + + // 创建管理服务器 + createMasterServer(); + + // 启动子进程 + startWorker(); +} + +// 启动主进程 +main().catch(error => { + console.error('[Master] Failed to start:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/service-manager.js b/src/service-manager.js index 1043312..6b4e750 100644 --- a/src/service-manager.js +++ b/src/service-manager.js @@ -93,6 +93,11 @@ export async function autoLinkProviderConfigs(config) { console.log('[Auto-Link] No new configs to link'); } + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = config.providerPools; + providerPoolManager.initializeProviderStatus(); + } return config.providerPools; } diff --git a/src/ui-manager.js b/src/ui-manager.js index 9d5b085..f9e22d7 100644 --- a/src/ui-manager.js +++ b/src/ui-manager.js @@ -1,10 +1,57 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { promises as fs } from 'fs'; import path from 'path'; +import os from 'os'; import multer from 'multer'; import crypto from 'crypto'; import AdmZip from 'adm-zip'; +import { exec } from 'child_process'; +import { promisify } from 'util'; import { getRequestBody } from './common.js'; + +const execAsync = promisify(exec); + +// CPU 使用率计算相关变量 +let previousCpuInfo = null; + +/** + * 获取 CPU 使用率百分比 + * @returns {string} CPU 使用率字符串,如 "25.5%" + */ +function getCpuUsagePercent() { + const cpus = os.cpus(); + + let totalIdle = 0; + let totalTick = 0; + + for (const cpu of cpus) { + for (const type in cpu.times) { + totalTick += cpu.times[type]; + } + totalIdle += cpu.times.idle; + } + + const currentCpuInfo = { + idle: totalIdle, + total: totalTick + }; + + let cpuPercent = 0; + + if (previousCpuInfo) { + const idleDiff = currentCpuInfo.idle - previousCpuInfo.idle; + const totalDiff = currentCpuInfo.total - previousCpuInfo.total; + + if (totalDiff > 0) { + cpuPercent = 100 - (100 * idleDiff / totalDiff); + } + } + + previousCpuInfo = currentCpuInfo; + + return `${cpuPercent.toFixed(1)}%`; +} + import { getAllProviderModels, getProviderModels } from './provider-models.js'; import { CONFIG } from './config-manager.js'; import { serviceInstances, getServiceAdapter } from './adapter.js'; @@ -109,7 +156,7 @@ async function readTokenStore() { return { tokens: {} }; } } catch (error) { - console.error('读取token存储文件失败:', error); + console.error('[Token Store] Failed to read token store file:', error); return { tokens: {} }; } } @@ -121,7 +168,7 @@ async function writeTokenStore(tokenStore) { try { await fs.writeFile(TOKEN_STORE_FILE, JSON.stringify(tokenStore, null, 2), 'utf8'); } catch (error) { - console.error('写入token存储文件失败:', error); + console.error('[Token Store] Failed to write token store file:', error); } } @@ -217,18 +264,18 @@ async function readPasswordFile() { const trimmedPassword = password.trim(); // 如果密码文件为空,使用默认密码 if (!trimmedPassword) { - console.log('[Auth] 密码文件为空,使用默认密码: ' + DEFAULT_PASSWORD); + console.log('[Auth] Password file is empty, using default password: ' + DEFAULT_PASSWORD); return DEFAULT_PASSWORD; } - console.log('[Auth] 成功读取密码文件'); + console.log('[Auth] Successfully read password file'); return trimmedPassword; } catch (error) { - // ENOENT 表示文件不存在,这是正常情况 + // ENOENT means file does not exist, which is normal if (error.code === 'ENOENT') { - console.log('[Auth] 密码文件不存在,使用默认密码: ' + DEFAULT_PASSWORD); + console.log('[Auth] Password file does not exist, using default password: ' + DEFAULT_PASSWORD); } else { - console.error('[Auth] 读取密码文件失败:', error.code || error.message); - console.log('[Auth] 使用默认密码: ' + DEFAULT_PASSWORD); + console.error('[Auth] Failed to read password file:', error.code || error.message); + console.log('[Auth] Using default password: ' + DEFAULT_PASSWORD); } return DEFAULT_PASSWORD; } @@ -239,9 +286,9 @@ async function readPasswordFile() { */ async function validateCredentials(password) { const storedPassword = await readPasswordFile(); - console.log('[Auth] 验证密码, 存储密码长度:', storedPassword ? storedPassword.length : 0, ', 输入密码长度:', password ? password.length : 0); + console.log('[Auth] Validating password, stored password length:', storedPassword ? storedPassword.length : 0, ', input password length:', password ? password.length : 0); const isValid = storedPassword && password === storedPassword; - console.log('[Auth] 密码验证结果:', isValid); + console.log('[Auth] Password validation result:', isValid); return isValid; } @@ -262,7 +309,7 @@ function parseRequestBody(req) { resolve(JSON.parse(body)); } } catch (error) { - reject(new Error('无效的JSON格式')); + reject(new Error('Invalid JSON format')); } }); req.on('error', reject); @@ -291,7 +338,7 @@ async function checkAuth(req) { async function handleLoginRequest(req, res) { if (req.method !== 'POST') { res.writeHead(405, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: false, message: '仅支持POST请求' })); + res.end(JSON.stringify({ success: false, message: 'Only POST requests are supported' })); return true; } @@ -301,18 +348,18 @@ async function handleLoginRequest(req, res) { if (!password) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: false, message: '密码不能为空' })); + res.end(JSON.stringify({ success: false, message: 'Password cannot be empty' })); return true; } const isValid = await validateCredentials(password); if (isValid) { - // 生成简单token + // Generate simple token const token = generateToken(); const expiryTime = getExpiryTime(); - // 存储token信息到本地文件 + // Store token info to local file await saveToken(token, { username: 'admin', loginTime: Date.now(), @@ -322,23 +369,23 @@ async function handleLoginRequest(req, res) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, - message: '登录成功', + message: 'Login successful', token, - expiresIn: '1小时' + expiresIn: '1 hour' })); } else { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, - message: '密码错误,请重试' + message: 'Incorrect password, please try again' })); } } catch (error) { - console.error('登录处理错误:', error); + console.error('[Auth] Login processing error:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, - message: error.message || '服务器错误' + message: error.message || 'Server error' })); } return true; @@ -373,7 +420,7 @@ const fileFilter = (req, file, cb) => { if (allowedTypes.includes(ext)) { cb(null, true); } else { - cb(new Error('不支持的文件类型'), false); + cb(new Error('Unsupported file type'), false); } }; @@ -482,7 +529,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo }); res.end(JSON.stringify({ error: { - message: '未授权访问,请先登录', + message: 'Unauthorized access, please login first', code: 'UNAUTHORIZED' } })); @@ -496,11 +543,11 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo uploadMiddleware(req, res, async (err) => { if (err) { - console.error('文件上传错误:', err.message); + console.error('[UI API] File upload error:', err.message); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: err.message || '文件上传失败' + message: err.message || 'File upload failed' } })); return; @@ -511,7 +558,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: '没有文件被上传' + message: 'No file was uploaded' } })); return; @@ -548,23 +595,23 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo timestamp: new Date().toISOString() }); - console.log(`[UI API] OAuth凭据文件已上传: ${targetFilePath} (提供商: ${provider})`); + console.log(`[UI API] OAuth credentials file uploaded: ${targetFilePath} (provider: ${provider})`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, - message: '文件上传成功', + message: 'File uploaded successfully', filePath: relativePath, originalName: req.file.originalname, provider: provider })); } catch (error) { - console.error('文件上传处理错误:', error); + console.error('[UI API] File upload processing error:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: '文件上传处理失败: ' + error.message + message: 'File upload processing failed: ' + error.message } })); } @@ -582,7 +629,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: '密码不能为空' + message: 'Password cannot be empty' } })); return true; @@ -597,7 +644,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, - message: '后台登录密码已更新' + message: 'Admin password updated successfully' })); return true; } catch (error) { @@ -605,7 +652,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: '更新密码失败: ' + error.message + message: 'Failed to update password: ' + error.message } })); return true; @@ -794,12 +841,16 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo console.warn('[UI API] Failed to read VERSION file:', error.message); } + // 计算 CPU 使用率 + const cpuUsage = getCpuUsagePercent(); + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ appVersion: appVersion, nodeVersion: process.version, serverTime: new Date().toLocaleString(), memoryUsage: `${Math.round(memUsage.heapUsed / 1024 / 1024)} MB / ${Math.round(memUsage.heapTotal / 1024 / 1024)} MB`, + cpuUsage: cpuUsage, uptime: process.uptime() })); return true; @@ -1257,7 +1308,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, - message: `成功重置 ${resetCount} 个节点的健康状态`, + message: `Successfully reset health status for ${resetCount} providers`, resetCount, totalCount: providers.length })); @@ -1303,7 +1354,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo results.push({ uuid: providerConfig.uuid, success: null, - message: '健康检测不支持此提供商类型' + message: 'Health check not supported for this provider type' }); continue; } @@ -1314,7 +1365,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo uuid: providerConfig.uuid, success: true, modelName: healthResult.modelName, - message: '健康' + message: 'Healthy' }); } else { providerPoolManager.markProviderUnhealthy(providerType, providerConfig, healthResult.errorMessage); @@ -1326,7 +1377,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo uuid: providerConfig.uuid, success: false, modelName: healthResult.modelName, - message: healthResult.errorMessage || '检测失败' + message: healthResult.errorMessage || 'Check failed' }); } } catch (error) { @@ -1366,7 +1417,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, - message: `健康检测完成: ${successCount} 个健康, ${failCount} 个异常`, + message: `Health check completed: ${successCount} healthy, ${failCount} unhealthy`, successCount, failCount, totalCount: providers.length, @@ -1421,7 +1472,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: `不支持的提供商类型: ${providerType}` + message: `Unsupported provider type: ${providerType}` } })); return true; @@ -1440,7 +1491,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: `生成授权链接失败: ${error.message}` + message: `Failed to generate auth URL: ${error.message}` } })); return true; @@ -1512,7 +1563,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(403, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: '访问被拒绝:只能查看configs目录下的文件' + message: 'Access denied: can only view files in configs directory' } })); return true; @@ -1522,7 +1573,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: '文件不存在' + message: 'File does not exist' } })); return true; @@ -1568,7 +1619,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(403, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: '访问被拒绝:只能删除configs目录下的文件' + message: 'Access denied: can only delete files in configs directory' } })); return true; @@ -1578,7 +1629,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: '文件不存在' + message: 'File does not exist' } })); return true; @@ -1597,7 +1648,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, - message: '文件删除成功', + message: 'File deleted successfully', filePath: relativePath })); return true; @@ -1619,7 +1670,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo const configsPath = path.join(process.cwd(), 'configs'); if (!existsSync(configsPath)) { res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'configs目录不存在' } })); + res.end(JSON.stringify({ error: { message: 'configs directory does not exist' } })); return true; } @@ -1660,7 +1711,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: '打包下载失败: ' + error.message + message: 'Failed to download zip: ' + error.message } })); return true; @@ -1688,7 +1739,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: '无法识别配置文件对应的提供商类型,请确保文件位于 configs/kiro/、configs/gemini/、configs/qwen/ 或 configs/antigravity/ 目录下' + message: 'Unable to identify provider type for config file, please ensure file is in configs/kiro/, configs/gemini/, configs/qwen/ or configs/antigravity/ directory' } })); return true; @@ -1726,7 +1777,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo if (isAlreadyLinked) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: '该配置文件已关联' } })); + res.end(JSON.stringify({ error: { message: 'This config file is already linked' } })); return true; } @@ -1769,7 +1820,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, - message: `配置已成功关联到 ${displayName}`, + message: `Config successfully linked to ${displayName}`, provider: newProvider, providerType: providerType })); @@ -1779,7 +1830,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: '关联失败: ' + error.message + message: 'Link failed: ' + error.message } })); return true; @@ -1820,7 +1871,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: '获取用量信息失败: ' + error.message + message: 'Failed to get usage info: ' + error.message } })); return true; @@ -1839,7 +1890,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo let usageResults; if (!refresh) { - // 优先读取缓存 + // Prefer reading from cache const cachedData = await readProviderUsageCache(providerType); if (cachedData) { console.log(`[Usage API] Returning cached usage data for ${providerType}`); @@ -1848,7 +1899,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo } if (!usageResults) { - // 缓存不存在或需要刷新,重新查询 + // Cache does not exist or refresh required, re-query console.log(`[Usage API] Fetching fresh usage data for ${providerType}`); usageResults = await getProviderTypeUsage(providerType, currentConfig, providerPoolManager); // 更新缓存 @@ -1863,7 +1914,45 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: `获取 ${providerType} 用量信息失败: ` + error.message + message: `Failed to get usage info for ${providerType}: ` + error.message + } + })); + return true; + } + } + + // Check for updates - compare local VERSION with latest git tag + if (method === 'GET' && pathParam === '/api/check-update') { + try { + const updateInfo = await checkForUpdates(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(updateInfo)); + return true; + } catch (error) { + console.error('[UI API] Failed to check for updates:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to check for updates: ' + error.message + } + })); + return true; + } + } + + // Perform update - git fetch and checkout to latest tag + if (method === 'POST' && pathParam === '/api/update') { + try { + const updateResult = await performUpdate(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(updateResult)); + return true; + } catch (error) { + console.error('[UI API] Failed to perform update:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Update failed: ' + error.message } })); return true; @@ -1887,7 +1976,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, - message: '配置文件重新加载成功', + message: 'Configuration files reloaded successfully', details: { configReloaded: true, configPath: 'configs/config.json', @@ -1900,13 +1989,86 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: '重新加载配置文件失败: ' + error.message + message: 'Failed to reload configuration files: ' + error.message } })); return true; } } + // Restart service (worker process) + // 重启服务端点 - 支持主进程-子进程架构 + if (method === 'POST' && pathParam === '/api/restart-service') { + try { + const IS_WORKER_PROCESS = process.env.IS_WORKER_PROCESS === 'true'; + + if (IS_WORKER_PROCESS && process.send) { + // 作为子进程运行,通知主进程重启 + console.log('[UI API] Requesting restart from master process...'); + process.send({ type: 'restart_request' }); + + // 广播重启事件 + broadcastEvent('service_restart', { + action: 'restart_requested', + timestamp: new Date().toISOString(), + message: 'Service restart requested, worker will be restarted by master process' + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'Restart request sent to master process', + mode: 'worker', + details: { + workerPid: process.pid, + restartMethod: 'master_controlled' + } + })); + } else { + // 独立运行模式,无法自动重启 + console.log('[UI API] Service is running in standalone mode, cannot auto-restart'); + + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + message: 'Service is running in standalone mode. Please use master.js to enable auto-restart feature.', + mode: 'standalone', + hint: 'Start the service with: node src/master.js [args]' + })); + } + return true; + } catch (error) { + console.error('[UI API] Failed to restart service:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to restart service: ' + error.message + } + })); + return true; + } + } + + // Get service mode information + // 获取服务运行模式信息 + if (method === 'GET' && pathParam === '/api/service-mode') { + const IS_WORKER_PROCESS = process.env.IS_WORKER_PROCESS === 'true'; + const masterPort = process.env.MASTER_PORT || 3100; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + mode: IS_WORKER_PROCESS ? 'worker' : 'standalone', + pid: process.pid, + ppid: process.ppid, + uptime: process.uptime(), + canAutoRestart: IS_WORKER_PROCESS && !!process.send, + masterPort: IS_WORKER_PROCESS ? masterPort : null, + nodeVersion: process.version, + platform: process.platform + })); + return true; + } + return false; } @@ -2109,7 +2271,7 @@ async function analyzeOAuthFile(filePath, usedPaths, currentConfig) { } } catch (readError) { isValid = false; - errorMessage = `无法读取文件: ${readError.message}`; + errorMessage = `Unable to read file: ${readError.message}`; } return { @@ -2161,8 +2323,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { pathsEqual(relativePath, currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { usageInfo.usageType = 'main_config'; usageInfo.usageDetails.push({ - type: '主要配置', - location: 'Gemini OAuth凭据文件路径', + type: 'Main Config', + location: 'Gemini OAuth credentials file path', configKey: 'GEMINI_OAUTH_CREDS_FILE_PATH' }); } @@ -2172,8 +2334,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { pathsEqual(relativePath, currentConfig.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { usageInfo.usageType = 'main_config'; usageInfo.usageDetails.push({ - type: '主要配置', - location: 'Kiro OAuth凭据文件路径', + type: 'Main Config', + location: 'Kiro OAuth credentials file path', configKey: 'KIRO_OAUTH_CREDS_FILE_PATH' }); } @@ -2183,8 +2345,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { pathsEqual(relativePath, currentConfig.QWEN_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { usageInfo.usageType = 'main_config'; usageInfo.usageDetails.push({ - type: '主要配置', - location: 'Qwen OAuth凭据文件路径', + type: 'Main Config', + location: 'Qwen OAuth credentials file path', configKey: 'QWEN_OAUTH_CREDS_FILE_PATH' }); } @@ -2204,8 +2366,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { (pathsEqual(relativePath, provider.GEMINI_OAUTH_CREDS_FILE_PATH) || pathsEqual(relativePath, provider.GEMINI_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { providerUsages.push({ - type: '提供商池', - location: `Gemini OAuth凭据 (节点${index + 1})`, + type: 'Provider Pool', + location: `Gemini OAuth credentials (node ${index + 1})`, providerType: providerType, providerIndex: index, configKey: 'GEMINI_OAUTH_CREDS_FILE_PATH' @@ -2216,8 +2378,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { (pathsEqual(relativePath, provider.KIRO_OAUTH_CREDS_FILE_PATH) || pathsEqual(relativePath, provider.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { providerUsages.push({ - type: '提供商池', - location: `Kiro OAuth凭据 (节点${index + 1})`, + type: 'Provider Pool', + location: `Kiro OAuth credentials (node ${index + 1})`, providerType: providerType, providerIndex: index, configKey: 'KIRO_OAUTH_CREDS_FILE_PATH' @@ -2228,8 +2390,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { (pathsEqual(relativePath, provider.QWEN_OAUTH_CREDS_FILE_PATH) || pathsEqual(relativePath, provider.QWEN_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { providerUsages.push({ - type: '提供商池', - location: `Qwen OAuth凭据 (节点${index + 1})`, + type: 'Provider Pool', + location: `Qwen OAuth credentials (node ${index + 1})`, providerType: providerType, providerIndex: index, configKey: 'QWEN_OAUTH_CREDS_FILE_PATH' @@ -2240,8 +2402,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { (pathsEqual(relativePath, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH) || pathsEqual(relativePath, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { providerUsages.push({ - type: '提供商池', - location: `Antigravity OAuth凭据 (节点${index + 1})`, + type: 'Provider Pool', + location: `Antigravity OAuth credentials (node ${index + 1})`, providerType: providerType, providerIndex: index, configKey: 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH' @@ -2393,15 +2555,15 @@ async function getProviderTypeUsage(providerType, currentConfig, providerPoolMan error: null }; - // 首先检查是否已禁用,已禁用的提供商跳过初始化 + // First check if disabled, skip initialization for disabled providers if (provider.isDisabled) { - instanceResult.error = '提供商已禁用'; + instanceResult.error = 'Provider is disabled'; result.errorCount++; } else if (!adapter) { - // 服务实例未初始化,尝试自动初始化 + // Service instance not initialized, try auto-initialization try { console.log(`[Usage API] Auto-initializing service adapter for ${providerType}: ${provider.uuid}`); - // 构建配置对象 + // Build configuration object const serviceConfig = { ...CONFIG, ...provider, @@ -2410,12 +2572,12 @@ async function getProviderTypeUsage(providerType, currentConfig, providerPoolMan adapter = getServiceAdapter(serviceConfig); } catch (initError) { console.error(`[Usage API] Failed to initialize adapter for ${providerType}: ${provider.uuid}:`, initError.message); - instanceResult.error = `服务实例初始化失败: ${initError.message}`; + instanceResult.error = `Service instance initialization failed: ${initError.message}`; result.errorCount++; } } - // 如果适配器存在(包括刚初始化的),且没有错误,尝试获取用量 + // If adapter exists (including just initialized), and no error, try to get usage if (adapter && !instanceResult.error) { try { const usage = await getAdapterUsage(adapter, providerType); @@ -2449,7 +2611,7 @@ async function getAdapterUsage(adapter, providerType) { const rawUsage = await adapter.kiroApiService.getUsageLimits(); return formatKiroUsage(rawUsage); } - throw new Error('该适配器不支持用量查询'); + throw new Error('This adapter does not support usage query'); } if (providerType === 'gemini-cli-oauth') { @@ -2460,7 +2622,7 @@ async function getAdapterUsage(adapter, providerType) { const rawUsage = await adapter.geminiApiService.getUsageLimits(); return formatGeminiUsage(rawUsage); } - throw new Error('该适配器不支持用量查询'); + throw new Error('This adapter does not support usage query'); } if (providerType === 'gemini-antigravity') { @@ -2471,10 +2633,10 @@ async function getAdapterUsage(adapter, providerType) { const rawUsage = await adapter.antigravityApiService.getUsageLimits(); return formatAntigravityUsage(rawUsage); } - throw new Error('该适配器不支持用量查询'); + throw new Error('This adapter does not support usage query'); } - throw new Error(`不支持的提供商类型: ${providerType}`); + throw new Error(`Unsupported provider type: ${providerType}`); } /** @@ -2499,5 +2661,218 @@ function getProviderDisplayName(provider, providerType) { return `${dirName}/${fileName}`; } - return provider.uuid || '未命名'; + return provider.uuid || 'Unnamed'; +} + +/** + * 比较版本号 + * @param {string} v1 - 版本号1 + * @param {string} v2 - 版本号2 + * @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if equal + */ +function compareVersions(v1, v2) { + // 移除 'v' 前缀(如果有) + const clean1 = v1.replace(/^v/, ''); + const clean2 = v2.replace(/^v/, ''); + + const parts1 = clean1.split('.').map(Number); + const parts2 = clean2.split('.').map(Number); + + const maxLen = Math.max(parts1.length, parts2.length); + + for (let i = 0; i < maxLen; i++) { + const num1 = parts1[i] || 0; + const num2 = parts2[i] || 0; + + if (num1 > num2) return 1; + if (num1 < num2) return -1; + } + + return 0; +} + +/** + * 检查是否有新版本可用 + * @returns {Promise} 更新信息 + */ +async function checkForUpdates() { + const versionFilePath = path.join(process.cwd(), 'VERSION'); + + // 读取本地版本 + let localVersion = 'unknown'; + try { + if (existsSync(versionFilePath)) { + localVersion = readFileSync(versionFilePath, 'utf8').trim(); + } + } catch (error) { + console.warn('[Update] Failed to read local VERSION file:', error.message); + } + + // 检查是否在 git 仓库中 + try { + await execAsync('git rev-parse --git-dir'); + } catch (error) { + return { + hasUpdate: false, + localVersion, + latestVersion: null, + error: 'Current directory is not a Git repository, cannot check for updates' + }; + } + + // 获取远程 tags + try { + console.log('[Update] Fetching remote tags...'); + await execAsync('git fetch --tags'); + } catch (error) { + console.warn('[Update] Failed to fetch tags:', error.message); + return { + hasUpdate: false, + localVersion, + latestVersion: null, + error: 'Unable to fetch remote tags: ' + error.message + }; + } + + // 获取最新的 tag(根据操作系统选择合适的命令) + let latestTag = null; + const isWindows = process.platform === 'win32'; + + try { + if (isWindows) { + // Windows: 使用 git for-each-ref,这是跨平台兼容的方式 + const { stdout } = await execAsync('git for-each-ref --sort=-v:refname --format="%(refname:short)" refs/tags --count=1'); + latestTag = stdout.trim(); + } else { + // Linux/macOS: 使用 head 命令,更高效 + const { stdout } = await execAsync('git tag --sort=-v:refname | head -n 1'); + latestTag = stdout.trim(); + } + } catch (error) { + // 备用方案:获取所有 tags 并在 JavaScript 中排序 + try { + const { stdout } = await execAsync('git tag'); + const tags = stdout.trim().split('\n').filter(t => t); + if (tags.length > 0) { + // 按版本号排序(降序) + tags.sort((a, b) => compareVersions(b, a)); + latestTag = tags[0]; + } + } catch (e) { + console.warn('[Update] Failed to get latest tag:', e.message); + return { + hasUpdate: false, + localVersion, + latestVersion: null, + error: 'Unable to get latest version tag' + }; + } + } + + if (!latestTag) { + return { + hasUpdate: false, + localVersion, + latestVersion: null, + error: 'No version tags found' + }; + } + + // 比较版本 + const comparison = compareVersions(latestTag, localVersion); + const hasUpdate = comparison > 0; + + console.log(`[Update] Local version: ${localVersion}, Latest tag: ${latestTag}, Has update: ${hasUpdate}`); + + return { + hasUpdate, + localVersion, + latestVersion: latestTag, + error: null + }; +} + +/** + * 执行更新操作 + * @returns {Promise} 更新结果 + */ +async function performUpdate() { + // 首先检查是否有更新 + const updateInfo = await checkForUpdates(); + + if (updateInfo.error) { + throw new Error(updateInfo.error); + } + + if (!updateInfo.hasUpdate) { + return { + success: true, + message: 'Already at the latest version', + localVersion: updateInfo.localVersion, + latestVersion: updateInfo.latestVersion, + updated: false + }; + } + + const latestTag = updateInfo.latestVersion; + + console.log(`[Update] Starting update to ${latestTag}...`); + + // 检查是否有未提交的更改 + try { + const { stdout: statusOutput } = await execAsync('git status --porcelain'); + if (statusOutput.trim()) { + // 有未提交的更改,先 stash + console.log('[Update] Stashing local changes...'); + await execAsync('git stash'); + } + } catch (error) { + console.warn('[Update] Failed to check git status:', error.message); + } + + // 执行 checkout 到最新 tag + try { + console.log(`[Update] Checking out to ${latestTag}...`); + await execAsync(`git checkout ${latestTag}`); + } catch (error) { + console.error('[Update] Failed to checkout:', error.message); + throw new Error('Failed to switch to new version: ' + error.message); + } + + // 更新 VERSION 文件(如果 tag 和 VERSION 文件不同步) + const versionFilePath = path.join(process.cwd(), 'VERSION'); + try { + const newVersion = latestTag.replace(/^v/, ''); + writeFileSync(versionFilePath, newVersion, 'utf8'); + console.log(`[Update] VERSION file updated to ${newVersion}`); + } catch (error) { + console.warn('[Update] Failed to update VERSION file:', error.message); + } + + // 检查是否需要安装依赖 + let needsRestart = false; + try { + // 确保本地版本号有 v 前缀,以匹配 git tag 格式 + const localVersionTag = updateInfo.localVersion.startsWith('v') ? updateInfo.localVersion : `v${updateInfo.localVersion}`; + const { stdout: diffOutput } = await execAsync(`git diff ${localVersionTag}..${latestTag} --name-only`); + if (diffOutput.includes('package.json') || diffOutput.includes('package-lock.json')) { + console.log('[Update] package.json changed, running npm install...'); + await execAsync('npm install'); + needsRestart = true; + } + } catch (error) { + console.warn('[Update] Failed to check package changes:', error.message); + } + + console.log(`[Update] Update completed successfully to ${latestTag}`); + + return { + success: true, + message: `Successfully updated to version ${latestTag}`, + localVersion: updateInfo.localVersion, + latestVersion: latestTag, + updated: true, + needsRestart: needsRestart, + restartMessage: needsRestart ? 'Dependencies updated, recommend restarting service to apply changes' : null + }; } diff --git a/static/app/constants.js b/static/app/constants.js index 6c8883c..73a9733 100644 --- a/static/app/constants.js +++ b/static/app/constants.js @@ -17,7 +17,7 @@ let providerStats = { // DOM元素 const elements = { serverStatus: document.getElementById('serverStatus'), - refreshBtn: document.getElementById('refreshBtn'), + restartBtn: document.getElementById('restartBtn'), sections: document.querySelectorAll('.section'), navItems: document.querySelectorAll('.nav-item'), logsContainer: document.getElementById('logsContainer'), diff --git a/static/app/event-handlers.js b/static/app/event-handlers.js index 94ca3f9..1d572b8 100644 --- a/static/app/event-handlers.js +++ b/static/app/event-handlers.js @@ -4,14 +4,15 @@ import { elements, autoScroll, setAutoScroll, clearLogs } from './constants.js'; import { showToast } from './utils.js'; import { fileUploadHandler } from './file-upload.js'; import { t } from './i18n.js'; +import { checkUpdate, performUpdate } from './provider-manager.js'; /** * 初始化所有事件监听器 */ function initEventListeners() { - // 刷新按钮 - if (elements.refreshBtn) { - elements.refreshBtn.addEventListener('click', handleRefresh); + // 重启按钮 + if (elements.restartBtn) { + elements.restartBtn.addEventListener('click', handleRestart); } // 清空日志 @@ -80,6 +81,18 @@ function initEventListeners() { // providerPoolsInput.addEventListener('input', handleProviderPoolsConfigChange); // } + // 检查更新按钮 + const checkUpdateBtn = document.getElementById('checkUpdateBtn'); + if (checkUpdateBtn) { + checkUpdateBtn.addEventListener('click', () => checkUpdate(false)); + } + + // 执行更新按钮 + const performUpdateBtn = document.getElementById('performUpdateBtn'); + if (performUpdateBtn) { + performUpdateBtn.addEventListener('click', performUpdate); + } + // 日志容器滚动 if (elements.logsContainer) { elements.logsContainer.addEventListener('scroll', () => { @@ -326,21 +339,100 @@ let loadInitialData; let saveConfiguration; let reloadConfig; -// 刷新处理函数 -async function handleRefresh() { +// 当前服务模式(由 provider-manager.js 设置) +let currentServiceMode = 'worker'; + +/** + * 设置当前服务模式 + * @param {string} mode - 服务模式 ('worker' 或 'standalone') + */ +export function setServiceMode(mode) { + currentServiceMode = mode; +} + +/** + * 获取当前服务模式 + * @returns {string} 当前服务模式 + */ +export function getServiceMode() { + return currentServiceMode; +} + +// 重启/重载服务处理函数 +async function handleRestart() { try { - // 先刷新基础数据 - if (loadInitialData) { - loadInitialData(); - } - - // 如果reloadConfig函数可用,则也刷新配置 - if (reloadConfig) { - await reloadConfig(); + // 根据服务模式执行不同操作 + if (currentServiceMode === 'standalone') { + // 独立模式:执行重载配置 + await handleReloadConfig(); + } else { + // 子进程模式:执行重启服务 + await handleRestartService(); } } catch (error) { - console.error('刷新失败:', error); - showToast(t('common.error'), t('common.refresh.failed') + ': ' + error.message, 'error'); + console.error('Operation failed:', error); + const errorKey = currentServiceMode === 'standalone' ? 'header.reload.failed' : 'header.restart.failed'; + showToast(t('common.error'), t(errorKey) + ': ' + error.message, 'error'); + } +} + +/** + * 重载配置(独立模式) + */ +async function handleReloadConfig() { + // 确认重载操作 + if (!confirm(t('header.reload.confirm'))) { + return; + } + + showToast(t('common.info'), t('header.reload.requesting'), 'info'); + + // 先刷新基础数据 + if (loadInitialData) { + loadInitialData(); + } + + // 如果reloadConfig函数可用,则也刷新配置 + if (reloadConfig) { + await reloadConfig(); + } +} + +/** + * 重启服务(子进程模式) + */ +async function handleRestartService() { + // 确认重启操作 + if (!confirm(t('header.restart.confirm'))) { + return; + } + + showToast(t('common.info'), t('header.restart.requesting'), 'info'); + + const result = await window.apiClient.post('/restart-service'); + + if (result.success) { + showToast(t('common.success'), result.message || t('header.restart.success'), 'success'); + + // 如果是 worker 模式,服务会自动重启,等待几秒后刷新页面 + if (result.mode === 'worker') { + setTimeout(() => { + showToast(t('common.info'), t('header.restart.reconnecting'), 'info'); + // 等待服务重启后刷新页面 + setTimeout(() => { + window.location.reload(); + }, 3000); + }, 2000); + } + } else { + // 显示错误信息 + const errorMsg = result.message || result.error?.message || t('header.restart.failed'); + showToast(t('common.error'), errorMsg, 'error'); + + // 如果是独立模式,显示提示 + if (result.mode === 'standalone') { + showToast(t('common.info'), result.hint, 'warning'); + } } } diff --git a/static/app/i18n.js b/static/app/i18n.js index db09b79..e7e2793 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -7,7 +7,18 @@ const translations = { 'header.status.connected': '已连接', 'header.status.disconnected': '连接断开', 'header.logout': '登出', + 'header.reload': '重载', + 'header.reload.confirm': '确定要重载配置吗?这将重新加载所有配置文件。', + 'header.reload.requesting': '正在重载配置...', + 'header.reload.success': '配置重载成功', + 'header.reload.failed': '配置重载失败', 'header.refresh': '重载', + 'header.restart': '重启', + 'header.restart.confirm': '确定要重启服务吗?服务将短暂中断。', + 'header.restart.requesting': '正在请求重启服务...', + 'header.restart.success': '重启请求已发送,服务即将重启', + 'header.restart.reconnecting': '正在重新连接...', + 'header.restart.failed': '重启服务失败', // Navigation 'nav.main': '主导航', @@ -23,9 +34,31 @@ const translations = { 'dashboard.uptime': '运行时间', 'dashboard.systemInfo': '系统信息', 'dashboard.version': '版本号', + 'dashboard.update.check': '检查更新', + 'dashboard.update.checkTitle': '检查是否有新版本可用', + 'dashboard.update.perform': '立即更新', + 'dashboard.update.performTitle': '更新到最新版本', + 'dashboard.update.checking': '正在检查...', + 'dashboard.update.upToDate': '已是最新', + 'dashboard.update.hasUpdate': '发现新版本: {version}', + 'dashboard.update.updating': '正在更新...', + 'dashboard.update.success': '更新成功', + 'dashboard.update.needsRestart': '代码已更新,请点击右上角「重启」按钮使更改生效', + 'dashboard.update.restartTitle': '更新完成', + 'dashboard.update.restartMsg': '代码已更新到版本 {version},请点击页面右上角的「重启」按钮使新代码生效。', + 'dashboard.update.failed': '更新失败: {error}', + 'dashboard.update.confirmTitle': '确认更新', + 'dashboard.update.confirmMsg': '确定要更新到版本 {version} 吗?更新期间服务可能会短暂不可用。', 'dashboard.nodeVersion': 'Node.js版本', 'dashboard.serverTime': '服务器时间', 'dashboard.memoryUsage': '内存使用', + 'dashboard.cpuUsage': 'CPU 使用', + 'dashboard.serviceMode': '运行模式', + 'dashboard.serviceMode.worker': '子进程模式', + 'dashboard.serviceMode.standalone': '独立模式', + 'dashboard.serviceMode.canRestart': '支持自动重启', + 'dashboard.processPid': '进程 PID', + 'dashboard.platform': '操作系统', 'dashboard.routing.title': '路径路由调用示例', 'dashboard.routing.description': '通过不同路径路由访问不同的AI模型提供商,支持灵活的模型切换', 'dashboard.routing.oauth': '突破限制', @@ -379,7 +412,18 @@ const translations = { 'header.status.connected': 'Connected', 'header.status.disconnected': 'Disconnected', 'header.logout': 'Logout', + 'header.reload': 'Reload', + 'header.reload.confirm': 'Are you sure you want to reload the configuration? This will reload all configuration files.', + 'header.reload.requesting': 'Reloading configuration...', + 'header.reload.success': 'Configuration reloaded successfully', + 'header.reload.failed': 'Failed to reload configuration', 'header.refresh': 'Reload', + 'header.restart': 'Restart', + 'header.restart.confirm': 'Are you sure you want to restart the service? The service will be briefly interrupted.', + 'header.restart.requesting': 'Requesting service restart...', + 'header.restart.success': 'Restart request sent, service will restart shortly', + 'header.restart.reconnecting': 'Reconnecting...', + 'header.restart.failed': 'Failed to restart service', // Navigation 'nav.main': 'Main Navigation', @@ -395,9 +439,31 @@ const translations = { 'dashboard.uptime': 'Uptime', 'dashboard.systemInfo': 'System Information', 'dashboard.version': 'Version', + 'dashboard.update.check': 'Check Update', + 'dashboard.update.checkTitle': 'Check for new version', + 'dashboard.update.perform': 'Update Now', + 'dashboard.update.performTitle': 'Update to latest version', + 'dashboard.update.checking': 'Checking...', + 'dashboard.update.upToDate': 'Up to date', + 'dashboard.update.hasUpdate': 'New version available: {version}', + 'dashboard.update.updating': 'Updating...', + 'dashboard.update.success': 'Update successful', + 'dashboard.update.needsRestart': 'Code updated, please click the "Restart" button in the top right corner for changes to take effect', + 'dashboard.update.restartTitle': 'Update Complete', + 'dashboard.update.restartMsg': 'Code has been updated to version {version}. Please click the "Restart" button in the top right corner for the new code to take effect.', + 'dashboard.update.failed': 'Update failed: {error}', + 'dashboard.update.confirmTitle': 'Confirm Update', + 'dashboard.update.confirmMsg': 'Are you sure you want to update to version {version}? Service might be briefly unavailable during update.', 'dashboard.nodeVersion': 'Node.js Version', 'dashboard.serverTime': 'Server Time', 'dashboard.memoryUsage': 'Memory Usage', + 'dashboard.cpuUsage': 'CPU Usage', + 'dashboard.serviceMode': 'Service Mode', + 'dashboard.serviceMode.worker': 'Worker Mode', + 'dashboard.serviceMode.standalone': 'Standalone Mode', + 'dashboard.serviceMode.canRestart': 'Auto-restart supported', + 'dashboard.processPid': 'Process PID', + 'dashboard.platform': 'Platform', 'dashboard.routing.title': 'Path Routing Examples', 'dashboard.routing.description': 'Access different AI model providers through different path routes, supporting flexible model switching', 'dashboard.routing.oauth': 'Limit Breakthrough', diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index c9b5167..bcf069e 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -5,6 +5,7 @@ import { showToast, formatUptime } from './utils.js'; import { fileUploadHandler } from './file-upload.js'; import { t, getCurrentLanguage } from './i18n.js'; import { loadConfigList } from './upload-config-manager.js'; +import { setServiceMode } from './event-handlers.js'; // 保存初始服务器时间和运行时间 let initialServerTime = null; @@ -22,11 +23,19 @@ async function loadSystemInfo() { const nodeVersionEl = document.getElementById('nodeVersion'); const serverTimeEl = document.getElementById('serverTime'); const memoryUsageEl = document.getElementById('memoryUsage'); + const cpuUsageEl = document.getElementById('cpuUsage'); const uptimeEl = document.getElementById('uptime'); if (appVersionEl) appVersionEl.textContent = data.appVersion ? `v${data.appVersion}` : '--'; + + // 自动检查更新 + if (data.appVersion) { + checkUpdate(true); + } + if (nodeVersionEl) nodeVersionEl.textContent = data.nodeVersion || '--'; if (memoryUsageEl) memoryUsageEl.textContent = data.memoryUsage || '--'; + if (cpuUsageEl) cpuUsageEl.textContent = data.cpuUsage || '--'; // 保存初始时间用于本地计算 if (data.serverTime && data.uptime !== undefined) { @@ -39,11 +48,99 @@ async function loadSystemInfo() { if (serverTimeEl) serverTimeEl.textContent = data.serverTime || '--'; if (uptimeEl) uptimeEl.textContent = data.uptime ? formatUptime(data.uptime) : '--'; + // 加载服务模式信息 + await loadServiceModeInfo(); + } catch (error) { console.error('Failed to load system info:', error); } } +/** + * 加载服务运行模式信息 + */ +async function loadServiceModeInfo() { + try { + const data = await window.apiClient.get('/service-mode'); + + const serviceModeEl = document.getElementById('serviceMode'); + const processPidEl = document.getElementById('processPid'); + const platformInfoEl = document.getElementById('platformInfo'); + + // 更新服务模式到 event-handlers + setServiceMode(data.mode || 'worker'); + + // 更新重启/重载按钮显示 + updateRestartButton(data.mode); + + if (serviceModeEl) { + const modeText = data.mode === 'worker' + ? t('dashboard.serviceMode.worker') + : t('dashboard.serviceMode.standalone'); + const canRestartIcon = data.canAutoRestart + ? '' + : ''; + serviceModeEl.innerHTML = modeText; + } + + if (processPidEl) { + processPidEl.textContent = data.pid || '--'; + } + + if (platformInfoEl) { + // 格式化平台信息 + const platformMap = { + 'win32': 'Windows', + 'darwin': 'macOS', + 'linux': 'Linux', + 'freebsd': 'FreeBSD' + }; + platformInfoEl.textContent = platformMap[data.platform] || data.platform || '--'; + } + + } catch (error) { + console.error('Failed to load service mode info:', error); + } +} + +/** + * 根据服务模式更新重启/重载按钮显示 + * @param {string} mode - 服务模式 ('worker' 或 'standalone') + */ +function updateRestartButton(mode) { + const restartBtn = document.getElementById('restartBtn'); + const restartBtnIcon = document.getElementById('restartBtnIcon'); + const restartBtnText = document.getElementById('restartBtnText'); + + if (!restartBtn) return; + + if (mode === 'standalone') { + // 独立模式:显示"重载"按钮 + if (restartBtnIcon) { + restartBtnIcon.className = 'fas fa-sync-alt'; + } + if (restartBtnText) { + restartBtnText.textContent = t('header.reload'); + restartBtnText.setAttribute('data-i18n', 'header.reload'); + } + restartBtn.setAttribute('aria-label', t('header.reload')); + restartBtn.setAttribute('data-i18n-aria-label', 'header.reload'); + restartBtn.title = t('header.reload'); + } else { + // 子进程模式:显示"重启"按钮 + if (restartBtnIcon) { + restartBtnIcon.className = 'fas fa-redo'; + } + if (restartBtnText) { + restartBtnText.textContent = t('header.restart'); + restartBtnText.setAttribute('data-i18n', 'header.restart'); + } + restartBtn.setAttribute('aria-label', t('header.restart')); + restartBtn.setAttribute('data-i18n-aria-label', 'header.restart'); + restartBtn.title = t('header.restart'); + } +} + /** * 更新服务器时间和运行时间显示(本地计算) */ @@ -754,6 +851,201 @@ function showAuthModal(authUrl, authInfo) { } +/** + * 显示需要重启的提示模态框 + * @param {string} version - 更新到的版本号 + */ +function showRestartRequiredModal(version) { + const modal = document.createElement('div'); + modal.className = 'modal-overlay restart-required-modal'; + modal.style.display = 'flex'; + + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + + // 关闭按钮事件 + const closeBtn = modal.querySelector('.modal-close'); + const confirmBtn = modal.querySelector('.restart-confirm-btn'); + + const closeModal = () => { + modal.remove(); + }; + + closeBtn.addEventListener('click', closeModal); + confirmBtn.addEventListener('click', closeModal); + + // 点击遮罩层关闭 + modal.addEventListener('click', (e) => { + if (e.target === modal) { + closeModal(); + } + }); +} + +/** + * 检查更新 + * @param {boolean} silent - 是否静默检查(不显示 Toast) + */ +async function checkUpdate(silent = false) { + const checkBtn = document.getElementById('checkUpdateBtn'); + const updateBtn = document.getElementById('performUpdateBtn'); + const updateBadge = document.getElementById('updateBadge'); + const latestVersionText = document.getElementById('latestVersionText'); + const checkBtnIcon = checkBtn?.querySelector('i'); + const checkBtnText = checkBtn?.querySelector('span'); + + try { + if (!silent && checkBtn) { + checkBtn.disabled = true; + if (checkBtnIcon) checkBtnIcon.className = 'fas fa-spinner fa-spin'; + if (checkBtnText) checkBtnText.textContent = t('dashboard.update.checking'); + } + + const data = await window.apiClient.get('/check-update'); + + if (data.hasUpdate) { + if (updateBtn) updateBtn.style.display = 'inline-flex'; + if (updateBadge) updateBadge.style.display = 'inline-flex'; + if (latestVersionText) latestVersionText.textContent = data.latestVersion; + + if (!silent) { + showToast(t('common.info'), t('dashboard.update.hasUpdate', { version: data.latestVersion }), 'info'); + } + } else { + if (updateBtn) updateBtn.style.display = 'none'; + if (updateBadge) updateBadge.style.display = 'none'; + if (!silent) { + showToast(t('common.info'), t('dashboard.update.upToDate'), 'success'); + } + } + } catch (error) { + console.error('Check update failed:', error); + if (!silent) { + showToast(t('common.error'), t('dashboard.update.failed', { error: error.message }), 'error'); + } + } finally { + if (checkBtn) { + checkBtn.disabled = false; + if (checkBtnIcon) checkBtnIcon.className = 'fas fa-sync-alt'; + if (checkBtnText) checkBtnText.textContent = t('dashboard.update.check'); + } + } +} + +/** + * 执行更新 + */ +async function performUpdate() { + const updateBtn = document.getElementById('performUpdateBtn'); + const latestVersionText = document.getElementById('latestVersionText'); + const version = latestVersionText?.textContent || ''; + + if (!confirm(t('dashboard.update.confirmMsg', { version }))) { + return; + } + + const updateBtnIcon = updateBtn?.querySelector('i'); + const updateBtnText = updateBtn?.querySelector('span'); + + try { + if (updateBtn) { + updateBtn.disabled = true; + if (updateBtnIcon) updateBtnIcon.className = 'fas fa-spinner fa-spin'; + if (updateBtnText) updateBtnText.textContent = t('dashboard.update.updating'); + } + + showToast(t('common.info'), t('dashboard.update.updating'), 'info'); + + const data = await window.apiClient.post('/update'); + + if (data.success) { + if (data.updated) { + // 代码已更新,直接调用重启服务 + showToast(t('common.success'), t('dashboard.update.success'), 'success'); + + // 自动重启服务 + await restartServiceAfterUpdate(); + } else { + // 已是最新版本 + showToast(t('common.info'), t('dashboard.update.upToDate'), 'info'); + } + } + } catch (error) { + console.error('Update failed:', error); + showToast(t('common.error'), t('dashboard.update.failed', { error: error.message }), 'error'); + } finally { + if (updateBtn) { + updateBtn.disabled = false; + if (updateBtnIcon) updateBtnIcon.className = 'fas fa-download'; + if (updateBtnText) updateBtnText.textContent = t('dashboard.update.perform'); + } + } +} + +/** + * 更新后自动重启服务 + */ +async function restartServiceAfterUpdate() { + try { + showToast(t('common.info'), t('header.restart.requesting'), 'info'); + + const token = localStorage.getItem('authToken'); + const response = await fetch('/api/restart-service', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': token ? `Bearer ${token}` : '' + } + }); + + const result = await response.json(); + + if (response.ok && result.success) { + showToast(t('common.success'), result.message || t('header.restart.success'), 'success'); + + // 如果是 worker 模式,服务会自动重启,等待几秒后刷新页面 + if (result.mode === 'worker') { + setTimeout(() => { + showToast(t('common.info'), t('header.restart.reconnecting'), 'info'); + // 等待服务重启后刷新页面 + setTimeout(() => { + window.location.reload(); + }, 3000); + }, 2000); + } + } else { + // 显示错误信息 + const errorMsg = result.message || result.error?.message || t('header.restart.failed'); + showToast(t('common.error'), errorMsg, 'error'); + + // 如果是独立模式,显示提示 + if (result.mode === 'standalone') { + showToast(t('common.info'), result.hint, 'warning'); + } + } + } catch (error) { + console.error('Restart after update failed:', error); + showToast(t('common.error'), t('header.restart.failed') + ': ' + error.message, 'error'); + } +} + export { loadSystemInfo, updateTimeDisplay, @@ -762,5 +1054,7 @@ export { updateProviderStatsDisplay, openProviderManager, showAuthModal, - executeGenerateAuthUrl + executeGenerateAuthUrl, + checkUpdate, + performUpdate }; \ No newline at end of file diff --git a/static/app/styles.css b/static/app/styles.css index 66ae319..a6fef34 100644 --- a/static/app/styles.css +++ b/static/app/styles.css @@ -956,8 +956,47 @@ textarea.form-control { .system-info-panel h3 { font-size: 1.25rem; font-weight: 600; - margin-bottom: 1rem; color: var(--text-primary); + margin: 0; +} + +.system-info-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.update-controls { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +.update-badge { + display: inline-flex; + align-items: center; + gap: 4px; + background: #fef3c7; + color: #92400e; + padding: 2px 8px; + border-radius: 9999px; + font-size: 11px; + font-weight: 600; + margin-left: 8px; + border: 1px solid #fde68a; + animation: bounce-in 0.5s ease-out; +} + +@keyframes bounce-in { + 0% { transform: scale(0.8); opacity: 0; } + 70% { transform: scale(1.1); } + 100% { transform: scale(1); opacity: 1; } } .info-grid { @@ -991,7 +1030,14 @@ textarea.form-control { color: var(--text-primary); font-size: 1rem; font-weight: 600; +} + +.version-display-wrapper { + display: flex; + align-items: center; padding-left: 1.5rem; + flex-wrap: wrap; + gap: 0.5rem; } .status-healthy { @@ -4591,3 +4637,132 @@ input:checked + .toggle-slider:before { left: 0; } } + +/* ==================== 重启提示模态框样式 ==================== */ +.restart-required-modal .restart-modal-content { + max-width: 550px; + border: 2px solid var(--primary-color); + box-shadow: 0 25px 80px rgba(5, 150, 105, 0.3); +} + +.restart-required-modal .restart-modal-header { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + border-bottom: none; +} + +.restart-required-modal .restart-modal-header h3 { + color: white; +} + +.restart-required-modal .restart-modal-header h3 i { + color: white; +} + +.restart-required-modal .modal-close { + color: white; +} + +.restart-required-modal .modal-close:hover { + background: rgba(255, 255, 255, 0.2); + color: white; +} + +.restart-icon-container { + text-align: center; + margin-bottom: 1.5rem; +} + +.restart-icon-container i { + font-size: 3rem; + color: var(--primary-color); + animation: spin 2s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.restart-notice { + background: linear-gradient(135deg, #d1fae5 0%, #ecfdf5 100%); + border: 1px solid var(--secondary-color); + border-radius: 0.5rem; + padding: 1rem; + margin-bottom: 1rem; + border-left: 4px solid var(--primary-color); +} + +.restart-notice p { + margin: 0; + color: #065f46; + font-weight: 500; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.restart-notice i { + color: var(--primary-color); +} + +.restart-instructions { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1rem; +} + +.restart-instructions p { + margin: 0; + color: var(--text-primary); + white-space: pre-line; + line-height: 1.6; + font-size: 0.875rem; +} + +.restart-confirm-btn { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: var(--transition); + box-shadow: 0 2px 8px rgba(5, 150, 105, 0.3); + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.restart-confirm-btn:hover { + background: linear-gradient(135deg, #047857 0%, #059669 100%); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(5, 150, 105, 0.4); +} + +.restart-confirm-btn:active { + transform: translateY(0); +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .restart-required-modal .restart-modal-content { + max-width: 95%; + } + + .restart-icon-container i { + font-size: 2.5rem; + } + + .restart-notice, + .restart-instructions { + padding: 0.75rem; + } +} diff --git a/static/index.html b/static/index.html index 1a5b64d..bb2dfdb 100644 --- a/static/index.html +++ b/static/index.html @@ -24,8 +24,8 @@ 登出 - @@ -98,31 +98,84 @@
-

系统信息

+
+

系统信息

+
+ + +
+
版本号 - -- +
+ -- + +
Node.js版本 +
-- +
服务器时间 +
-- +
+
+
+ + 操作系统 + +
+ -- +
内存使用 +
-- +
+
+
+ + CPU 使用 + +
+ -- +
+
+
+ + 运行模式 + +
+ -- +
+
+
+ + 进程 PID + +
+ -- +