Compare commits

...

5 Commits

Author SHA1 Message Date
hkfires
02a01e5afc feat(logs): add request id parsing and refactor row layout 2025-12-24 10:46:33 +08:00
Supra4E8C
561e06503c feat: update README.md README_CN.md 2025-12-22 23:20:31 +08:00
Supra4E8C
94962158ef feat(settings): move request logging toggle behind hidden entry 2025-12-22 22:41:50 +08:00
Supra4E8C
68974ffc68 feat(ai-providers): add prefix editing for provider configs 2025-12-21 23:46:39 +08:00
Supra4E8C
f8ed787f92 fix(splash): prevent login flicker on startup 2025-12-21 20:22:22 +08:00
18 changed files with 546 additions and 413 deletions

216
README.md
View File

@@ -1,190 +1,130 @@
# CLI Proxy API Management Center # CLI Proxy API Management Center
A modern React-based WebUI for managing the CLI Proxy API, completely refactored with a modern tech stack for enhanced maintainability, type safety, and user experience. A single-file WebUI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, logs, and usage).
[中文文档](README_CN.md) [中文文档](README_CN.md)
**Main Project**: https://github.com/router-for-me/CLIProxyAPI **Main Project**: https://github.com/router-for-me/CLIProxyAPI
**Example URL**: https://remote.router-for.me/ **Example URL**: https://remote.router-for.me/
**Minimum Required Version**: ≥ 6.3.0 (recommended ≥ 6.5.0) **Minimum Required Version**: ≥ 6.3.0 (recommended ≥ 6.5.0)
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running. Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
## Features ## What this is (and isnt)
### Core Capabilities - This repository is the WebUI only. It talks to the CLI Proxy API **Management API** (`/v0/management`) to read/update config, upload credentials, view logs, and inspect usage.
- It is **not** a proxy and does not forward traffic.
- **Login & Authentication**: Auto-detects current address (manual override supported), encrypted auto-login with secure localStorage, session persistence ## Quick start
- **Basic Settings**: Debug mode, proxy URL, request retries with custom config, quota fallback (auto-switch project/preview models), usage statistics toggle, request logging & file logging, WebSocket `/ws/*` authentication
- **API Keys Management**: Manage proxy auth keys with add/edit/delete operations
- **AI Providers**: Configure Gemini/Codex/Claude settings, OpenAI-compatible providers with custom base URLs/headers/proxy/model aliases, Vertex AI credential import from service-account JSON
- **Auth Files & OAuth**: Upload/download/search/paginate JSON credentials; type filters (Qwen/Gemini/GeminiCLI/AIStudio/Claude/Codex/Antigravity/iFlow/Vertex/Empty); bulk delete; OAuth/Device flows for multiple providers
- **Logs Viewer**: Real-time log viewer with auto-refresh, download and clear capabilities (appears when logging-to-file is enabled)
- **Usage Analytics**: Overview cards, hourly/daily toggles, interactive charts with multiple model lines, per-API statistics table
- **Config Management**: In-browser YAML editor for `/config.yaml` with syntax highlighting, reload/save functionality
- **System Information**: Connection status, config cache, server version/build date, UI version in footer
### User Experience ### Option A: Use the WebUI bundled in CLIProxyAPI (recommended)
- **Responsive Design**: Full mobile support with collapsible sidebar 1. Start your CLI Proxy API service.
- **Theme System**: Light/dark mode with persistent preference 2. Open: `http://<host>:<api_port>/management.html`
- **Internationalization**: English and Simplified Chinese (zh-CN) with seamless switching 3. Enter your **management key** and connect.
- **Real-time Feedback**: Toast notifications for all operations
- **Security**: Masked secrets, encrypted local storage
## Tech Stack The address is auto-detected from the current page URL; manual override is supported.
- **Frontend Framework**: React 19 with TypeScript ### Option B: Run the dev server
- **Build Tool**: Vite 7 with single-file output ([vite-plugin-singlefile](https://github.com/nicknisi/vite-plugin-singlefile))
- **State Management**: [Zustand](https://github.com/pmndrs/zustand) for global stores
- **Routing**: React Router 7 with HashRouter
- **HTTP Client**: Axios with interceptors for auth & error handling
- **Internationalization**: i18next + react-i18next
- **Styling**: SCSS with CSS Modules, CSS Variables for theming
- **Charts**: Chart.js + react-chartjs-2
- **Code Editor**: @uiw/react-codemirror with YAML support
## Getting Started
### Prerequisites
- Node.js 18+ (LTS recommended)
- npm 9+
### Installation
```bash ```bash
# Clone the repository
git clone https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
cd Cli-Proxy-API-Management-Center
# Install dependencies
npm install npm install
npm run dev
``` ```
### Development Open `http://localhost:5173`, then connect to your CLI Proxy API instance.
### Option C: Build a single HTML file
```bash ```bash
npm run dev # Start Vite dev server (default: http://localhost:5173) npm install
npm run build
``` ```
### Build - Output: `dist/index.html` (all assets are inlined).
- For CLIProxyAPI bundling, the release workflow renames it to `management.html`.
- To preview locally: `npm run preview`
```bash Tip: opening `dist/index.html` via `file://` may be blocked by browser CORS; serving it (preview/static server) is more reliable.
npm run build # TypeScript check + Vite production build
```
The build outputs a single `dist/index.html` file with all assets inlined. ## Connecting to the server
### Other Commands ### API address
```bash You can enter any of the following; the UI will normalize it:
npm run preview # Preview production build locally
npm run lint # ESLint with strict mode (--max-warnings 0)
npm run format # Prettier formatting for src/**/*.{ts,tsx,css,scss}
npm run type-check # TypeScript type checking only (tsc --noEmit)
```
## Usage - `localhost:8317`
- `http://192.168.1.10:8317`
- `https://example.com:8317`
- `http://example.com:8317/v0/management` (also accepted; the suffix is removed internally)
### Access Methods ### Management key (not the same as API keys)
1. **Integrated with CLI Proxy API (Recommended)** The management key is sent with every request as:
After starting the CLI Proxy API service, visit `http://your-server:8317/management.html`
2. **Standalone (Built file)** - `Authorization: Bearer <MANAGEMENT_KEY>` (default)
Open the built `dist/index.html` directly in a browser, or host it on any static file server
3. **Development Server** This is different from the proxy `api-keys` you manage inside the UI (those are for client requests to the proxy endpoints).
Run `npm run dev` and open `http://localhost:5173`
### Initial Configuration ### Remote management
1. The login page auto-detects the current address; you can modify it if needed If you connect from a non-localhost browser, the server must allow remote management (e.g. `allow-remote-management: true`).
2. Enter your management key See `api.md` for the full authentication rules, server-side limits, and edge cases.
3. Click Connect to authenticate
4. Credentials are encrypted and saved locally for auto-login
> **Tip**: The Logs navigation item appears only after enabling "Logging to file" in Basic Settings. ## What you can manage (mapped to the UI pages)
## Project Structure - **Dashboard**: connection status, server version/build date, quick counts, model availability snapshot.
- **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project/preview models), usage statistics, request logging, file logging, WebSocket auth.
- **API Keys**: manage proxy `api-keys` (add/edit/delete).
- **AI Providers**:
- Gemini/Codex/Claude key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
- OpenAI-compatible providers (multiple API keys, custom headers, model alias import via `/v1/models`, optional browser-side “chat/completions” test).
- Ampcode integration (upstream URL/key, force mappings, model mapping table).
- **Auth Files**: upload/download/delete JSON credentials, filter/search/pagination, runtime-only indicators, view supported models per credential (when the server supports it), manage OAuth excluded models (supports `*` wildcards).
- **OAuth**: start OAuth/device flows for supported providers, poll status, optionally submit callback `redirect_url`; includes iFlow cookie import.
- **Usage**: requests/tokens charts (hour/day), per-API & per-model breakdown, cached/reasoning token breakdown, RPM/TPM window, optional cost estimation with locally-saved model pricing.
- **Config**: edit `/config.yaml` in-browser with YAML highlighting + search, then save/reload.
- **Logs**: tail logs with incremental polling, auto-refresh, search, hide management traffic, clear logs; download request error log files.
- **System**: quick links + fetch `/v1/models` (grouped view). Requires at least one proxy API key to query models.
``` ## Build & release notes
├── src/
│ ├── components/
│ │ ├── common/ # Shared components (NotificationContainer)
│ │ ├── layout/ # App shell (MainLayout with sidebar)
│ │ └── ui/ # Reusable UI primitives (Button, Input, Modal, etc.)
│ ├── hooks/ # Custom hooks (useApi, useDebounce, usePagination, etc.)
│ ├── i18n/
│ │ ├── locales/ # Translation files (zh-CN.json, en.json)
│ │ └── index.ts # i18next configuration
│ ├── pages/ # Route page components with co-located .module.scss
│ ├── router/ # ProtectedRoute wrapper
│ ├── services/
│ │ ├── api/ # API layer (client.ts singleton, feature modules)
│ │ └── storage/ # Secure storage utilities
│ ├── stores/ # Zustand stores (auth, config, theme, language, notification)
│ ├── styles/ # Global SCSS (variables, mixins, themes, components)
│ ├── types/ # TypeScript type definitions
│ ├── utils/ # Utility functions (constants, format, validation, etc.)
│ ├── App.tsx # Root component with routing
│ └── main.tsx # Entry point
├── dist/ # Build output (single-file bundle)
├── vite.config.ts # Vite configuration
├── tsconfig.json # TypeScript configuration
└── package.json
```
### Key Architecture Patterns - Vite produces a **single HTML** output (`dist/index.html`) with all assets inlined (via `vite-plugin-singlefile`).
- Tagging `vX.Y.Z` triggers `.github/workflows/release.yml` to publish `dist/management.html`.
- The UI version shown in the footer is injected at build time (env `VERSION`, git tag, or `package.json` fallback).
- **Path Alias**: Use `@/` to import from `src/` (configured in vite.config.ts and tsconfig.json) ## Security notes
- **API Client**: Singleton `apiClient` in `src/services/api/client.ts` with auth interceptors
- **State Management**: Zustand stores with localStorage persistence for auth/theme/language - The management key is stored in browser `localStorage` using a lightweight obfuscation format (`enc::v1::...`) to avoid plaintext storage; treat it as sensitive.
- **Styling**: SCSS variables auto-injected; CSS Modules for component-scoped styles - Use a dedicated browser profile/device for management. Be cautious when enabling remote management and evaluate its exposure surface.
- **Build Output**: Single-file bundle for easy distribution (all assets inlined)
## Troubleshooting ## Troubleshooting
### Connection Issues - **Cant connect / 401**: confirm the API address and management key; remote access may require enabling remote management in the server config.
- **Repeated auth failures**: the server may temporarily block remote IPs.
- **Logs page missing**: enable “Logging to file” in Basic Settings; the navigation item is shown only when file logging is enabled.
- **Some features show “unsupported”**: the backend may be too old or the endpoint is disabled/absent (common for model lists per auth file, excluded models, logs).
- **OpenAI provider test fails**: the test runs in the browser and depends on network/CORS of the provider endpoint; a failure here does not always mean the server cannot reach it.
1. Confirm the CLI Proxy API service is running ## Development
2. Check if the API address is correct
3. Verify that the management key is valid
4. Ensure your firewall allows the connection
### Data Not Updating ```bash
npm run dev # Vite dev server
1. Click the "Refresh All" button in the header npm run build # tsc + Vite build
2. Check your network connection npm run preview # serve dist locally
3. Open browser DevTools console for error details npm run lint # ESLint (fails on warnings)
npm run format # Prettier
### Logs & Config Editor npm run type-check # tsc --noEmit
```
- **Logs**: Requires server-side logging-to-file enabled; 404 indicates old server version or logging disabled
- **Config Editor**: Requires `/config.yaml` endpoint; ensure valid YAML syntax before saving
### Usage Statistics
- If charts are empty, enable "Usage statistics" in settings; data resets on server restart
## Contributing ## Contributing
We welcome Issues and Pull Requests! Please follow these guidelines: Issues and PRs are welcome. Please include:
1. Fork the repository - Reproduction steps (server version + UI version)
2. Create a feature branch (`git checkout -b feature/amazing-feature`) - Screenshots for UI changes
3. Commit your changes with clear messages - Verification notes (`npm run lint`, `npm run type-check`)
4. Push to your branch
5. Open a Pull Request
### Development Guidelines
- Run `npm run lint` and `npm run type-check` before committing
- Follow existing code patterns and naming conventions
- Use TypeScript strict mode
- Write meaningful commit messages
## License ## License
This project is licensed under the MIT License. MIT

View File

@@ -1,190 +1,130 @@
# CLI Proxy API 管理中心 # CLI Proxy API 管理中心
用于管理 CLI Proxy API 的现代化 React Web 界面,采用全新技术栈重构,提供更好的可维护性、类型安全性和用户体验 用于管理与排障 **CLI Proxy API** 的单文件 WebUIReact + TypeScript通过 **Management API** 完成配置、凭据、日志与统计等运维工作
[English](README.md) [English](README.md)
**主项目**: https://github.com/router-for-me/CLIProxyAPI **主项目**: https://github.com/router-for-me/CLIProxyAPI
**示例地址**: https://remote.router-for.me/ **示例地址**: https://remote.router-for.me/
**最低版本要求**: ≥ 6.3.0(推荐 ≥ 6.5.0 **最低版本要求**: ≥ 6.3.0(推荐 ≥ 6.5.0
自 6.0.19 版本起WebUI 已集成到主程序中,启动服务后可通过 `/management.html` 访问。 Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
## 功能特点 ## 这是什么(以及不是什么)
### 核心功能 - 本仓库只包含 Web 管理界面本身,通过 CLI Proxy API 的 **Management API**`/v0/management`)读取/修改配置、上传凭据、查看日志与使用统计。
-**不是** 代理本体,不参与流量转发。
- **登录与认证**: 自动检测当前地址(支持手动修改),加密自动登录,会话持久化
- **基础设置**: 调试模式、代理 URL、请求重试配置、配额溢出自动切换项目/预览模型、使用统计开关、请求日志与文件日志、WebSocket `/ws/*` 鉴权
- **API 密钥管理**: 管理代理认证密钥,支持添加/编辑/删除操作
- **AI 提供商**: 配置 Gemini/Codex/ClaudeOpenAI 兼容提供商(自定义 Base URL/Headers/代理/模型别名Vertex AI 服务账号 JSON 导入
- **认证文件与 OAuth**: 上传/下载/搜索/分页 JSON 凭据类型筛选Qwen/Gemini/GeminiCLI/AIStudio/Claude/Codex/Antigravity/iFlow/Vertex/Empty批量删除多提供商 OAuth/设备码流程
- **日志查看**: 实时日志查看,支持自动刷新、下载和清空(启用"写入日志文件"后显示)
- **使用统计**: 概览卡片、小时/天切换、多模型交互式图表、按 API 统计表格
- **配置管理**: 内置 YAML 编辑器,支持 `/config.yaml` 语法高亮、重载/保存
- **系统信息**: 连接状态、配置缓存、服务器版本/构建时间、底栏显示 UI 版本
### 用户体验
- **响应式设计**: 完整移动端支持,可折叠侧边栏
- **主题系统**: 明/暗模式切换,偏好持久化
- **国际化**: 简体中文和英文,无缝切换
- **实时反馈**: 所有操作的消息通知
- **安全性**: 密钥遮蔽、加密本地存储
## 技术栈
- **前端框架**: React 19 + TypeScript
- **构建工具**: Vite 7单文件输出[vite-plugin-singlefile](https://github.com/nicknisi/vite-plugin-singlefile)
- **状态管理**: [Zustand](https://github.com/pmndrs/zustand)
- **路由**: React Router 7 (HashRouter)
- **HTTP 客户端**: Axios带认证和错误处理拦截器
- **国际化**: i18next + react-i18next
- **样式**: SCSS + CSS ModulesCSS 变量主题
- **图表**: Chart.js + react-chartjs-2
- **代码编辑器**: @uiw/react-codemirrorYAML 支持)
## 快速开始 ## 快速开始
### 环境要求 ### 方式 A使用 CLIProxyAPI 自带的 WebUI推荐
- Node.js 18+(推荐 LTS 版本) 1. 启动 CLI Proxy API 服务。
- npm 9+ 2. 打开:`http://<host>:<api_port>/management.html`
3. 输入 **管理密钥** 并连接。
### 安装 页面会根据当前地址自动推断 API 地址,也支持手动修改。
### 方式 B开发调试
```bash ```bash
# 克隆仓库
git clone https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
cd Cli-Proxy-API-Management-Center
# 安装依赖
npm install npm install
npm run dev
``` ```
### 开发 打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 实例。
### 方式 C构建单文件 HTML
```bash ```bash
npm run dev # 启动 Vite 开发服务器(默认: http://localhost:5173 npm install
npm run build
``` ```
### 构建 - 构建产物:`dist/index.html`(资源已全部内联)。
- 在 CLIProxyAPI 的发布流程里会重命名为 `management.html`
- 本地预览:`npm run preview`
提示:直接用 `file://` 打开 `dist/index.html` 可能遇到浏览器 CORS 限制;更稳妥的方式是用预览/静态服务器打开。
## 连接说明
### API 地址怎么填
以下格式均可WebUI 会自动归一化:
- `localhost:8317`
- `http://192.168.1.10:8317`
- `https://example.com:8317`
- `http://example.com:8317/v0/management`(也可填写,后缀会被自动去除)
### 管理密钥(注意:不是 API Keys
管理密钥会以如下方式随请求发送:
- `Authorization: Bearer <MANAGEMENT_KEY>`(默认)
这与 WebUI 中“API Keys”页面管理的 `api-keys` 不同:后者是代理对外接口(如 OpenAI 兼容接口)给客户端使用的鉴权 key。
### 远程管理
当你从非 localhost 的浏览器访问时,服务端通常需要开启远程管理(例如 `allow-remote-management: true`)。
完整鉴权规则、限制与边界情况请查看 `api.md`
## 功能一览(按页面对应)
- **仪表盘**:连接状态、服务版本/构建时间、关键数量概览、可用模型概览。
- **基础设置**:调试开关、代理 URL、请求重试、配额回退切项目/切预览模型、使用统计、请求日志、文件日志、WebSocket 鉴权。
- **API Keys**:管理代理 `api-keys`(增/改/删)。
- **AI 提供商**
- Gemini/Codex/Claude 配置Base URL、Headers、代理、模型别名、排除模型、Prefix
- OpenAI 兼容提供商(多 Key、Header、自助从 `/v1/models` 拉取并导入模型别名、可选浏览器侧 `chat/completions` 测试)。
- Ampcode 集成(上游地址/密钥、强制映射、模型映射表)。
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only查看单个凭据可用模型依赖后端支持管理 OAuth 排除模型(支持 `*` 通配符)。
- **OAuth**:对支持的提供商发起 OAuth/设备码流程,轮询状态;可选提交回调 `redirect_url`;包含 iFlow Cookie 导入。
- **使用统计**:按小时/天图表、按 API 与按模型统计、缓存/推理 Token 拆分、RPM/TPM 时间窗、可选本地保存的模型价格用于费用估算。
- **配置文件**:浏览器内编辑 `/config.yaml`YAML 高亮 + 搜索),保存/重载。
- **日志**:增量拉取日志、自动刷新、搜索、隐藏管理端流量、清空日志;下载请求错误日志文件。
- **系统信息**:快捷链接 + 拉取 `/v1/models` 并分组展示(需要至少一个代理 API Key 才能查询模型)。
## 构建与发布说明
- 使用 Vite 输出 **单文件 HTML**`dist/index.html`),资源全部内联(`vite-plugin-singlefile`)。
-`vX.Y.Z` 标签会触发 `.github/workflows/release.yml`,发布 `dist/management.html`
- 页脚显示的 UI 版本在构建期注入(优先使用环境变量 `VERSION`,否则使用 git tag / `package.json`)。
## 安全提示
- 管理密钥会存入浏览器 `localStorage`,并使用轻量混淆格式(`enc::v1::...`)避免明文;仍应视为敏感信息。
- 建议使用独立浏览器配置/设备进行管理;开启远程管理时请谨慎评估暴露面。
## 常见问题
- **无法连接 / 401**:确认 API 地址与管理密钥;远程访问可能需要服务端开启远程管理。
- **反复输错密钥**:服务端可能对远程 IP 进行临时封禁。
- **日志页面不显示**:需要在“基础设置”里开启“写入日志文件”,导航项才会出现。
- **功能提示不支持**:多为后端版本较旧或接口未启用/不存在(如:认证文件模型列表、排除模型、日志相关接口)。
- **OpenAI 提供商测试失败**:测试在浏览器侧执行,会受网络与 CORS 影响;这里失败不一定代表服务端不可用。
## 开发命令
```bash ```bash
npm run build # TypeScript 检查 + Vite 生产构建 npm run dev # 启动开发服务器
npm run build # tsc + Vite 构建
npm run preview # 本地预览 dist
npm run lint # ESLintwarnings 视为失败)
npm run format # Prettier
npm run type-check # tsc --noEmit
``` ```
构建输出单个 `dist/index.html` 文件,所有资源已内联。
### 其他命令
```bash
npm run preview # 本地预览生产构建
npm run lint # ESLint 严格模式(--max-warnings 0
npm run format # Prettier 格式化 src/**/*.{ts,tsx,css,scss}
npm run type-check # 仅 TypeScript 类型检查tsc --noEmit
```
## 使用方法
### 访问方式
1. **与 CLI Proxy API 集成使用(推荐)**
启动 CLI Proxy API 服务后,访问 `http://您的服务器:8317/management.html`
2. **独立使用(构建后文件)**
直接在浏览器打开构建的 `dist/index.html`,或部署到任意静态文件服务器
3. **开发服务器**
运行 `npm run dev` 后打开 `http://localhost:5173`
### 初始配置
1. 登录页会自动检测当前地址,可根据需要修改
2. 输入管理密钥
3. 点击连接进行认证
4. 凭据会加密保存到本地,下次自动登录
> **提示**: 只有在"基础设置"中启用"写入日志文件"后,才会显示"日志查看"导航项。
## 项目结构
```
├── src/
│ ├── components/
│ │ ├── common/ # 公共组件NotificationContainer
│ │ ├── layout/ # 应用外壳MainLayout 侧边栏布局)
│ │ └── ui/ # 可复用 UI 组件Button、Input、Modal 等)
│ ├── hooks/ # 自定义 HooksuseApi、useDebounce、usePagination 等)
│ ├── i18n/
│ │ ├── locales/ # 翻译文件zh-CN.json、en.json
│ │ └── index.ts # i18next 配置
│ ├── pages/ # 路由页面组件,配套 .module.scss 样式
│ ├── router/ # ProtectedRoute 路由守卫
│ ├── services/
│ │ ├── api/ # API 层client.ts 单例,功能模块)
│ │ └── storage/ # 安全存储工具
│ ├── stores/ # Zustand 状态管理auth、config、theme、language、notification
│ ├── styles/ # 全局 SCSSvariables、mixins、themes、components
│ ├── types/ # TypeScript 类型定义
│ ├── utils/ # 工具函数constants、format、validation 等)
│ ├── App.tsx # 根组件与路由
│ └── main.tsx # 入口文件
├── dist/ # 构建输出(单文件打包)
├── vite.config.ts # Vite 配置
├── tsconfig.json # TypeScript 配置
└── package.json
```
### 核心架构模式
- **路径别名**: 使用 `@/` 导入 `src/` 目录(在 vite.config.ts 和 tsconfig.json 中配置)
- **API 客户端**: `src/services/api/client.ts` 单例,带认证拦截器
- **状态管理**: Zustand storesauth/theme/language 持久化到 localStorage
- **样式**: SCSS 变量自动注入CSS Modules 实现组件作用域样式
- **构建输出**: 单文件打包,便于分发(所有资源内联)
## 故障排除
### 连接问题
1. 确认 CLI Proxy API 服务正在运行
2. 检查 API 地址是否正确
3. 验证管理密钥是否有效
4. 确认防火墙设置允许连接
### 数据不更新
1. 点击顶栏的"刷新全部"按钮
2. 检查网络连接
3. 打开浏览器开发者工具控制台查看错误信息
### 日志与配置编辑
- **日志**: 需要服务端启用写文件日志;返回 404 说明服务器版本过旧或未启用日志
- **配置编辑**: 依赖 `/config.yaml` 接口;保存前请确保 YAML 语法正确
### 使用统计
- 若图表为空,请在设置中启用"使用统计";数据在服务重启后会清空
## 贡献 ## 贡献
欢迎提 Issue Pull Request请遵循以下指南 欢迎提 Issue PR。建议附上
1. Fork 本仓库 - 复现步骤(服务端版本 + UI 版本)
2. 创建功能分支(`git checkout -b feature/amazing-feature` - UI 改动截图
3. 提交更改,使用清晰的提交信息 - 验证记录(`npm run lint``npm run type-check`
4. 推送到分支
5. 开启 Pull Request
### 开发规范
- 提交前运行 `npm run lint``npm run type-check`
- 遵循现有代码模式和命名规范
- 使用 TypeScript 严格模式
- 编写有意义的提交信息
## 许可证 ## 许可证
本项目采用 MIT 许可证。 MIT

View File

@@ -17,18 +17,24 @@ import { MainLayout } from '@/components/layout/MainLayout';
import { ProtectedRoute } from '@/router/ProtectedRoute'; import { ProtectedRoute } from '@/router/ProtectedRoute';
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores'; import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores';
const SPLASH_DURATION = 1500;
const SPLASH_FADE_DURATION = 400;
function App() { function App() {
const initializeTheme = useThemeStore((state) => state.initializeTheme); const initializeTheme = useThemeStore((state) => state.initializeTheme);
const language = useLanguageStore((state) => state.language); const language = useLanguageStore((state) => state.language);
const setLanguage = useLanguageStore((state) => state.setLanguage); const setLanguage = useLanguageStore((state) => state.setLanguage);
const restoreSession = useAuthStore((state) => state.restoreSession); const restoreSession = useAuthStore((state) => state.restoreSession);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const [splashReadyToFade, setSplashReadyToFade] = useState(false);
const [showSplash, setShowSplash] = useState(true); const [showSplash, setShowSplash] = useState(true);
const [authReady, setAuthReady] = useState(false);
useEffect(() => { useEffect(() => {
initializeTheme(); initializeTheme();
restoreSession(); void restoreSession().finally(() => {
setAuthReady(true);
});
}, [initializeTheme, restoreSession]); }, [initializeTheme, restoreSession]);
useEffect(() => { useEffect(() => {
@@ -36,13 +42,25 @@ function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅用于首屏同步 i18n 语言 }, []); // 仅用于首屏同步 i18n 语言
useEffect(() => {
const timer = setTimeout(() => {
setSplashReadyToFade(true);
}, SPLASH_DURATION - SPLASH_FADE_DURATION);
return () => clearTimeout(timer);
}, []);
const handleSplashFinish = useCallback(() => { const handleSplashFinish = useCallback(() => {
setShowSplash(false); setShowSplash(false);
}, []); }, []);
// 仅在已认证时显示闪屏 if (showSplash) {
if (showSplash && isAuthenticated) { return (
return <SplashScreen onFinish={handleSplashFinish} duration={1500} />; <SplashScreen
fadeOut={splashReadyToFade && authReady}
onFinish={handleSplashFinish}
/>
);
} }
return ( return (

View File

@@ -1,29 +1,25 @@
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import './SplashScreen.scss'; import './SplashScreen.scss';
interface SplashScreenProps { interface SplashScreenProps {
onFinish: () => void; onFinish: () => void;
duration?: number; fadeOut?: boolean;
} }
export function SplashScreen({ onFinish, duration = 1500 }: SplashScreenProps) { const FADE_OUT_DURATION = 400;
const [fadeOut, setFadeOut] = useState(false);
export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) {
useEffect(() => { useEffect(() => {
const fadeTimer = setTimeout(() => { if (!fadeOut) return;
setFadeOut(true);
}, duration - 400);
const finishTimer = setTimeout(() => { const finishTimer = setTimeout(() => {
onFinish(); onFinish();
}, duration); }, FADE_OUT_DURATION);
return () => { return () => {
clearTimeout(fadeTimer);
clearTimeout(finishTimer); clearTimeout(finishTimer);
}; };
}, [duration, onFinish]); }, [fadeOut, onFinish]);
return ( return (
<div className={`splash-screen ${fadeOut ? 'fade-out' : ''}`}> <div className={`splash-screen ${fadeOut ? 'fade-out' : ''}`}>

View File

@@ -2,6 +2,8 @@ import { ReactNode, SVGProps, useCallback, useEffect, useLayoutEffect, useRef, u
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { import {
IconBot, IconBot,
IconChartLine, IconChartLine,
@@ -16,7 +18,7 @@ import {
} from '@/components/ui/icons'; } from '@/components/ui/icons';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, useThemeStore } from '@/stores'; import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, useThemeStore } from '@/stores';
import { versionApi } from '@/services/api'; import { configApi, versionApi } from '@/services/api';
const sidebarIcons: Record<string, ReactNode> = { const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard size={18} />, dashboard: <IconLayoutDashboard size={18} />,
@@ -148,6 +150,7 @@ export function MainLayout() {
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig); const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearCache = useConfigStore((state) => state.clearCache); const clearCache = useConfigStore((state) => state.clearCache);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const theme = useThemeStore((state) => state.theme); const theme = useThemeStore((state) => state.theme);
const toggleTheme = useThemeStore((state) => state.toggleTheme); const toggleTheme = useThemeStore((state) => state.toggleTheme);
@@ -157,11 +160,20 @@ export function MainLayout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [checkingVersion, setCheckingVersion] = useState(false); const [checkingVersion, setCheckingVersion] = useState(false);
const [brandExpanded, setBrandExpanded] = useState(true); const [brandExpanded, setBrandExpanded] = useState(true);
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
const [requestLogDraft, setRequestLogDraft] = useState(false);
const [requestLogTouched, setRequestLogTouched] = useState(false);
const [requestLogSaving, setRequestLogSaving] = useState(false);
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerRef = useRef<HTMLElement | null>(null); const headerRef = useRef<HTMLElement | null>(null);
const versionTapCount = useRef(0);
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const fullBrandName = 'CLI Proxy API Management Center'; const fullBrandName = 'CLI Proxy API Management Center';
const abbrBrandName = t('title.abbr'); const abbrBrandName = t('title.abbr');
const requestLogEnabled = config?.requestLog ?? false;
const requestLogDirty = requestLogDraft !== requestLogEnabled;
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动 // 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -203,6 +215,20 @@ export function MainLayout() {
}; };
}, []); }, []);
useEffect(() => {
if (requestLogModalOpen && !requestLogTouched) {
setRequestLogDraft(requestLogEnabled);
}
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
useEffect(() => {
return () => {
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
};
}, []);
const handleBrandClick = useCallback(() => { const handleBrandClick = useCallback(() => {
if (!brandExpanded) { if (!brandExpanded) {
setBrandExpanded(true); setBrandExpanded(true);
@@ -216,6 +242,60 @@ export function MainLayout() {
} }
}, [brandExpanded]); }, [brandExpanded]);
const openRequestLogModal = useCallback(() => {
setRequestLogTouched(false);
setRequestLogDraft(requestLogEnabled);
setRequestLogModalOpen(true);
}, [requestLogEnabled]);
const handleRequestLogClose = useCallback(() => {
setRequestLogModalOpen(false);
setRequestLogTouched(false);
}, []);
const handleVersionTap = useCallback(() => {
versionTapCount.current += 1;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
versionTapTimer.current = setTimeout(() => {
versionTapCount.current = 0;
}, 1500);
if (versionTapCount.current >= 7) {
versionTapCount.current = 0;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
versionTapTimer.current = null;
}
openRequestLogModal();
}
}, [openRequestLogModal]);
const handleRequestLogSave = async () => {
if (!canEditRequestLog) return;
if (!requestLogDirty) {
setRequestLogModalOpen(false);
return;
}
const previous = requestLogEnabled;
setRequestLogSaving(true);
updateConfigValue('request-log', requestLogDraft);
try {
await configApi.updateRequestLog(requestLogDraft);
clearCache('request-log');
showNotification(t('notification.request_log_updated'), 'success');
setRequestLogModalOpen(false);
} catch (error: any) {
updateConfigValue('request-log', previous);
showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error');
} finally {
setRequestLogSaving(false);
}
};
useEffect(() => { useEffect(() => {
fetchConfig().catch(() => { fetchConfig().catch(() => {
// ignore initial failure; login flow会提示 // ignore initial failure; login flow会提示
@@ -369,7 +449,7 @@ export function MainLayout() {
<span> <span>
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')} {t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
</span> </span>
<span> <span onClick={handleVersionTap}>
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')} {t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
</span> </span>
<span> <span>
@@ -379,6 +459,40 @@ export function MainLayout() {
</footer> </footer>
</div> </div>
</div> </div>
<Modal
open={requestLogModalOpen}
onClose={handleRequestLogClose}
title={t('basic_settings.request_log_title')}
footer={
<>
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
{t('common.cancel')}
</Button>
<Button
onClick={handleRequestLogSave}
loading={requestLogSaving}
disabled={!canEditRequestLog || !requestLogDirty}
>
{t('common.save')}
</Button>
</>
}
>
<div className="request-log-modal">
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
<ToggleSwitch
label={t('basic_settings.request_log_enable')}
labelPosition="left"
checked={requestLogDraft}
disabled={!canEditRequestLog || requestLogSaving}
onChange={(value) => {
setRequestLogDraft(value);
setRequestLogTouched(true);
}}
/>
</div>
</Modal>
</div> </div>
); );
} }

View File

@@ -5,15 +5,26 @@ interface ToggleSwitchProps {
onChange: (value: boolean) => void; onChange: (value: boolean) => void;
label?: ReactNode; label?: ReactNode;
disabled?: boolean; disabled?: boolean;
labelPosition?: 'left' | 'right';
} }
export function ToggleSwitch({ checked, onChange, label, disabled = false }: ToggleSwitchProps) { export function ToggleSwitch({
checked,
onChange,
label,
disabled = false,
labelPosition = 'right'
}: ToggleSwitchProps) {
const handleChange = (event: ChangeEvent<HTMLInputElement>) => { const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.checked); onChange(event.target.checked);
}; };
const className = ['switch', labelPosition === 'left' ? 'switch-label-left' : '']
.filter(Boolean)
.join(' ');
return ( return (
<label className="switch"> <label className={className}>
<input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} /> <input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} />
<span className="track"> <span className="track">
<span className="thumb" /> <span className="thumb" />

View File

@@ -29,6 +29,7 @@
"required": "Required", "required": "Required",
"api_key": "Key", "api_key": "Key",
"base_url": "Address", "base_url": "Address",
"prefix": "Prefix",
"proxy_url": "Proxy", "proxy_url": "Proxy",
"alias": "Alias", "alias": "Alias",
"failure": "Failure", "failure": "Failure",
@@ -132,7 +133,9 @@
"usage_statistics_enable": "Enable usage statistics", "usage_statistics_enable": "Enable usage statistics",
"logging_title": "Logging", "logging_title": "Logging",
"logging_to_file_enable": "Enable logging to file", "logging_to_file_enable": "Enable logging to file",
"request_log_title": "Request Logging",
"request_log_enable": "Enable request logging", "request_log_enable": "Enable request logging",
"request_log_warning": "Keep this off unless you need detailed troubleshooting.",
"ws_auth_title": "WebSocket Authentication", "ws_auth_title": "WebSocket Authentication",
"ws_auth_enable": "Require auth for /ws/*" "ws_auth_enable": "Require auth for /ws/*"
}, },
@@ -171,6 +174,9 @@
"excluded_models_placeholder": "Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash", "excluded_models_placeholder": "Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash",
"excluded_models_hint": "Leave empty to allow all models; values are trimmed and deduplicated automatically.", "excluded_models_hint": "Leave empty to allow all models; values are trimmed and deduplicated automatically.",
"excluded_models_count": "Excluding {{count}} models", "excluded_models_count": "Excluding {{count}} models",
"prefix_label": "Prefix (Optional):",
"prefix_placeholder": "e.g.: team-a",
"prefix_hint": "When set, call models as prefix/<model> to target this entry.",
"config_toggle_label": "Enabled", "config_toggle_label": "Enabled",
"config_disabled_badge": "Disabled", "config_disabled_badge": "Disabled",
"codex_title": "Codex API Configuration", "codex_title": "Codex API Configuration",

View File

@@ -29,6 +29,7 @@
"required": "必填", "required": "必填",
"api_key": "密钥", "api_key": "密钥",
"base_url": "地址", "base_url": "地址",
"prefix": "前缀",
"proxy_url": "代理", "proxy_url": "代理",
"alias": "别名", "alias": "别名",
"failure": "失败", "failure": "失败",
@@ -132,7 +133,9 @@
"usage_statistics_enable": "启用使用统计", "usage_statistics_enable": "启用使用统计",
"logging_title": "日志记录", "logging_title": "日志记录",
"logging_to_file_enable": "启用日志记录到文件", "logging_to_file_enable": "启用日志记录到文件",
"request_log_title": "请求日志",
"request_log_enable": "启用请求日志", "request_log_enable": "启用请求日志",
"request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。",
"ws_auth_title": "WebSocket 鉴权", "ws_auth_title": "WebSocket 鉴权",
"ws_auth_enable": "启用 /ws/* 鉴权" "ws_auth_enable": "启用 /ws/* 鉴权"
}, },
@@ -171,6 +174,9 @@
"excluded_models_placeholder": "用逗号或换行分隔,例如: gemini-1.5-pro, gemini-1.5-flash", "excluded_models_placeholder": "用逗号或换行分隔,例如: gemini-1.5-pro, gemini-1.5-flash",
"excluded_models_hint": "留空表示不过滤;保存时会自动去重并忽略空白。", "excluded_models_hint": "留空表示不过滤;保存时会自动去重并忽略空白。",
"excluded_models_count": "排除 {{count}} 个模型", "excluded_models_count": "排除 {{count}} 个模型",
"prefix_label": "前缀 (可选):",
"prefix_placeholder": "例如: team-a",
"prefix_hint": "设置后可用 prefix/<model> 选择该条目。",
"config_toggle_label": "启用", "config_toggle_label": "启用",
"config_disabled_badge": "已停用", "config_disabled_badge": "已停用",
"codex_title": "Codex API 配置", "codex_title": "Codex API 配置",

View File

@@ -39,6 +39,7 @@ interface ModelEntry {
interface OpenAIFormState { interface OpenAIFormState {
name: string; name: string;
prefix: string;
baseUrl: string; baseUrl: string;
headers: HeaderEntry[]; headers: HeaderEntry[];
testModel?: string; testModel?: string;
@@ -200,6 +201,7 @@ export function AiProvidersPage() {
const [geminiForm, setGeminiForm] = useState<GeminiKeyConfig & { excludedText: string }>({ const [geminiForm, setGeminiForm] = useState<GeminiKeyConfig & { excludedText: string }>({
apiKey: '', apiKey: '',
prefix: '',
baseUrl: '', baseUrl: '',
headers: {}, headers: {},
excludedModels: [], excludedModels: [],
@@ -209,6 +211,7 @@ export function AiProvidersPage() {
ProviderKeyConfig & { modelEntries: ModelEntry[]; excludedText: string } ProviderKeyConfig & { modelEntries: ModelEntry[]; excludedText: string }
>({ >({
apiKey: '', apiKey: '',
prefix: '',
baseUrl: '', baseUrl: '',
proxyUrl: '', proxyUrl: '',
headers: {}, headers: {},
@@ -219,6 +222,7 @@ export function AiProvidersPage() {
}); });
const [openaiForm, setOpenaiForm] = useState<OpenAIFormState>({ const [openaiForm, setOpenaiForm] = useState<OpenAIFormState>({
name: '', name: '',
prefix: '',
baseUrl: '', baseUrl: '',
headers: [], headers: [],
apiKeyEntries: [buildApiKeyEntry()], apiKeyEntries: [buildApiKeyEntry()],
@@ -317,6 +321,7 @@ export function AiProvidersPage() {
setModal(null); setModal(null);
setGeminiForm({ setGeminiForm({
apiKey: '', apiKey: '',
prefix: '',
baseUrl: '', baseUrl: '',
headers: {}, headers: {},
excludedModels: [], excludedModels: [],
@@ -324,6 +329,7 @@ export function AiProvidersPage() {
}); });
setProviderForm({ setProviderForm({
apiKey: '', apiKey: '',
prefix: '',
baseUrl: '', baseUrl: '',
proxyUrl: '', proxyUrl: '',
headers: {}, headers: {},
@@ -334,6 +340,7 @@ export function AiProvidersPage() {
}); });
setOpenaiForm({ setOpenaiForm({
name: '', name: '',
prefix: '',
baseUrl: '', baseUrl: '',
headers: [], headers: [],
apiKeyEntries: [buildApiKeyEntry()], apiKeyEntries: [buildApiKeyEntry()],
@@ -410,6 +417,7 @@ export function AiProvidersPage() {
const modelEntries = modelsToEntries(entry.models); const modelEntries = modelsToEntries(entry.models);
setOpenaiForm({ setOpenaiForm({
name: entry.name, name: entry.name,
prefix: entry.prefix ?? '',
baseUrl: entry.baseUrl, baseUrl: entry.baseUrl,
headers: headersToEntries(entry.headers), headers: headersToEntries(entry.headers),
testModel: entry.testModel, testModel: entry.testModel,
@@ -757,6 +765,7 @@ export function AiProvidersPage() {
try { try {
const payload: GeminiKeyConfig = { const payload: GeminiKeyConfig = {
apiKey: geminiForm.apiKey.trim(), apiKey: geminiForm.apiKey.trim(),
prefix: geminiForm.prefix?.trim() || undefined,
baseUrl: geminiForm.baseUrl?.trim() || undefined, baseUrl: geminiForm.baseUrl?.trim() || undefined,
headers: buildHeaderObject(headersToEntries(geminiForm.headers as any)), headers: buildHeaderObject(headersToEntries(geminiForm.headers as any)),
excludedModels: parseExcludedModels(geminiForm.excludedText), excludedModels: parseExcludedModels(geminiForm.excludedText),
@@ -900,6 +909,7 @@ export function AiProvidersPage() {
const payload: ProviderKeyConfig = { const payload: ProviderKeyConfig = {
apiKey: providerForm.apiKey.trim(), apiKey: providerForm.apiKey.trim(),
prefix: providerForm.prefix?.trim() || undefined,
baseUrl, baseUrl,
proxyUrl: providerForm.proxyUrl?.trim() || undefined, proxyUrl: providerForm.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(headersToEntries(providerForm.headers as any)), headers: buildHeaderObject(headersToEntries(providerForm.headers as any)),
@@ -970,6 +980,7 @@ export function AiProvidersPage() {
try { try {
const payload: OpenAIProviderConfig = { const payload: OpenAIProviderConfig = {
name: openaiForm.name.trim(), name: openaiForm.name.trim(),
prefix: openaiForm.prefix?.trim() || undefined,
baseUrl: openaiForm.baseUrl.trim(), baseUrl: openaiForm.baseUrl.trim(),
headers: buildHeaderObject(openaiForm.headers), headers: buildHeaderObject(openaiForm.headers),
apiKeyEntries: openaiForm.apiKeyEntries.map((entry) => ({ apiKeyEntries: openaiForm.apiKeyEntries.map((entry) => ({
@@ -1176,6 +1187,12 @@ export function AiProvidersPage() {
<span className={styles.fieldLabel}>{t('common.api_key')}:</span> <span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span> <span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div> </div>
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
{/* Base URL 行 */} {/* Base URL 行 */}
{item.baseUrl && ( {item.baseUrl && (
<div className={styles.fieldRow}> <div className={styles.fieldRow}>
@@ -1274,6 +1291,12 @@ export function AiProvidersPage() {
<span className={styles.fieldLabel}>{t('common.api_key')}:</span> <span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span> <span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div> </div>
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
{/* Base URL 行 */} {/* Base URL 行 */}
{item.baseUrl && ( {item.baseUrl && (
<div className={styles.fieldRow}> <div className={styles.fieldRow}>
@@ -1379,6 +1402,12 @@ export function AiProvidersPage() {
<span className={styles.fieldLabel}>{t('common.api_key')}:</span> <span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span> <span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div> </div>
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
{/* Base URL 行 */} {/* Base URL 行 */}
{item.baseUrl && ( {item.baseUrl && (
<div className={styles.fieldRow}> <div className={styles.fieldRow}>
@@ -1567,6 +1596,12 @@ export function AiProvidersPage() {
return ( return (
<Fragment> <Fragment>
<div className="item-title">{item.name}</div> <div className="item-title">{item.name}</div>
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
{/* Base URL 行 */} {/* Base URL 行 */}
<div className={styles.fieldRow}> <div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.base_url')}:</span> <span className={styles.fieldLabel}>{t('common.base_url')}:</span>
@@ -1785,6 +1820,13 @@ export function AiProvidersPage() {
value={geminiForm.apiKey} value={geminiForm.apiKey}
onChange={(e) => setGeminiForm((prev) => ({ ...prev, apiKey: e.target.value }))} onChange={(e) => setGeminiForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/> />
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={geminiForm.prefix ?? ''}
onChange={(e) => setGeminiForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input <Input
label={t('ai_providers.gemini_base_url_label')} label={t('ai_providers.gemini_base_url_label')}
placeholder={t('ai_providers.gemini_base_url_placeholder')} placeholder={t('ai_providers.gemini_base_url_placeholder')}
@@ -1849,6 +1891,13 @@ export function AiProvidersPage() {
value={providerForm.apiKey} value={providerForm.apiKey}
onChange={(e) => setProviderForm((prev) => ({ ...prev, apiKey: e.target.value }))} onChange={(e) => setProviderForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/> />
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={providerForm.prefix ?? ''}
onChange={(e) => setProviderForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input <Input
label={ label={
modal?.type === 'codex' modal?.type === 'codex'
@@ -1931,6 +1980,13 @@ export function AiProvidersPage() {
value={openaiForm.name} value={openaiForm.name}
onChange={(e) => setOpenaiForm((prev) => ({ ...prev, name: e.target.value }))} onChange={(e) => setOpenaiForm((prev) => ({ ...prev, name: e.target.value }))}
/> />
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={openaiForm.prefix ?? ''}
onChange={(e) => setOpenaiForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input <Input
label={t('ai_providers.openai_add_modal_url_label')} label={t('ai_providers.openai_add_modal_url_label')}
value={openaiForm.baseUrl} value={openaiForm.baseUrl}

View File

@@ -288,12 +288,6 @@ export function DashboardPage() {
{config.loggingToFile ? t('common.yes') : t('common.no')} {config.loggingToFile ? t('common.yes') : t('common.no')}
</span> </span>
</div> </div>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.request_log_enable')}</span>
<span className={`${styles.configValue} ${config.requestLog ? styles.enabled : styles.disabled}`}>
{config.requestLog ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configItem}> <div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.retry_count_label')}</span> <span className={styles.configLabel}>{t('basic_settings.retry_count_label')}</span>
<span className={styles.configValue}>{config.requestRetry ?? 0}</span> <span className={styles.configValue}>{config.requestRetry ?? 0}</span>

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { Navigate, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
@@ -44,12 +44,10 @@ export function LoginPage() {
init(); init();
}, [detectedBase, restoreSession, storedBase, storedKey]); }, [detectedBase, restoreSession, storedBase, storedKey]);
useEffect(() => { if (isAuthenticated) {
if (isAuthenticated) { const redirect = (location.state as any)?.from?.pathname || '/';
const redirect = (location.state as any)?.from?.pathname || '/'; return <Navigate to={redirect} replace />;
navigate(redirect, { replace: true }); }
}
}, [isAuthenticated, navigate, location.state]);
const handleUseCurrent = () => { const handleUseCurrent = () => {
setApiBase(detectedBase); setApiBase(detectedBase);

View File

@@ -187,15 +187,8 @@
.rowMain { .rowMain {
display: flex; display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.rowMeta {
display: flex;
align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
align-items: baseline;
gap: 6px; gap: 6px;
min-width: 0; min-width: 0;
} }
@@ -236,6 +229,15 @@
} }
} }
.requestIdBadge {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 11px;
color: #0891b2;
background: rgba(8, 145, 178, 0.1);
border-color: rgba(8, 145, 178, 0.25);
}
.statusBadge { .statusBadge {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -308,6 +310,5 @@
.message { .message {
color: var(--text-secondary); color: var(--text-secondary);
white-space: pre-wrap; white-space: nowrap;
word-break: break-word;
} }

View File

@@ -44,11 +44,12 @@ type HttpMethod = (typeof HTTP_METHODS)[number];
const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`); const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`);
const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/; const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/;
const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\]?(?=\s|\[|$)\s*/i; const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\s*\]?(?=\s|\[|$)\s*/i;
const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/; const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/;
const LOG_LATENCY_REGEX = /\b(\d+(?:\.\d+)?)(?:\s*)(µs|us|ms|s)\b/i; const LOG_LATENCY_REGEX = /\b(\d+(?:\.\d+)?)(?:\s*)(µs|us|ms|s)\b/i;
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/; const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i; const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------|---------)$/i;
const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/; const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
const GIN_TIMESTAMP_SEGMENT_REGEX = const GIN_TIMESTAMP_SEGMENT_REGEX =
/^\[GIN\]\s+(\d{4})\/(\d{2})\/(\d{2})\s*-\s*(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*$/; /^\[GIN\]\s+(\d{4})\/(\d{2})\/(\d{2})\s*-\s*(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*$/;
@@ -102,6 +103,7 @@ type ParsedLogLine = {
timestamp?: string; timestamp?: string;
level?: LogLevel; level?: LogLevel;
source?: string; source?: string;
requestId?: string;
statusCode?: number; statusCode?: number;
latency?: string; latency?: string;
ip?: string; ip?: string;
@@ -173,6 +175,7 @@ const parseLogLine = (raw: string): ParsedLogLine => {
let ip: string | undefined; let ip: string | undefined;
let method: HttpMethod | undefined; let method: HttpMethod | undefined;
let path: string | undefined; let path: string | undefined;
let requestId: string | undefined;
let message = remaining; let message = remaining;
if (remaining.includes('|')) { if (remaining.includes('|')) {
@@ -199,6 +202,19 @@ const parseLogLine = (raw: string): ParsedLogLine => {
} }
} }
// request id (8-char hex or dashes)
const requestIdIndex = segments.findIndex((segment) => LOG_REQUEST_ID_REGEX.test(segment));
if (requestIdIndex >= 0) {
const match = segments[requestIdIndex].match(LOG_REQUEST_ID_REGEX);
if (match) {
const id = match[1];
if (!/^-+$/.test(id)) {
requestId = id;
}
consumed.add(requestIdIndex);
}
}
// status code // status code
const statusIndex = segments.findIndex((segment) => /^\d{3}\b/.test(segment)); const statusIndex = segments.findIndex((segment) => /^\d{3}\b/.test(segment));
if (statusIndex >= 0) { if (statusIndex >= 0) {
@@ -244,6 +260,16 @@ const parseLogLine = (raw: string): ParsedLogLine => {
consumed.add(methodIndex); consumed.add(methodIndex);
} }
// source (e.g. [gin_logger.go:94])
const sourceIndex = segments.findIndex((segment) => LOG_SOURCE_REGEX.test(segment));
if (sourceIndex >= 0) {
const match = segments[sourceIndex].match(LOG_SOURCE_REGEX);
if (match) {
source = match[1];
consumed.add(sourceIndex);
}
}
message = segments.filter((_, index) => !consumed.has(index)).join(' | '); message = segments.filter((_, index) => !consumed.has(index)).join(' | ');
} else { } else {
statusCode = detectHttpStatusCode(remaining); statusCode = detectHttpStatusCode(remaining);
@@ -276,6 +302,7 @@ const parseLogLine = (raw: string): ParsedLogLine => {
timestamp, timestamp,
level, level,
source, source,
requestId,
statusCode, statusCode,
latency, latency,
ip, ip,
@@ -735,65 +762,74 @@ export function LogsPage() {
> >
<div className={styles.timestamp}>{line.timestamp || ''}</div> <div className={styles.timestamp}>{line.timestamp || ''}</div>
<div className={styles.rowMain}> <div className={styles.rowMain}>
<div className={styles.rowMeta}> {line.level && (
{line.level && ( <span
<span className={[
className={[ styles.badge,
styles.badge, line.level === 'info' ? styles.levelInfo : '',
line.level === 'info' ? styles.levelInfo : '', line.level === 'warn' ? styles.levelWarn : '',
line.level === 'warn' ? styles.levelWarn : '', line.level === 'error' || line.level === 'fatal'
line.level === 'error' || line.level === 'fatal' ? styles.levelError
? styles.levelError : '',
: '', line.level === 'debug' ? styles.levelDebug : '',
line.level === 'debug' ? styles.levelDebug : '', line.level === 'trace' ? styles.levelTrace : '',
line.level === 'trace' ? styles.levelTrace : '', ]
] .filter(Boolean)
.filter(Boolean) .join(' ')}
.join(' ')} >
> {line.level.toUpperCase()}
{line.level.toUpperCase()} </span>
</span> )}
)}
{line.source && ( {line.source && (
<span className={styles.source} title={line.source}> <span className={styles.source} title={line.source}>
{line.source} {line.source}
</span> </span>
)} )}
{typeof line.statusCode === 'number' && ( {typeof line.statusCode === 'number' && (
<span <span
className={[ className={[
styles.badge, styles.badge,
styles.statusBadge, styles.statusBadge,
line.statusCode >= 200 && line.statusCode < 300 line.statusCode >= 200 && line.statusCode < 300
? styles.statusSuccess ? styles.statusSuccess
: line.statusCode >= 300 && line.statusCode < 400 : line.statusCode >= 300 && line.statusCode < 400
? styles.statusInfo ? styles.statusInfo
: line.statusCode >= 400 && line.statusCode < 500 : line.statusCode >= 400 && line.statusCode < 500
? styles.statusWarn ? styles.statusWarn
: styles.statusError, : styles.statusError,
].join(' ')} ].join(' ')}
> >
{line.statusCode} {line.statusCode}
</span> </span>
)} )}
{line.latency && <span className={styles.pill}>{line.latency}</span>} {line.latency && <span className={styles.pill}>{line.latency}</span>}
{line.ip && <span className={styles.pill}>{line.ip}</span>} {line.ip && <span className={styles.pill}>{line.ip}</span>}
{line.method && ( {line.method && (
<span className={[styles.badge, styles.methodBadge].join(' ')}> <span className={[styles.badge, styles.methodBadge].join(' ')}>
{line.method} {line.method}
</span> </span>
)} )}
{line.path && (
<span className={styles.path} title={line.path}> {line.requestId && (
{line.path} <span
</span> className={[styles.badge, styles.requestIdBadge].join(' ')}
)} title={line.requestId}
</div> >
{line.message && <div className={styles.message}>{line.message}</div>} {line.requestId}
</span>
)}
{line.path && (
<span className={styles.path} title={line.path}>
{line.path}
</span>
)}
{line.message && <span className={styles.message}>{line.message}</span>}
</div> </div>
</div> </div>
); );

View File

@@ -16,7 +16,6 @@ type PendingKey =
| 'switchProject' | 'switchProject'
| 'switchPreview' | 'switchPreview'
| 'usage' | 'usage'
| 'requestLog'
| 'loggingToFile' | 'loggingToFile'
| 'wsAuth'; | 'wsAuth';
@@ -70,7 +69,7 @@ export function SettingsPage() {
const toggleSetting = async ( const toggleSetting = async (
section: PendingKey, section: PendingKey,
rawKey: 'debug' | 'usage-statistics-enabled' | 'request-log' | 'logging-to-file' | 'ws-auth', rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth',
value: boolean, value: boolean,
updater: (val: boolean) => Promise<any>, updater: (val: boolean) => Promise<any>,
successMessage: string successMessage: string
@@ -81,8 +80,6 @@ export function SettingsPage() {
return config?.debug ?? false; return config?.debug ?? false;
case 'usage-statistics-enabled': case 'usage-statistics-enabled':
return config?.usageStatisticsEnabled ?? false; return config?.usageStatisticsEnabled ?? false;
case 'request-log':
return config?.requestLog ?? false;
case 'logging-to-file': case 'logging-to-file':
return config?.loggingToFile ?? false; return config?.loggingToFile ?? false;
case 'ws-auth': case 'ws-auth':
@@ -200,21 +197,6 @@ export function SettingsPage() {
} }
/> />
<ToggleSwitch
label={t('basic_settings.request_log_enable')}
checked={config?.requestLog ?? false}
disabled={disableControls || pending.requestLog || loading}
onChange={(value) =>
toggleSetting(
'requestLog',
'request-log',
value,
configApi.updateRequestLog,
t('notification.request_log_updated')
)
}
/>
<ToggleSwitch <ToggleSwitch
label={t('basic_settings.logging_to_file_enable')} label={t('basic_settings.logging_to_file_enable')}
checked={config?.loggingToFile ?? false} checked={config?.loggingToFile ?? false}

View File

@@ -48,6 +48,7 @@ const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
const serializeProviderKey = (config: ProviderKeyConfig) => { const serializeProviderKey = (config: ProviderKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey }; const payload: Record<string, any> = { 'api-key': config.apiKey };
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl; if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl; if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
const headers = serializeHeaders(config.headers); const headers = serializeHeaders(config.headers);
@@ -62,6 +63,7 @@ const serializeProviderKey = (config: ProviderKeyConfig) => {
const serializeGeminiKey = (config: GeminiKeyConfig) => { const serializeGeminiKey = (config: GeminiKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey }; const payload: Record<string, any> = { 'api-key': config.apiKey };
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl; if (config.baseUrl) payload['base-url'] = config.baseUrl;
const headers = serializeHeaders(config.headers); const headers = serializeHeaders(config.headers);
if (headers) payload.headers = headers; if (headers) payload.headers = headers;
@@ -79,6 +81,7 @@ const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
? provider.apiKeyEntries.map((entry) => serializeApiKeyEntry(entry)) ? provider.apiKeyEntries.map((entry) => serializeApiKeyEntry(entry))
: [] : []
}; };
if (provider.prefix?.trim()) payload.prefix = provider.prefix.trim();
const headers = serializeHeaders(provider.headers); const headers = serializeHeaders(provider.headers);
if (headers) payload.headers = headers; if (headers) payload.headers = headers;
const models = serializeModelAliases(provider.models); const models = serializeModelAliases(provider.models);

View File

@@ -70,6 +70,12 @@ const normalizeExcludedModels = (input: any): string[] => {
return normalized; return normalized;
}; };
const normalizePrefix = (value: any): string | undefined => {
if (value === undefined || value === null) return undefined;
const trimmed = String(value).trim();
return trimmed ? trimmed : undefined;
};
const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => { const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => {
if (!entry) return null; if (!entry) return null;
const apiKey = entry['api-key'] ?? entry.apiKey ?? entry.key ?? (typeof entry === 'string' ? entry : ''); const apiKey = entry['api-key'] ?? entry.apiKey ?? entry.key ?? (typeof entry === 'string' ? entry : '');
@@ -93,6 +99,8 @@ const normalizeProviderKeyConfig = (item: any): ProviderKeyConfig | null => {
if (!trimmed) return null; if (!trimmed) return null;
const config: ProviderKeyConfig = { apiKey: trimmed }; const config: ProviderKeyConfig = { apiKey: trimmed };
const prefix = normalizePrefix(item.prefix ?? item['prefix']);
if (prefix) config.prefix = prefix;
const baseUrl = item['base-url'] ?? item.baseUrl; const baseUrl = item['base-url'] ?? item.baseUrl;
const proxyUrl = item['proxy-url'] ?? item.proxyUrl; const proxyUrl = item['proxy-url'] ?? item.proxyUrl;
if (baseUrl) config.baseUrl = String(baseUrl); if (baseUrl) config.baseUrl = String(baseUrl);
@@ -118,6 +126,8 @@ const normalizeGeminiKeyConfig = (item: any): GeminiKeyConfig | null => {
if (!trimmed) return null; if (!trimmed) return null;
const config: GeminiKeyConfig = { apiKey: trimmed }; const config: GeminiKeyConfig = { apiKey: trimmed };
const prefix = normalizePrefix(item.prefix ?? item['prefix']);
if (prefix) config.prefix = prefix;
const baseUrl = item['base-url'] ?? item.baseUrl ?? item['base_url']; const baseUrl = item['base-url'] ?? item.baseUrl ?? item['base_url'];
if (baseUrl) config.baseUrl = String(baseUrl); if (baseUrl) config.baseUrl = String(baseUrl);
const headers = normalizeHeaders(item.headers); const headers = normalizeHeaders(item.headers);
@@ -155,6 +165,8 @@ const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null =>
apiKeyEntries apiKeyEntries
}; };
const prefix = normalizePrefix(provider.prefix ?? provider['prefix']);
if (prefix) result.prefix = prefix;
if (headers) result.headers = headers; if (headers) result.headers = headers;
if (models.length) result.models = models; if (models.length) result.models = models;
if (priority !== undefined) result.priority = Number(priority); if (priority !== undefined) result.priority = Number(priority);

View File

@@ -308,6 +308,12 @@ textarea {
} }
} }
.switch-label-left {
.label {
order: -1;
}
}
.pill { .pill {
padding: 4px 10px; padding: 4px 10px;
border-radius: $radius-full; border-radius: $radius-full;
@@ -410,6 +416,17 @@ textarea {
background: var(--bg-primary); background: var(--bg-primary);
} }
.request-log-modal {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $spacing-md;
.status-badge {
margin-bottom: 0;
}
}
.empty-state { .empty-state {
border: 1px dashed var(--border-color); border: 1px dashed var(--border-color);
border-radius: $radius-lg; border-radius: $radius-lg;

View File

@@ -18,6 +18,7 @@ export interface ApiKeyEntry {
export interface GeminiKeyConfig { export interface GeminiKeyConfig {
apiKey: string; apiKey: string;
prefix?: string;
baseUrl?: string; baseUrl?: string;
headers?: Record<string, string>; headers?: Record<string, string>;
excludedModels?: string[]; excludedModels?: string[];
@@ -25,6 +26,7 @@ export interface GeminiKeyConfig {
export interface ProviderKeyConfig { export interface ProviderKeyConfig {
apiKey: string; apiKey: string;
prefix?: string;
baseUrl?: string; baseUrl?: string;
proxyUrl?: string; proxyUrl?: string;
headers?: Record<string, string>; headers?: Record<string, string>;
@@ -34,6 +36,7 @@ export interface ProviderKeyConfig {
export interface OpenAIProviderConfig { export interface OpenAIProviderConfig {
name: string; name: string;
prefix?: string;
baseUrl: string; baseUrl: string;
apiKeyEntries: ApiKeyEntry[]; apiKeyEntries: ApiKeyEntry[];
headers?: Record<string, string>; headers?: Record<string, string>;