Compare commits
168 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e050aee804 | ||
|
|
7aee0577ea | ||
|
|
879112d856 | ||
|
|
027ab483d4 | ||
|
|
535c303aec | ||
|
|
6c2cd761ba | ||
|
|
3783bec983 | ||
|
|
b90239d39c | ||
|
|
f8d66917fd | ||
|
|
36bfd0fa6a | ||
|
|
709ce4c8dd | ||
|
|
525b152a76 | ||
|
|
e053854544 | ||
|
|
0b54b6de64 | ||
|
|
0c8686cefa | ||
|
|
385117d01a | ||
|
|
700bff1d03 | ||
|
|
680b24026c | ||
|
|
2da4099d0b | ||
|
|
8acef95e5a | ||
|
|
c892d939c7 | ||
|
|
50ab96c3ed | ||
|
|
0bb8090686 | ||
|
|
cade2647d6 | ||
|
|
3661530f5f | ||
|
|
f833f0dfd2 | ||
|
|
d5ccef8b24 | ||
|
|
ad6a3bd732 | ||
|
|
ad1387d076 | ||
|
|
babc7d9294 | ||
|
|
1d93296ce2 | ||
|
|
8911e325e1 | ||
|
|
131da6c718 | ||
|
|
b6d0a1e0f6 | ||
|
|
5f2566074f | ||
|
|
e2afc17f22 | ||
|
|
26fa1ea98e | ||
|
|
e568e4a2b5 | ||
|
|
4a0386472d | ||
|
|
b9001c27c5 | ||
|
|
e6e62e2992 | ||
|
|
f53d333198 | ||
|
|
adcf0b6582 | ||
|
|
11c2498be6 | ||
|
|
43ec39040d | ||
|
|
d981506942 | ||
|
|
7d41afb5f1 | ||
|
|
d4bc0bc622 | ||
|
|
5241d52b14 | ||
|
|
9887a78889 | ||
|
|
759e369d42 | ||
|
|
db487dc49d | ||
|
|
a94a9791bc | ||
|
|
ee60be6f0a | ||
|
|
259bf88f81 | ||
|
|
149d89a3d8 | ||
|
|
473cece09e | ||
|
|
aebe95d221 | ||
|
|
08e8fe2edd | ||
|
|
20d93142d6 | ||
|
|
9ee120feb8 | ||
|
|
dd21ec2c72 | ||
|
|
f54172e2df | ||
|
|
bca7082bb0 | ||
|
|
a61893d102 | ||
|
|
8513bd4186 | ||
|
|
d996b95f0a | ||
|
|
c4642444ef | ||
|
|
d9272d6d0e | ||
|
|
f8c4a434ed | ||
|
|
237cca5680 | ||
|
|
f0735dbc1e | ||
|
|
f77cbad98e | ||
|
|
0d40eecbe7 | ||
|
|
ce47d6d985 | ||
|
|
01a69ff32b | ||
|
|
fd1174e010 | ||
|
|
3e55d601a1 | ||
|
|
c6fabcb6bc | ||
|
|
460519ed00 | ||
|
|
1053e91fe4 | ||
|
|
b4d08dd0d7 | ||
|
|
1502e14ca7 | ||
|
|
7b77520526 | ||
|
|
525541ea0d | ||
|
|
e7a33f8852 | ||
|
|
70968bbc4c | ||
|
|
c93030370e | ||
|
|
96307873c5 | ||
|
|
b4eb2d790c | ||
|
|
3d33958d9e | ||
|
|
e4c5f80b02 | ||
|
|
291f67e2b9 | ||
|
|
3cdcb7a2a3 | ||
|
|
3d83d0bfe2 | ||
|
|
129d89cf67 | ||
|
|
5c85df486e | ||
|
|
34b6d114d3 | ||
|
|
94f0038f19 | ||
|
|
aa9c7d89f9 | ||
|
|
9bbf61e1b6 | ||
|
|
73198d6929 | ||
|
|
ab86fcf674 | ||
|
|
a88078e171 | ||
|
|
8148851a06 | ||
|
|
dcdb20159b | ||
|
|
54e6121234 | ||
|
|
8b3c4189f1 | ||
|
|
db5fb0d125 | ||
|
|
9515d88e3c | ||
|
|
2bf721974b | ||
|
|
0c53dcfa80 | ||
|
|
6096ffc94e | ||
|
|
77fe9905b1 | ||
|
|
eaaa8f6e80 | ||
|
|
4932374bdd | ||
|
|
82cb521b2e | ||
|
|
034c086e31 | ||
|
|
76e9eb4aa0 | ||
|
|
f22d392b21 | ||
|
|
2539710075 | ||
|
|
6bdc87aed6 | ||
|
|
268b92c59b | ||
|
|
c89bbd5098 | ||
|
|
2715f44a5e | ||
|
|
305ddef900 | ||
|
|
7e56d33bf0 | ||
|
|
80daf03fa6 | ||
|
|
8c3ac0d50a | ||
|
|
883059b031 | ||
|
|
e4850656a5 | ||
|
|
d077b5dd26 | ||
|
|
d79ccc480d | ||
|
|
7b0d6dc7e9 | ||
|
|
b8d7b8997c | ||
|
|
0bb34ca74b | ||
|
|
99c4fbc30d | ||
|
|
a44257edda | ||
|
|
ebb80df24a | ||
|
|
5165715d37 | ||
|
|
73ee6eb2f3 | ||
|
|
161d5d1e7f | ||
|
|
3cbd04b296 | ||
|
|
859f7f120c | ||
|
|
fea29f7318 | ||
|
|
f663b83ac8 | ||
|
|
ee99836285 | ||
|
|
2086c348a9 | ||
|
|
a8abf71bfe | ||
|
|
8dca670358 | ||
|
|
71556a51c5 | ||
|
|
2a92ea8862 | ||
|
|
681fc3cee5 | ||
|
|
916dd3ec26 | ||
|
|
692f7f3cde | ||
|
|
bf20f3d86e | ||
|
|
b7e720133d | ||
|
|
e914337e57 | ||
|
|
6364bac1f2 | ||
|
|
38a3e20427 | ||
|
|
334d75f2dd | ||
|
|
42eb783395 | ||
|
|
84b219957e | ||
|
|
f5c1ef36ce | ||
|
|
fae4fb0fed | ||
|
|
1d8729ec53 | ||
|
|
c6ef8a259f | ||
|
|
0efef5a789 |
4
.gitignore
vendored
@@ -18,6 +18,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
skills
|
||||
|
||||
# Editor directories and files
|
||||
settings.local.json
|
||||
@@ -30,3 +31,6 @@ settings.local.json
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
tmpclaude*
|
||||
.claude
|
||||
CLIProxyAPI
|
||||
363
README.md
@@ -1,130 +1,305 @@
|
||||
# CLI Proxy API Management Center
|
||||
# CLI Proxy API 管理中心 (CPAMC)
|
||||
|
||||
A single-file WebUI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, logs, and usage).
|
||||
> 一个基于官方仓库二次创作的 Web 管理界面
|
||||
|
||||
[中文文档](README_CN.md)
|
||||
**[English](README_EN.md) | [中文](README.md)**
|
||||
|
||||
**Main Project**: https://github.com/router-for-me/CLIProxyAPI
|
||||
**Example URL**: https://remote.router-for.me/
|
||||
**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.
|
||||
## 关于本项目
|
||||
|
||||
## What this is (and isn’t)
|
||||
本项目是基于官方 [CLI Proxy API WebUI](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) 进行开发的日志监控和数据可视化管理界面
|
||||
|
||||
- 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.
|
||||
### 与官方版本的区别
|
||||
|
||||
## Quick start
|
||||
本版本与官方版本其他功能保持一致,主要差异在于**新增监控中心**,对日志分析和查看的增强
|
||||
|
||||
### Option A: Use the WebUI bundled in CLIProxyAPI (recommended)
|
||||
### 界面预览
|
||||
|
||||
1. Start your CLI Proxy API service.
|
||||
2. Open: `http://<host>:<api_port>/management.html`
|
||||
3. Enter your **management key** and connect.
|
||||
管理界面展示
|
||||
|
||||
The address is auto-detected from the current page URL; manual override is supported.
|
||||

|
||||
|
||||
### Option B: Run the dev server
|
||||
---
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
## 快速开始
|
||||
|
||||
### 使用本管理界面
|
||||
|
||||
在你的 `config.yaml` 中修改以下配置:
|
||||
|
||||
```yaml
|
||||
remote-management:
|
||||
panel-github-repository: "https://github.com/kongkongyo/Cli-Proxy-API-Management-Center"
|
||||
```
|
||||
|
||||
Open `http://localhost:5173`, then connect to your CLI Proxy API instance.
|
||||
配置完成后,重启 CLI Proxy API 服务,访问 `http://<host>:<api_port>/management.html` 即可查看管理界面
|
||||
|
||||
### Option C: Build a single HTML file
|
||||
详细配置说明请参考官方文档:https://help.router-for.me/cn/management/webui.html
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
---
|
||||
|
||||
## 主要功能
|
||||
|
||||
### 监控中心 - 核心新增功能
|
||||
|
||||
这是本管理界面相对于官方版本的唯一新增功能,提供了全方位的数据可视化和监控能力
|
||||
|
||||
> 注意:CLI Proxy API 主程序目前没有数据持久化功能,重启程序后统计数据会丢失。需要先通过 API 使用相关服务产生数据后,才能在监控中心看到统计信息。
|
||||
|
||||
#### KPI 指标仪表盘
|
||||
|
||||
实时展示核心运营指标,支持按时间范围筛选:
|
||||
- **请求数**:总请求数、成功/失败统计、成功率百分比
|
||||
- **Token 数**:总 Token 数、输入 Token、输出 Token
|
||||
- **平均 TPM**:每分钟 Token 使用量
|
||||
- **平均 RPM**:每分钟请求数
|
||||
- **日均 RPD**:日均请求数
|
||||
|
||||
所有指标都会根据选择的时间范围(今天/7天/14天/30天)动态计算,实时更新
|
||||
|
||||
#### 模型用量分布
|
||||
|
||||
直观的饼图展示不同模型的使用占比:
|
||||
- 按请求数分布
|
||||
- 按 Token 数分布
|
||||
- 可切换查看请求占比或 Token 占比
|
||||
|
||||
#### 每日趋势分析
|
||||
|
||||
详细的时间序列图表,展示每日用量变化趋势:
|
||||
- 请求数趋势曲线
|
||||
- 输入 Token 趋势
|
||||
- 输出 Token 趋势
|
||||
- 思考 Token 趋势(如支持)
|
||||
- 缓存 Token 趋势
|
||||
|
||||
#### 每小时分析
|
||||
|
||||
两个详细的小时级图表,帮助定位高峰时段:
|
||||
|
||||
**每小时模型请求分布**
|
||||
- 柱状图展示不同模型在各小时的请求数
|
||||
- 支持最近 6 小时/12 小时/24 小时/全部视图切换
|
||||
|
||||
**每小时 Token 用量**
|
||||
- 堆叠柱状图展示 Token 使用构成
|
||||
- 区分输入 Token、输出 Token、思考 Token、缓存 Token
|
||||
|
||||
#### 渠道统计
|
||||
|
||||
详细表格展示各渠道(API Key/模型)的使用情况:
|
||||
- 可按全部渠道/特定渠道筛选
|
||||
- 可按全部模型/特定模型筛选
|
||||
- 可按全部状态/仅成功/仅失败筛选
|
||||
- 显示渠道名称、请求数、成功率
|
||||
- 点击展开查看该渠道下各模型的详细统计
|
||||
- 显示最近请求状态(最近 10 次请求的迷你状态条)
|
||||
- 最近请求时间
|
||||
|
||||
#### 失败来源分析
|
||||
|
||||
帮助定位问题渠道和模型:
|
||||
- 按渠道统计失败次数
|
||||
- 显示最近失败时间
|
||||
- 列出主要失败的模型
|
||||
- 点击展开查看该渠道下所有失败的请求详情
|
||||
|
||||
#### 请求日志 - 高级功能
|
||||
|
||||
功能强大的请求日志表格,支持海量数据流畅浏览
|
||||
|
||||
**多维度筛选**
|
||||
- 按 API Key 筛选
|
||||
- 按提供商类型筛选(OpenAI/Gemini/Claude 等)
|
||||
- 按模型名称筛选
|
||||
- 按来源渠道筛选
|
||||
- 按请求状态筛选(全部/成功/失败)
|
||||
|
||||
**独立时间范围**
|
||||
- 支持今天/7天/14天/30天/自定义日期范围
|
||||
- 与主页面时间范围独立控制
|
||||
|
||||
**虚拟滚动**
|
||||
- 支持 10 万+ 条日志流畅浏览
|
||||
- 显示当前可见范围统计
|
||||
- 性能优化,只渲染可见行
|
||||
|
||||
**智能信息展示**
|
||||
- 自动匹配 API Key 到提供商名称(基于配置信息)
|
||||
- 完整的渠道信息(提供商名称 + 掩码后的密钥)
|
||||
- 请求类型/模型名称/请求状态
|
||||
- 最近 10 次请求的状态可视化(绿点=成功,红点=失败)
|
||||
- 成功率百分比
|
||||
- 总请求数/输入 Token/输出 Token/总 Token
|
||||
- 请求时间(完整时间戳)
|
||||
|
||||
**自动刷新**
|
||||
- 支持手动刷新 / 5秒 / 10秒 / 15秒 / 30秒 / 60秒 自动刷新
|
||||
- 倒计时显示下次刷新时间
|
||||
- 独立数据加载,不阻塞主页面
|
||||
|
||||
**一键禁用模型**
|
||||
- 支持直接在日志中禁用某渠道的某个模型
|
||||
- 只对支持该操作的渠道类型生效
|
||||
- 不支持时显示提示和手动操作指南
|
||||
|
||||
---
|
||||
|
||||
## 官方版本功能
|
||||
|
||||
以下功能与官方版本一致,通过改进的界面提供更好的使用体验
|
||||
|
||||
### 仪表盘
|
||||
- 连接状态实时监控
|
||||
- 服务器版本和构建信息一目了然
|
||||
- 使用数据快速概览,掌握全局
|
||||
- 可用模型统计
|
||||
|
||||
### API 密钥管理
|
||||
- 添加、编辑、删除 API 密钥
|
||||
- 管理代理服务认证
|
||||
|
||||
### AI 提供商配置
|
||||
- **Gemini**:API 密钥管理、排除模型、模型前缀
|
||||
- **Claude**:API 密钥和配置、自定义模型列表
|
||||
- **Codex**:完整配置管理(API 密钥、Base URL、代理)
|
||||
- **Vertex**:模型映射配置
|
||||
- **OpenAI 兼容**:多密钥管理、模型别名导入、连通性测试
|
||||
- **Ampcode**:上游集成和模型映射
|
||||
|
||||
### 认证文件管理
|
||||
- 上传、下载、删除 JSON 认证文件
|
||||
- 支持多种提供商(Qwen、Gemini、Claude 等)
|
||||
- 搜索、筛选、分页浏览
|
||||
- 查看每个凭证支持的模型
|
||||
|
||||
### OAuth 登录
|
||||
- 一键启动 OAuth 授权流程
|
||||
- 支持 Codex、Anthropic、Gemini CLI、Qwen、iFlow 等
|
||||
- 自动保存认证文件
|
||||
- 支持远程浏览器回调提交
|
||||
|
||||
### 配额管理
|
||||
- Antigravity 额度查询
|
||||
- Codex 额度查询(5 小时、周限额、代码审查)
|
||||
- Gemini CLI 额度查询
|
||||
- 一键刷新所有额度
|
||||
|
||||
### 使用统计
|
||||
- 请求/Token 趋势图表
|
||||
- 按模型和 API 的详细统计
|
||||
- RPM/TPM 实时速率
|
||||
- 缓存和推理 Token 分解
|
||||
- 成本估算(支持自定义价格)
|
||||
|
||||
### 配置管理
|
||||
- 在线编辑 `config.yaml`
|
||||
- YAML 语法高亮
|
||||
- 搜索和导航
|
||||
- 保存和重载配置
|
||||
|
||||
### 日志查看
|
||||
- 实时日志流
|
||||
- 搜索和过滤
|
||||
- 自动刷新
|
||||
- 下载错误日志
|
||||
- 屏蔽管理端流量
|
||||
|
||||
### 中心信息
|
||||
- 连接状态检查
|
||||
- 版本更新检查
|
||||
- 可用模型列表展示
|
||||
- 快捷链接入口
|
||||
|
||||
---
|
||||
|
||||
## 连接说明
|
||||
|
||||
### API 地址格式
|
||||
|
||||
以下格式都可以,系统会自动识别
|
||||
|
||||
```
|
||||
localhost:8317
|
||||
http://192.168.1.10:8317
|
||||
https://example.com:8317
|
||||
```
|
||||
|
||||
- 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`
|
||||
### 管理密钥
|
||||
|
||||
Tip: opening `dist/index.html` via `file://` may be blocked by browser CORS; serving it (preview/static server) is more reliable.
|
||||
管理密钥是验证管理操作的钥匙,和客户端使用的 API 密钥不一样
|
||||
|
||||
## Connecting to the server
|
||||
### 远程管理
|
||||
|
||||
### API address
|
||||
从非本地浏览器访问的时候,需要在服务器启用远程管理(`allow-remote-management: true`)
|
||||
|
||||
You can enter any of the following; the UI will normalize it:
|
||||
---
|
||||
|
||||
- `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)
|
||||
## 界面特性
|
||||
|
||||
### Management key (not the same as API keys)
|
||||
### 主题切换
|
||||
- 亮色模式
|
||||
- 暗色模式
|
||||
- 跟随系统
|
||||
|
||||
The management key is sent with every request as:
|
||||
### 语言支持
|
||||
- 简体中文
|
||||
- English
|
||||
|
||||
- `Authorization: Bearer <MANAGEMENT_KEY>` (default)
|
||||
### 响应式设计
|
||||
- 桌面端完整功能
|
||||
- 移动端适配体验
|
||||
- 侧边栏可折叠
|
||||
|
||||
This is different from the proxy `api-keys` you manage inside the UI (those are for client requests to the proxy endpoints).
|
||||
---
|
||||
|
||||
### Remote management
|
||||
## 常见问题
|
||||
|
||||
If you connect from a non-localhost browser, the server must allow remote management (e.g. `allow-remote-management: true`).
|
||||
See `api.md` for the full authentication rules, server-side limits, and edge cases.
|
||||
**Q: 如何使用这个自定义 UI?**
|
||||
|
||||
## What you can manage (mapped to the UI pages)
|
||||
|
||||
- **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
|
||||
|
||||
- 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).
|
||||
|
||||
## Security notes
|
||||
|
||||
- The management key is stored in browser `localStorage` using a lightweight obfuscation format (`enc::v1::...`) to avoid plaintext storage; treat it as sensitive.
|
||||
- Use a dedicated browser profile/device for management. Be cautious when enabling remote management and evaluate its exposure surface.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Can’t 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.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server
|
||||
npm run build # tsc + Vite build
|
||||
npm run preview # serve dist locally
|
||||
npm run lint # ESLint (fails on warnings)
|
||||
npm run format # Prettier
|
||||
npm run type-check # tsc --noEmit
|
||||
A: 在 CLI Proxy API 的配置文件中添加以下配置即可
|
||||
```yaml
|
||||
remote-management:
|
||||
panel-github-repository: "https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard"
|
||||
```
|
||||
|
||||
## Contributing
|
||||
**Q: 无法连接到服务器?**
|
||||
|
||||
Issues and PRs are welcome. Please include:
|
||||
A: 请检查以下内容
|
||||
- API 地址是否正确
|
||||
- 管理密钥是否正确
|
||||
- 服务器是否启动
|
||||
- 远程访问是否启用
|
||||
|
||||
- Reproduction steps (server version + UI version)
|
||||
- Screenshots for UI changes
|
||||
- Verification notes (`npm run lint`, `npm run type-check`)
|
||||
**Q: 日志页面不显示?**
|
||||
|
||||
## License
|
||||
A: 需要去"基础设置"里开启"日志记录到文件"功能
|
||||
|
||||
MIT
|
||||
**Q: 某些功能显示"不支持"?**
|
||||
|
||||
A: 可能是服务器版本太旧,升级到最新版本的 CLI Proxy API
|
||||
|
||||
**Q: OpenAI 提供商测试失败?**
|
||||
|
||||
A: 测试是在浏览器端执行的,可能会受到 CORS 限制,失败不一定代表服务器端不能用
|
||||
|
||||
**Q: 这个版本和官方版本有什么区别?**
|
||||
|
||||
A: 主要区别有两个:
|
||||
1. **界面风格**:全新的视觉设计,UI 细节更精致
|
||||
2. **监控中心**:这是唯一新增的功能模块,提供了强大的数据可视化和监控能力,包括 KPI 仪表盘、模型用量分布、趋势分析、小时级图表、渠道统计、失败分析和高级请求日志等功能
|
||||
|
||||
其他所有功能与官方版本保持一致
|
||||
|
||||
---
|
||||
|
||||
## 相关链接
|
||||
|
||||
- **官方主程序**: https://github.com/router-for-me/CLIProxyAPI
|
||||
- **官方 WebUI**: https://github.com/router-for-me/Cli-Proxy-API-Management-Center
|
||||
- **本仓库**: https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
130
README_CN.md
@@ -1,130 +0,0 @@
|
||||
# CLI Proxy API 管理中心
|
||||
|
||||
用于管理与排障 **CLI Proxy API** 的单文件 WebUI(React + TypeScript),通过 **Management API** 完成配置、凭据、日志与统计等运维工作。
|
||||
|
||||
[English](README.md)
|
||||
|
||||
**主项目**: https://github.com/router-for-me/CLIProxyAPI
|
||||
**示例地址**: https://remote.router-for.me/
|
||||
**最低版本要求**: ≥ 6.3.0(推荐 ≥ 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.
|
||||
|
||||
## 这是什么(以及不是什么)
|
||||
|
||||
- 本仓库只包含 Web 管理界面本身,通过 CLI Proxy API 的 **Management API**(`/v0/management`)读取/修改配置、上传凭据、查看日志与使用统计。
|
||||
- 它 **不是** 代理本体,不参与流量转发。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 方式 A:使用 CLIProxyAPI 自带的 WebUI(推荐)
|
||||
|
||||
1. 启动 CLI Proxy API 服务。
|
||||
2. 打开:`http://<host>:<api_port>/management.html`
|
||||
3. 输入 **管理密钥** 并连接。
|
||||
|
||||
页面会根据当前地址自动推断 API 地址,也支持手动修改。
|
||||
|
||||
### 方式 B:开发调试
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 实例。
|
||||
|
||||
### 方式 C:构建单文件 HTML
|
||||
|
||||
```bash
|
||||
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
|
||||
npm run dev # 启动开发服务器
|
||||
npm run build # tsc + Vite 构建
|
||||
npm run preview # 本地预览 dist
|
||||
npm run lint # ESLint(warnings 视为失败)
|
||||
npm run format # Prettier
|
||||
npm run type-check # tsc --noEmit
|
||||
```
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提 Issue 与 PR。建议附上:
|
||||
|
||||
- 复现步骤(服务端版本 + UI 版本)
|
||||
- UI 改动截图
|
||||
- 验证记录(`npm run lint`、`npm run type-check`)
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
305
README_EN.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# CLI Proxy API Management Center (CPAMC)
|
||||
|
||||
> A Web management interface based on the official repository with custom modifications
|
||||
|
||||
**[English](README_EN.md) | [中文](README.md)**
|
||||
|
||||
---
|
||||
|
||||
## About This Project
|
||||
|
||||
This project is a log monitoring and data visualization management interface developed based on the official [CLI Proxy API WebUI](https://github.com/router-for-me/Cli-Proxy-API-Management-Center)
|
||||
|
||||
### Differences from Official Version
|
||||
|
||||
This version is consistent with the official version in other functions, with the main difference being the **new monitoring center**, which enhances log analysis and viewing
|
||||
|
||||
### Interface Preview
|
||||
|
||||
Management interface display
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using This Management Interface
|
||||
|
||||
Modify following configuration in your `config.yaml`:
|
||||
|
||||
```yaml
|
||||
remote-management:
|
||||
panel-github-repository: "https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard"
|
||||
```
|
||||
|
||||
After configuration, restart the CLI Proxy API service and visit `http://<host>:<api_port>/management.html` to view the management interface
|
||||
|
||||
For detailed configuration instructions, please refer to the official documentation: https://help.router-for.me/cn/management/webui.html
|
||||
|
||||
---
|
||||
|
||||
## Main Features
|
||||
|
||||
### Monitoring Center - Core New Feature
|
||||
|
||||
This is the only new feature of this management interface compared to the official version, providing comprehensive data visualization and monitoring capabilities
|
||||
|
||||
> Note: The CLI Proxy API main program currently does not have data persistence functionality. Statistical data will be lost after restarting the program. You need to use related services through the API first to generate data before you can see statistical information in the monitoring center.
|
||||
|
||||
#### KPI Dashboard
|
||||
|
||||
Real-time display of core operational metrics, supports filtering by time range:
|
||||
- **Request Count**: Total requests, success/failure statistics, success rate percentage
|
||||
- **Token Count**: Total tokens, input tokens, output tokens
|
||||
- **Average TPM**: Tokens per minute
|
||||
- **Average RPM**: Requests per minute
|
||||
- **Average RPD**: Daily average requests
|
||||
|
||||
All metrics are dynamically calculated and updated in real-time based on the selected time range (today/7 days/14 days/30 days)
|
||||
|
||||
#### Model Usage Distribution
|
||||
|
||||
Intuitive pie chart showing the usage distribution of different models:
|
||||
- Distribution by request count
|
||||
- Distribution by token count
|
||||
- Switchable between request percentage and token percentage
|
||||
|
||||
#### Daily Trend Analysis
|
||||
|
||||
Detailed time series charts showing daily usage trends:
|
||||
- Request count trend curve
|
||||
- Input token trend
|
||||
- Output token trend
|
||||
- Thinking token trend (if supported)
|
||||
- Cache token trend
|
||||
|
||||
#### Hourly Analysis
|
||||
|
||||
Two detailed hourly charts to help identify peak periods:
|
||||
|
||||
**Hourly Model Request Distribution**
|
||||
- Bar chart showing requests for different models in each hour
|
||||
- Supports switching between recent 6 hours/12 hours/24 hours/all views
|
||||
|
||||
**Hourly Token Usage**
|
||||
- Stacked bar chart showing token usage composition
|
||||
- Distinguishes between input tokens, output tokens, thinking tokens, cache tokens
|
||||
|
||||
#### Channel Statistics
|
||||
|
||||
Detailed table showing usage of each channel (API Key/model):
|
||||
- Filter by all channels/specific channel
|
||||
- Filter by all models/specific model
|
||||
- Filter by all status/success only/failure only
|
||||
- Display channel name, request count, success rate
|
||||
- Click to expand and view detailed statistics of each model under that channel
|
||||
- Display recent request status (mini status bar of recent 10 requests)
|
||||
- Most recent request time
|
||||
|
||||
#### Failure Source Analysis
|
||||
|
||||
Help locate problematic channels and models:
|
||||
- Statistics of failure counts by channel
|
||||
- Display most recent failure time
|
||||
- List of main failed models
|
||||
- Click to expand and view all failed request details under that channel
|
||||
|
||||
#### Request Logs - Advanced Feature
|
||||
|
||||
Powerful request log table, supports smooth browsing of massive data
|
||||
|
||||
**Multi-dimensional Filtering**
|
||||
- Filter by API Key
|
||||
- Filter by provider type (OpenAI/Gemini/Claude, etc.)
|
||||
- Filter by model name
|
||||
- Filter by source channel
|
||||
- Filter by request status (all/success/failure)
|
||||
|
||||
**Independent Time Range**
|
||||
- Supports today/7 days/14 days/30 days/custom date range
|
||||
- Independent control from main page time range
|
||||
|
||||
**Virtual Scrolling**
|
||||
- Supports smooth browsing of 100,000+ logs
|
||||
- Display current visible range statistics
|
||||
- Performance optimized, only renders visible rows
|
||||
|
||||
**Smart Information Display**
|
||||
- Automatically match API Key to provider name (based on configuration)
|
||||
- Complete channel information (provider name + masked key)
|
||||
- Request type/model name/request status
|
||||
- Status visualization of recent 10 requests (green dot=success, red dot=failure)
|
||||
- Success rate percentage
|
||||
- Total requests/input tokens/output tokens/total tokens
|
||||
- Request time (complete timestamp)
|
||||
|
||||
**Auto Refresh**
|
||||
- Supports manual refresh / 5s / 10s / 15s / 30s / 60s auto refresh
|
||||
- Countdown display for next refresh time
|
||||
- Independent data loading, does not block main page
|
||||
|
||||
**One-click Disable Model**
|
||||
- Supports directly disabling a specific model of a channel in logs
|
||||
- Only effective for channel types that support this operation
|
||||
- Shows prompt and manual operation guide when not supported
|
||||
|
||||
---
|
||||
|
||||
## Official Version Features
|
||||
|
||||
The following features are consistent with the official version, providing a better user experience through an improved interface
|
||||
|
||||
### Dashboard
|
||||
- Real-time connection status monitoring
|
||||
- Server version and build information at a glance
|
||||
- Quick overview of usage data
|
||||
- Available model statistics
|
||||
|
||||
### API Key Management
|
||||
- Add, edit, delete API keys
|
||||
- Manage proxy service authentication
|
||||
|
||||
### AI Provider Configuration
|
||||
- **Gemini**: API key management, model exclusion, model prefix
|
||||
- **Claude**: API key and configuration, custom model list
|
||||
- **Codex**: Complete configuration management (API key, Base URL, proxy)
|
||||
- **Vertex**: Model mapping configuration
|
||||
- **OpenAI Compatible**: Multi-key management, model alias import, connectivity testing
|
||||
- **Ampcode**: Upstream integration and model mapping
|
||||
|
||||
### Authentication File Management
|
||||
- Upload, download, delete JSON authentication files
|
||||
- Supports multiple providers (Qwen, Gemini, Claude, etc.)
|
||||
- Search, filter, paginated browsing
|
||||
- View models supported by each credential
|
||||
|
||||
### OAuth Login
|
||||
- One-click start OAuth authorization flow
|
||||
- Supports Codex, Anthropic, Gemini CLI, Qwen, iFlow, etc.
|
||||
- Automatically save authentication files
|
||||
- Supports remote browser callback submission
|
||||
|
||||
### Quota Management
|
||||
- Antigravity quota query
|
||||
- Codex quota query (5 hours, weekly limit, code review)
|
||||
- Gemini CLI quota query
|
||||
- One-click refresh all quotas
|
||||
|
||||
### Usage Statistics
|
||||
- Request/Token trend charts
|
||||
- Detailed statistics by model and API
|
||||
- RPM/TPM real-time rates
|
||||
- Cache and reasoning token breakdown
|
||||
- Cost estimation (supports custom prices)
|
||||
|
||||
### Configuration Management
|
||||
- Online editing of `config.yaml`
|
||||
- YAML syntax highlighting
|
||||
- Search and navigation
|
||||
- Save and reload configuration
|
||||
|
||||
### Log Viewing
|
||||
- Real-time log stream
|
||||
- Search and filtering
|
||||
- Auto refresh
|
||||
- Download error logs
|
||||
- Mask management traffic
|
||||
|
||||
### Center Information
|
||||
- Connection status check
|
||||
- Version update check
|
||||
- Available model list display
|
||||
- Quick link entry
|
||||
|
||||
---
|
||||
|
||||
## Connection Instructions
|
||||
|
||||
### API Address Format
|
||||
|
||||
The following formats are all supported, and the system will automatically recognize them
|
||||
|
||||
```
|
||||
localhost:8317
|
||||
http://192.168.1.10:8317
|
||||
https://example.com:8317
|
||||
```
|
||||
|
||||
### Management Key
|
||||
|
||||
The management key is the key for verifying management operations and is different from the API key used by clients
|
||||
|
||||
### Remote Management
|
||||
|
||||
When accessing from a non-local browser, you need to enable remote management on the server (`allow-remote-management: true`)
|
||||
|
||||
---
|
||||
|
||||
## Interface Features
|
||||
|
||||
### Theme Switching
|
||||
- Light mode
|
||||
- Dark mode
|
||||
- Follow system
|
||||
|
||||
### Language Support
|
||||
- Simplified Chinese
|
||||
- English
|
||||
|
||||
### Responsive Design
|
||||
- Full functionality on desktop
|
||||
- Mobile-adapted experience
|
||||
- Collapsible sidebar
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: How to use this custom UI?**
|
||||
|
||||
A: Add the following configuration to your CLI Proxy API configuration file
|
||||
```yaml
|
||||
remote-management:
|
||||
panel-github-repository: "https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard"
|
||||
```
|
||||
|
||||
**Q: Cannot connect to the server?**
|
||||
|
||||
A: Please check the following
|
||||
- Is the API address correct?
|
||||
- Is the management key correct?
|
||||
- Is the server started?
|
||||
- Is remote access enabled?
|
||||
|
||||
**Q: Log page not displaying?**
|
||||
|
||||
A: You need to enable the "Log to file" function in "Basic Settings"
|
||||
|
||||
**Q: Some functions show "not supported"?**
|
||||
|
||||
A: The server version may be too old. Upgrade to the latest version of CLI Proxy API
|
||||
|
||||
**Q: OpenAI provider test failed?**
|
||||
|
||||
A: Tests are executed in the browser and may be subject to CORS restrictions. Failure does not necessarily mean it won't work on the server side
|
||||
|
||||
**Q: What is the difference between this version and the official version?**
|
||||
|
||||
A: There are two main differences:
|
||||
1. **Interface Style**: Completely new visual design with more refined UI details
|
||||
2. **Monitoring Center**: This is the only newly added feature module, providing powerful data visualization and monitoring capabilities, including KPI dashboard, model usage distribution, trend analysis, hourly charts, channel statistics, failure analysis, and advanced request logs
|
||||
|
||||
All other features remain consistent with the official version
|
||||
|
||||
---
|
||||
|
||||
## Related Links
|
||||
|
||||
- **Official Main Program**: https://github.com/router-for-me/CLIProxyAPI
|
||||
- **Official WebUI**: https://github.com/router-for-me/Cli-Proxy-API-Management-Center
|
||||
- **This Repository**: https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
BIN
dashboard-preview.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
90
package-lock.json
generated
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "cli-proxy-webui-react",
|
||||
"version": "0.0.0",
|
||||
"version": "1.1.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cli-proxy-webui-react",
|
||||
"version": "0.0.0",
|
||||
"version": "1.1.3",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@uiw/react-codemirror": "^4.25.3",
|
||||
"axios": "^1.13.2",
|
||||
"chart.js": "^4.5.1",
|
||||
@@ -19,6 +20,7 @@
|
||||
"react-dom": "^19.2.1",
|
||||
"react-i18next": "^16.4.0",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"yaml": "^2.8.2",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -71,6 +73,7 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -465,6 +468,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
|
||||
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
@@ -1240,6 +1244,18 @@
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@openai/codex": {
|
||||
"version": "0.98.0",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.98.0.tgz",
|
||||
"integrity": "sha512-CKjrhAmzTvWn7Vbsi27iZRKBAJw9a7ZTTkWQDbLgQZP1weGbDIBk1r6wiLEp1ZmDO7w0fHPLYgnVspiOrYgcxg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"codex": "bin/codex.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
|
||||
@@ -1865,6 +1881,33 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.18",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
|
||||
"integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.18",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
|
||||
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1930,6 +1973,7 @@
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2017,6 +2061,7 @@
|
||||
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.48.1",
|
||||
"@typescript-eslint/types": "8.48.1",
|
||||
@@ -2334,6 +2379,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2545,6 +2591,7 @@
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
@@ -2809,6 +2856,7 @@
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3285,6 +3333,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
@@ -3614,6 +3663,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3720,6 +3770,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
||||
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -3737,6 +3788,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
|
||||
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -3780,9 +3832,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz",
|
||||
"integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==",
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
|
||||
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -3802,12 +3854,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz",
|
||||
"integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==",
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
|
||||
"integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.10.1"
|
||||
"react-router": "7.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -3845,6 +3897,7 @@
|
||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -4027,6 +4080,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -4103,6 +4157,7 @@
|
||||
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -4227,6 +4282,22 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"dev": true,
|
||||
@@ -4244,6 +4315,7 @@
|
||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "cli-proxy-webui-react",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.1.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@uiw/react-codemirror": "^4.25.3",
|
||||
"axios": "^1.13.2",
|
||||
"chart.js": "^4.5.1",
|
||||
@@ -23,6 +24,7 @@
|
||||
"react-dom": "^19.2.1",
|
||||
"react-i18next": "^16.4.0",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"yaml": "^2.8.2",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
41
src/App.tsx
@@ -1,32 +1,21 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { HashRouter, Route, Routes } from 'react-router-dom';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
||||
import { SplashScreen } from '@/components/common/SplashScreen';
|
||||
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
|
||||
import { MainLayout } from '@/components/layout/MainLayout';
|
||||
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
||||
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores';
|
||||
|
||||
const SPLASH_DURATION = 1500;
|
||||
const SPLASH_FADE_DURATION = 400;
|
||||
import { useLanguageStore, useThemeStore } from '@/stores';
|
||||
|
||||
function App() {
|
||||
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
||||
const language = useLanguageStore((state) => state.language);
|
||||
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
||||
|
||||
const [splashReadyToFade, setSplashReadyToFade] = useState(false);
|
||||
const [showSplash, setShowSplash] = useState(true);
|
||||
const [authReady, setAuthReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanupTheme = initializeTheme();
|
||||
void restoreSession().finally(() => {
|
||||
setAuthReady(true);
|
||||
});
|
||||
return cleanupTheme;
|
||||
}, [initializeTheme, restoreSession]);
|
||||
}, [initializeTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
setLanguage(language);
|
||||
@@ -37,30 +26,10 @@ function App() {
|
||||
document.documentElement.lang = language;
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setSplashReadyToFade(true);
|
||||
}, SPLASH_DURATION - SPLASH_FADE_DURATION);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleSplashFinish = useCallback(() => {
|
||||
setShowSplash(false);
|
||||
}, []);
|
||||
|
||||
if (showSplash) {
|
||||
return (
|
||||
<SplashScreen
|
||||
fadeOut={splashReadyToFade && authReady}
|
||||
onFinish={handleSplashFinish}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HashRouter>
|
||||
<NotificationContainer />
|
||||
<ConfirmationModal />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
|
||||
25
src/assets/icons/codex_drak.svg
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="256.000000pt" height="256.000000pt" viewBox="0 0 256.000000 256.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,256.000000) scale(0.100000,-0.100000)"
|
||||
fill="#FFFFFF" stroke="none">
|
||||
<path d="M1107 2290 c-316 -57 -615 -283 -748 -565 -68 -144 -91 -241 -96
|
||||
-406 -6 -156 7 -249 49 -374 87 -254 291 -478 542 -596 146 -68 226 -84 426
|
||||
-84 152 0 186 3 260 23 182 50 327 136 465 277 147 150 245 334 282 529 23
|
||||
123 14 344 -20 456 -35 116 -69 190 -134 290 -131 200 -340 354 -578 426 -78
|
||||
23 -111 27 -245 30 -85 1 -177 -1 -203 -6z m362 -216 c91 -21 224 -86 310
|
||||
-152 133 -101 249 -275 293 -439 16 -60 21 -108 21 -203 0 -152 -21 -240 -88
|
||||
-368 -130 -253 -350 -407 -634 -443 -393 -50 -777 214 -882 607 -30 110 -30
|
||||
296 0 408 72 270 282 489 552 576 130 41 287 47 428 14z"/>
|
||||
<path d="M849 1637 c-31 -24 -52 -67 -46 -95 3 -15 35 -78 71 -139 36 -61 66
|
||||
-115 66 -119 0 -5 -30 -58 -66 -119 -36 -60 -68 -123 -70 -140 -7 -42 26 -90
|
||||
70 -105 31 -10 42 -9 72 7 31 15 51 43 125 173 93 162 101 188 73 243 -50 97
|
||||
-169 289 -185 297 -25 14 -91 12 -110 -3z"/>
|
||||
<path d="M1353 1139 c-42 -12 -73 -53 -73 -96 0 -27 8 -43 35 -70 l34 -34 216
|
||||
3 217 3 30 34 c26 29 29 40 25 73 -7 49 -29 75 -76 88 -45 12 -364 12 -408 -1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
25
src/assets/icons/codex_light.svg
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="256.000000pt" height="256.000000pt" viewBox="0 0 256.000000 256.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,256.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M1107 2290 c-316 -57 -615 -283 -748 -565 -68 -144 -91 -241 -96
|
||||
-406 -6 -156 7 -249 49 -374 87 -254 291 -478 542 -596 146 -68 226 -84 426
|
||||
-84 152 0 186 3 260 23 182 50 327 136 465 277 147 150 245 334 282 529 23
|
||||
123 14 344 -20 456 -35 116 -69 190 -134 290 -131 200 -340 354 -578 426 -78
|
||||
23 -111 27 -245 30 -85 1 -177 -1 -203 -6z m362 -216 c91 -21 224 -86 310
|
||||
-152 133 -101 249 -275 293 -439 16 -60 21 -108 21 -203 0 -152 -21 -240 -88
|
||||
-368 -130 -253 -350 -407 -634 -443 -393 -50 -777 214 -882 607 -30 110 -30
|
||||
296 0 408 72 270 282 489 552 576 130 41 287 47 428 14z"/>
|
||||
<path d="M849 1637 c-31 -24 -52 -67 -46 -95 3 -15 35 -78 71 -139 36 -61 66
|
||||
-115 66 -119 0 -5 -30 -58 -66 -119 -36 -60 -68 -123 -70 -140 -7 -42 26 -90
|
||||
70 -105 31 -10 42 -9 72 7 31 15 51 43 125 173 93 162 101 188 73 243 -50 97
|
||||
-169 289 -185 297 -25 14 -91 12 -110 -3z"/>
|
||||
<path d="M1353 1139 c-42 -12 -73 -53 -73 -96 0 -27 8 -43 35 -70 l34 -34 216
|
||||
3 217 3 30 34 c26 29 29 40 25 73 -7 49 -29 75 -76 88 -45 12 -364 12 -408 -1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
src/assets/icons/deepseek.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
1
src/assets/icons/glm.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
1
src/assets/icons/grok.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>
|
||||
|
After Width: | Height: | Size: 756 B |
1
src/assets/icons/kimi-dark.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="#FFFFFF" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#FFFFFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>
|
||||
|
After Width: | Height: | Size: 706 B |
1
src/assets/icons/kimi-light.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#027AFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>
|
||||
|
After Width: | Height: | Size: 711 B |
4
src/assets/icons/kiro.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#FF9900"/>
|
||||
<path d="M12 6L8 10h3v4H8l4 4 4-4h-3v-4h3l-4-4z" fill="#232F3E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 244 B |
1
src/assets/icons/minimax.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-fill" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-fill)" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
65
src/components/common/ConfirmationModal.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
|
||||
export function ConfirmationModal() {
|
||||
const { t } = useTranslation();
|
||||
const confirmation = useNotificationStore((state) => state.confirmation);
|
||||
const hideConfirmation = useNotificationStore((state) => state.hideConfirmation);
|
||||
const setConfirmationLoading = useNotificationStore((state) => state.setConfirmationLoading);
|
||||
|
||||
const { isOpen, isLoading, options } = confirmation;
|
||||
|
||||
if (!isOpen || !options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { title, message, onConfirm, onCancel, confirmText, cancelText, variant = 'primary' } = options;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setConfirmationLoading(true);
|
||||
await onConfirm();
|
||||
hideConfirmation();
|
||||
} catch (error) {
|
||||
console.error('Confirmation action failed:', error);
|
||||
// Optional: show error notification here if needed,
|
||||
// but usually the calling component handles specific errors.
|
||||
} finally {
|
||||
setConfirmationLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
hideConfirmation();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onClose={handleCancel} title={title} closeDisabled={isLoading}>
|
||||
{typeof message === 'string' ? (
|
||||
<p style={{ margin: '1rem 0' }}>{message}</p>
|
||||
) : (
|
||||
<div style={{ margin: '1rem 0' }}>{message}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '2rem' }}>
|
||||
<Button variant="ghost" onClick={handleCancel} disabled={isLoading}>
|
||||
{cancelText || t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={handleConfirm}
|
||||
loading={isLoading}
|
||||
>
|
||||
{confirmText || t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -14,26 +14,41 @@
|
||||
gap: $spacing-lg;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
background: var(--bg-secondary);
|
||||
backface-visibility: hidden;
|
||||
transform: translateZ(0);
|
||||
|
||||
// During animation, exit layer uses absolute positioning
|
||||
&--exit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
&--stacked {
|
||||
display: none;
|
||||
|
||||
// Keep the previous layer rendered (but invisible) to avoid a blank flash when popping back.
|
||||
// Older stacked layers remain `display: none` for performance.
|
||||
&.page-transition__layer--stacked-keep {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--animating &__layer {
|
||||
will-change: transform, opacity;
|
||||
backface-visibility: hidden;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
// When both layers exist, current layer also needs positioning
|
||||
&--animating &__layer:not(&__layer--exit) {
|
||||
&--animating &__layer:not(.page-transition__layer--exit):not(.page-transition__layer--stacked) {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation, type Location } from 'react-router-dom';
|
||||
import gsap from 'gsap';
|
||||
import { PageTransitionLayerContext, type LayerStatus } from './PageTransitionLayer';
|
||||
import './PageTransition.scss';
|
||||
|
||||
interface PageTransitionProps {
|
||||
render: (location: Location) => ReactNode;
|
||||
getRouteOrder?: (pathname: string) => number | null;
|
||||
getTransitionVariant?: (fromPathname: string, toPathname: string) => TransitionVariant;
|
||||
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
const TRANSITION_DURATION = 0.5;
|
||||
const EXIT_DURATION = 0.45;
|
||||
const ENTER_DELAY = 0.08;
|
||||
|
||||
type LayerStatus = 'current' | 'exiting';
|
||||
const VERTICAL_TRANSITION_DURATION = 0.35;
|
||||
const VERTICAL_TRAVEL_DISTANCE = 60;
|
||||
const IOS_TRANSITION_DURATION = 0.42;
|
||||
const IOS_ENTER_FROM_X_PERCENT = 100;
|
||||
const IOS_EXIT_TO_X_PERCENT_FORWARD = -30;
|
||||
const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
|
||||
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
|
||||
const IOS_EXIT_DIM_OPACITY = 0.72;
|
||||
|
||||
type Layer = {
|
||||
key: string;
|
||||
@@ -23,18 +34,25 @@ type Layer = {
|
||||
|
||||
type TransitionDirection = 'forward' | 'backward';
|
||||
|
||||
type TransitionVariant = 'vertical' | 'ios';
|
||||
|
||||
export function PageTransition({
|
||||
render,
|
||||
getRouteOrder,
|
||||
getTransitionVariant,
|
||||
scrollContainerRef,
|
||||
}: PageTransitionProps) {
|
||||
const location = useLocation();
|
||||
const currentLayerRef = useRef<HTMLDivElement>(null);
|
||||
const exitingLayerRef = useRef<HTMLDivElement>(null);
|
||||
const transitionDirectionRef = useRef<TransitionDirection>('forward');
|
||||
const transitionVariantRef = useRef<TransitionVariant>('vertical');
|
||||
const exitScrollOffsetRef = useRef(0);
|
||||
const enterScrollOffsetRef = useRef(0);
|
||||
const scrollPositionsRef = useRef(new Map<string, number>());
|
||||
const nextLayersRef = useRef<Layer[] | null>(null);
|
||||
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [transitionDirection, setTransitionDirection] = useState<TransitionDirection>('forward');
|
||||
const [layers, setLayers] = useState<Layer[]>(() => [
|
||||
{
|
||||
key: location.key,
|
||||
@@ -42,8 +60,10 @@ export function PageTransition({
|
||||
status: 'current',
|
||||
},
|
||||
]);
|
||||
const currentLayerKey = layers[layers.length - 1]?.key ?? location.key;
|
||||
const currentLayerPathname = layers[layers.length - 1]?.location.pathname;
|
||||
const currentLayer =
|
||||
layers.find((layer) => layer.status === 'current') ?? layers[layers.length - 1];
|
||||
const currentLayerKey = currentLayer?.key ?? location.key;
|
||||
const currentLayerPathname = currentLayer?.location.pathname;
|
||||
|
||||
const resolveScrollContainer = useCallback(() => {
|
||||
if (scrollContainerRef?.current) return scrollContainerRef.current;
|
||||
@@ -51,11 +71,16 @@ export function PageTransition({
|
||||
return document.scrollingElement as HTMLElement | null;
|
||||
}, [scrollContainerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (isAnimating) return;
|
||||
if (location.key === currentLayerKey) return;
|
||||
if (currentLayerPathname === location.pathname) return;
|
||||
const scrollContainer = resolveScrollContainer();
|
||||
exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0;
|
||||
const exitScrollOffset = scrollContainer?.scrollTop ?? 0;
|
||||
exitScrollOffsetRef.current = exitScrollOffset;
|
||||
scrollPositionsRef.current.set(currentLayerKey, exitScrollOffset);
|
||||
|
||||
enterScrollOffsetRef.current = scrollPositionsRef.current.get(location.key) ?? 0;
|
||||
const resolveOrderIndex = (pathname?: string) => {
|
||||
if (!getRouteOrder || !pathname) return null;
|
||||
const index = getRouteOrder(pathname);
|
||||
@@ -63,21 +88,99 @@ export function PageTransition({
|
||||
};
|
||||
const fromIndex = resolveOrderIndex(currentLayerPathname);
|
||||
const toIndex = resolveOrderIndex(location.pathname);
|
||||
const nextDirection: TransitionDirection =
|
||||
const nextVariant: TransitionVariant = getTransitionVariant
|
||||
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
|
||||
: 'vertical';
|
||||
|
||||
let nextDirection: TransitionDirection =
|
||||
fromIndex === null || toIndex === null || fromIndex === toIndex
|
||||
? 'forward'
|
||||
: toIndex > fromIndex
|
||||
? 'forward'
|
||||
: 'backward';
|
||||
setTransitionDirection(nextDirection);
|
||||
|
||||
// When using iOS-style stacking, history POP within the same "section" can have equal route order.
|
||||
// In that case, prefer treating navigation to an existing layer as a backward (pop) transition.
|
||||
if (nextVariant === 'ios' && layers.some((layer) => layer.key === location.key)) {
|
||||
nextDirection = 'backward';
|
||||
}
|
||||
|
||||
transitionDirectionRef.current = nextDirection;
|
||||
transitionVariantRef.current = nextVariant;
|
||||
|
||||
const shouldSkipExitLayer = (() => {
|
||||
if (nextVariant !== 'ios' || nextDirection !== 'backward') return false;
|
||||
const normalizeSegments = (pathname: string) =>
|
||||
pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.filter((segment) => segment.length > 0);
|
||||
const fromSegments = normalizeSegments(currentLayerPathname ?? '');
|
||||
const toSegments = normalizeSegments(location.pathname);
|
||||
if (!fromSegments.length || !toSegments.length) return false;
|
||||
return fromSegments[0] === toSegments[0] && toSegments.length === 1;
|
||||
})();
|
||||
|
||||
setLayers((prev) => {
|
||||
const prevCurrent = prev[prev.length - 1];
|
||||
return [
|
||||
prevCurrent
|
||||
? { ...prevCurrent, status: 'exiting' }
|
||||
: { key: location.key, location, status: 'exiting' },
|
||||
{ key: location.key, location, status: 'current' },
|
||||
];
|
||||
const variant = transitionVariantRef.current;
|
||||
const direction = transitionDirectionRef.current;
|
||||
const previousCurrentIndex = prev.findIndex((layer) => layer.status === 'current');
|
||||
const resolvedCurrentIndex =
|
||||
previousCurrentIndex >= 0 ? previousCurrentIndex : prev.length - 1;
|
||||
const previousCurrent = prev[resolvedCurrentIndex];
|
||||
const previousStack: Layer[] = prev
|
||||
.filter((_, idx) => idx !== resolvedCurrentIndex)
|
||||
.map((layer): Layer => ({ ...layer, status: 'stacked' }));
|
||||
|
||||
const nextCurrent: Layer = { key: location.key, location, status: 'current' };
|
||||
|
||||
if (!previousCurrent) {
|
||||
nextLayersRef.current = [nextCurrent];
|
||||
return [nextCurrent];
|
||||
}
|
||||
|
||||
if (variant === 'ios') {
|
||||
if (direction === 'forward') {
|
||||
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
|
||||
const stackedLayer: Layer = { ...previousCurrent, status: 'stacked' };
|
||||
|
||||
nextLayersRef.current = [...previousStack, stackedLayer, nextCurrent];
|
||||
return [...previousStack, exitingLayer, nextCurrent];
|
||||
}
|
||||
|
||||
const targetIndex = prev.findIndex((layer) => layer.key === location.key);
|
||||
if (targetIndex !== -1) {
|
||||
const targetStack: Layer[] = prev
|
||||
.slice(0, targetIndex + 1)
|
||||
.map((layer, idx): Layer => {
|
||||
const isTarget = idx === targetIndex;
|
||||
return {
|
||||
...layer,
|
||||
location: isTarget ? location : layer.location,
|
||||
status: isTarget ? 'current' : 'stacked',
|
||||
};
|
||||
});
|
||||
|
||||
if (shouldSkipExitLayer) {
|
||||
nextLayersRef.current = targetStack;
|
||||
return targetStack;
|
||||
}
|
||||
|
||||
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
|
||||
nextLayersRef.current = targetStack;
|
||||
return [...targetStack, exitingLayer];
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSkipExitLayer) {
|
||||
nextLayersRef.current = [nextCurrent];
|
||||
return [nextCurrent];
|
||||
}
|
||||
|
||||
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
|
||||
|
||||
nextLayersRef.current = [nextCurrent];
|
||||
return [exitingLayer, nextCurrent];
|
||||
});
|
||||
setIsAnimating(true);
|
||||
}, [
|
||||
@@ -86,7 +189,9 @@ export function PageTransition({
|
||||
currentLayerKey,
|
||||
currentLayerPathname,
|
||||
getRouteOrder,
|
||||
getTransitionVariant,
|
||||
resolveScrollContainer,
|
||||
layers,
|
||||
]);
|
||||
|
||||
// Run GSAP animation when animating starts
|
||||
@@ -95,77 +200,181 @@ export function PageTransition({
|
||||
|
||||
if (!currentLayerRef.current) return;
|
||||
|
||||
const scrollContainer = resolveScrollContainer();
|
||||
const scrollOffset = exitScrollOffsetRef.current;
|
||||
if (scrollContainer && scrollOffset > 0) {
|
||||
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||
const currentLayerEl = currentLayerRef.current;
|
||||
const exitingLayerEl = exitingLayerRef.current;
|
||||
const transitionVariant = transitionVariantRef.current;
|
||||
|
||||
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||
if (exitingLayerEl) {
|
||||
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||
}
|
||||
|
||||
const containerHeight = scrollContainer?.clientHeight ?? 0;
|
||||
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight;
|
||||
const travelDistance = Math.max(containerHeight, viewportHeight, 1);
|
||||
const enterFromY = transitionDirection === 'forward' ? travelDistance : -travelDistance;
|
||||
const exitToY = transitionDirection === 'forward' ? -travelDistance : travelDistance;
|
||||
const exitBaseY = scrollOffset ? -scrollOffset : 0;
|
||||
const scrollContainer = resolveScrollContainer();
|
||||
const exitScrollOffset = exitScrollOffsetRef.current;
|
||||
const enterScrollOffset = enterScrollOffsetRef.current;
|
||||
if (scrollContainer && exitScrollOffset !== enterScrollOffset) {
|
||||
scrollContainer.scrollTo({ top: enterScrollOffset, left: 0, behavior: 'auto' });
|
||||
}
|
||||
|
||||
const transitionDirection = transitionDirectionRef.current;
|
||||
const isForward = transitionDirection === 'forward';
|
||||
const enterFromY = isForward ? VERTICAL_TRAVEL_DISTANCE : -VERTICAL_TRAVEL_DISTANCE;
|
||||
const exitToY = isForward ? -VERTICAL_TRAVEL_DISTANCE : VERTICAL_TRAVEL_DISTANCE;
|
||||
const exitBaseY = enterScrollOffset - exitScrollOffset;
|
||||
|
||||
const tl = gsap.timeline({
|
||||
onComplete: () => {
|
||||
setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting'));
|
||||
const nextLayers = nextLayersRef.current;
|
||||
nextLayersRef.current = null;
|
||||
setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting'));
|
||||
setIsAnimating(false);
|
||||
|
||||
if (currentLayerEl) {
|
||||
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||
}
|
||||
if (exitingLayerEl) {
|
||||
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Exit animation: fly out to top (slow-to-fast)
|
||||
if (exitingLayerRef.current) {
|
||||
gsap.set(exitingLayerRef.current, { y: exitBaseY });
|
||||
tl.fromTo(
|
||||
exitingLayerRef.current,
|
||||
{ y: exitBaseY, opacity: 1 },
|
||||
if (transitionVariant === 'ios') {
|
||||
const exitToXPercent = isForward
|
||||
? IOS_EXIT_TO_X_PERCENT_FORWARD
|
||||
: IOS_EXIT_TO_X_PERCENT_BACKWARD;
|
||||
const enterFromXPercent = isForward
|
||||
? IOS_ENTER_FROM_X_PERCENT
|
||||
: IOS_ENTER_FROM_X_PERCENT_BACKWARD;
|
||||
|
||||
if (exitingLayerEl) {
|
||||
gsap.set(exitingLayerEl, {
|
||||
y: exitBaseY,
|
||||
xPercent: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
}
|
||||
|
||||
gsap.set(currentLayerEl, {
|
||||
xPercent: enterFromXPercent,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
const shadowValue = '-14px 0 24px rgba(0, 0, 0, 0.16)';
|
||||
|
||||
const topLayerEl = isForward ? currentLayerEl : exitingLayerEl;
|
||||
if (topLayerEl) {
|
||||
gsap.set(topLayerEl, { boxShadow: shadowValue });
|
||||
}
|
||||
|
||||
if (exitingLayerEl) {
|
||||
tl.to(
|
||||
exitingLayerEl,
|
||||
{
|
||||
xPercent: exitToXPercent,
|
||||
opacity: isForward ? IOS_EXIT_DIM_OPACITY : 1,
|
||||
duration: IOS_TRANSITION_DURATION,
|
||||
ease: 'power2.out',
|
||||
force3D: true,
|
||||
},
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
tl.to(
|
||||
currentLayerEl,
|
||||
{
|
||||
y: exitBaseY + exitToY,
|
||||
opacity: 0,
|
||||
duration: EXIT_DURATION,
|
||||
ease: 'power2.in', // fast finish to clear screen
|
||||
xPercent: 0,
|
||||
opacity: 1,
|
||||
duration: IOS_TRANSITION_DURATION,
|
||||
ease: 'power2.out',
|
||||
force3D: true,
|
||||
},
|
||||
0
|
||||
);
|
||||
} else {
|
||||
// Exit animation: fade out with slight movement (runs simultaneously)
|
||||
if (exitingLayerEl) {
|
||||
gsap.set(exitingLayerEl, { y: exitBaseY });
|
||||
tl.to(
|
||||
exitingLayerEl,
|
||||
{
|
||||
y: exitBaseY + exitToY,
|
||||
opacity: 0,
|
||||
duration: VERTICAL_TRANSITION_DURATION,
|
||||
ease: 'circ.out',
|
||||
force3D: true,
|
||||
},
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
// Enter animation: fade in with slight movement (runs simultaneously)
|
||||
tl.fromTo(
|
||||
currentLayerEl,
|
||||
{ y: enterFromY, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: VERTICAL_TRANSITION_DURATION,
|
||||
ease: 'circ.out',
|
||||
force3D: true,
|
||||
onComplete: () => {
|
||||
if (currentLayerEl) {
|
||||
gsap.set(currentLayerEl, { clearProps: 'transform,opacity' });
|
||||
}
|
||||
},
|
||||
},
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
// Enter animation: slide in from bottom (slow-to-fast)
|
||||
tl.fromTo(
|
||||
currentLayerRef.current,
|
||||
{ y: enterFromY, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: TRANSITION_DURATION,
|
||||
ease: 'power2.out', // smooth settle
|
||||
clearProps: 'transform,opacity',
|
||||
force3D: true,
|
||||
},
|
||||
ENTER_DELAY
|
||||
);
|
||||
|
||||
return () => {
|
||||
tl.kill();
|
||||
gsap.killTweensOf([currentLayerRef.current, exitingLayerRef.current]);
|
||||
gsap.killTweensOf([currentLayerEl, exitingLayerEl]);
|
||||
};
|
||||
}, [isAnimating, transitionDirection, resolveScrollContainer]);
|
||||
}, [isAnimating, resolveScrollContainer]);
|
||||
|
||||
return (
|
||||
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
|
||||
{layers.map((layer) => (
|
||||
{(() => {
|
||||
const currentIndex = layers.findIndex((layer) => layer.status === 'current');
|
||||
const resolvedCurrentIndex = currentIndex === -1 ? layers.length - 1 : currentIndex;
|
||||
const keepStackedIndex = layers
|
||||
.slice(0, resolvedCurrentIndex)
|
||||
.map((layer, index) => ({ layer, index }))
|
||||
.reverse()
|
||||
.find(({ layer }) => layer.status === 'stacked')?.index;
|
||||
|
||||
return layers.map((layer, index) => {
|
||||
const shouldKeepStacked = layer.status === 'stacked' && index === keepStackedIndex;
|
||||
return (
|
||||
<div
|
||||
key={layer.key}
|
||||
className={`page-transition__layer${
|
||||
layer.status === 'exiting' ? ' page-transition__layer--exit' : ''
|
||||
}`}
|
||||
ref={layer.status === 'exiting' ? exitingLayerRef : currentLayerRef}
|
||||
className={[
|
||||
'page-transition__layer',
|
||||
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
|
||||
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
|
||||
shouldKeepStacked ? 'page-transition__layer--stacked-keep' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-hidden={layer.status !== 'current'}
|
||||
inert={layer.status !== 'current'}
|
||||
ref={
|
||||
layer.status === 'exiting'
|
||||
? exitingLayerRef
|
||||
: layer.status === 'current'
|
||||
? currentLayerRef
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{render(layer.location)}
|
||||
<PageTransitionLayerContext.Provider value={{ status: layer.status }}>
|
||||
{render(layer.location)}
|
||||
</PageTransitionLayerContext.Provider>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
15
src/components/common/PageTransitionLayer.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type LayerStatus = 'current' | 'exiting' | 'stacked';
|
||||
|
||||
type PageTransitionLayerContextValue = {
|
||||
status: LayerStatus;
|
||||
};
|
||||
|
||||
export const PageTransitionLayerContext =
|
||||
createContext<PageTransitionLayerContextValue | null>(null);
|
||||
|
||||
export function usePageTransitionLayer() {
|
||||
return useContext(PageTransitionLayerContext);
|
||||
}
|
||||
|
||||
84
src/components/common/SecondaryScreenShell.module.scss
Normal file
@@ -0,0 +1,84 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.topBar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.topBarTitle {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
padding-left: 6px;
|
||||
padding-right: 10px;
|
||||
justify-self: start;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.backButton > span:last-child {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.backIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.backText {
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.rightSlot {
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.loadingState {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-2xl 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
78
src/components/common/SecondaryScreenShell.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { forwardRef, type ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { IconChevronLeft } from '@/components/ui/icons';
|
||||
import styles from './SecondaryScreenShell.module.scss';
|
||||
|
||||
export type SecondaryScreenShellProps = {
|
||||
title: ReactNode;
|
||||
onBack?: () => void;
|
||||
backLabel?: string;
|
||||
backAriaLabel?: string;
|
||||
rightAction?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
loadingLabel?: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const SecondaryScreenShell = forwardRef<HTMLDivElement, SecondaryScreenShellProps>(
|
||||
function SecondaryScreenShell(
|
||||
{
|
||||
title,
|
||||
onBack,
|
||||
backLabel = 'Back',
|
||||
backAriaLabel,
|
||||
rightAction,
|
||||
isLoading = false,
|
||||
loadingLabel = 'Loading...',
|
||||
className = '',
|
||||
contentClassName = '',
|
||||
children,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const containerClassName = [styles.container, className].filter(Boolean).join(' ');
|
||||
const contentClasses = [styles.content, contentClassName].filter(Boolean).join(' ');
|
||||
const titleTooltip = typeof title === 'string' ? title : undefined;
|
||||
const resolvedBackAriaLabel = backAriaLabel ?? backLabel;
|
||||
|
||||
return (
|
||||
<div className={containerClassName} ref={ref}>
|
||||
<div className={styles.topBar}>
|
||||
{onBack ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onBack}
|
||||
className={styles.backButton}
|
||||
aria-label={resolvedBackAriaLabel}
|
||||
>
|
||||
<span className={styles.backIcon}>
|
||||
<IconChevronLeft size={18} />
|
||||
</span>
|
||||
<span className={styles.backText}>{backLabel}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className={styles.topBarTitle} title={titleTooltip}>
|
||||
{title}
|
||||
</div>
|
||||
<div className={styles.rightSlot}>{rightAction}</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className={styles.loadingState}>
|
||||
<LoadingSpinner size={16} />
|
||||
<span>{loadingLabel}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={contentClasses}>{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
22
src/components/config/ConfigSection.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
interface ConfigSectionProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ConfigSection({ title, description, className, children }: PropsWithChildren<ConfigSectionProps>) {
|
||||
return (
|
||||
<Card title={title} className={className}>
|
||||
{description && (
|
||||
<p style={{ margin: '-4px 0 16px 0', color: 'var(--text-secondary)', fontSize: 13 }}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
37
src/components/config/VisualConfigEditor.module.scss
Normal file
@@ -0,0 +1,37 @@
|
||||
.payloadRuleModelRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.payloadRuleModelRowProtocolFirst {
|
||||
grid-template-columns: 160px 1fr auto;
|
||||
}
|
||||
|
||||
.payloadRuleParamRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 140px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.payloadFilterModelRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.payloadRuleModelRow,
|
||||
.payloadRuleModelRowProtocolFirst,
|
||||
.payloadRuleParamRow,
|
||||
.payloadFilterModelRow {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.payloadRowActionButton {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
1177
src/components/config/VisualConfigEditor.tsx
Normal file
@@ -10,8 +10,6 @@ import {
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { PageTransition } from '@/components/common/PageTransition';
|
||||
import { MainRoutes } from '@/router/MainRoutes';
|
||||
import {
|
||||
@@ -19,13 +17,12 @@ import {
|
||||
IconChartLine,
|
||||
IconFileText,
|
||||
IconInfo,
|
||||
IconKey,
|
||||
IconLayoutDashboard,
|
||||
IconScrollText,
|
||||
IconSettings,
|
||||
IconShield,
|
||||
IconSlidersHorizontal,
|
||||
IconTimer,
|
||||
IconActivity,
|
||||
} from '@/components/ui/icons';
|
||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||
import {
|
||||
@@ -35,13 +32,13 @@ import {
|
||||
useNotificationStore,
|
||||
useThemeStore,
|
||||
} from '@/stores';
|
||||
import { configApi, versionApi } from '@/services/api';
|
||||
import { versionApi } from '@/services/api';
|
||||
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
|
||||
import { isSupportedLanguage } from '@/utils/language';
|
||||
|
||||
const sidebarIcons: Record<string, ReactNode> = {
|
||||
dashboard: <IconLayoutDashboard size={18} />,
|
||||
settings: <IconSlidersHorizontal size={18} />,
|
||||
apiKeys: <IconKey size={18} />,
|
||||
aiProviders: <IconBot size={18} />,
|
||||
authFiles: <IconFileText size={18} />,
|
||||
oauth: <IconShield size={18} />,
|
||||
@@ -50,6 +47,7 @@ const sidebarIcons: Record<string, ReactNode> = {
|
||||
config: <IconSettings size={18} />,
|
||||
logs: <IconScrollText size={18} />,
|
||||
system: <IconInfo size={18} />,
|
||||
monitor: <IconActivity size={18} />,
|
||||
};
|
||||
|
||||
// Header action icons - smaller size for header buttons
|
||||
@@ -176,44 +174,36 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
|
||||
};
|
||||
|
||||
export function MainLayout() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const location = useLocation();
|
||||
|
||||
const apiBase = useAuthStore((state) => state.apiBase);
|
||||
const serverVersion = useAuthStore((state) => state.serverVersion);
|
||||
const serverBuildDate = useAuthStore((state) => state.serverBuildDate);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const cycleTheme = useThemeStore((state) => state.cycleTheme);
|
||||
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
||||
const language = useLanguageStore((state) => state.language);
|
||||
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [checkingVersion, setCheckingVersion] = useState(false);
|
||||
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
|
||||
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 contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const languageMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | 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 abbrBrandName = t('title.abbr');
|
||||
const requestLogEnabled = config?.requestLog ?? false;
|
||||
const requestLogDirty = requestLogDraft !== requestLogEnabled;
|
||||
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
|
||||
const isLogsPage = location.pathname.startsWith('/logs');
|
||||
|
||||
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
||||
@@ -245,6 +235,38 @@ export function MainLayout() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 将主内容区的中心点写入 CSS 变量,供底部浮层(配置面板操作栏、提供商导航)对齐到内容区
|
||||
useLayoutEffect(() => {
|
||||
const updateContentCenter = () => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
document.documentElement.style.setProperty('--content-center-x', `${centerX}px`);
|
||||
};
|
||||
|
||||
updateContentCenter();
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver !== 'undefined' && contentRef.current
|
||||
? new ResizeObserver(updateContentCenter)
|
||||
: null;
|
||||
|
||||
if (resizeObserver && contentRef.current) {
|
||||
resizeObserver.observe(contentRef.current);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateContentCenter);
|
||||
|
||||
return () => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
window.removeEventListener('resize', updateContentCenter);
|
||||
document.documentElement.style.removeProperty('--content-center-x');
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 5秒后自动收起品牌名称
|
||||
useEffect(() => {
|
||||
brandCollapseTimer.current = setTimeout(() => {
|
||||
@@ -259,18 +281,30 @@ export function MainLayout() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestLogModalOpen && !requestLogTouched) {
|
||||
setRequestLogDraft(requestLogEnabled);
|
||||
if (!languageMenuOpen) {
|
||||
return;
|
||||
}
|
||||
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (versionTapTimer.current) {
|
||||
clearTimeout(versionTapTimer.current);
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!languageMenuRef.current?.contains(event.target as Node)) {
|
||||
setLanguageMenuOpen(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setLanguageMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [languageMenuOpen]);
|
||||
|
||||
const handleBrandClick = useCallback(() => {
|
||||
if (!brandExpanded) {
|
||||
@@ -285,59 +319,20 @@ export function MainLayout() {
|
||||
}
|
||||
}, [brandExpanded]);
|
||||
|
||||
const openRequestLogModal = useCallback(() => {
|
||||
setRequestLogTouched(false);
|
||||
setRequestLogDraft(requestLogEnabled);
|
||||
setRequestLogModalOpen(true);
|
||||
}, [requestLogEnabled]);
|
||||
|
||||
const handleRequestLogClose = useCallback(() => {
|
||||
setRequestLogModalOpen(false);
|
||||
setRequestLogTouched(false);
|
||||
const toggleLanguageMenu = useCallback(() => {
|
||||
setLanguageMenuOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
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;
|
||||
const handleLanguageSelect = useCallback(
|
||||
(nextLanguage: string) => {
|
||||
if (!isSupportedLanguage(nextLanguage)) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
setLanguage(nextLanguage);
|
||||
setLanguageMenuOpen(false);
|
||||
},
|
||||
[setLanguage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig().catch(() => {
|
||||
@@ -357,24 +352,48 @@ export function MainLayout() {
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
|
||||
{ path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings },
|
||||
{ path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys },
|
||||
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
|
||||
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
|
||||
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
|
||||
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
|
||||
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
|
||||
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
|
||||
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
|
||||
...(config?.loggingToFile
|
||||
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
|
||||
: []),
|
||||
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
|
||||
{ path: '/monitor', label: t('nav.monitor'), icon: sidebarIcons.monitor },
|
||||
];
|
||||
const navOrder = navItems.map((item) => item.path);
|
||||
const getRouteOrder = (pathname: string) => {
|
||||
const trimmedPath =
|
||||
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath;
|
||||
|
||||
const aiProvidersIndex = navOrder.indexOf('/ai-providers');
|
||||
if (aiProvidersIndex !== -1) {
|
||||
if (normalizedPath === '/ai-providers') return aiProvidersIndex;
|
||||
if (normalizedPath.startsWith('/ai-providers/')) {
|
||||
if (normalizedPath.startsWith('/ai-providers/gemini')) return aiProvidersIndex + 0.1;
|
||||
if (normalizedPath.startsWith('/ai-providers/codex')) return aiProvidersIndex + 0.2;
|
||||
if (normalizedPath.startsWith('/ai-providers/claude')) return aiProvidersIndex + 0.3;
|
||||
if (normalizedPath.startsWith('/ai-providers/vertex')) return aiProvidersIndex + 0.4;
|
||||
if (normalizedPath.startsWith('/ai-providers/ampcode')) return aiProvidersIndex + 0.5;
|
||||
if (normalizedPath.startsWith('/ai-providers/openai')) return aiProvidersIndex + 0.6;
|
||||
return aiProvidersIndex + 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
const authFilesIndex = navOrder.indexOf('/auth-files');
|
||||
if (authFilesIndex !== -1) {
|
||||
if (normalizedPath === '/auth-files') return authFilesIndex;
|
||||
if (normalizedPath.startsWith('/auth-files/')) {
|
||||
if (normalizedPath.startsWith('/auth-files/oauth-excluded')) return authFilesIndex + 0.1;
|
||||
if (normalizedPath.startsWith('/auth-files/oauth-model-alias')) return authFilesIndex + 0.2;
|
||||
return authFilesIndex + 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
const exactIndex = navOrder.indexOf(normalizedPath);
|
||||
if (exactIndex !== -1) return exactIndex;
|
||||
const nestedIndex = navOrder.findIndex(
|
||||
@@ -383,6 +402,24 @@ export function MainLayout() {
|
||||
return nestedIndex === -1 ? null : nestedIndex;
|
||||
};
|
||||
|
||||
const getTransitionVariant = useCallback((fromPathname: string, toPathname: string) => {
|
||||
const normalize = (pathname: string) => {
|
||||
const trimmed =
|
||||
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
return trimmed === '/dashboard' ? '/' : trimmed;
|
||||
};
|
||||
|
||||
const from = normalize(fromPathname);
|
||||
const to = normalize(toPathname);
|
||||
const isAuthFiles = (pathname: string) =>
|
||||
pathname === '/auth-files' || pathname.startsWith('/auth-files/');
|
||||
const isAiProviders = (pathname: string) =>
|
||||
pathname === '/ai-providers' || pathname.startsWith('/ai-providers/');
|
||||
if (isAuthFiles(from) && isAuthFiles(to)) return 'ios';
|
||||
if (isAiProviders(from) && isAiProviders(to)) return 'ios';
|
||||
return 'vertical';
|
||||
}, []);
|
||||
|
||||
const handleRefreshAll = async () => {
|
||||
clearCache();
|
||||
const results = await Promise.allSettled([
|
||||
@@ -407,7 +444,8 @@ export function MainLayout() {
|
||||
setCheckingVersion(true);
|
||||
try {
|
||||
const data = await versionApi.checkLatest();
|
||||
const latest = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
|
||||
const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
|
||||
const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? '');
|
||||
const comparison = compareVersions(latest, serverVersion);
|
||||
|
||||
if (!latest) {
|
||||
@@ -425,8 +463,11 @@ export function MainLayout() {
|
||||
} else {
|
||||
showNotification(t('system_info.version_is_latest'), 'success');
|
||||
}
|
||||
} catch (error: any) {
|
||||
showNotification(`${t('system_info.version_check_error')}: ${error?.message || ''}`, 'error');
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
|
||||
const suffix = message ? `: ${message}` : '';
|
||||
showNotification(`${t('system_info.version_check_error')}${suffix}`, 'error');
|
||||
} finally {
|
||||
setCheckingVersion(false);
|
||||
}
|
||||
@@ -498,9 +539,36 @@ export function MainLayout() {
|
||||
>
|
||||
{headerIcons.update}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
|
||||
{headerIcons.language}
|
||||
</Button>
|
||||
<div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleLanguageMenu}
|
||||
title={t('language.switch')}
|
||||
aria-label={t('language.switch')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={languageMenuOpen}
|
||||
>
|
||||
{headerIcons.language}
|
||||
</Button>
|
||||
{languageMenuOpen && (
|
||||
<div className="notification entering language-menu-popover" role="menu" aria-label={t('language.switch')}>
|
||||
{LANGUAGE_ORDER.map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
className={`language-menu-option ${language === lang ? 'active' : ''}`}
|
||||
onClick={() => handleLanguageSelect(lang)}
|
||||
role="menuitemradio"
|
||||
aria-checked={language === lang}
|
||||
>
|
||||
<span>{t(LANGUAGE_LABEL_KEYS[lang])}</span>
|
||||
{language === lang ? <span className="language-menu-check">✓</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
|
||||
{theme === 'auto'
|
||||
? headerIcons.autoTheme
|
||||
@@ -540,60 +608,12 @@ export function MainLayout() {
|
||||
<PageTransition
|
||||
render={(location) => <MainRoutes location={location} />}
|
||||
getRouteOrder={getRouteOrder}
|
||||
getTransitionVariant={getTransitionVariant}
|
||||
scrollContainerRef={contentRef}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<footer className="footer">
|
||||
<span>
|
||||
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
|
||||
</span>
|
||||
<span className="footer-version" onClick={handleVersionTap}>
|
||||
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
|
||||
</span>
|
||||
<span>
|
||||
{t('footer.build_date')}:{' '}
|
||||
{serverBuildDate
|
||||
? new Date(serverBuildDate).toLocaleString(i18n.language)
|
||||
: t('system_info.version_unknown')}
|
||||
</span>
|
||||
</footer>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
359
src/components/modelAlias/ModelMappingDiagram.module.scss
Normal file
@@ -0,0 +1,359 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.scrollContainer {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.tapHint {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
min-width: 100%;
|
||||
min-height: 300px;
|
||||
justify-content: space-between;
|
||||
padding: 20px 0;
|
||||
user-select: none;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
// Give mobile extra horizontal room to reduce line overlap; users can swipe to scroll.
|
||||
min-width: max(100%, 960px);
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
// SVG layer for connection lines (behind columns so links are visible)
|
||||
.connections {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
|
||||
path {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
z-index: 2;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.providers {
|
||||
align-items: flex-end;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
&.sources {
|
||||
align-items: flex-start;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
&.aliases {
|
||||
align-items: flex-start;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.columnHeader {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.item {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&.dropTarget {
|
||||
background-color: var(--bg-secondary);
|
||||
border-color: var(--primary-color);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--primary-color);
|
||||
background-color: var(--bg-secondary);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
// Mindmap-style provider branch (root node)
|
||||
.providerItem {
|
||||
border-left: 3px solid transparent;
|
||||
padding-left: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.providerLabel {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collapseBtn {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chevronDown,
|
||||
.chevronRight {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.chevronDown {
|
||||
border-width: 5px 4px 0 4px;
|
||||
border-color: currentColor transparent transparent transparent;
|
||||
}
|
||||
|
||||
.chevronRight {
|
||||
border-width: 4px 0 4px 5px;
|
||||
border-color: transparent transparent transparent currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.providerGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sourceItem,
|
||||
.aliasItem {
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
opacity: 0.5;
|
||||
border-style: dashed;
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
margin-top: -3px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.dotLeft {
|
||||
left: -3px;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.sourceItem .dot {
|
||||
right: -3px;
|
||||
}
|
||||
|
||||
.providerBadge {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.itemCount {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-left: 8px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.contextMenu {
|
||||
position: fixed;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 9999;
|
||||
min-width: 120px;
|
||||
overflow: hidden;
|
||||
padding: 4px 0;
|
||||
|
||||
.menuItem {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--error-color);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-error-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menuDivider {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
background: var(--border-color);
|
||||
padding: 0;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.settingsEmpty {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: $spacing-lg 0;
|
||||
}
|
||||
|
||||
.settingsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.settingsRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1fr) auto;
|
||||
gap: $spacing-md;
|
||||
align-items: center;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background: var(--bg-secondary);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.settingsNames {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settingsSource,
|
||||
.settingsAlias {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.settingsArrow {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.settingsActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.settingsLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.settingsDelete {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--error-color);
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-error-light);
|
||||
}
|
||||
}
|
||||
657
src/components/modelAlias/ModelMappingDiagram.tsx
Normal file
@@ -0,0 +1,657 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { OAuthModelAliasEntry } from '@/types';
|
||||
import { useThemeStore } from '@/stores';
|
||||
import { AliasColumn, ProviderColumn, SourceColumn } from './ModelMappingDiagramColumns';
|
||||
import { DiagramContextMenu } from './ModelMappingDiagramContextMenu';
|
||||
import {
|
||||
AddAliasModal,
|
||||
RenameAliasModal,
|
||||
SettingsAliasModal,
|
||||
SettingsSourceModal
|
||||
} from './ModelMappingDiagramModals';
|
||||
import type {
|
||||
AliasNode,
|
||||
AuthFileModelItem,
|
||||
ContextMenuState,
|
||||
DiagramLine,
|
||||
SourceNode
|
||||
} from './ModelMappingDiagramTypes';
|
||||
import styles from './ModelMappingDiagram.module.scss';
|
||||
|
||||
export interface ModelMappingDiagramProps {
|
||||
modelAlias: Record<string, OAuthModelAliasEntry[]>;
|
||||
allProviderModels?: Record<string, AuthFileModelItem[]>;
|
||||
onUpdate?: (provider: string, sourceModel: string, newAlias: string) => void;
|
||||
onDeleteLink?: (provider: string, sourceModel: string, alias: string) => void;
|
||||
onToggleFork?: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
|
||||
onRenameAlias?: (oldAlias: string, newAlias: string) => void;
|
||||
onDeleteAlias?: (alias: string) => void;
|
||||
onEditProvider?: (provider: string) => void;
|
||||
onDeleteProvider?: (provider: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PROVIDER_COLORS = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
|
||||
'#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'
|
||||
];
|
||||
|
||||
function getProviderColor(provider: string): string {
|
||||
const hash = provider.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
||||
return PROVIDER_COLORS[hash % PROVIDER_COLORS.length];
|
||||
}
|
||||
|
||||
export interface ModelMappingDiagramRef {
|
||||
collapseAll: () => void;
|
||||
refreshLayout: () => void;
|
||||
}
|
||||
|
||||
export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappingDiagramProps>(function ModelMappingDiagram({
|
||||
modelAlias,
|
||||
allProviderModels = {},
|
||||
onUpdate,
|
||||
onDeleteLink,
|
||||
onToggleFork,
|
||||
onRenameAlias,
|
||||
onDeleteAlias,
|
||||
onEditProvider,
|
||||
onDeleteProvider,
|
||||
className
|
||||
}, ref) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
const enableTapLinking = useMemo(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia === 'undefined') return false;
|
||||
return (
|
||||
window.matchMedia('(any-pointer: coarse)').matches &&
|
||||
!window.matchMedia('(any-pointer: fine)').matches
|
||||
);
|
||||
}, []);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [lines, setLines] = useState<DiagramLine[]>([]);
|
||||
const [draggedSource, setDraggedSource] = useState<SourceNode | null>(null);
|
||||
const [draggedAlias, setDraggedAlias] = useState<string | null>(null);
|
||||
const [dropTargetAlias, setDropTargetAlias] = useState<string | null>(null);
|
||||
const [dropTargetSource, setDropTargetSource] = useState<string | null>(null);
|
||||
const [tapSourceId, setTapSourceId] = useState<string | null>(null);
|
||||
const [tapAlias, setTapAlias] = useState<string | null>(null);
|
||||
const [extraAliases, setExtraAliases] = useState<string[]>([]);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [collapsedProviders, setCollapsedProviders] = useState<Set<string>>(new Set());
|
||||
const [providerGroupHeights, setProviderGroupHeights] = useState<Record<string, number>>({});
|
||||
const [renameState, setRenameState] = useState<{ oldAlias: string } | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [renameError, setRenameError] = useState('');
|
||||
const [addAliasOpen, setAddAliasOpen] = useState(false);
|
||||
const [addAliasValue, setAddAliasValue] = useState('');
|
||||
const [addAliasError, setAddAliasError] = useState('');
|
||||
const [settingsAlias, setSettingsAlias] = useState<string | null>(null);
|
||||
const [settingsSourceId, setSettingsSourceId] = useState<string | null>(null);
|
||||
|
||||
// Parse data: each source model (provider+name) and each alias is distinct by id; 1 source -> many aliases.
|
||||
const { aliasNodes, providerNodes } = useMemo(() => {
|
||||
const sourceMap = new Map<
|
||||
string,
|
||||
{ provider: string; name: string; aliases: Map<string, boolean> }
|
||||
>();
|
||||
const aliasSet = new Set<string>();
|
||||
|
||||
// 1. Existing mappings: group by (provider, name), each source has a set of aliases
|
||||
Object.entries(modelAlias).forEach(([provider, mappings]) => {
|
||||
(mappings ?? []).forEach((m) => {
|
||||
const name = (m?.name || '').trim();
|
||||
const alias = (m?.alias || '').trim();
|
||||
if (!name || !alias) return;
|
||||
|
||||
const pk = `${provider.toLowerCase()}::${name.toLowerCase()}`;
|
||||
if (!sourceMap.has(pk)) {
|
||||
sourceMap.set(pk, { provider, name, aliases: new Map() });
|
||||
}
|
||||
sourceMap.get(pk)!.aliases.set(alias, m?.fork === true);
|
||||
aliasSet.add(alias);
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Unmapped models from allProviderModels (no mapping yet)
|
||||
Object.entries(allProviderModels).forEach(([provider, models]) => {
|
||||
(models ?? []).forEach((m) => {
|
||||
const name = (m.id || '').trim();
|
||||
if (!name) return;
|
||||
const pk = `${provider.toLowerCase()}::${name.toLowerCase()}`;
|
||||
if (sourceMap.has(pk)) {
|
||||
// Already in sourceMap from mappings; keep provider from mapping for correct grouping.
|
||||
return;
|
||||
}
|
||||
sourceMap.set(pk, { provider, name, aliases: new Map() });
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Source nodes: distinct by id = provider::name
|
||||
const sources: SourceNode[] = Array.from(sourceMap.entries())
|
||||
.map(([id, v]) => ({
|
||||
id,
|
||||
provider: v.provider,
|
||||
name: v.name,
|
||||
aliases: Array.from(v.aliases.entries()).map(([alias, fork]) => ({ alias, fork }))
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// 4. Extra aliases (no mapping yet)
|
||||
extraAliases.forEach((alias) => aliasSet.add(alias));
|
||||
|
||||
// 5. Alias nodes: distinct by id = alias; sources = SourceNodes that have this alias in their aliases
|
||||
const aliasNodesList: AliasNode[] = Array.from(aliasSet)
|
||||
.map((alias) => ({
|
||||
id: alias,
|
||||
alias,
|
||||
sources: sources.filter((s) => s.aliases.some((entry) => entry.alias === alias))
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (b.sources.length !== a.sources.length) return b.sources.length - a.sources.length;
|
||||
return a.alias.localeCompare(b.alias);
|
||||
});
|
||||
|
||||
// 6. Group sources by provider
|
||||
const providerMap = new Map<string, SourceNode[]>();
|
||||
sources.forEach((s) => {
|
||||
if (!providerMap.has(s.provider)) providerMap.set(s.provider, []);
|
||||
providerMap.get(s.provider)!.push(s);
|
||||
});
|
||||
const providerNodesList = Array.from(providerMap.entries())
|
||||
.map(([provider, providerSources]) => ({ provider, sources: providerSources }))
|
||||
.sort((a, b) => a.provider.localeCompare(b.provider));
|
||||
|
||||
return { aliasNodes: aliasNodesList, providerNodes: providerNodesList };
|
||||
}, [modelAlias, allProviderModels, extraAliases]);
|
||||
|
||||
// Track element positions
|
||||
const providerRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const sourceRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const aliasRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
const toggleProviderCollapse = (provider: string) => {
|
||||
setCollapsedProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(provider)) next.delete(provider);
|
||||
else next.add(provider);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Calculate lines: provider→source, source→alias (when expanded); midpoint + linkData for source→alias
|
||||
const updateLines = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const newLines: { path: string; color: string; id: string }[] = [];
|
||||
const nextProviderGroupHeights: Record<string, number> = {};
|
||||
|
||||
const bezier = (
|
||||
x1: number, y1: number,
|
||||
x2: number, y2: number
|
||||
) => {
|
||||
const cpx1 = x1 + (x2 - x1) * 0.5;
|
||||
const cpx2 = x2 - (x2 - x1) * 0.5;
|
||||
return `M ${x1} ${y1} C ${cpx1} ${y1}, ${cpx2} ${y2}, ${x2} ${y2}`;
|
||||
};
|
||||
|
||||
providerNodes.forEach(({ provider, sources }) => {
|
||||
const collapsed = collapsedProviders.has(provider);
|
||||
if (collapsed) return;
|
||||
|
||||
if (sources.length > 0) {
|
||||
const firstEl = sourceRefs.current.get(sources[0].id);
|
||||
const lastEl = sourceRefs.current.get(sources[sources.length - 1].id);
|
||||
if (firstEl && lastEl) {
|
||||
const height = Math.max(0, Math.round(lastEl.getBoundingClientRect().bottom - firstEl.getBoundingClientRect().top));
|
||||
if (height > 0) nextProviderGroupHeights[provider] = height;
|
||||
}
|
||||
}
|
||||
|
||||
const providerEl = providerRefs.current.get(provider);
|
||||
if (!providerEl) return;
|
||||
const providerRect = providerEl.getBoundingClientRect();
|
||||
const px = providerRect.right - containerRect.left;
|
||||
const py = providerRect.top + providerRect.height / 2 - containerRect.top;
|
||||
const color = getProviderColor(provider);
|
||||
|
||||
// Provider → Source (branch link, no dot)
|
||||
sources.forEach((source) => {
|
||||
const sourceEl = sourceRefs.current.get(source.id);
|
||||
if (!sourceEl) return;
|
||||
const sourceRect = sourceEl.getBoundingClientRect();
|
||||
const sx = sourceRect.left - containerRect.left;
|
||||
const sy = sourceRect.top + sourceRect.height / 2 - containerRect.top;
|
||||
newLines.push({
|
||||
id: `provider-${provider}-source-${source.id}`,
|
||||
path: bezier(px, py, sx, sy),
|
||||
color
|
||||
});
|
||||
});
|
||||
// Source → Alias: one line per alias
|
||||
sources.forEach((source) => {
|
||||
if (!source.aliases || source.aliases.length === 0) return;
|
||||
|
||||
source.aliases.forEach((aliasEntry) => {
|
||||
const sourceEl = sourceRefs.current.get(source.id);
|
||||
const aliasEl = aliasRefs.current.get(aliasEntry.alias);
|
||||
if (!sourceEl || !aliasEl) return;
|
||||
|
||||
const sourceRect = sourceEl.getBoundingClientRect();
|
||||
const aliasRect = aliasEl.getBoundingClientRect();
|
||||
|
||||
// Calculate coordinates relative to the container
|
||||
const x1 = sourceRect.right - containerRect.left;
|
||||
const y1 = sourceRect.top + sourceRect.height / 2 - containerRect.top;
|
||||
const x2 = aliasRect.left - containerRect.left;
|
||||
const y2 = aliasRect.top + aliasRect.height / 2 - containerRect.top;
|
||||
|
||||
newLines.push({
|
||||
id: `${source.id}-${aliasEntry.alias}`,
|
||||
path: bezier(x1, y1, x2, y2),
|
||||
color
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
setLines(newLines);
|
||||
setProviderGroupHeights((prev) => {
|
||||
const prevKeys = Object.keys(prev);
|
||||
const nextKeys = Object.keys(nextProviderGroupHeights);
|
||||
if (prevKeys.length !== nextKeys.length) return nextProviderGroupHeights;
|
||||
for (const key of nextKeys) {
|
||||
if (!(key in prev) || prev[key] !== nextProviderGroupHeights[key]) {
|
||||
return nextProviderGroupHeights;
|
||||
}
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [providerNodes, collapsedProviders]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
collapseAll: () => setCollapsedProviders(new Set(providerNodes.map((p) => p.provider))),
|
||||
refreshLayout: () => updateLines()
|
||||
}),
|
||||
[providerNodes, updateLines]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// updateLines is called after layout is calculated, ensuring elements are in place.
|
||||
const raf = requestAnimationFrame(updateLines);
|
||||
window.addEventListener('resize', updateLines);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', updateLines);
|
||||
};
|
||||
}, [updateLines, aliasNodes]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const raf = requestAnimationFrame(updateLines);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [providerGroupHeights, updateLines]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(() => updateLines());
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [updateLines]);
|
||||
|
||||
// Drag and Drop handlers
|
||||
// 1. Source -> Alias
|
||||
const handleDragStart = (e: DragEvent, source: SourceNode) => {
|
||||
setTapSourceId(null);
|
||||
setTapAlias(null);
|
||||
setDraggedSource(source);
|
||||
e.dataTransfer.setData('text/plain', source.id);
|
||||
e.dataTransfer.effectAllowed = 'link';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent, alias: string) => {
|
||||
if (!draggedSource || draggedSource.aliases.some((entry) => entry.alias === alias)) return;
|
||||
e.preventDefault(); // Allow drop
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
setDropTargetAlias(alias);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDropTargetAlias(null);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent, alias: string) => {
|
||||
e.preventDefault();
|
||||
if (draggedSource && !draggedSource.aliases.some((entry) => entry.alias === alias) && onUpdate) {
|
||||
onUpdate(draggedSource.provider, draggedSource.name, alias);
|
||||
}
|
||||
setDraggedSource(null);
|
||||
setDropTargetAlias(null);
|
||||
};
|
||||
|
||||
// 2. Alias -> Source
|
||||
const handleDragStartAlias = (e: DragEvent, alias: string) => {
|
||||
setTapSourceId(null);
|
||||
setTapAlias(null);
|
||||
setDraggedAlias(alias);
|
||||
e.dataTransfer.setData('text/plain', alias);
|
||||
e.dataTransfer.effectAllowed = 'link';
|
||||
};
|
||||
|
||||
const handleDragOverSource = (e: DragEvent, source: SourceNode) => {
|
||||
if (!draggedAlias || source.aliases.some((entry) => entry.alias === draggedAlias)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
setDropTargetSource(source.id);
|
||||
};
|
||||
|
||||
const handleDragLeaveSource = () => {
|
||||
setDropTargetSource(null);
|
||||
};
|
||||
|
||||
const handleDropOnSource = (e: DragEvent, source: SourceNode) => {
|
||||
e.preventDefault();
|
||||
if (draggedAlias && !source.aliases.some((entry) => entry.alias === draggedAlias) && onUpdate) {
|
||||
onUpdate(source.provider, source.name, draggedAlias);
|
||||
}
|
||||
setDraggedAlias(null);
|
||||
setDropTargetSource(null);
|
||||
};
|
||||
|
||||
const handleContextMenu = (
|
||||
e: ReactMouseEvent,
|
||||
type: 'alias' | 'background' | 'provider' | 'source',
|
||||
data?: string
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
type,
|
||||
data
|
||||
});
|
||||
};
|
||||
|
||||
const closeContextMenu = () => setContextMenu(null);
|
||||
|
||||
const resolveSourceById = useCallback(
|
||||
(id: string | null) => {
|
||||
if (!id) return null;
|
||||
for (const { sources } of providerNodes) {
|
||||
const found = sources.find((source) => source.id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[providerNodes]
|
||||
);
|
||||
|
||||
const handleTapSelectSource = (source: SourceNode) => {
|
||||
if (!onUpdate) return;
|
||||
if (tapSourceId === source.id) {
|
||||
setTapSourceId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tapAlias) {
|
||||
onUpdate(source.provider, source.name, tapAlias);
|
||||
setTapSourceId(null);
|
||||
setTapAlias(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setTapSourceId(source.id);
|
||||
setTapAlias(null);
|
||||
};
|
||||
|
||||
const handleTapSelectAlias = (alias: string) => {
|
||||
if (!onUpdate) return;
|
||||
if (tapAlias === alias) {
|
||||
setTapAlias(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tapSourceId) {
|
||||
const source = resolveSourceById(tapSourceId);
|
||||
if (source) {
|
||||
onUpdate(source.provider, source.name, alias);
|
||||
}
|
||||
setTapSourceId(null);
|
||||
setTapAlias(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setTapAlias(alias);
|
||||
setTapSourceId(null);
|
||||
};
|
||||
|
||||
const handleUnlinkSource = (provider: string, sourceModel: string, alias: string) => {
|
||||
if (onDeleteLink) onDeleteLink(provider, sourceModel, alias);
|
||||
};
|
||||
|
||||
const handleToggleFork = (
|
||||
provider: string,
|
||||
sourceModel: string,
|
||||
alias: string,
|
||||
value: boolean
|
||||
) => {
|
||||
if (onToggleFork) onToggleFork(provider, sourceModel, alias, value);
|
||||
};
|
||||
|
||||
const handleAddAlias = () => {
|
||||
closeContextMenu();
|
||||
setAddAliasOpen(true);
|
||||
setAddAliasValue('');
|
||||
setAddAliasError('');
|
||||
};
|
||||
|
||||
const handleAddAliasSubmit = () => {
|
||||
const trimmed = addAliasValue.trim();
|
||||
if (!trimmed) {
|
||||
setAddAliasError(t('oauth_model_alias.diagram_please_enter_alias'));
|
||||
return;
|
||||
}
|
||||
if (aliasNodes.some(a => a.alias === trimmed)) {
|
||||
setAddAliasError(t('oauth_model_alias.diagram_alias_exists'));
|
||||
return;
|
||||
}
|
||||
setExtraAliases(prev => [...prev, trimmed]);
|
||||
setAddAliasOpen(false);
|
||||
};
|
||||
|
||||
const handleRenameClick = (oldAlias: string) => {
|
||||
closeContextMenu();
|
||||
setRenameState({ oldAlias });
|
||||
setRenameValue(oldAlias);
|
||||
setRenameError('');
|
||||
};
|
||||
|
||||
const handleRenameSubmit = () => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (!trimmed) {
|
||||
setRenameError(t('oauth_model_alias.diagram_please_enter_alias'));
|
||||
return;
|
||||
}
|
||||
if (trimmed === renameState?.oldAlias) {
|
||||
setRenameState(null);
|
||||
return;
|
||||
}
|
||||
if (aliasNodes.some(a => a.alias === trimmed)) {
|
||||
setRenameError(t('oauth_model_alias.diagram_alias_exists'));
|
||||
return;
|
||||
}
|
||||
if (onRenameAlias && renameState) onRenameAlias(renameState.oldAlias, trimmed);
|
||||
if (extraAliases.includes(renameState?.oldAlias ?? '')) {
|
||||
setExtraAliases(prev => prev.map(a => a === renameState?.oldAlias ? trimmed : a));
|
||||
}
|
||||
setRenameState(null);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (alias: string) => {
|
||||
closeContextMenu();
|
||||
const node = aliasNodes.find(n => n.alias === alias);
|
||||
if (!node) return;
|
||||
|
||||
if (node.sources.length === 0) {
|
||||
setExtraAliases(prev => prev.filter(a => a !== alias));
|
||||
} else {
|
||||
if (onDeleteAlias) onDeleteAlias(alias);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className={[styles.scrollContainer, className].filter(Boolean).join(' ')}>
|
||||
{enableTapLinking && onUpdate && (
|
||||
<div className={styles.tapHint}>{t('oauth_model_alias.diagram_tap_hint')}</div>
|
||||
)}
|
||||
<div
|
||||
className={styles.container}
|
||||
ref={containerRef}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, 'background');
|
||||
}}
|
||||
>
|
||||
<svg className={styles.connections}>
|
||||
{lines.map((line) => (
|
||||
<path
|
||||
key={line.id}
|
||||
d={line.path}
|
||||
stroke={line.color}
|
||||
strokeOpacity={isDark ? 0.4 : 0.3}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<ProviderColumn
|
||||
providerNodes={providerNodes}
|
||||
collapsedProviders={collapsedProviders}
|
||||
getProviderColor={getProviderColor}
|
||||
providerGroupHeights={providerGroupHeights}
|
||||
providerRefs={providerRefs}
|
||||
onToggleCollapse={toggleProviderCollapse}
|
||||
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
|
||||
label={t('oauth_model_alias.diagram_providers')}
|
||||
expandLabel={t('oauth_model_alias.diagram_expand')}
|
||||
collapseLabel={t('oauth_model_alias.diagram_collapse')}
|
||||
/>
|
||||
<SourceColumn
|
||||
providerNodes={providerNodes}
|
||||
collapsedProviders={collapsedProviders}
|
||||
sourceRefs={sourceRefs}
|
||||
getProviderColor={getProviderColor}
|
||||
selectedSourceId={enableTapLinking ? tapSourceId : null}
|
||||
onSelectSource={enableTapLinking ? handleTapSelectSource : undefined}
|
||||
draggedSource={draggedSource}
|
||||
dropTargetSource={dropTargetSource}
|
||||
draggable={!!onUpdate}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={() => {
|
||||
setDraggedSource(null);
|
||||
setDropTargetAlias(null);
|
||||
}}
|
||||
onDragOver={handleDragOverSource}
|
||||
onDragLeave={handleDragLeaveSource}
|
||||
onDrop={handleDropOnSource}
|
||||
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
|
||||
label={t('oauth_model_alias.diagram_source_models')}
|
||||
/>
|
||||
<AliasColumn
|
||||
aliasNodes={aliasNodes}
|
||||
aliasRefs={aliasRefs}
|
||||
dropTargetAlias={dropTargetAlias}
|
||||
draggedAlias={draggedAlias}
|
||||
selectedAlias={enableTapLinking ? tapAlias : null}
|
||||
onSelectAlias={enableTapLinking ? handleTapSelectAlias : undefined}
|
||||
draggable={!!onUpdate}
|
||||
onDragStart={handleDragStartAlias}
|
||||
onDragEnd={() => {
|
||||
setDraggedAlias(null);
|
||||
setDropTargetSource(null);
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
|
||||
label={t('oauth_model_alias.diagram_aliases')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DiagramContextMenu
|
||||
contextMenu={contextMenu}
|
||||
t={t}
|
||||
onRequestClose={() => setContextMenu(null)}
|
||||
onAddAlias={handleAddAlias}
|
||||
onRenameAlias={handleRenameClick}
|
||||
onOpenAliasSettings={(alias) => {
|
||||
setContextMenu(null);
|
||||
setSettingsAlias(alias);
|
||||
}}
|
||||
onDeleteAlias={handleDeleteClick}
|
||||
onEditProvider={(provider) => {
|
||||
setContextMenu(null);
|
||||
onEditProvider?.(provider);
|
||||
}}
|
||||
onDeleteProvider={(provider) => {
|
||||
setContextMenu(null);
|
||||
onDeleteProvider?.(provider);
|
||||
}}
|
||||
onOpenSourceSettings={(sourceId) => {
|
||||
setContextMenu(null);
|
||||
setSettingsSourceId(sourceId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<RenameAliasModal
|
||||
open={!!renameState}
|
||||
t={t}
|
||||
value={renameValue}
|
||||
error={renameError}
|
||||
onChange={(value) => {
|
||||
setRenameValue(value);
|
||||
setRenameError('');
|
||||
}}
|
||||
onClose={() => setRenameState(null)}
|
||||
onSubmit={handleRenameSubmit}
|
||||
/>
|
||||
<AddAliasModal
|
||||
open={addAliasOpen}
|
||||
t={t}
|
||||
value={addAliasValue}
|
||||
error={addAliasError}
|
||||
onChange={(value) => {
|
||||
setAddAliasValue(value);
|
||||
setAddAliasError('');
|
||||
}}
|
||||
onClose={() => setAddAliasOpen(false)}
|
||||
onSubmit={handleAddAliasSubmit}
|
||||
/>
|
||||
<SettingsAliasModal
|
||||
open={Boolean(settingsAlias)}
|
||||
t={t}
|
||||
alias={settingsAlias}
|
||||
aliasNodes={aliasNodes}
|
||||
onClose={() => setSettingsAlias(null)}
|
||||
onToggleFork={handleToggleFork}
|
||||
onUnlink={handleUnlinkSource}
|
||||
/>
|
||||
<SettingsSourceModal
|
||||
open={Boolean(settingsSourceId)}
|
||||
t={t}
|
||||
source={resolveSourceById(settingsSourceId)}
|
||||
onClose={() => setSettingsSourceId(null)}
|
||||
onToggleFork={handleToggleFork}
|
||||
onUnlink={handleUnlinkSource}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
251
src/components/modelAlias/ModelMappingDiagramColumns.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import type { DragEvent, MouseEvent as ReactMouseEvent, RefObject } from 'react';
|
||||
import type { AliasNode, ProviderNode, SourceNode } from './ModelMappingDiagramTypes';
|
||||
import styles from './ModelMappingDiagram.module.scss';
|
||||
|
||||
interface ProviderColumnProps {
|
||||
providerNodes: ProviderNode[];
|
||||
collapsedProviders: Set<string>;
|
||||
getProviderColor: (provider: string) => string;
|
||||
providerGroupHeights?: Record<string, number>;
|
||||
providerRefs: RefObject<Map<string, HTMLDivElement>>;
|
||||
onToggleCollapse: (provider: string) => void;
|
||||
onContextMenu: (e: ReactMouseEvent, type: 'provider' | 'background', data?: string) => void;
|
||||
label: string;
|
||||
expandLabel: string;
|
||||
collapseLabel: string;
|
||||
}
|
||||
|
||||
export function ProviderColumn({
|
||||
providerNodes,
|
||||
collapsedProviders,
|
||||
getProviderColor,
|
||||
providerGroupHeights = {},
|
||||
providerRefs,
|
||||
onToggleCollapse,
|
||||
onContextMenu,
|
||||
label,
|
||||
expandLabel,
|
||||
collapseLabel
|
||||
}: ProviderColumnProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.column} ${styles.providers}`}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'background');
|
||||
}}
|
||||
>
|
||||
<div className={styles.columnHeader}>{label}</div>
|
||||
{providerNodes.map(({ provider, sources }) => {
|
||||
const collapsed = collapsedProviders.has(provider);
|
||||
const groupHeight = collapsed ? undefined : providerGroupHeights[provider];
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className={styles.providerGroup}
|
||||
style={groupHeight ? { height: groupHeight } : undefined}
|
||||
>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) providerRefs.current?.set(provider, el);
|
||||
else providerRefs.current?.delete(provider);
|
||||
}}
|
||||
className={`${styles.item} ${styles.providerItem}`}
|
||||
style={{ borderLeftColor: getProviderColor(provider) }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'provider', provider);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.collapseBtn}
|
||||
onClick={() => onToggleCollapse(provider)}
|
||||
aria-label={collapsed ? expandLabel : collapseLabel}
|
||||
title={collapsed ? expandLabel : collapseLabel}
|
||||
>
|
||||
<span className={collapsed ? styles.chevronRight : styles.chevronDown} />
|
||||
</button>
|
||||
<span className={styles.providerLabel} style={{ color: getProviderColor(provider) }}>
|
||||
{provider}
|
||||
</span>
|
||||
<span className={styles.itemCount}>{sources.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SourceColumnProps {
|
||||
providerNodes: ProviderNode[];
|
||||
collapsedProviders: Set<string>;
|
||||
sourceRefs: RefObject<Map<string, HTMLDivElement>>;
|
||||
getProviderColor: (provider: string) => string;
|
||||
selectedSourceId?: string | null;
|
||||
onSelectSource?: (source: SourceNode) => void;
|
||||
draggedSource: SourceNode | null;
|
||||
dropTargetSource: string | null;
|
||||
draggable: boolean;
|
||||
onDragStart: (e: DragEvent, source: SourceNode) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: DragEvent, source: SourceNode) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: DragEvent, source: SourceNode) => void;
|
||||
onContextMenu: (e: ReactMouseEvent, type: 'source' | 'background', data?: string) => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function SourceColumn({
|
||||
providerNodes,
|
||||
collapsedProviders,
|
||||
sourceRefs,
|
||||
getProviderColor,
|
||||
selectedSourceId,
|
||||
onSelectSource,
|
||||
draggedSource,
|
||||
dropTargetSource,
|
||||
draggable,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onContextMenu,
|
||||
label
|
||||
}: SourceColumnProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.column} ${styles.sources}`}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'background');
|
||||
}}
|
||||
>
|
||||
<div className={styles.columnHeader}>{label}</div>
|
||||
{providerNodes.flatMap(({ provider, sources }) => {
|
||||
if (collapsedProviders.has(provider)) return [];
|
||||
return sources.map((source) => (
|
||||
<div
|
||||
key={source.id}
|
||||
ref={(el) => {
|
||||
if (el) sourceRefs.current?.set(source.id, el);
|
||||
else sourceRefs.current?.delete(source.id);
|
||||
}}
|
||||
className={`${styles.item} ${styles.sourceItem} ${
|
||||
draggedSource?.id === source.id ? styles.dragging : ''
|
||||
} ${dropTargetSource === source.id ? styles.dropTarget : ''} ${
|
||||
selectedSourceId === source.id ? styles.selected : ''
|
||||
}`}
|
||||
onClick={() => onSelectSource?.(source)}
|
||||
draggable={draggable}
|
||||
onDragStart={(e) => onDragStart(e, source)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => onDragOver(e, source)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, source)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'source', source.id);
|
||||
}}
|
||||
>
|
||||
<span className={styles.itemName} title={source.name}>
|
||||
{source.name}
|
||||
</span>
|
||||
<div
|
||||
className={styles.dot}
|
||||
style={{
|
||||
background: getProviderColor(source.provider),
|
||||
opacity: source.aliases.length > 0 ? 1 : 0.3
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AliasColumnProps {
|
||||
aliasNodes: AliasNode[];
|
||||
aliasRefs: RefObject<Map<string, HTMLDivElement>>;
|
||||
dropTargetAlias: string | null;
|
||||
draggedAlias: string | null;
|
||||
selectedAlias?: string | null;
|
||||
onSelectAlias?: (alias: string) => void;
|
||||
draggable: boolean;
|
||||
onDragStart: (e: DragEvent, alias: string) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: DragEvent, alias: string) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: DragEvent, alias: string) => void;
|
||||
onContextMenu: (e: ReactMouseEvent, type: 'alias' | 'background', data?: string) => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function AliasColumn({
|
||||
aliasNodes,
|
||||
aliasRefs,
|
||||
dropTargetAlias,
|
||||
draggedAlias,
|
||||
selectedAlias,
|
||||
onSelectAlias,
|
||||
draggable,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onContextMenu,
|
||||
label
|
||||
}: AliasColumnProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.column} ${styles.aliases}`}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'background');
|
||||
}}
|
||||
>
|
||||
<div className={styles.columnHeader}>{label}</div>
|
||||
{aliasNodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
ref={(el) => {
|
||||
if (el) aliasRefs.current?.set(node.id, el);
|
||||
else aliasRefs.current?.delete(node.id);
|
||||
}}
|
||||
className={`${styles.item} ${styles.aliasItem} ${
|
||||
dropTargetAlias === node.alias ? styles.dropTarget : ''
|
||||
} ${draggedAlias === node.alias ? styles.dragging : ''} ${
|
||||
selectedAlias === node.alias ? styles.selected : ''
|
||||
}`}
|
||||
onClick={() => onSelectAlias?.(node.alias)}
|
||||
draggable={draggable}
|
||||
onDragStart={(e) => onDragStart(e, node.alias)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => onDragOver(e, node.alias)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, node.alias)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'alias', node.alias);
|
||||
}}
|
||||
>
|
||||
<div className={`${styles.dot} ${styles.dotLeft}`} />
|
||||
<span className={styles.itemName} title={node.alias}>
|
||||
{node.alias}
|
||||
</span>
|
||||
<span className={styles.itemCount}>{node.sources.length}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/components/modelAlias/ModelMappingDiagramContextMenu.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { ContextMenuState } from './ModelMappingDiagramTypes';
|
||||
import styles from './ModelMappingDiagram.module.scss';
|
||||
|
||||
interface DiagramContextMenuProps {
|
||||
contextMenu: ContextMenuState | null;
|
||||
t: TFunction;
|
||||
onRequestClose: () => void;
|
||||
onAddAlias: () => void;
|
||||
onRenameAlias: (alias: string) => void;
|
||||
onOpenAliasSettings: (alias: string) => void;
|
||||
onDeleteAlias: (alias: string) => void;
|
||||
onEditProvider: (provider: string) => void;
|
||||
onDeleteProvider: (provider: string) => void;
|
||||
onOpenSourceSettings: (sourceId: string) => void;
|
||||
}
|
||||
|
||||
export function DiagramContextMenu({
|
||||
contextMenu,
|
||||
t,
|
||||
onRequestClose,
|
||||
onAddAlias,
|
||||
onRenameAlias,
|
||||
onOpenAliasSettings,
|
||||
onDeleteAlias,
|
||||
onEditProvider,
|
||||
onDeleteProvider,
|
||||
onOpenSourceSettings
|
||||
}: DiagramContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
const handleClick = (event: globalThis.MouseEvent) => {
|
||||
if (!menuRef.current?.contains(event.target as Node)) {
|
||||
onRequestClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [contextMenu, onRequestClose]);
|
||||
|
||||
if (!contextMenu) return null;
|
||||
|
||||
const { type, data } = contextMenu;
|
||||
|
||||
const renderBackground = () => (
|
||||
<div className={styles.menuItem} onClick={onAddAlias}>
|
||||
<span>{t('oauth_model_alias.diagram_add_alias')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAlias = () => {
|
||||
if (!data) return null;
|
||||
return (
|
||||
<>
|
||||
<div className={styles.menuItem} onClick={() => onRenameAlias(data)}>
|
||||
<span>{t('oauth_model_alias.diagram_rename')}</span>
|
||||
</div>
|
||||
<div className={styles.menuItem} onClick={() => onOpenAliasSettings(data)}>
|
||||
<span>{t('oauth_model_alias.diagram_settings')}</span>
|
||||
</div>
|
||||
<div className={styles.menuDivider} />
|
||||
<div className={`${styles.menuItem} ${styles.danger}`} onClick={() => onDeleteAlias(data)}>
|
||||
<span>{t('oauth_model_alias.diagram_delete_alias')}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderProvider = () => {
|
||||
if (!data) return null;
|
||||
return (
|
||||
<>
|
||||
<div className={styles.menuItem} onClick={() => onEditProvider(data)}>
|
||||
<span>{t('common.edit')}</span>
|
||||
</div>
|
||||
<div className={styles.menuDivider} />
|
||||
<div className={`${styles.menuItem} ${styles.danger}`} onClick={() => onDeleteProvider(data)}>
|
||||
<span>{t('oauth_model_alias.delete')}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSource = () => {
|
||||
if (!data) return null;
|
||||
return (
|
||||
<div className={styles.menuItem} onClick={() => onOpenSourceSettings(data)}>
|
||||
<span>{t('oauth_model_alias.diagram_settings')}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={styles.contextMenu}
|
||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{type === 'background' && renderBackground()}
|
||||
{type === 'alias' && renderAlias()}
|
||||
{type === 'provider' && renderProvider()}
|
||||
{type === 'source' && renderSource()}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
267
src/components/modelAlias/ModelMappingDiagramModals.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { IconTrash2 } from '@/components/ui/icons';
|
||||
import type { AliasNode, SourceNode } from './ModelMappingDiagramTypes';
|
||||
import styles from './ModelMappingDiagram.module.scss';
|
||||
|
||||
interface RenameAliasModalProps {
|
||||
open: boolean;
|
||||
t: TFunction;
|
||||
value: string;
|
||||
error: string;
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export function RenameAliasModal({
|
||||
open,
|
||||
t,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onClose,
|
||||
onSubmit
|
||||
}: RenameAliasModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('oauth_model_alias.diagram_rename_alias_title')}
|
||||
width={400}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={onSubmit}>{t('oauth_model_alias.diagram_rename_btn')}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('oauth_model_alias.diagram_rename_alias_label')}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') onSubmit();
|
||||
}}
|
||||
error={error}
|
||||
placeholder={t('oauth_model_alias.diagram_rename_placeholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface AddAliasModalProps {
|
||||
open: boolean;
|
||||
t: TFunction;
|
||||
value: string;
|
||||
error: string;
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export function AddAliasModal({
|
||||
open,
|
||||
t,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onClose,
|
||||
onSubmit
|
||||
}: AddAliasModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('oauth_model_alias.diagram_add_alias_title')}
|
||||
width={400}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={onSubmit}>{t('oauth_model_alias.diagram_add_btn')}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('oauth_model_alias.diagram_add_alias_label')}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') onSubmit();
|
||||
}}
|
||||
error={error}
|
||||
placeholder={t('oauth_model_alias.diagram_add_placeholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsAliasModalProps {
|
||||
open: boolean;
|
||||
t: TFunction;
|
||||
alias: string | null;
|
||||
aliasNodes: AliasNode[];
|
||||
onClose: () => void;
|
||||
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
|
||||
onUnlink: (provider: string, sourceModel: string, alias: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsAliasModal({
|
||||
open,
|
||||
t,
|
||||
alias,
|
||||
aliasNodes,
|
||||
onClose,
|
||||
onToggleFork,
|
||||
onUnlink
|
||||
}: SettingsAliasModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('oauth_model_alias.diagram_settings_title', { alias: alias ?? '' })}
|
||||
width={720}
|
||||
footer={
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{alias ? (
|
||||
(() => {
|
||||
const node = aliasNodes.find((n) => n.alias === alias);
|
||||
if (!node || node.sources.length === 0) {
|
||||
return <div className={styles.settingsEmpty}>{t('oauth_model_alias.diagram_settings_empty')}</div>;
|
||||
}
|
||||
return (
|
||||
<div className={styles.settingsList}>
|
||||
{node.sources.map((source) => {
|
||||
const entry = source.aliases.find((item) => item.alias === alias);
|
||||
const forkEnabled = entry?.fork === true;
|
||||
return (
|
||||
<div key={source.id} className={styles.settingsRow}>
|
||||
<div className={styles.settingsNames}>
|
||||
<span className={styles.settingsSource}>{source.name}</span>
|
||||
<span className={styles.settingsArrow}>→</span>
|
||||
<span className={styles.settingsAlias}>{alias}</span>
|
||||
</div>
|
||||
<div className={styles.settingsActions}>
|
||||
<span className={styles.settingsLabel}>
|
||||
{t('oauth_model_alias.alias_fork_label')}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
checked={forkEnabled}
|
||||
onChange={(value) => onToggleFork(source.provider, source.name, alias, value)}
|
||||
ariaLabel={t('oauth_model_alias.alias_fork_label')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.settingsDelete}
|
||||
onClick={() => onUnlink(source.provider, source.name, alias)}
|
||||
aria-label={t('oauth_model_alias.diagram_delete_link', {
|
||||
provider: source.provider,
|
||||
name: source.name
|
||||
})}
|
||||
title={t('oauth_model_alias.diagram_delete_link', {
|
||||
provider: source.provider,
|
||||
name: source.name
|
||||
})}
|
||||
>
|
||||
<IconTrash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsSourceModalProps {
|
||||
open: boolean;
|
||||
t: TFunction;
|
||||
source: SourceNode | null;
|
||||
onClose: () => void;
|
||||
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
|
||||
onUnlink: (provider: string, sourceModel: string, alias: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsSourceModal({
|
||||
open,
|
||||
t,
|
||||
source,
|
||||
onClose,
|
||||
onToggleFork,
|
||||
onUnlink
|
||||
}: SettingsSourceModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('oauth_model_alias.diagram_settings_source_title')}
|
||||
width={720}
|
||||
footer={
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{source ? (
|
||||
source.aliases.length === 0 ? (
|
||||
<div className={styles.settingsEmpty}>{t('oauth_model_alias.diagram_settings_empty')}</div>
|
||||
) : (
|
||||
<div className={styles.settingsList}>
|
||||
{source.aliases.map((entry) => (
|
||||
<div key={`${source.id}-${entry.alias}`} className={styles.settingsRow}>
|
||||
<div className={styles.settingsNames}>
|
||||
<span className={styles.settingsSource}>{source.name}</span>
|
||||
<span className={styles.settingsArrow}>→</span>
|
||||
<span className={styles.settingsAlias}>{entry.alias}</span>
|
||||
</div>
|
||||
<div className={styles.settingsActions}>
|
||||
<span className={styles.settingsLabel}>
|
||||
{t('oauth_model_alias.alias_fork_label')}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
checked={entry.fork === true}
|
||||
onChange={(value) => onToggleFork(source.provider, source.name, entry.alias, value)}
|
||||
ariaLabel={t('oauth_model_alias.alias_fork_label')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.settingsDelete}
|
||||
onClick={() => onUnlink(source.provider, source.name, entry.alias)}
|
||||
aria-label={t('oauth_model_alias.diagram_delete_link', {
|
||||
provider: source.provider,
|
||||
name: source.name
|
||||
})}
|
||||
title={t('oauth_model_alias.diagram_delete_link', {
|
||||
provider: source.provider,
|
||||
name: source.name
|
||||
})}
|
||||
>
|
||||
<IconTrash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
33
src/components/modelAlias/ModelMappingDiagramTypes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface AuthFileModelItem {
|
||||
id: string;
|
||||
display_name?: string;
|
||||
type?: string;
|
||||
owned_by?: string;
|
||||
}
|
||||
|
||||
export interface SourceNode {
|
||||
id: string; // unique: provider::name
|
||||
provider: string;
|
||||
name: string;
|
||||
aliases: { alias: string; fork: boolean }[]; // all aliases this source maps to
|
||||
}
|
||||
|
||||
export interface AliasNode {
|
||||
id: string; // alias
|
||||
alias: string;
|
||||
sources: SourceNode[];
|
||||
}
|
||||
|
||||
export interface ProviderNode {
|
||||
provider: string;
|
||||
sources: SourceNode[];
|
||||
}
|
||||
|
||||
export interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
type: 'alias' | 'background' | 'provider' | 'source';
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export type DiagramLine = { path: string; color: string; id: string };
|
||||
2
src/components/modelAlias/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ModelMappingDiagram } from './ModelMappingDiagram';
|
||||
export type { ModelMappingDiagramProps, ModelMappingDiagramRef } from './ModelMappingDiagram';
|
||||
409
src/components/monitor/ChannelStats.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import { useMemo, useState, useCallback, Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { useDisableModel } from '@/hooks';
|
||||
import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector';
|
||||
import { DisableModelModal } from './DisableModelModal';
|
||||
import {
|
||||
formatTimestamp,
|
||||
getRateClassName,
|
||||
filterDataByTimeRange,
|
||||
getProviderDisplayParts,
|
||||
type DateRange,
|
||||
} from '@/utils/monitor';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface ChannelStatsProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
providerMap: Record<string, string>;
|
||||
providerModels: Record<string, Set<string>>;
|
||||
}
|
||||
|
||||
interface ModelStat {
|
||||
requests: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
successRate: number;
|
||||
recentRequests: { failed: boolean; timestamp: number }[];
|
||||
lastTimestamp: number;
|
||||
}
|
||||
|
||||
interface ChannelStat {
|
||||
source: string;
|
||||
displayName: string;
|
||||
providerName: string | null;
|
||||
maskedKey: string;
|
||||
totalRequests: number;
|
||||
successRequests: number;
|
||||
failedRequests: number;
|
||||
successRate: number;
|
||||
lastRequestTime: number;
|
||||
recentRequests: { failed: boolean; timestamp: number }[];
|
||||
models: Record<string, ModelStat>;
|
||||
}
|
||||
|
||||
export function ChannelStats({ data, loading, providerMap, providerModels }: ChannelStatsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedChannel, setExpandedChannel] = useState<string | null>(null);
|
||||
const [filterChannel, setFilterChannel] = useState('');
|
||||
const [filterModel, setFilterModel] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<'' | 'success' | 'failed'>('');
|
||||
|
||||
// 时间范围状态
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(7);
|
||||
const [customRange, setCustomRange] = useState<DateRange | undefined>();
|
||||
|
||||
// 使用禁用模型 Hook
|
||||
const {
|
||||
disableState,
|
||||
disabling,
|
||||
isModelDisabled,
|
||||
handleDisableClick: onDisableClick,
|
||||
handleConfirmDisable,
|
||||
handleCancelDisable,
|
||||
} = useDisableModel({ providerMap, providerModels });
|
||||
|
||||
// 处理时间范围变化
|
||||
const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => {
|
||||
setTimeRange(range);
|
||||
if (custom) {
|
||||
setCustomRange(custom);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 根据时间范围过滤数据
|
||||
const timeFilteredData = useMemo(() => {
|
||||
return filterDataByTimeRange(data, timeRange, customRange);
|
||||
}, [data, timeRange, customRange]);
|
||||
|
||||
// 计算渠道统计数据
|
||||
const channelStats = useMemo(() => {
|
||||
if (!timeFilteredData?.apis) return [];
|
||||
|
||||
const stats: Record<string, ChannelStat> = {};
|
||||
|
||||
Object.values(timeFilteredData.apis).forEach((apiData) => {
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
const source = detail.source || 'unknown';
|
||||
// 获取渠道显示信息
|
||||
const { provider, masked } = getProviderDisplayParts(source, providerMap);
|
||||
// 只统计在 providerMap 中存在的渠道
|
||||
if (!provider) return;
|
||||
|
||||
const displayName = `${provider} (${masked})`;
|
||||
const timestamp = detail.timestamp ? new Date(detail.timestamp).getTime() : 0;
|
||||
|
||||
if (!stats[displayName]) {
|
||||
stats[displayName] = {
|
||||
source,
|
||||
displayName,
|
||||
providerName: provider,
|
||||
maskedKey: masked,
|
||||
totalRequests: 0,
|
||||
successRequests: 0,
|
||||
failedRequests: 0,
|
||||
successRate: 0,
|
||||
lastRequestTime: 0,
|
||||
recentRequests: [],
|
||||
models: {},
|
||||
};
|
||||
}
|
||||
|
||||
stats[displayName].totalRequests++;
|
||||
if (detail.failed) {
|
||||
stats[displayName].failedRequests++;
|
||||
} else {
|
||||
stats[displayName].successRequests++;
|
||||
}
|
||||
|
||||
// 更新最近请求时间
|
||||
if (timestamp > stats[displayName].lastRequestTime) {
|
||||
stats[displayName].lastRequestTime = timestamp;
|
||||
}
|
||||
|
||||
// 收集请求状态
|
||||
stats[displayName].recentRequests.push({ failed: detail.failed, timestamp });
|
||||
|
||||
// 模型统计
|
||||
if (!stats[displayName].models[modelName]) {
|
||||
stats[displayName].models[modelName] = {
|
||||
requests: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
successRate: 0,
|
||||
recentRequests: [],
|
||||
lastTimestamp: 0,
|
||||
};
|
||||
}
|
||||
stats[displayName].models[modelName].requests++;
|
||||
if (detail.failed) {
|
||||
stats[displayName].models[modelName].failed++;
|
||||
} else {
|
||||
stats[displayName].models[modelName].success++;
|
||||
}
|
||||
stats[displayName].models[modelName].recentRequests.push({ failed: detail.failed, timestamp });
|
||||
if (timestamp > stats[displayName].models[modelName].lastTimestamp) {
|
||||
stats[displayName].models[modelName].lastTimestamp = timestamp;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 计算成功率并排序请求
|
||||
Object.values(stats).forEach((stat) => {
|
||||
stat.successRate = stat.totalRequests > 0
|
||||
? (stat.successRequests / stat.totalRequests) * 100
|
||||
: 0;
|
||||
// 按时间排序,取最近12个
|
||||
stat.recentRequests.sort((a, b) => a.timestamp - b.timestamp);
|
||||
stat.recentRequests = stat.recentRequests.slice(-12);
|
||||
|
||||
Object.values(stat.models).forEach((model) => {
|
||||
model.successRate = model.requests > 0
|
||||
? (model.success / model.requests) * 100
|
||||
: 0;
|
||||
model.recentRequests.sort((a, b) => a.timestamp - b.timestamp);
|
||||
model.recentRequests = model.recentRequests.slice(-12);
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(stats)
|
||||
.filter((stat) => stat.totalRequests > 0)
|
||||
.sort((a, b) => b.totalRequests - a.totalRequests)
|
||||
.slice(0, 10);
|
||||
}, [timeFilteredData, providerMap]);
|
||||
|
||||
// 获取所有渠道和模型列表
|
||||
const { channels, models } = useMemo(() => {
|
||||
const channelSet = new Set<string>();
|
||||
const modelSet = new Set<string>();
|
||||
|
||||
channelStats.forEach((stat) => {
|
||||
channelSet.add(stat.displayName);
|
||||
Object.keys(stat.models).forEach((model) => modelSet.add(model));
|
||||
});
|
||||
|
||||
return {
|
||||
channels: Array.from(channelSet).sort(),
|
||||
models: Array.from(modelSet).sort(),
|
||||
};
|
||||
}, [channelStats]);
|
||||
|
||||
// 过滤后的数据
|
||||
const filteredStats = useMemo(() => {
|
||||
return channelStats.filter((stat) => {
|
||||
if (filterChannel && stat.displayName !== filterChannel) return false;
|
||||
if (filterModel && !stat.models[filterModel]) return false;
|
||||
if (filterStatus === 'success' && stat.failedRequests > 0) return false;
|
||||
if (filterStatus === 'failed' && stat.failedRequests === 0) return false;
|
||||
return true;
|
||||
});
|
||||
}, [channelStats, filterChannel, filterModel, filterStatus]);
|
||||
|
||||
// 切换展开状态
|
||||
const toggleExpand = (displayName: string) => {
|
||||
setExpandedChannel(expandedChannel === displayName ? null : displayName);
|
||||
};
|
||||
|
||||
// 开始禁用流程(阻止事件冒泡)
|
||||
const handleDisableClick = (source: string, model: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDisableClick(source, model);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={t('monitor.channel.title')}
|
||||
subtitle={
|
||||
<span>
|
||||
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.channel.subtitle')}
|
||||
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.channel.click_hint')}</span>
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<TimeRangeSelector
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
customRange={customRange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* 筛选器 */}
|
||||
<div className={styles.logFilters}>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterChannel}
|
||||
onChange={(e) => setFilterChannel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.channel.all_channels')}</option>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel} value={channel}>{channel}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterModel}
|
||||
onChange={(e) => setFilterModel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.channel.all_models')}</option>
|
||||
{models.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value as '' | 'success' | 'failed')}
|
||||
>
|
||||
<option value="">{t('monitor.channel.all_status')}</option>
|
||||
<option value="success">{t('monitor.channel.only_success')}</option>
|
||||
<option value="failed">{t('monitor.channel.only_failed')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className={styles.tableWrapper}>
|
||||
{loading ? (
|
||||
<div className={styles.emptyState}>{t('common.loading')}</div>
|
||||
) : filteredStats.length === 0 ? (
|
||||
<div className={styles.emptyState}>{t('monitor.no_data')}</div>
|
||||
) : (
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('monitor.channel.header_name')}</th>
|
||||
<th>{t('monitor.channel.header_count')}</th>
|
||||
<th>{t('monitor.channel.header_rate')}</th>
|
||||
<th>{t('monitor.channel.header_recent')}</th>
|
||||
<th>{t('monitor.channel.header_time')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredStats.map((stat) => (
|
||||
<Fragment key={stat.displayName}>
|
||||
<tr
|
||||
className={styles.expandable}
|
||||
onClick={() => toggleExpand(stat.displayName)}
|
||||
>
|
||||
<td>
|
||||
{stat.providerName ? (
|
||||
<>
|
||||
<span className={styles.channelName}>{stat.providerName}</span>
|
||||
<span className={styles.channelSecret}> ({stat.maskedKey})</span>
|
||||
</>
|
||||
) : (
|
||||
stat.maskedKey
|
||||
)}
|
||||
</td>
|
||||
<td>{stat.totalRequests.toLocaleString()}</td>
|
||||
<td className={getRateClassName(stat.successRate, styles)}>
|
||||
{stat.successRate.toFixed(1)}%
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.statusBars}>
|
||||
{stat.recentRequests.map((req, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatTimestamp(stat.lastRequestTime)}</td>
|
||||
</tr>
|
||||
{expandedChannel === stat.displayName && (
|
||||
<tr key={`${stat.displayName}-detail`}>
|
||||
<td colSpan={5} className={styles.expandDetail}>
|
||||
<div className={styles.expandTableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('monitor.channel.model')}</th>
|
||||
<th>{t('monitor.channel.header_count')}</th>
|
||||
<th>{t('monitor.channel.header_rate')}</th>
|
||||
<th>{t('monitor.channel.success')}/{t('monitor.channel.failed')}</th>
|
||||
<th>{t('monitor.channel.header_recent')}</th>
|
||||
<th>{t('monitor.channel.header_time')}</th>
|
||||
<th>{t('monitor.logs.header_actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(stat.models)
|
||||
.sort((a, b) => {
|
||||
const aDisabled = isModelDisabled(stat.source, a[0]);
|
||||
const bDisabled = isModelDisabled(stat.source, b[0]);
|
||||
// 已禁用的排在后面
|
||||
if (aDisabled !== bDisabled) {
|
||||
return aDisabled ? 1 : -1;
|
||||
}
|
||||
// 然后按请求数降序
|
||||
return b[1].requests - a[1].requests;
|
||||
})
|
||||
.map(([modelName, modelStat]) => {
|
||||
const disabled = isModelDisabled(stat.source, modelName);
|
||||
return (
|
||||
<tr key={modelName} className={disabled ? styles.modelDisabled : ''}>
|
||||
<td>{modelName}</td>
|
||||
<td>{modelStat.requests.toLocaleString()}</td>
|
||||
<td className={getRateClassName(modelStat.successRate, styles)}>
|
||||
{modelStat.successRate.toFixed(1)}%
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.kpiSuccess}>{modelStat.success}</span>
|
||||
{' / '}
|
||||
<span className={styles.kpiFailure}>{modelStat.failed}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.statusBars}>
|
||||
{modelStat.recentRequests.map((req, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatTimestamp(modelStat.lastTimestamp)}</td>
|
||||
<td>
|
||||
{disabled ? (
|
||||
<span className={styles.disabledLabel}>{t('monitor.logs.removed')}</span>
|
||||
) : stat.source && stat.source !== '-' && stat.source !== 'unknown' ? (
|
||||
<button
|
||||
className={styles.disableBtn}
|
||||
onClick={(e) => handleDisableClick(stat.source, modelName, e)}
|
||||
>
|
||||
{t('monitor.logs.disable')}
|
||||
</button>
|
||||
) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 禁用确认弹窗 */}
|
||||
<DisableModelModal
|
||||
disableState={disableState}
|
||||
disabling={disabling}
|
||||
onConfirm={handleConfirmDisable}
|
||||
onCancel={handleCancelDisable}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
279
src/components/monitor/DailyTrendChart.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Chart } from 'react-chartjs-2';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface DailyTrendChartProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
isDark: boolean;
|
||||
timeRange: number;
|
||||
}
|
||||
|
||||
interface DailyStat {
|
||||
date: string;
|
||||
requests: number;
|
||||
successRequests: number;
|
||||
failedRequests: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
reasoningTokens: number;
|
||||
cachedTokens: number;
|
||||
}
|
||||
|
||||
export function DailyTrendChart({ data, loading, isDark, timeRange }: DailyTrendChartProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 按日期聚合数据
|
||||
const dailyData = useMemo((): DailyStat[] => {
|
||||
if (!data?.apis) return [];
|
||||
|
||||
const dailyStats: Record<string, {
|
||||
requests: number;
|
||||
successRequests: number;
|
||||
failedRequests: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
reasoningTokens: number;
|
||||
cachedTokens: number;
|
||||
}> = {};
|
||||
|
||||
// 辅助函数:获取本地日期字符串 YYYY-MM-DD
|
||||
const getLocalDateString = (timestamp: string): string => {
|
||||
const date = new Date(timestamp);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
Object.values(data.apis).forEach((apiData) => {
|
||||
Object.values(apiData.models).forEach((modelData) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
// 使用本地日期而非 UTC 日期
|
||||
const date = getLocalDateString(detail.timestamp);
|
||||
if (!dailyStats[date]) {
|
||||
dailyStats[date] = {
|
||||
requests: 0,
|
||||
successRequests: 0,
|
||||
failedRequests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
cachedTokens: 0,
|
||||
};
|
||||
}
|
||||
dailyStats[date].requests++;
|
||||
if (detail.failed) {
|
||||
dailyStats[date].failedRequests++;
|
||||
} else {
|
||||
dailyStats[date].successRequests++;
|
||||
// 只统计成功请求的 Token
|
||||
dailyStats[date].inputTokens += detail.tokens.input_tokens || 0;
|
||||
dailyStats[date].outputTokens += detail.tokens.output_tokens || 0;
|
||||
dailyStats[date].reasoningTokens += detail.tokens.reasoning_tokens || 0;
|
||||
dailyStats[date].cachedTokens += detail.tokens.cached_tokens || 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 转换为数组并按日期排序
|
||||
return Object.entries(dailyStats)
|
||||
.map(([date, stats]) => ({ date, ...stats }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}, [data]);
|
||||
|
||||
// 图表数据
|
||||
const chartData = useMemo(() => {
|
||||
const labels = dailyData.map((item) => {
|
||||
const date = new Date(item.date);
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
type: 'line' as const,
|
||||
label: t('monitor.trend.requests'),
|
||||
data: dailyData.map((item) => item.requests),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#3b82f6',
|
||||
borderWidth: 3,
|
||||
fill: false,
|
||||
tension: 0.35,
|
||||
yAxisID: 'y1',
|
||||
order: 0,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: '#3b82f6',
|
||||
},
|
||||
{
|
||||
type: 'bar' as const,
|
||||
label: t('monitor.trend.input_tokens'),
|
||||
data: dailyData.map((item) => item.inputTokens / 1000),
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.7)',
|
||||
borderColor: 'rgba(34, 197, 94, 0.7)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 0,
|
||||
yAxisID: 'y',
|
||||
order: 1,
|
||||
stack: 'tokens',
|
||||
},
|
||||
{
|
||||
type: 'bar' as const,
|
||||
label: t('monitor.trend.output_tokens'),
|
||||
data: dailyData.map((item) => item.outputTokens / 1000),
|
||||
backgroundColor: 'rgba(249, 115, 22, 0.7)',
|
||||
borderColor: 'rgba(249, 115, 22, 0.7)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
yAxisID: 'y',
|
||||
order: 1,
|
||||
stack: 'tokens',
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [dailyData, t]);
|
||||
|
||||
// 图表配置
|
||||
const chartOptions = useMemo(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
usePointStyle: true,
|
||||
padding: 16,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
generateLabels: (chart: any) => {
|
||||
return chart.data.datasets.map((dataset: any, i: number) => {
|
||||
const isLine = dataset.type === 'line';
|
||||
return {
|
||||
text: dataset.label,
|
||||
fillStyle: dataset.backgroundColor,
|
||||
strokeStyle: dataset.borderColor,
|
||||
lineWidth: 0,
|
||||
hidden: !chart.isDatasetVisible(i),
|
||||
datasetIndex: i,
|
||||
pointStyle: isLine ? 'circle' : 'rect',
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#374151' : '#ffffff',
|
||||
titleColor: isDark ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDark ? '#d1d5db' : '#4b5563',
|
||||
borderColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const label = context.dataset.label || '';
|
||||
const value = context.raw;
|
||||
if (context.dataset.yAxisID === 'y1') {
|
||||
return `${label}: ${value.toLocaleString()}`;
|
||||
}
|
||||
return `${label}: ${value.toFixed(1)}K`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
type: 'linear' as const,
|
||||
position: 'left' as const,
|
||||
stacked: true,
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
callback: (value: string | number) => `${value}K`,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Tokens (K)',
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
type: 'linear' as const,
|
||||
position: 'right' as const,
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('monitor.trend.requests'),
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), [isDark, t]);
|
||||
|
||||
const timeRangeLabel = timeRange === 1
|
||||
? t('monitor.today')
|
||||
: t('monitor.last_n_days', { n: timeRange });
|
||||
|
||||
return (
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div>
|
||||
<h3 className={styles.chartTitle}>{t('monitor.trend.title')}</h3>
|
||||
<p className={styles.chartSubtitle}>
|
||||
{timeRangeLabel} · {t('monitor.trend.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.chartContent}>
|
||||
{loading || dailyData.length === 0 ? (
|
||||
<div className={styles.chartEmpty}>
|
||||
{loading ? t('common.loading') : t('monitor.no_data')}
|
||||
</div>
|
||||
) : (
|
||||
<Chart type="bar" data={chartData} options={chartOptions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/components/monitor/DisableModelModal.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 禁用模型确认弹窗组件
|
||||
* 封装三次确认的 UI 逻辑
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { DisableState } from '@/utils/monitor';
|
||||
|
||||
interface DisableModelModalProps {
|
||||
/** 禁用状态 */
|
||||
disableState: DisableState | null;
|
||||
/** 是否正在禁用中 */
|
||||
disabling: boolean;
|
||||
/** 确认回调 */
|
||||
onConfirm: () => void;
|
||||
/** 取消回调 */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function DisableModelModal({
|
||||
disableState,
|
||||
disabling,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DisableModelModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isZh = i18n.language === 'zh-CN' || i18n.language === 'zh';
|
||||
|
||||
// 获取警告内容
|
||||
const getWarningContent = () => {
|
||||
if (!disableState) return null;
|
||||
|
||||
if (disableState.step === 1) {
|
||||
return (
|
||||
<p style={{ marginBottom: 16, lineHeight: 1.6 }}>
|
||||
{isZh ? '确定要禁用 ' : 'Are you sure you want to disable '}
|
||||
<strong>{disableState.displayName}</strong>
|
||||
{isZh ? ' 吗?' : '?'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (disableState.step === 2) {
|
||||
return (
|
||||
<p style={{ marginBottom: 16, lineHeight: 1.6, color: 'var(--warning-color, #f59e0b)' }}>
|
||||
{isZh
|
||||
? '⚠️ 警告:此操作将从配置中移除该模型映射!'
|
||||
: '⚠️ Warning: this removes the model mapping from config!'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<p style={{ marginBottom: 16, lineHeight: 1.6, color: 'var(--danger-color, #ef4444)' }}>
|
||||
{isZh
|
||||
? '🚨 最后确认:禁用后需要手动重新添加才能恢复!'
|
||||
: "🚨 Final confirmation: you'll need to add it back manually later!"}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
// 获取确认按钮文本
|
||||
const getConfirmButtonText = () => {
|
||||
if (!disableState) return '';
|
||||
const btnTexts = isZh
|
||||
? ['确认禁用 (3)', '我确定 (2)', '立即禁用 (1)']
|
||||
: ['Confirm (3)', "I'm sure (2)", 'Disable now (1)'];
|
||||
return btnTexts[disableState.step - 1] || btnTexts[0];
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={!!disableState}
|
||||
onClose={onCancel}
|
||||
title={t('monitor.logs.disable_confirm_title')}
|
||||
width={400}
|
||||
>
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
{getWarningContent()}
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={disabling}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={onConfirm}
|
||||
disabled={disabling}
|
||||
>
|
||||
{disabling ? t('monitor.logs.disabling') : getConfirmButtonText()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
420
src/components/monitor/FailureAnalysis.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
import { useMemo, useState, useCallback, Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { useDisableModel } from '@/hooks';
|
||||
import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector';
|
||||
import { DisableModelModal } from './DisableModelModal';
|
||||
import {
|
||||
formatTimestamp,
|
||||
getRateClassName,
|
||||
filterDataByTimeRange,
|
||||
getProviderDisplayParts,
|
||||
type DateRange,
|
||||
} from '@/utils/monitor';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface FailureAnalysisProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
providerMap: Record<string, string>;
|
||||
providerModels: Record<string, Set<string>>;
|
||||
}
|
||||
|
||||
interface ModelFailureStat {
|
||||
success: number;
|
||||
failure: number;
|
||||
total: number;
|
||||
successRate: number;
|
||||
recentRequests: { failed: boolean; timestamp: number }[];
|
||||
lastTimestamp: number;
|
||||
}
|
||||
|
||||
interface FailureStat {
|
||||
source: string;
|
||||
displayName: string;
|
||||
providerName: string | null;
|
||||
maskedKey: string;
|
||||
failedCount: number;
|
||||
lastFailTime: number;
|
||||
models: Record<string, ModelFailureStat>;
|
||||
}
|
||||
|
||||
export function FailureAnalysis({ data, loading, providerMap, providerModels }: FailureAnalysisProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedChannel, setExpandedChannel] = useState<string | null>(null);
|
||||
const [filterChannel, setFilterChannel] = useState('');
|
||||
const [filterModel, setFilterModel] = useState('');
|
||||
|
||||
// 时间范围状态
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(7);
|
||||
const [customRange, setCustomRange] = useState<DateRange | undefined>();
|
||||
|
||||
// 使用禁用模型 Hook
|
||||
const {
|
||||
disableState,
|
||||
disabling,
|
||||
isModelDisabled,
|
||||
handleDisableClick: onDisableClick,
|
||||
handleConfirmDisable,
|
||||
handleCancelDisable,
|
||||
} = useDisableModel({ providerMap, providerModels });
|
||||
|
||||
// 处理时间范围变化
|
||||
const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => {
|
||||
setTimeRange(range);
|
||||
if (custom) {
|
||||
setCustomRange(custom);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 根据时间范围过滤数据
|
||||
const timeFilteredData = useMemo(() => {
|
||||
return filterDataByTimeRange(data, timeRange, customRange);
|
||||
}, [data, timeRange, customRange]);
|
||||
|
||||
// 计算失败统计数据
|
||||
const failureStats = useMemo(() => {
|
||||
if (!timeFilteredData?.apis) return [];
|
||||
|
||||
// 首先收集有失败记录的渠道
|
||||
const failedSources = new Set<string>();
|
||||
Object.values(timeFilteredData.apis).forEach((apiData) => {
|
||||
Object.values(apiData.models).forEach((modelData) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
if (detail.failed) {
|
||||
const source = detail.source || 'unknown';
|
||||
const { provider } = getProviderDisplayParts(source, providerMap);
|
||||
if (provider) {
|
||||
failedSources.add(source);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 统计这些渠道的所有请求
|
||||
const stats: Record<string, FailureStat> = {};
|
||||
|
||||
Object.values(timeFilteredData.apis).forEach((apiData) => {
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
const source = detail.source || 'unknown';
|
||||
// 只统计有失败记录的渠道
|
||||
if (!failedSources.has(source)) return;
|
||||
|
||||
const { provider, masked } = getProviderDisplayParts(source, providerMap);
|
||||
const displayName = provider ? `${provider} (${masked})` : masked;
|
||||
const timestamp = detail.timestamp ? new Date(detail.timestamp).getTime() : 0;
|
||||
|
||||
if (!stats[displayName]) {
|
||||
stats[displayName] = {
|
||||
source,
|
||||
displayName,
|
||||
providerName: provider,
|
||||
maskedKey: masked,
|
||||
failedCount: 0,
|
||||
lastFailTime: 0,
|
||||
models: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (detail.failed) {
|
||||
stats[displayName].failedCount++;
|
||||
if (timestamp > stats[displayName].lastFailTime) {
|
||||
stats[displayName].lastFailTime = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// 按模型统计
|
||||
if (!stats[displayName].models[modelName]) {
|
||||
stats[displayName].models[modelName] = {
|
||||
success: 0,
|
||||
failure: 0,
|
||||
total: 0,
|
||||
successRate: 0,
|
||||
recentRequests: [],
|
||||
lastTimestamp: 0,
|
||||
};
|
||||
}
|
||||
stats[displayName].models[modelName].total++;
|
||||
if (detail.failed) {
|
||||
stats[displayName].models[modelName].failure++;
|
||||
} else {
|
||||
stats[displayName].models[modelName].success++;
|
||||
}
|
||||
stats[displayName].models[modelName].recentRequests.push({ failed: detail.failed, timestamp });
|
||||
if (timestamp > stats[displayName].models[modelName].lastTimestamp) {
|
||||
stats[displayName].models[modelName].lastTimestamp = timestamp;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 计算成功率并排序请求
|
||||
Object.values(stats).forEach((stat) => {
|
||||
Object.values(stat.models).forEach((model) => {
|
||||
model.successRate = model.total > 0
|
||||
? (model.success / model.total) * 100
|
||||
: 0;
|
||||
model.recentRequests.sort((a, b) => a.timestamp - b.timestamp);
|
||||
model.recentRequests = model.recentRequests.slice(-12);
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(stats)
|
||||
.filter((stat) => stat.failedCount > 0)
|
||||
.sort((a, b) => b.failedCount - a.failedCount)
|
||||
.slice(0, 10);
|
||||
}, [timeFilteredData, providerMap]);
|
||||
|
||||
// 获取所有渠道和模型列表
|
||||
const { channels, models } = useMemo(() => {
|
||||
const channelSet = new Set<string>();
|
||||
const modelSet = new Set<string>();
|
||||
|
||||
failureStats.forEach((stat) => {
|
||||
channelSet.add(stat.displayName);
|
||||
Object.keys(stat.models).forEach((model) => modelSet.add(model));
|
||||
});
|
||||
|
||||
return {
|
||||
channels: Array.from(channelSet).sort(),
|
||||
models: Array.from(modelSet).sort(),
|
||||
};
|
||||
}, [failureStats]);
|
||||
|
||||
// 过滤后的数据
|
||||
const filteredStats = useMemo(() => {
|
||||
return failureStats.filter((stat) => {
|
||||
if (filterChannel && stat.displayName !== filterChannel) return false;
|
||||
if (filterModel && !stat.models[filterModel]) return false;
|
||||
return true;
|
||||
});
|
||||
}, [failureStats, filterChannel, filterModel]);
|
||||
|
||||
// 切换展开状态
|
||||
const toggleExpand = (displayName: string) => {
|
||||
setExpandedChannel(expandedChannel === displayName ? null : displayName);
|
||||
};
|
||||
|
||||
// 获取主要失败模型(前2个,已禁用的排在后面)
|
||||
const getTopFailedModels = (source: string, modelsMap: Record<string, ModelFailureStat>) => {
|
||||
return Object.entries(modelsMap)
|
||||
.filter(([, stat]) => stat.failure > 0)
|
||||
.sort((a, b) => {
|
||||
const aDisabled = isModelDisabled(source, a[0]);
|
||||
const bDisabled = isModelDisabled(source, b[0]);
|
||||
// 已禁用的排在后面
|
||||
if (aDisabled !== bDisabled) {
|
||||
return aDisabled ? 1 : -1;
|
||||
}
|
||||
// 然后按失败数降序
|
||||
return b[1].failure - a[1].failure;
|
||||
})
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
// 开始禁用流程(阻止事件冒泡)
|
||||
const handleDisableClick = (source: string, model: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDisableClick(source, model);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={t('monitor.failure.title')}
|
||||
subtitle={
|
||||
<span>
|
||||
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.failure.subtitle')}
|
||||
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.failure.click_hint')}</span>
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<TimeRangeSelector
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
customRange={customRange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* 筛选器 */}
|
||||
<div className={styles.logFilters}>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterChannel}
|
||||
onChange={(e) => setFilterChannel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.channel.all_channels')}</option>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel} value={channel}>{channel}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterModel}
|
||||
onChange={(e) => setFilterModel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.channel.all_models')}</option>
|
||||
{models.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className={styles.tableWrapper}>
|
||||
{loading ? (
|
||||
<div className={styles.emptyState}>{t('common.loading')}</div>
|
||||
) : filteredStats.length === 0 ? (
|
||||
<div className={styles.emptyState}>{t('monitor.failure.no_failures')}</div>
|
||||
) : (
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('monitor.failure.header_name')}</th>
|
||||
<th>{t('monitor.failure.header_count')}</th>
|
||||
<th>{t('monitor.failure.header_time')}</th>
|
||||
<th>{t('monitor.failure.header_models')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredStats.map((stat) => {
|
||||
const topModels = getTopFailedModels(stat.source, stat.models);
|
||||
const totalFailedModels = Object.values(stat.models).filter(m => m.failure > 0).length;
|
||||
|
||||
return (
|
||||
<Fragment key={stat.displayName}>
|
||||
<tr
|
||||
className={styles.expandable}
|
||||
onClick={() => toggleExpand(stat.displayName)}
|
||||
>
|
||||
<td>
|
||||
{stat.providerName ? (
|
||||
<>
|
||||
<span className={styles.channelName}>{stat.providerName}</span>
|
||||
<span className={styles.channelSecret}> ({stat.maskedKey})</span>
|
||||
</>
|
||||
) : (
|
||||
stat.maskedKey
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.kpiFailure}>{stat.failedCount.toLocaleString()}</td>
|
||||
<td>{formatTimestamp(stat.lastFailTime)}</td>
|
||||
<td>
|
||||
{topModels.map(([model, modelStat]) => {
|
||||
const percent = ((modelStat.failure / stat.failedCount) * 100).toFixed(0);
|
||||
const shortModel = model.length > 16 ? model.slice(0, 13) + '...' : model;
|
||||
const disabled = isModelDisabled(stat.source, model);
|
||||
return (
|
||||
<span
|
||||
key={model}
|
||||
className={`${styles.failureModelTag} ${disabled ? styles.modelDisabled : ''}`}
|
||||
title={`${model}: ${modelStat.failure} (${percent}%)${disabled ? ` - ${t('monitor.logs.removed')}` : ''}`}
|
||||
>
|
||||
{shortModel}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{totalFailedModels > 2 && (
|
||||
<span className={styles.moreModelsHint}>
|
||||
+{totalFailedModels - 2}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expandedChannel === stat.displayName && (
|
||||
<tr key={`${stat.displayName}-detail`}>
|
||||
<td colSpan={4} className={styles.expandDetail}>
|
||||
<div className={styles.expandTableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('monitor.channel.model')}</th>
|
||||
<th>{t('monitor.channel.header_count')}</th>
|
||||
<th>{t('monitor.channel.header_rate')}</th>
|
||||
<th>{t('monitor.channel.success')}/{t('monitor.channel.failed')}</th>
|
||||
<th>{t('monitor.channel.header_recent')}</th>
|
||||
<th>{t('monitor.channel.header_time')}</th>
|
||||
<th>{t('monitor.logs.header_actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(stat.models)
|
||||
.filter(([, m]) => m.failure > 0)
|
||||
.sort((a, b) => {
|
||||
const aDisabled = isModelDisabled(stat.source, a[0]);
|
||||
const bDisabled = isModelDisabled(stat.source, b[0]);
|
||||
// 已禁用的排在后面
|
||||
if (aDisabled !== bDisabled) {
|
||||
return aDisabled ? 1 : -1;
|
||||
}
|
||||
// 然后按失败数降序
|
||||
return b[1].failure - a[1].failure;
|
||||
})
|
||||
.map(([modelName, modelStat]) => {
|
||||
const disabled = isModelDisabled(stat.source, modelName);
|
||||
return (
|
||||
<tr key={modelName} className={disabled ? styles.modelDisabled : ''}>
|
||||
<td>{modelName}</td>
|
||||
<td>{modelStat.total.toLocaleString()}</td>
|
||||
<td className={getRateClassName(modelStat.successRate, styles)}>
|
||||
{modelStat.successRate.toFixed(1)}%
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.kpiSuccess}>{modelStat.success}</span>
|
||||
{' / '}
|
||||
<span className={styles.kpiFailure}>{modelStat.failure}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.statusBars}>
|
||||
{modelStat.recentRequests.map((req, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatTimestamp(modelStat.lastTimestamp)}</td>
|
||||
<td>
|
||||
{disabled ? (
|
||||
<span className={styles.disabledLabel}>{t('monitor.logs.removed')}</span>
|
||||
) : stat.source && stat.source !== '-' && stat.source !== 'unknown' ? (
|
||||
<button
|
||||
className={styles.disableBtn}
|
||||
onClick={(e) => handleDisableClick(stat.source, modelName, e)}
|
||||
>
|
||||
{t('monitor.logs.disable')}
|
||||
</button>
|
||||
) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 禁用确认弹窗 */}
|
||||
<DisableModelModal
|
||||
disableState={disableState}
|
||||
disabling={disabling}
|
||||
onConfirm={handleConfirmDisable}
|
||||
onCancel={handleCancelDisable}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
314
src/components/monitor/HourlyModelChart.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Chart } from 'react-chartjs-2';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface HourlyModelChartProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
// 颜色调色板
|
||||
const COLORS = [
|
||||
'rgba(59, 130, 246, 0.7)', // 蓝色
|
||||
'rgba(34, 197, 94, 0.7)', // 绿色
|
||||
'rgba(249, 115, 22, 0.7)', // 橙色
|
||||
'rgba(139, 92, 246, 0.7)', // 紫色
|
||||
'rgba(236, 72, 153, 0.7)', // 粉色
|
||||
'rgba(6, 182, 212, 0.7)', // 青色
|
||||
];
|
||||
|
||||
type HourRange = 6 | 12 | 24;
|
||||
|
||||
export function HourlyModelChart({ data, loading, isDark }: HourlyModelChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [hourRange, setHourRange] = useState<HourRange>(12);
|
||||
|
||||
// 按小时聚合数据
|
||||
const hourlyData = useMemo(() => {
|
||||
if (!data?.apis) return { hours: [], models: [], modelData: {} as Record<string, number[]>, successRates: [] };
|
||||
|
||||
const now = new Date();
|
||||
let cutoffTime: Date;
|
||||
let hoursCount: number;
|
||||
|
||||
cutoffTime = new Date(now.getTime() - hourRange * 60 * 60 * 1000);
|
||||
cutoffTime.setMinutes(0, 0, 0);
|
||||
hoursCount = hourRange + 1;
|
||||
|
||||
// 生成所有小时的时间点
|
||||
const allHours: string[] = [];
|
||||
for (let i = 0; i < hoursCount; i++) {
|
||||
const hourTime = new Date(cutoffTime.getTime() + i * 60 * 60 * 1000);
|
||||
const hourKey = hourTime.toISOString().slice(0, 13); // YYYY-MM-DDTHH
|
||||
allHours.push(hourKey);
|
||||
}
|
||||
|
||||
// 收集每小时每个模型的请求数
|
||||
const hourlyStats: Record<string, Record<string, { success: number; failed: number }>> = {};
|
||||
const modelSet = new Set<string>();
|
||||
|
||||
// 初始化所有小时
|
||||
allHours.forEach((hour) => {
|
||||
hourlyStats[hour] = {};
|
||||
});
|
||||
|
||||
Object.values(data.apis).forEach((apiData) => {
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
modelSet.add(modelName);
|
||||
modelData.details.forEach((detail) => {
|
||||
const timestamp = new Date(detail.timestamp);
|
||||
if (timestamp < cutoffTime) return;
|
||||
|
||||
const hourKey = timestamp.toISOString().slice(0, 13); // YYYY-MM-DDTHH
|
||||
if (!hourlyStats[hourKey]) {
|
||||
hourlyStats[hourKey] = {};
|
||||
}
|
||||
if (!hourlyStats[hourKey][modelName]) {
|
||||
hourlyStats[hourKey][modelName] = { success: 0, failed: 0 };
|
||||
}
|
||||
if (detail.failed) {
|
||||
hourlyStats[hourKey][modelName].failed++;
|
||||
} else {
|
||||
hourlyStats[hourKey][modelName].success++;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 获取排序后的小时列表
|
||||
const hours = allHours.sort();
|
||||
|
||||
// 计算每个模型的总请求数,取 Top 6
|
||||
const modelTotals: Record<string, number> = {};
|
||||
hours.forEach((hour) => {
|
||||
Object.entries(hourlyStats[hour]).forEach(([model, stats]) => {
|
||||
modelTotals[model] = (modelTotals[model] || 0) + stats.success + stats.failed;
|
||||
});
|
||||
});
|
||||
|
||||
const topModels = Object.entries(modelTotals)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 6)
|
||||
.map(([name]) => name);
|
||||
|
||||
// 构建每个模型的数据数组
|
||||
const modelData: Record<string, number[]> = {};
|
||||
topModels.forEach((model) => {
|
||||
modelData[model] = hours.map((hour) => {
|
||||
const stats = hourlyStats[hour][model];
|
||||
return stats ? stats.success + stats.failed : 0;
|
||||
});
|
||||
});
|
||||
|
||||
// 计算每小时的成功率
|
||||
const successRates = hours.map((hour) => {
|
||||
let totalSuccess = 0;
|
||||
let totalRequests = 0;
|
||||
Object.values(hourlyStats[hour]).forEach((stats) => {
|
||||
totalSuccess += stats.success;
|
||||
totalRequests += stats.success + stats.failed;
|
||||
});
|
||||
return totalRequests > 0 ? (totalSuccess / totalRequests) * 100 : 0;
|
||||
});
|
||||
|
||||
return { hours, models: topModels, modelData, successRates };
|
||||
}, [data, hourRange]);
|
||||
|
||||
// 获取时间范围标签
|
||||
const hourRangeLabel = useMemo(() => {
|
||||
if (hourRange === 6) return t('monitor.hourly.last_6h');
|
||||
if (hourRange === 12) return t('monitor.hourly.last_12h');
|
||||
return t('monitor.hourly.last_24h');
|
||||
}, [hourRange, t]);
|
||||
|
||||
// 图表数据
|
||||
const chartData = useMemo(() => {
|
||||
const labels = hourlyData.hours.map((hour) => {
|
||||
const date = new Date(hour + ':00:00Z'); // 添加 Z 表示 UTC 时间,确保正确转换为本地时间显示
|
||||
return `${date.getHours()}:00`;
|
||||
});
|
||||
|
||||
// 成功率折线放在最前面
|
||||
const datasets: any[] = [{
|
||||
type: 'line' as const,
|
||||
label: t('monitor.hourly.success_rate'),
|
||||
data: hourlyData.successRates,
|
||||
borderColor: '#4ef0c3',
|
||||
backgroundColor: '#4ef0c3',
|
||||
borderWidth: 2.5,
|
||||
tension: 0.4,
|
||||
yAxisID: 'y1',
|
||||
stack: '',
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: '#4ef0c3',
|
||||
pointBorderColor: '#4ef0c3',
|
||||
}];
|
||||
|
||||
// 添加模型柱状图
|
||||
hourlyData.models.forEach((model, index) => {
|
||||
datasets.push({
|
||||
type: 'bar' as const,
|
||||
label: model,
|
||||
data: hourlyData.modelData[model],
|
||||
backgroundColor: COLORS[index % COLORS.length],
|
||||
borderColor: COLORS[index % COLORS.length],
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
stack: 'models',
|
||||
yAxisID: 'y',
|
||||
});
|
||||
});
|
||||
|
||||
return { labels, datasets };
|
||||
}, [hourlyData, t]);
|
||||
|
||||
// 图表配置
|
||||
const chartOptions = useMemo(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
usePointStyle: true,
|
||||
padding: 12,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
generateLabels: (chart: any) => {
|
||||
return chart.data.datasets.map((dataset: any, i: number) => {
|
||||
const isLine = dataset.type === 'line';
|
||||
return {
|
||||
text: dataset.label,
|
||||
fillStyle: dataset.backgroundColor,
|
||||
strokeStyle: dataset.borderColor,
|
||||
lineWidth: 0,
|
||||
hidden: !chart.isDatasetVisible(i),
|
||||
datasetIndex: i,
|
||||
pointStyle: isLine ? 'circle' : 'rect',
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#374151' : '#ffffff',
|
||||
titleColor: isDark ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDark ? '#d1d5db' : '#4b5563',
|
||||
borderColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
position: 'left' as const,
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('monitor.hourly.requests'),
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
position: 'right' as const,
|
||||
min: 0,
|
||||
max: 100,
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
callback: (value: string | number) => `${value}%`,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('monitor.hourly.success_rate'),
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), [isDark, t]);
|
||||
|
||||
return (
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div>
|
||||
<h3 className={styles.chartTitle}>{t('monitor.hourly_model.title')}</h3>
|
||||
<p className={styles.chartSubtitle}>
|
||||
{hourRangeLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.chartControls}>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 6 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(6)}
|
||||
>
|
||||
{t('monitor.hourly.last_6h')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 12 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(12)}
|
||||
>
|
||||
{t('monitor.hourly.last_12h')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 24 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(24)}
|
||||
>
|
||||
{t('monitor.hourly.last_24h')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.chartContent}>
|
||||
{loading || hourlyData.hours.length === 0 ? (
|
||||
<div className={styles.chartEmpty}>
|
||||
{loading ? t('common.loading') : t('monitor.no_data')}
|
||||
</div>
|
||||
) : (
|
||||
<Chart type="bar" data={chartData} options={chartOptions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
274
src/components/monitor/HourlyTokenChart.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Chart } from 'react-chartjs-2';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface HourlyTokenChartProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
type HourRange = 6 | 12 | 24;
|
||||
|
||||
export function HourlyTokenChart({ data, loading, isDark }: HourlyTokenChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [hourRange, setHourRange] = useState<HourRange>(12);
|
||||
|
||||
// 按小时聚合 Token 数据
|
||||
const hourlyData = useMemo(() => {
|
||||
if (!data?.apis) return { hours: [], totalTokens: [], inputTokens: [], outputTokens: [], reasoningTokens: [], cachedTokens: [] };
|
||||
|
||||
const now = new Date();
|
||||
let cutoffTime: Date;
|
||||
let hoursCount: number;
|
||||
|
||||
cutoffTime = new Date(now.getTime() - hourRange * 60 * 60 * 1000);
|
||||
cutoffTime.setMinutes(0, 0, 0);
|
||||
hoursCount = hourRange + 1;
|
||||
|
||||
// 生成所有小时的时间点
|
||||
const allHours: string[] = [];
|
||||
for (let i = 0; i < hoursCount; i++) {
|
||||
const hourTime = new Date(cutoffTime.getTime() + i * 60 * 60 * 1000);
|
||||
const hourKey = hourTime.toISOString().slice(0, 13); // YYYY-MM-DDTHH
|
||||
allHours.push(hourKey);
|
||||
}
|
||||
|
||||
// 初始化所有小时的数据为0
|
||||
const hourlyStats: Record<string, {
|
||||
total: number;
|
||||
input: number;
|
||||
output: number;
|
||||
reasoning: number;
|
||||
cached: number;
|
||||
}> = {};
|
||||
allHours.forEach((hour) => {
|
||||
hourlyStats[hour] = { total: 0, input: 0, output: 0, reasoning: 0, cached: 0 };
|
||||
});
|
||||
|
||||
// 收集每小时的 Token 数据(只统计成功请求)
|
||||
Object.values(data.apis).forEach((apiData) => {
|
||||
Object.values(apiData.models).forEach((modelData) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
// 跳过失败请求,失败请求的 Token 数据不准确
|
||||
if (detail.failed) return;
|
||||
|
||||
const timestamp = new Date(detail.timestamp);
|
||||
if (timestamp < cutoffTime) return;
|
||||
|
||||
const hourKey = timestamp.toISOString().slice(0, 13); // YYYY-MM-DDTHH
|
||||
if (!hourlyStats[hourKey]) {
|
||||
hourlyStats[hourKey] = { total: 0, input: 0, output: 0, reasoning: 0, cached: 0 };
|
||||
}
|
||||
hourlyStats[hourKey].total += detail.tokens.total_tokens || 0;
|
||||
hourlyStats[hourKey].input += detail.tokens.input_tokens || 0;
|
||||
hourlyStats[hourKey].output += detail.tokens.output_tokens || 0;
|
||||
hourlyStats[hourKey].reasoning += detail.tokens.reasoning_tokens || 0;
|
||||
hourlyStats[hourKey].cached += detail.tokens.cached_tokens || 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 获取排序后的小时列表
|
||||
const hours = allHours.sort();
|
||||
|
||||
return {
|
||||
hours,
|
||||
totalTokens: hours.map((h) => (hourlyStats[h]?.total || 0) / 1000),
|
||||
inputTokens: hours.map((h) => (hourlyStats[h]?.input || 0) / 1000),
|
||||
outputTokens: hours.map((h) => (hourlyStats[h]?.output || 0) / 1000),
|
||||
reasoningTokens: hours.map((h) => (hourlyStats[h]?.reasoning || 0) / 1000),
|
||||
cachedTokens: hours.map((h) => (hourlyStats[h]?.cached || 0) / 1000),
|
||||
};
|
||||
}, [data, hourRange]);
|
||||
|
||||
// 获取时间范围标签
|
||||
const hourRangeLabel = useMemo(() => {
|
||||
if (hourRange === 6) return t('monitor.hourly.last_6h');
|
||||
if (hourRange === 12) return t('monitor.hourly.last_12h');
|
||||
return t('monitor.hourly.last_24h');
|
||||
}, [hourRange, t]);
|
||||
|
||||
// 图表数据
|
||||
const chartData = useMemo(() => {
|
||||
const labels = hourlyData.hours.map((hour) => {
|
||||
const date = new Date(hour + ':00:00Z'); // 添加 Z 表示 UTC 时间,确保正确转换为本地时间显示
|
||||
return `${date.getHours()}:00`;
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
type: 'line' as const,
|
||||
label: t('monitor.hourly_token.input'),
|
||||
data: hourlyData.inputTokens,
|
||||
borderColor: '#22c55e',
|
||||
backgroundColor: '#22c55e',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
yAxisID: 'y',
|
||||
order: 0,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: '#22c55e',
|
||||
},
|
||||
{
|
||||
type: 'line' as const,
|
||||
label: t('monitor.hourly_token.output'),
|
||||
data: hourlyData.outputTokens,
|
||||
borderColor: '#f97316',
|
||||
backgroundColor: '#f97316',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
yAxisID: 'y',
|
||||
order: 0,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: '#f97316',
|
||||
},
|
||||
{
|
||||
type: 'bar' as const,
|
||||
label: t('monitor.hourly_token.total'),
|
||||
data: hourlyData.totalTokens,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.6)',
|
||||
borderColor: 'rgba(59, 130, 246, 0.6)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
yAxisID: 'y',
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [hourlyData, t]);
|
||||
|
||||
// 图表配置
|
||||
const chartOptions = useMemo(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
usePointStyle: true,
|
||||
padding: 12,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
generateLabels: (chart: any) => {
|
||||
return chart.data.datasets.map((dataset: any, i: number) => {
|
||||
const isLine = dataset.type === 'line';
|
||||
return {
|
||||
text: dataset.label,
|
||||
fillStyle: dataset.backgroundColor,
|
||||
strokeStyle: dataset.borderColor,
|
||||
lineWidth: 0,
|
||||
hidden: !chart.isDatasetVisible(i),
|
||||
datasetIndex: i,
|
||||
pointStyle: isLine ? 'circle' : 'rect',
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#374151' : '#ffffff',
|
||||
titleColor: isDark ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDark ? '#d1d5db' : '#4b5563',
|
||||
borderColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const label = context.dataset.label || '';
|
||||
const value = context.raw;
|
||||
return `${label}: ${value.toFixed(1)}K`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
position: 'left' as const,
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
callback: (value: string | number) => `${value}K`,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Tokens (K)',
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), [isDark]);
|
||||
|
||||
return (
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div>
|
||||
<h3 className={styles.chartTitle}>{t('monitor.hourly_token.title')}</h3>
|
||||
<p className={styles.chartSubtitle}>
|
||||
{hourRangeLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.chartControls}>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 6 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(6)}
|
||||
>
|
||||
{t('monitor.hourly.last_6h')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 12 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(12)}
|
||||
>
|
||||
{t('monitor.hourly.last_12h')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 24 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(24)}
|
||||
>
|
||||
{t('monitor.hourly.last_24h')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.chartContent}>
|
||||
{loading || hourlyData.hours.length === 0 ? (
|
||||
<div className={styles.chartEmpty}>
|
||||
{loading ? t('common.loading') : t('monitor.no_data')}
|
||||
</div>
|
||||
) : (
|
||||
<Chart type="bar" data={chartData} options={chartOptions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
src/components/monitor/KpiCards.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface KpiCardsProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
timeRange: number;
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000000) {
|
||||
return (num / 1000000000).toFixed(2) + 'B';
|
||||
}
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(2) + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(2) + 'K';
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
export function KpiCards({ data, loading, timeRange }: KpiCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 计算统计数据
|
||||
const stats = useMemo(() => {
|
||||
if (!data?.apis) {
|
||||
return {
|
||||
totalRequests: 0,
|
||||
successRequests: 0,
|
||||
failedRequests: 0,
|
||||
successRate: 0,
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
cachedTokens: 0,
|
||||
avgTpm: 0,
|
||||
avgRpm: 0,
|
||||
avgRpd: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let totalRequests = 0;
|
||||
let successRequests = 0;
|
||||
let failedRequests = 0;
|
||||
let totalTokens = 0;
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let reasoningTokens = 0;
|
||||
let cachedTokens = 0;
|
||||
|
||||
// 收集所有时间戳用于计算 TPM/RPM
|
||||
const timestamps: number[] = [];
|
||||
|
||||
Object.values(data.apis).forEach((apiData) => {
|
||||
Object.values(apiData.models).forEach((modelData) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
totalRequests++;
|
||||
if (detail.failed) {
|
||||
failedRequests++;
|
||||
} else {
|
||||
successRequests++;
|
||||
}
|
||||
|
||||
totalTokens += detail.tokens.total_tokens || 0;
|
||||
inputTokens += detail.tokens.input_tokens || 0;
|
||||
outputTokens += detail.tokens.output_tokens || 0;
|
||||
reasoningTokens += detail.tokens.reasoning_tokens || 0;
|
||||
cachedTokens += detail.tokens.cached_tokens || 0;
|
||||
|
||||
timestamps.push(new Date(detail.timestamp).getTime());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const successRate = totalRequests > 0 ? (successRequests / totalRequests) * 100 : 0;
|
||||
|
||||
// 计算 TPM 和 RPM(基于实际时间跨度)
|
||||
let avgTpm = 0;
|
||||
let avgRpm = 0;
|
||||
let avgRpd = 0;
|
||||
|
||||
if (timestamps.length > 0) {
|
||||
const minTime = Math.min(...timestamps);
|
||||
const maxTime = Math.max(...timestamps);
|
||||
const timeSpanMinutes = Math.max((maxTime - minTime) / (1000 * 60), 1);
|
||||
const timeSpanDays = Math.max(timeSpanMinutes / (60 * 24), 1);
|
||||
|
||||
avgTpm = Math.round(totalTokens / timeSpanMinutes);
|
||||
avgRpm = Math.round(totalRequests / timeSpanMinutes * 10) / 10;
|
||||
avgRpd = Math.round(totalRequests / timeSpanDays);
|
||||
}
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
successRequests,
|
||||
failedRequests,
|
||||
successRate,
|
||||
totalTokens,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cachedTokens,
|
||||
avgTpm,
|
||||
avgRpm,
|
||||
avgRpd,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const timeRangeLabel = timeRange === 1
|
||||
? t('monitor.today')
|
||||
: t('monitor.last_n_days', { n: timeRange });
|
||||
|
||||
return (
|
||||
<div className={styles.kpiGrid}>
|
||||
{/* 请求数 */}
|
||||
<div className={styles.kpiCard}>
|
||||
<div className={styles.kpiTitle}>
|
||||
<span className={styles.kpiLabel}>{t('monitor.kpi.requests')}</span>
|
||||
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||
</div>
|
||||
<div className={styles.kpiValue}>
|
||||
{loading ? '--' : formatNumber(stats.totalRequests)}
|
||||
</div>
|
||||
<div className={styles.kpiMeta}>
|
||||
<span className={styles.kpiSuccess}>
|
||||
{t('monitor.kpi.success')}: {loading ? '--' : stats.successRequests.toLocaleString()}
|
||||
</span>
|
||||
<span className={styles.kpiFailure}>
|
||||
{t('monitor.kpi.failed')}: {loading ? '--' : stats.failedRequests.toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
{t('monitor.kpi.rate')}: {loading ? '--' : stats.successRate.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tokens */}
|
||||
<div className={`${styles.kpiCard} ${styles.green}`}>
|
||||
<div className={styles.kpiTitle}>
|
||||
<span className={styles.kpiLabel}>{t('monitor.kpi.tokens')}</span>
|
||||
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||
</div>
|
||||
<div className={styles.kpiValue}>
|
||||
{loading ? '--' : formatNumber(stats.totalTokens)}
|
||||
</div>
|
||||
<div className={styles.kpiMeta}>
|
||||
<span>{t('monitor.kpi.input')}: {loading ? '--' : formatNumber(stats.inputTokens)}</span>
|
||||
<span>{t('monitor.kpi.output')}: {loading ? '--' : formatNumber(stats.outputTokens)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 平均 TPM */}
|
||||
<div className={`${styles.kpiCard} ${styles.purple}`}>
|
||||
<div className={styles.kpiTitle}>
|
||||
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_tpm')}</span>
|
||||
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||
</div>
|
||||
<div className={styles.kpiValue}>
|
||||
{loading ? '--' : formatNumber(stats.avgTpm)}
|
||||
</div>
|
||||
<div className={styles.kpiMeta}>
|
||||
<span>{t('monitor.kpi.tokens_per_minute')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 平均 RPM */}
|
||||
<div className={`${styles.kpiCard} ${styles.orange}`}>
|
||||
<div className={styles.kpiTitle}>
|
||||
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_rpm')}</span>
|
||||
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||
</div>
|
||||
<div className={styles.kpiValue}>
|
||||
{loading ? '--' : stats.avgRpm.toFixed(1)}
|
||||
</div>
|
||||
<div className={styles.kpiMeta}>
|
||||
<span>{t('monitor.kpi.requests_per_minute')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日均 RPD */}
|
||||
<div className={`${styles.kpiCard} ${styles.cyan}`}>
|
||||
<div className={styles.kpiTitle}>
|
||||
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_rpd')}</span>
|
||||
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||
</div>
|
||||
<div className={styles.kpiValue}>
|
||||
{loading ? '--' : formatNumber(stats.avgRpd)}
|
||||
</div>
|
||||
<div className={styles.kpiMeta}>
|
||||
<span>{t('monitor.kpi.requests_per_day')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
src/components/monitor/ModelDistributionChart.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Doughnut } from 'react-chartjs-2';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface ModelDistributionChartProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
isDark: boolean;
|
||||
timeRange: number;
|
||||
}
|
||||
|
||||
// 颜色调色板
|
||||
const COLORS = [
|
||||
'#3b82f6', // 蓝色
|
||||
'#22c55e', // 绿色
|
||||
'#f97316', // 橙色
|
||||
'#8b5cf6', // 紫色
|
||||
'#ec4899', // 粉色
|
||||
'#06b6d4', // 青色
|
||||
'#eab308', // 黄色
|
||||
'#ef4444', // 红色
|
||||
'#14b8a6', // 青绿
|
||||
'#6366f1', // 靛蓝
|
||||
];
|
||||
|
||||
type ViewMode = 'request' | 'token';
|
||||
|
||||
export function ModelDistributionChart({ data, loading, isDark, timeRange }: ModelDistributionChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('request');
|
||||
|
||||
const timeRangeLabel = timeRange === 1
|
||||
? t('monitor.today')
|
||||
: t('monitor.last_n_days', { n: timeRange });
|
||||
|
||||
// 计算模型分布数据
|
||||
const distributionData = useMemo(() => {
|
||||
if (!data?.apis) return [];
|
||||
|
||||
const modelStats: Record<string, { requests: number; tokens: number }> = {};
|
||||
|
||||
Object.values(data.apis).forEach((apiData) => {
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
if (!modelStats[modelName]) {
|
||||
modelStats[modelName] = { requests: 0, tokens: 0 };
|
||||
}
|
||||
modelData.details.forEach((detail) => {
|
||||
modelStats[modelName].requests++;
|
||||
modelStats[modelName].tokens += detail.tokens.total_tokens || 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 转换为数组并排序
|
||||
const sorted = Object.entries(modelStats)
|
||||
.map(([name, stats]) => ({
|
||||
name,
|
||||
requests: stats.requests,
|
||||
tokens: stats.tokens,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (viewMode === 'request') {
|
||||
return b.requests - a.requests;
|
||||
}
|
||||
return b.tokens - a.tokens;
|
||||
});
|
||||
|
||||
// 取 Top 10
|
||||
return sorted.slice(0, 10);
|
||||
}, [data, viewMode]);
|
||||
|
||||
// 计算总数
|
||||
const total = useMemo(() => {
|
||||
return distributionData.reduce((sum, item) => {
|
||||
return sum + (viewMode === 'request' ? item.requests : item.tokens);
|
||||
}, 0);
|
||||
}, [distributionData, viewMode]);
|
||||
|
||||
// 图表数据
|
||||
const chartData = useMemo(() => {
|
||||
return {
|
||||
labels: distributionData.map((item) => item.name),
|
||||
datasets: [
|
||||
{
|
||||
data: distributionData.map((item) =>
|
||||
viewMode === 'request' ? item.requests : item.tokens
|
||||
),
|
||||
backgroundColor: COLORS.slice(0, distributionData.length),
|
||||
borderColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [distributionData, viewMode, isDark]);
|
||||
|
||||
// 图表配置
|
||||
const chartOptions = useMemo(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '65%',
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#374151' : '#ffffff',
|
||||
titleColor: isDark ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDark ? '#d1d5db' : '#4b5563',
|
||||
borderColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const value = context.raw;
|
||||
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
|
||||
if (viewMode === 'request') {
|
||||
return `${value.toLocaleString()} ${t('monitor.requests')} (${percentage}%)`;
|
||||
}
|
||||
return `${value.toLocaleString()} tokens (${percentage}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), [isDark, total, viewMode, t]);
|
||||
|
||||
// 格式化数值
|
||||
const formatValue = (value: number) => {
|
||||
if (value >= 1000000) {
|
||||
return (value / 1000000).toFixed(1) + 'M';
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div>
|
||||
<h3 className={styles.chartTitle}>{t('monitor.distribution.title')}</h3>
|
||||
<p className={styles.chartSubtitle}>
|
||||
{timeRangeLabel} · {viewMode === 'request' ? t('monitor.distribution.by_requests') : t('monitor.distribution.by_tokens')}
|
||||
{' · Top 10'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.chartControls}>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${viewMode === 'request' ? styles.active : ''}`}
|
||||
onClick={() => setViewMode('request')}
|
||||
>
|
||||
{t('monitor.distribution.requests')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${viewMode === 'token' ? styles.active : ''}`}
|
||||
onClick={() => setViewMode('token')}
|
||||
>
|
||||
{t('monitor.distribution.tokens')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading || distributionData.length === 0 ? (
|
||||
<div className={styles.chartContent}>
|
||||
<div className={styles.chartEmpty}>
|
||||
{loading ? t('common.loading') : t('monitor.no_data')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.distributionContent}>
|
||||
<div className={styles.donutWrapper}>
|
||||
<Doughnut data={chartData} options={chartOptions} />
|
||||
<div className={styles.donutCenter}>
|
||||
<div className={styles.donutLabel}>
|
||||
{viewMode === 'request' ? t('monitor.distribution.request_share') : t('monitor.distribution.token_share')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.legendList}>
|
||||
{distributionData.map((item, index) => {
|
||||
const value = viewMode === 'request' ? item.requests : item.tokens;
|
||||
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0';
|
||||
return (
|
||||
<div key={item.name} className={styles.legendItem}>
|
||||
<span
|
||||
className={styles.legendDot}
|
||||
style={{ backgroundColor: COLORS[index] }}
|
||||
/>
|
||||
<span className={styles.legendName} title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
<span className={styles.legendValue}>
|
||||
{formatValue(value)} ({percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
710
src/components/monitor/RequestLogs.tsx
Normal file
@@ -0,0 +1,710 @@
|
||||
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { usageApi, authFilesApi } from '@/services/api';
|
||||
import { useDisableModel } from '@/hooks';
|
||||
import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector';
|
||||
import { DisableModelModal } from './DisableModelModal';
|
||||
import { UnsupportedDisableModal } from './UnsupportedDisableModal';
|
||||
import {
|
||||
maskSecret,
|
||||
formatProviderDisplay,
|
||||
formatTimestamp,
|
||||
getRateClassName,
|
||||
getProviderDisplayParts,
|
||||
type DateRange,
|
||||
} from '@/utils/monitor';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface RequestLogsProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
providerMap: Record<string, string>;
|
||||
providerTypeMap: Record<string, string>;
|
||||
apiFilter: string;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
timestampMs: number;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
source: string;
|
||||
displayName: string;
|
||||
providerName: string | null;
|
||||
providerType: string;
|
||||
maskedKey: string;
|
||||
failed: boolean;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
authIndex: string;
|
||||
}
|
||||
|
||||
interface ChannelModelRequest {
|
||||
failed: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// 预计算的统计数据缓存
|
||||
interface PrecomputedStats {
|
||||
recentRequests: ChannelModelRequest[];
|
||||
successRate: string;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
// 虚拟滚动行高
|
||||
const ROW_HEIGHT = 40;
|
||||
|
||||
export function RequestLogs({ data, loading: parentLoading, providerMap, providerTypeMap, apiFilter }: RequestLogsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [filterApi, setFilterApi] = useState('');
|
||||
const [filterModel, setFilterModel] = useState('');
|
||||
const [filterSource, setFilterSource] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<'' | 'success' | 'failed'>('');
|
||||
const [filterProviderType, setFilterProviderType] = useState('');
|
||||
const [autoRefresh, setAutoRefresh] = useState(10);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// 用 ref 存储 fetchLogData,避免作为定时器 useEffect 的依赖
|
||||
const fetchLogDataRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
|
||||
// 虚拟滚动容器 ref
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
// 固定表头容器 ref
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 同步表头和内容的水平滚动
|
||||
const handleScroll = useCallback(() => {
|
||||
if (tableContainerRef.current && headerRef.current) {
|
||||
headerRef.current.scrollLeft = tableContainerRef.current.scrollLeft;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 时间范围状态
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(7);
|
||||
const [customRange, setCustomRange] = useState<DateRange | undefined>();
|
||||
|
||||
// 日志独立数据状态
|
||||
const [logData, setLogData] = useState<UsageData | null>(null);
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||
|
||||
// 认证文件索引到名称的映射
|
||||
const [authIndexMap, setAuthIndexMap] = useState<Record<string, string>>({});
|
||||
|
||||
// 使用禁用模型 Hook
|
||||
const {
|
||||
disableState,
|
||||
unsupportedState,
|
||||
disabling,
|
||||
isModelDisabled,
|
||||
handleDisableClick,
|
||||
handleConfirmDisable,
|
||||
handleCancelDisable,
|
||||
handleCloseUnsupported,
|
||||
} = useDisableModel({ providerMap, providerTypeMap });
|
||||
|
||||
// 处理时间范围变化
|
||||
const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => {
|
||||
setTimeRange(range);
|
||||
if (custom) {
|
||||
setCustomRange(custom);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 使用日志独立数据或父组件数据
|
||||
const effectiveData = logData || data;
|
||||
// 只在首次加载且没有数据时显示 loading 状态
|
||||
const showLoading = (parentLoading && isFirstLoad && !effectiveData) || (logLoading && !effectiveData);
|
||||
|
||||
// 当父组件数据加载完成时,标记首次加载完成
|
||||
useEffect(() => {
|
||||
if (!parentLoading && data) {
|
||||
setIsFirstLoad(false);
|
||||
}
|
||||
}, [parentLoading, data]);
|
||||
|
||||
// 加载认证文件映射(authIndex -> 文件名)
|
||||
const loadAuthIndexMap = useCallback(async () => {
|
||||
try {
|
||||
const response = await authFilesApi.list();
|
||||
const files = response?.files || [];
|
||||
const map: Record<string, string> = {};
|
||||
files.forEach((file) => {
|
||||
// 兼容 auth_index 和 authIndex 两种字段名(API 返回的是 auth_index)
|
||||
const rawAuthIndex = (file as Record<string, unknown>)['auth_index'] ?? file.authIndex;
|
||||
if (rawAuthIndex !== undefined && rawAuthIndex !== null) {
|
||||
const authIndexKey = String(rawAuthIndex).trim();
|
||||
if (authIndexKey) {
|
||||
map[authIndexKey] = file.name;
|
||||
}
|
||||
}
|
||||
});
|
||||
setAuthIndexMap(map);
|
||||
} catch (err) {
|
||||
console.warn('Failed to load auth files for index mapping:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始加载认证文件映射
|
||||
useEffect(() => {
|
||||
loadAuthIndexMap();
|
||||
}, [loadAuthIndexMap]);
|
||||
|
||||
// 独立获取日志数据
|
||||
const fetchLogData = useCallback(async () => {
|
||||
setLogLoading(true);
|
||||
try {
|
||||
const response = await usageApi.getUsage();
|
||||
const usageData = (response?.usage ?? response) as Record<string, unknown>;
|
||||
|
||||
// 应用时间范围过滤
|
||||
if (usageData?.apis) {
|
||||
const apis = usageData.apis as UsageData['apis'];
|
||||
const now = new Date();
|
||||
let cutoffStart: Date;
|
||||
let cutoffEnd: Date = new Date(now.getTime());
|
||||
cutoffEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
if (timeRange === 'custom' && customRange) {
|
||||
cutoffStart = customRange.start;
|
||||
cutoffEnd = customRange.end;
|
||||
} else if (typeof timeRange === 'number') {
|
||||
cutoffStart = new Date(now.getTime() - timeRange * 24 * 60 * 60 * 1000);
|
||||
cutoffStart.setHours(0, 0, 0, 0);
|
||||
} else {
|
||||
cutoffStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
cutoffStart.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
const filtered: UsageData = { apis: {} };
|
||||
|
||||
Object.entries(apis).forEach(([apiKey, apiData]) => {
|
||||
// 如果有 API 过滤器,检查是否匹配
|
||||
if (apiFilter && !apiKey.toLowerCase().includes(apiFilter.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiData?.models) return;
|
||||
|
||||
const filteredModels: Record<string, { details: UsageData['apis'][string]['models'][string]['details'] }> = {};
|
||||
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
if (!modelData?.details || !Array.isArray(modelData.details)) return;
|
||||
|
||||
const filteredDetails = modelData.details.filter((detail) => {
|
||||
const timestamp = new Date(detail.timestamp);
|
||||
return timestamp >= cutoffStart && timestamp <= cutoffEnd;
|
||||
});
|
||||
|
||||
if (filteredDetails.length > 0) {
|
||||
filteredModels[modelName] = { details: filteredDetails };
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(filteredModels).length > 0) {
|
||||
filtered.apis[apiKey] = { models: filteredModels };
|
||||
}
|
||||
});
|
||||
|
||||
setLogData(filtered);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('日志刷新失败:', err);
|
||||
} finally {
|
||||
setLogLoading(false);
|
||||
}
|
||||
}, [timeRange, customRange, apiFilter]);
|
||||
|
||||
// 同步 fetchLogData 到 ref,确保定时器始终调用最新版本
|
||||
useEffect(() => {
|
||||
fetchLogDataRef.current = fetchLogData;
|
||||
}, [fetchLogData]);
|
||||
|
||||
// 统一的自动刷新定时器管理
|
||||
useEffect(() => {
|
||||
// 清理旧定时器
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
|
||||
// 禁用自动刷新时
|
||||
if (autoRefresh <= 0) {
|
||||
setCountdown(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置初始倒计时
|
||||
setCountdown(autoRefresh);
|
||||
|
||||
// 创建新定时器
|
||||
countdownRef.current = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
// 倒计时结束,触发刷新并重置倒计时
|
||||
fetchLogDataRef.current();
|
||||
return autoRefresh;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// 组件卸载或 autoRefresh 变化时清理
|
||||
return () => {
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefresh]);
|
||||
|
||||
// 时间范围变化时立即刷新数据
|
||||
useEffect(() => {
|
||||
fetchLogData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timeRange, customRange]);
|
||||
|
||||
// 获取倒计时显示文本
|
||||
const getCountdownText = () => {
|
||||
if (logLoading) {
|
||||
return t('monitor.logs.refreshing');
|
||||
}
|
||||
if (autoRefresh === 0) {
|
||||
return t('monitor.logs.manual_refresh');
|
||||
}
|
||||
if (countdown > 0) {
|
||||
return t('monitor.logs.refresh_in_seconds', { seconds: countdown });
|
||||
}
|
||||
return t('monitor.logs.refreshing');
|
||||
};
|
||||
|
||||
// 将数据转换为日志条目
|
||||
const logEntries = useMemo(() => {
|
||||
if (!effectiveData?.apis) return [];
|
||||
|
||||
const entries: LogEntry[] = [];
|
||||
let idCounter = 0;
|
||||
|
||||
Object.entries(effectiveData.apis).forEach(([apiKey, apiData]) => {
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
const source = detail.source || 'unknown';
|
||||
const { provider, masked } = getProviderDisplayParts(source, providerMap);
|
||||
const displayName = provider ? `${provider} (${masked})` : masked;
|
||||
const timestampMs = detail.timestamp ? new Date(detail.timestamp).getTime() : 0;
|
||||
// 获取提供商类型
|
||||
const providerType = providerTypeMap[source] || '--';
|
||||
entries.push({
|
||||
id: `${idCounter++}`,
|
||||
timestamp: detail.timestamp,
|
||||
timestampMs,
|
||||
apiKey,
|
||||
model: modelName,
|
||||
source,
|
||||
displayName,
|
||||
providerName: provider,
|
||||
providerType,
|
||||
maskedKey: masked,
|
||||
failed: detail.failed,
|
||||
inputTokens: detail.tokens.input_tokens || 0,
|
||||
outputTokens: detail.tokens.output_tokens || 0,
|
||||
totalTokens: detail.tokens.total_tokens || 0,
|
||||
authIndex: detail.auth_index || '',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 按时间倒序排序
|
||||
return entries.sort((a, b) => b.timestampMs - a.timestampMs);
|
||||
}, [effectiveData, providerMap, providerTypeMap]);
|
||||
|
||||
// 预计算所有条目的统计数据(一次性计算,避免渲染时重复计算)
|
||||
const precomputedStats = useMemo(() => {
|
||||
const statsMap = new Map<string, PrecomputedStats>();
|
||||
|
||||
// 首先按渠道+模型分组,并按时间排序
|
||||
const channelModelGroups: Record<string, { entry: LogEntry; index: number }[]> = {};
|
||||
|
||||
logEntries.forEach((entry, index) => {
|
||||
const key = `${entry.source}|||${entry.model}`;
|
||||
if (!channelModelGroups[key]) {
|
||||
channelModelGroups[key] = [];
|
||||
}
|
||||
channelModelGroups[key].push({ entry, index });
|
||||
});
|
||||
|
||||
// 对每个分组按时间正序排序(用于计算累计统计)
|
||||
Object.values(channelModelGroups).forEach((group) => {
|
||||
group.sort((a, b) => a.entry.timestampMs - b.entry.timestampMs);
|
||||
});
|
||||
|
||||
// 计算每个条目的统计数据
|
||||
Object.entries(channelModelGroups).forEach(([, group]) => {
|
||||
let successCount = 0;
|
||||
let totalCount = 0;
|
||||
const recentRequests: ChannelModelRequest[] = [];
|
||||
|
||||
group.forEach(({ entry }) => {
|
||||
totalCount++;
|
||||
if (!entry.failed) {
|
||||
successCount++;
|
||||
}
|
||||
|
||||
// 维护最近 10 次请求
|
||||
recentRequests.push({ failed: entry.failed, timestamp: entry.timestampMs });
|
||||
if (recentRequests.length > 10) {
|
||||
recentRequests.shift();
|
||||
}
|
||||
|
||||
// 计算成功率
|
||||
const successRate = totalCount > 0 ? ((successCount / totalCount) * 100).toFixed(1) : '0.0';
|
||||
|
||||
// 存储该条目的统计数据
|
||||
statsMap.set(entry.id, {
|
||||
recentRequests: [...recentRequests],
|
||||
successRate,
|
||||
totalCount,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return statsMap;
|
||||
}, [logEntries]);
|
||||
|
||||
// 获取筛选选项
|
||||
const { apis, models, sources, providerTypes } = useMemo(() => {
|
||||
const apiSet = new Set<string>();
|
||||
const modelSet = new Set<string>();
|
||||
const sourceSet = new Set<string>();
|
||||
const providerTypeSet = new Set<string>();
|
||||
|
||||
logEntries.forEach((entry) => {
|
||||
apiSet.add(entry.apiKey);
|
||||
modelSet.add(entry.model);
|
||||
sourceSet.add(entry.source);
|
||||
if (entry.providerType && entry.providerType !== '--') {
|
||||
providerTypeSet.add(entry.providerType);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
apis: Array.from(apiSet).sort(),
|
||||
models: Array.from(modelSet).sort(),
|
||||
sources: Array.from(sourceSet).sort(),
|
||||
providerTypes: Array.from(providerTypeSet).sort(),
|
||||
};
|
||||
}, [logEntries]);
|
||||
|
||||
// 过滤后的数据
|
||||
const filteredEntries = useMemo(() => {
|
||||
return logEntries.filter((entry) => {
|
||||
if (filterApi && entry.apiKey !== filterApi) return false;
|
||||
if (filterModel && entry.model !== filterModel) return false;
|
||||
if (filterSource && entry.source !== filterSource) return false;
|
||||
if (filterStatus === 'success' && entry.failed) return false;
|
||||
if (filterStatus === 'failed' && !entry.failed) return false;
|
||||
if (filterProviderType && entry.providerType !== filterProviderType) return false;
|
||||
return true;
|
||||
});
|
||||
}, [logEntries, filterApi, filterModel, filterSource, filterStatus, filterProviderType]);
|
||||
|
||||
// 虚拟滚动配置
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: filteredEntries.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 10, // 预渲染上下各 10 行
|
||||
});
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
// 获取预计算的统计数据
|
||||
const getStats = (entry: LogEntry): PrecomputedStats => {
|
||||
return precomputedStats.get(entry.id) || {
|
||||
recentRequests: [],
|
||||
successRate: '0.0',
|
||||
totalCount: 0,
|
||||
};
|
||||
};
|
||||
|
||||
// 渲染单行
|
||||
const renderRow = (entry: LogEntry) => {
|
||||
const stats = getStats(entry);
|
||||
const rateValue = parseFloat(stats.successRate);
|
||||
const disabled = isModelDisabled(entry.source, entry.model);
|
||||
// 将 authIndex 映射为文件名
|
||||
const authDisplayName = entry.authIndex ? (authIndexMap[entry.authIndex] || entry.authIndex) : '-';
|
||||
|
||||
return (
|
||||
<>
|
||||
<td title={authDisplayName}>
|
||||
{authDisplayName}
|
||||
</td>
|
||||
<td title={entry.apiKey}>
|
||||
{maskSecret(entry.apiKey)}
|
||||
</td>
|
||||
<td>{entry.providerType}</td>
|
||||
<td title={entry.model}>
|
||||
{entry.model}
|
||||
</td>
|
||||
<td title={entry.source}>
|
||||
{entry.providerName ? (
|
||||
<>
|
||||
<span className={styles.channelName}>{entry.providerName}</span>
|
||||
<span className={styles.channelSecret}> ({entry.maskedKey})</span>
|
||||
</>
|
||||
) : (
|
||||
entry.maskedKey
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`${styles.statusPill} ${entry.failed ? styles.failed : styles.success}`}>
|
||||
{entry.failed ? t('monitor.logs.failed') : t('monitor.logs.success')}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.statusBars}>
|
||||
{stats.recentRequests.map((req, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className={getRateClassName(rateValue, styles)}>
|
||||
{stats.successRate}%
|
||||
</td>
|
||||
<td>{formatNumber(stats.totalCount)}</td>
|
||||
<td>{formatNumber(entry.inputTokens)}</td>
|
||||
<td>{formatNumber(entry.outputTokens)}</td>
|
||||
<td>{formatNumber(entry.totalTokens)}</td>
|
||||
<td>{formatTimestamp(entry.timestamp)}</td>
|
||||
<td>
|
||||
{entry.source && entry.source !== '-' && entry.source !== 'unknown' ? (
|
||||
disabled ? (
|
||||
<span className={styles.disabledLabel}>
|
||||
{t('monitor.logs.disabled')}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className={styles.disableBtn}
|
||||
title={t('monitor.logs.disable_model')}
|
||||
onClick={() => handleDisableClick(entry.source, entry.model)}
|
||||
>
|
||||
{t('monitor.logs.disable')}
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={t('monitor.logs.title')}
|
||||
subtitle={
|
||||
<span>
|
||||
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.logs.total_count', { count: logEntries.length })}
|
||||
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.logs.scroll_hint')}</span>
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<TimeRangeSelector
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
customRange={customRange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* 筛选器 */}
|
||||
<div className={styles.logFilters}>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterApi}
|
||||
onChange={(e) => setFilterApi(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.logs.all_apis')}</option>
|
||||
{apis.map((api) => (
|
||||
<option key={api} value={api}>
|
||||
{maskSecret(api)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterProviderType}
|
||||
onChange={(e) => setFilterProviderType(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.logs.all_provider_types')}</option>
|
||||
{providerTypes.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterModel}
|
||||
onChange={(e) => setFilterModel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.logs.all_models')}</option>
|
||||
{models.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterSource}
|
||||
onChange={(e) => setFilterSource(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.logs.all_sources')}</option>
|
||||
{sources.map((source) => (
|
||||
<option key={source} value={source}>
|
||||
{formatProviderDisplay(source, providerMap)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value as '' | 'success' | 'failed')}
|
||||
>
|
||||
<option value="">{t('monitor.logs.all_status')}</option>
|
||||
<option value="success">{t('monitor.logs.success')}</option>
|
||||
<option value="failed">{t('monitor.logs.failed')}</option>
|
||||
</select>
|
||||
|
||||
<span className={styles.logLastUpdate}>
|
||||
{getCountdownText()}
|
||||
</span>
|
||||
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(Number(e.target.value))}
|
||||
>
|
||||
<option value="0">{t('monitor.logs.manual_refresh')}</option>
|
||||
<option value="5">{t('monitor.logs.refresh_5s')}</option>
|
||||
<option value="10">{t('monitor.logs.refresh_10s')}</option>
|
||||
<option value="15">{t('monitor.logs.refresh_15s')}</option>
|
||||
<option value="30">{t('monitor.logs.refresh_30s')}</option>
|
||||
<option value="60">{t('monitor.logs.refresh_60s')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 虚拟滚动表格 */}
|
||||
<div className={styles.tableWrapper}>
|
||||
{showLoading ? (
|
||||
<div className={styles.emptyState}>{t('common.loading')}</div>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<div className={styles.emptyState}>{t('monitor.no_data')}</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 固定表头 */}
|
||||
<div ref={headerRef} className={styles.stickyHeader}>
|
||||
<table className={`${styles.table} ${styles.virtualTable}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('monitor.logs.header_auth')}</th>
|
||||
<th>{t('monitor.logs.header_api')}</th>
|
||||
<th>{t('monitor.logs.header_request_type')}</th>
|
||||
<th>{t('monitor.logs.header_model')}</th>
|
||||
<th>{t('monitor.logs.header_source')}</th>
|
||||
<th>{t('monitor.logs.header_status')}</th>
|
||||
<th>{t('monitor.logs.header_recent')}</th>
|
||||
<th>{t('monitor.logs.header_rate')}</th>
|
||||
<th>{t('monitor.logs.header_count')}</th>
|
||||
<th>{t('monitor.logs.header_input')}</th>
|
||||
<th>{t('monitor.logs.header_output')}</th>
|
||||
<th>{t('monitor.logs.header_total')}</th>
|
||||
<th>{t('monitor.logs.header_time')}</th>
|
||||
<th>{t('monitor.logs.header_actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 虚拟滚动容器 */}
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className={styles.virtualScrollContainer}
|
||||
style={{
|
||||
height: 'calc(100vh - 420px)',
|
||||
minHeight: '360px',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<table className={`${styles.table} ${styles.virtualTable}`}>
|
||||
<tbody>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const entry = filteredEntries[virtualRow.index];
|
||||
return (
|
||||
<tr
|
||||
key={entry.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
display: 'table',
|
||||
tableLayout: 'fixed',
|
||||
}}
|
||||
>
|
||||
{renderRow(entry)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
{filteredEntries.length > 0 && (
|
||||
<div style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-tertiary)', marginTop: 8 }}>
|
||||
{t('monitor.logs.total_count', { count: filteredEntries.length })}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 禁用确认弹窗 */}
|
||||
<DisableModelModal
|
||||
disableState={disableState}
|
||||
disabling={disabling}
|
||||
onConfirm={handleConfirmDisable}
|
||||
onCancel={handleCancelDisable}
|
||||
/>
|
||||
|
||||
{/* 不支持自动禁用提示弹窗 */}
|
||||
<UnsupportedDisableModal
|
||||
state={unsupportedState}
|
||||
onClose={handleCloseUnsupported}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
158
src/components/monitor/TimeRangeSelector.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
export type TimeRange = 1 | 7 | 14 | 30 | 'custom';
|
||||
|
||||
interface DateRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
interface TimeRangeSelectorProps {
|
||||
value: TimeRange;
|
||||
onChange: (range: TimeRange, customRange?: DateRange) => void;
|
||||
customRange?: DateRange;
|
||||
}
|
||||
|
||||
export function TimeRangeSelector({ value, onChange, customRange }: TimeRangeSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showCustom, setShowCustom] = useState(value === 'custom');
|
||||
const [startDate, setStartDate] = useState(() => {
|
||||
if (customRange?.start) {
|
||||
return formatDateForInput(customRange.start);
|
||||
}
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 7);
|
||||
return formatDateForInput(date);
|
||||
});
|
||||
const [endDate, setEndDate] = useState(() => {
|
||||
if (customRange?.end) {
|
||||
return formatDateForInput(customRange.end);
|
||||
}
|
||||
return formatDateForInput(new Date());
|
||||
});
|
||||
|
||||
const handleTimeClick = useCallback((range: TimeRange) => {
|
||||
if (range === 'custom') {
|
||||
setShowCustom(true);
|
||||
onChange(range);
|
||||
} else {
|
||||
setShowCustom(false);
|
||||
onChange(range);
|
||||
}
|
||||
}, [onChange]);
|
||||
|
||||
const handleApplyCustom = useCallback(() => {
|
||||
if (startDate && endDate) {
|
||||
const start = new Date(startDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(endDate);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
if (start <= end) {
|
||||
onChange('custom', { start, end });
|
||||
}
|
||||
}
|
||||
}, [startDate, endDate, onChange]);
|
||||
|
||||
return (
|
||||
<div className={styles.timeRangeSelector}>
|
||||
<div className={styles.timeButtons}>
|
||||
{([1, 7, 14, 30, 'custom'] as TimeRange[]).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
className={`${styles.timeButton} ${value === range ? styles.active : ''}`}
|
||||
onClick={() => handleTimeClick(range)}
|
||||
>
|
||||
{range === 1
|
||||
? t('monitor.time.today')
|
||||
: range === 'custom'
|
||||
? t('monitor.time.custom')
|
||||
: t('monitor.time.last_n_days', { n: range })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{showCustom && (
|
||||
<div className={styles.customDatePicker}>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.dateInput}
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<span className={styles.dateSeparator}>{t('monitor.time.to')}</span>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.dateInput}
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
<button className={styles.dateApplyBtn} onClick={handleApplyCustom}>
|
||||
{t('monitor.time.apply')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateForInput(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 根据时间范围过滤数据的工具函数
|
||||
export function filterByTimeRange<T extends { timestamp?: string }>(
|
||||
items: T[],
|
||||
range: TimeRange,
|
||||
customRange?: DateRange
|
||||
): T[] {
|
||||
const now = new Date();
|
||||
let cutoffStart: Date;
|
||||
let cutoffEnd: Date = new Date(now.getTime());
|
||||
cutoffEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
if (range === 'custom' && customRange) {
|
||||
cutoffStart = customRange.start;
|
||||
cutoffEnd = customRange.end;
|
||||
} else if (typeof range === 'number') {
|
||||
cutoffStart = new Date(now.getTime() - range * 24 * 60 * 60 * 1000);
|
||||
cutoffStart.setHours(0, 0, 0, 0);
|
||||
} else {
|
||||
// 默认7天
|
||||
cutoffStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
cutoffStart.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
return items.filter((item) => {
|
||||
if (!item.timestamp) return false;
|
||||
const timestamp = new Date(item.timestamp);
|
||||
return timestamp >= cutoffStart && timestamp <= cutoffEnd;
|
||||
});
|
||||
}
|
||||
|
||||
// 格式化时间范围显示
|
||||
export function formatTimeRangeCaption(
|
||||
range: TimeRange,
|
||||
customRange?: DateRange,
|
||||
t?: (key: string, options?: any) => string
|
||||
): string {
|
||||
if (range === 'custom' && customRange) {
|
||||
const startStr = formatDateForDisplay(customRange.start);
|
||||
const endStr = formatDateForDisplay(customRange.end);
|
||||
return `${startStr} - ${endStr}`;
|
||||
}
|
||||
if (range === 1) {
|
||||
return t ? t('monitor.time.today') : '今天';
|
||||
}
|
||||
return t ? t('monitor.time.last_n_days', { n: range }) : `最近 ${range} 天`;
|
||||
}
|
||||
|
||||
function formatDateForDisplay(date: Date): string {
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
return `${month}/${day}`;
|
||||
}
|
||||
82
src/components/monitor/UnsupportedDisableModal.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 不支持自动禁用提示弹窗组件
|
||||
* 显示手动操作指南
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { UnsupportedDisableState } from '@/hooks/useDisableModel';
|
||||
|
||||
interface UnsupportedDisableModalProps {
|
||||
/** 不支持禁用的状态 */
|
||||
state: UnsupportedDisableState | null;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function UnsupportedDisableModal({
|
||||
state,
|
||||
onClose,
|
||||
}: UnsupportedDisableModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={!!state}
|
||||
onClose={onClose}
|
||||
title={t('monitor.logs.disable_unsupported_title')}
|
||||
width={450}
|
||||
>
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
{/* 提示信息 */}
|
||||
<p style={{
|
||||
marginBottom: 16,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--warning-color, #f59e0b)',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
⚠️ {t('monitor.logs.disable_unsupported_desc', { providerType: state.providerType })}
|
||||
</p>
|
||||
|
||||
{/* 手动操作指南 */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<p style={{
|
||||
fontWeight: 600,
|
||||
marginBottom: 8,
|
||||
color: 'var(--text-primary)',
|
||||
}}>
|
||||
{t('monitor.logs.disable_unsupported_guide_title')}
|
||||
</p>
|
||||
<ul style={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
listStyle: 'none',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.8,
|
||||
color: 'var(--text-secondary)',
|
||||
}}>
|
||||
<li>{t('monitor.logs.disable_unsupported_guide_step1')}</li>
|
||||
<li>{t('monitor.logs.disable_unsupported_guide_step2', { providerType: state.providerType })}</li>
|
||||
<li>{t('monitor.logs.disable_unsupported_guide_step3', { model: state.model })}</li>
|
||||
<li>{t('monitor.logs.disable_unsupported_guide_step4')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
{t('monitor.logs.disable_unsupported_close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
8
src/components/monitor/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { KpiCards } from './KpiCards';
|
||||
export { ModelDistributionChart } from './ModelDistributionChart';
|
||||
export { DailyTrendChart } from './DailyTrendChart';
|
||||
export { HourlyModelChart } from './HourlyModelChart';
|
||||
export { HourlyTokenChart } from './HourlyTokenChart';
|
||||
export { ChannelStats } from './ChannelStats';
|
||||
export { FailureAnalysis } from './FailureAnalysis';
|
||||
export { RequestLogs } from './RequestLogs';
|
||||
@@ -1,264 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { ampcodeApi } from '@/services/api';
|
||||
import type { AmpcodeConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '../utils';
|
||||
import type { AmpcodeFormState } from '../types';
|
||||
|
||||
interface AmpcodeModalProps {
|
||||
isOpen: boolean;
|
||||
disableControls: boolean;
|
||||
onClose: () => void;
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
}
|
||||
|
||||
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
const [form, setForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [mappingsDirty, setMappingsDirty] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return '';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onBusyChange?.(loading || saving);
|
||||
}, [loading, saving, onBusyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
initializedRef.current = false;
|
||||
setLoading(false);
|
||||
setSaving(false);
|
||||
setError('');
|
||||
setLoaded(false);
|
||||
setMappingsDirty(false);
|
||||
setForm(buildAmpcodeFormState(null));
|
||||
onBusyChange?.(false);
|
||||
return;
|
||||
}
|
||||
if (initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
|
||||
setLoading(true);
|
||||
setLoaded(false);
|
||||
setMappingsDirty(false);
|
||||
setError('');
|
||||
setForm(buildAmpcodeFormState(config?.ampcode ?? null));
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const ampcode = await ampcodeApi.getAmpcode();
|
||||
setLoaded(true);
|
||||
updateConfigValue('ampcode', ampcode);
|
||||
clearCache('ampcode');
|
||||
setForm(buildAmpcodeFormState(ampcode));
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err) || t('notification.refresh_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
|
||||
|
||||
const clearAmpcodeUpstreamApiKey = async () => {
|
||||
if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await ampcodeApi.clearUpstreamApiKey();
|
||||
const previous = config?.ampcode ?? {};
|
||||
const next: AmpcodeConfig = { ...previous };
|
||||
delete next.upstreamApiKey;
|
||||
updateConfigValue('ampcode', next);
|
||||
clearCache('ampcode');
|
||||
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
setError(message);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAmpcode = async () => {
|
||||
if (!loaded && mappingsDirty) {
|
||||
const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm'));
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const upstreamUrl = form.upstreamUrl.trim();
|
||||
const overrideKey = form.upstreamApiKey.trim();
|
||||
const modelMappings = entriesToAmpcodeMappings(form.mappingEntries);
|
||||
|
||||
if (upstreamUrl) {
|
||||
await ampcodeApi.updateUpstreamUrl(upstreamUrl);
|
||||
} else {
|
||||
await ampcodeApi.clearUpstreamUrl();
|
||||
}
|
||||
|
||||
await ampcodeApi.updateForceModelMappings(form.forceModelMappings);
|
||||
|
||||
if (loaded || mappingsDirty) {
|
||||
if (modelMappings.length) {
|
||||
await ampcodeApi.saveModelMappings(modelMappings);
|
||||
} else {
|
||||
await ampcodeApi.clearModelMappings();
|
||||
}
|
||||
}
|
||||
|
||||
if (overrideKey) {
|
||||
await ampcodeApi.updateUpstreamApiKey(overrideKey);
|
||||
}
|
||||
|
||||
const previous = config?.ampcode ?? {};
|
||||
const next: AmpcodeConfig = {
|
||||
upstreamUrl: upstreamUrl || undefined,
|
||||
forceModelMappings: form.forceModelMappings,
|
||||
};
|
||||
|
||||
if (previous.upstreamApiKey) {
|
||||
next.upstreamApiKey = previous.upstreamApiKey;
|
||||
}
|
||||
|
||||
if (Array.isArray(previous.modelMappings)) {
|
||||
next.modelMappings = previous.modelMappings;
|
||||
}
|
||||
|
||||
if (overrideKey) {
|
||||
next.upstreamApiKey = overrideKey;
|
||||
}
|
||||
|
||||
if (loaded || mappingsDirty) {
|
||||
if (modelMappings.length) {
|
||||
next.modelMappings = modelMappings;
|
||||
} else {
|
||||
delete next.modelMappings;
|
||||
}
|
||||
}
|
||||
|
||||
updateConfigValue('ampcode', next);
|
||||
clearCache('ampcode');
|
||||
showNotification(t('notification.ampcode_updated'), 'success');
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
setError(message);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={t('ai_providers.ampcode_modal_title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={saving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={saveAmpcode} loading={saving} disabled={disableControls || loading}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
<Input
|
||||
label={t('ai_providers.ampcode_upstream_url_label')}
|
||||
placeholder={t('ai_providers.ampcode_upstream_url_placeholder')}
|
||||
value={form.upstreamUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))}
|
||||
disabled={loading || saving}
|
||||
hint={t('ai_providers.ampcode_upstream_url_hint')}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.ampcode_upstream_api_key_label')}
|
||||
placeholder={t('ai_providers.ampcode_upstream_api_key_placeholder')}
|
||||
type="password"
|
||||
value={form.upstreamApiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))}
|
||||
disabled={loading || saving}
|
||||
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
marginTop: -8,
|
||||
marginBottom: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div className="hint" style={{ margin: 0 }}>
|
||||
{t('ai_providers.ampcode_upstream_api_key_current', {
|
||||
key: config?.ampcode?.upstreamApiKey
|
||||
? maskApiKey(config.ampcode.upstreamApiKey)
|
||||
: t('common.not_set'),
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={clearAmpcodeUpstreamApiKey}
|
||||
disabled={loading || saving || !config?.ampcode?.upstreamApiKey}
|
||||
>
|
||||
{t('ai_providers.ampcode_clear_upstream_api_key')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.ampcode_force_model_mappings_label')}
|
||||
checked={form.forceModelMappings}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, forceModelMappings: value }))}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.ampcode_force_model_mappings_hint')}</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.ampcode_model_mappings_label')}</label>
|
||||
<ModelInputList
|
||||
entries={form.mappingEntries}
|
||||
onChange={(entries) => {
|
||||
setMappingsDirty(true);
|
||||
setForm((prev) => ({ ...prev, mappingEntries: entries }));
|
||||
}}
|
||||
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
|
||||
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
|
||||
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -5,34 +5,24 @@ import type { AmpcodeConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AmpcodeModal } from './AmpcodeModal';
|
||||
|
||||
interface AmpcodeSectionProps {
|
||||
config: AmpcodeConfig | null | undefined;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSaving: boolean;
|
||||
isSwitching: boolean;
|
||||
isBusy: boolean;
|
||||
isModalOpen: boolean;
|
||||
onOpen: () => void;
|
||||
onCloseModal: () => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
export function AmpcodeSection({
|
||||
config,
|
||||
loading,
|
||||
disableControls,
|
||||
isSaving,
|
||||
isSwitching,
|
||||
isBusy,
|
||||
isModalOpen,
|
||||
onOpen,
|
||||
onCloseModal,
|
||||
onBusyChange,
|
||||
onEdit,
|
||||
}: AmpcodeSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const showLoadingPlaceholder = loading && !config;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -46,14 +36,14 @@ export function AmpcodeSection({
|
||||
extra={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onOpen}
|
||||
disabled={disableControls || isSaving || isBusy || isSwitching}
|
||||
onClick={onEdit}
|
||||
disabled={disableControls || loading || isSwitching}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
{showLoadingPlaceholder ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -99,13 +89,6 @@ export function AmpcodeSection({
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<AmpcodeModal
|
||||
isOpen={isModalOpen}
|
||||
disableControls={disableControls}
|
||||
onClose={onCloseModal}
|
||||
onBusyChange={onBusyChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { excludedModelsToText } from '../utils';
|
||||
import type { ProviderFormState, ProviderModalProps } from '../types';
|
||||
|
||||
interface ClaudeModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const buildEmptyForm = (): ProviderFormState => ({
|
||||
apiKey: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
proxyUrl: '',
|
||||
headers: {},
|
||||
models: [],
|
||||
excludedModels: [],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
excludedText: '',
|
||||
});
|
||||
|
||||
export function ClaudeModal({
|
||||
isOpen,
|
||||
editIndex,
|
||||
initialData,
|
||||
onClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: ClaudeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (initialData) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setForm({
|
||||
...initialData,
|
||||
headers: initialData.headers ?? {},
|
||||
modelEntries: modelsToEntries(initialData.models),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
}, [initialData, isOpen]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={
|
||||
editIndex !== null
|
||||
? t('ai_providers.claude_edit_modal_title')
|
||||
: t('ai_providers.claude_add_modal_title')
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('ai_providers.claude_add_modal_key_label')}
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
placeholder={t('ai_providers.prefix_placeholder')}
|
||||
value={form.prefix ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||
hint={t('ai_providers.prefix_hint')}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.claude_add_modal_url_label')}
|
||||
value={form.baseUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.claude_add_modal_proxy_label')}
|
||||
value={form.proxyUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||
/>
|
||||
<HeaderInputList
|
||||
entries={headersToEntries(form.headers)}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.claude_models_label')}</label>
|
||||
<ModelInputList
|
||||
entries={form.modelEntries}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||
addLabel={t('ai_providers.claude_models_add_btn')}
|
||||
namePlaceholder={t('common.model_name_placeholder')}
|
||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||
value={form.excludedText}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||
rows={4}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -6,13 +6,16 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import type { ProviderFormState } from '../types';
|
||||
import { ClaudeModal } from './ClaudeModal';
|
||||
|
||||
interface ClaudeSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
@@ -20,16 +23,11 @@ interface ClaudeSectionProps {
|
||||
usageDetails: UsageDetail[];
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSaving: boolean;
|
||||
isSwitching: boolean;
|
||||
isModalOpen: boolean;
|
||||
modalIndex: number | null;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onToggle: (index: number, enabled: boolean) => void;
|
||||
onCloseModal: () => void;
|
||||
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ClaudeSection({
|
||||
@@ -38,33 +36,34 @@ export function ClaudeSection({
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSaving,
|
||||
isSwitching,
|
||||
isModalOpen,
|
||||
modalIndex,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onCloseModal,
|
||||
onSave,
|
||||
}: ClaudeSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
|
||||
const actionsDisabled = disableControls || loading || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || isSwitching;
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
const allApiKeys = new Set<string>();
|
||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
||||
allApiKeys.forEach((apiKey) => {
|
||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
||||
|
||||
configs.forEach((config) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const candidateSet = new Set(candidates);
|
||||
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
@@ -99,12 +98,11 @@ export function ClaudeSection({
|
||||
/>
|
||||
)}
|
||||
renderContent={(item) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData =
|
||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
@@ -188,15 +186,6 @@ export function ClaudeSection({
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<ClaudeModal
|
||||
isOpen={isModalOpen}
|
||||
editIndex={modalIndex}
|
||||
initialData={initialData}
|
||||
onClose={onCloseModal}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { modelsToEntries } from '@/components/ui/ModelInputList';
|
||||
import { excludedModelsToText } from '../utils';
|
||||
import type { ProviderFormState, ProviderModalProps } from '../types';
|
||||
|
||||
interface CodexModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const buildEmptyForm = (): ProviderFormState => ({
|
||||
apiKey: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
proxyUrl: '',
|
||||
headers: {},
|
||||
models: [],
|
||||
excludedModels: [],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
excludedText: '',
|
||||
});
|
||||
|
||||
export function CodexModal({
|
||||
isOpen,
|
||||
editIndex,
|
||||
initialData,
|
||||
onClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: CodexModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (initialData) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setForm({
|
||||
...initialData,
|
||||
headers: initialData.headers ?? {},
|
||||
modelEntries: modelsToEntries(initialData.models),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
}, [initialData, isOpen]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={
|
||||
editIndex !== null
|
||||
? t('ai_providers.codex_edit_modal_title')
|
||||
: t('ai_providers.codex_add_modal_title')
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('ai_providers.codex_add_modal_key_label')}
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
placeholder={t('ai_providers.prefix_placeholder')}
|
||||
value={form.prefix ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||
hint={t('ai_providers.prefix_hint')}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.codex_add_modal_url_label')}
|
||||
value={form.baseUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.codex_add_modal_proxy_label')}
|
||||
value={form.proxyUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||
/>
|
||||
<HeaderInputList
|
||||
entries={headersToEntries(form.headers)}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||
value={form.excludedText}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||
rows={4}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -3,17 +3,20 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import iconCodexLight from '@/assets/icons/codex_light.svg';
|
||||
import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import type { ProviderFormState } from '../types';
|
||||
import { CodexModal } from './CodexModal';
|
||||
|
||||
interface CodexSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
@@ -21,17 +24,12 @@ interface CodexSectionProps {
|
||||
usageDetails: UsageDetail[];
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSaving: boolean;
|
||||
isSwitching: boolean;
|
||||
resolvedTheme: string;
|
||||
isModalOpen: boolean;
|
||||
modalIndex: number | null;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onToggle: (index: number, enabled: boolean) => void;
|
||||
onCloseModal: () => void;
|
||||
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export function CodexSection({
|
||||
@@ -40,41 +38,42 @@ export function CodexSection({
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSaving,
|
||||
isSwitching,
|
||||
resolvedTheme,
|
||||
isModalOpen,
|
||||
modalIndex,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onCloseModal,
|
||||
onSave,
|
||||
}: CodexSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
|
||||
const actionsDisabled = disableControls || loading || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || isSwitching;
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
const allApiKeys = new Set<string>();
|
||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
||||
allApiKeys.forEach((apiKey) => {
|
||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
||||
|
||||
configs.forEach((config) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const candidateSet = new Set(candidates);
|
||||
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
|
||||
src={resolvedTheme === 'dark' ? iconCodexDark : iconCodexLight}
|
||||
alt=""
|
||||
className={styles.cardTitleIcon}
|
||||
/>
|
||||
@@ -106,12 +105,11 @@ export function CodexSection({
|
||||
/>
|
||||
)}
|
||||
renderContent={(item) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData =
|
||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
@@ -180,15 +178,6 @@ export function CodexSection({
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<CodexModal
|
||||
isOpen={isModalOpen}
|
||||
editIndex={modalIndex}
|
||||
initialData={initialData}
|
||||
onClose={onCloseModal}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import type { GeminiKeyConfig } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { excludedModelsToText } from '../utils';
|
||||
import type { GeminiFormState, ProviderModalProps } from '../types';
|
||||
|
||||
interface GeminiModalProps extends ProviderModalProps<GeminiKeyConfig, GeminiFormState> {
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const buildEmptyForm = (): GeminiFormState => ({
|
||||
apiKey: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
headers: {},
|
||||
excludedModels: [],
|
||||
excludedText: '',
|
||||
});
|
||||
|
||||
export function GeminiModal({
|
||||
isOpen,
|
||||
editIndex,
|
||||
initialData,
|
||||
onClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: GeminiModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<GeminiFormState>(buildEmptyForm);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (initialData) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setForm({
|
||||
...initialData,
|
||||
headers: initialData.headers ?? {},
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
}, [initialData, isOpen]);
|
||||
|
||||
const handleSave = () => {
|
||||
void onSave(form, editIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={
|
||||
editIndex !== null
|
||||
? t('ai_providers.gemini_edit_modal_title')
|
||||
: t('ai_providers.gemini_add_modal_title')
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} loading={isSaving}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('ai_providers.gemini_add_modal_key_label')}
|
||||
placeholder={t('ai_providers.gemini_add_modal_key_placeholder')}
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
placeholder={t('ai_providers.prefix_placeholder')}
|
||||
value={form.prefix ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||
hint={t('ai_providers.prefix_hint')}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.gemini_base_url_label')}
|
||||
placeholder={t('ai_providers.gemini_base_url_placeholder')}
|
||||
value={form.baseUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
/>
|
||||
<HeaderInputList
|
||||
entries={headersToEntries(form.headers)}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||
value={form.excludedText}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||
rows={4}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -6,13 +6,16 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import type { GeminiKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import type { GeminiFormState } from '../types';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import { GeminiModal } from './GeminiModal';
|
||||
|
||||
interface GeminiSectionProps {
|
||||
configs: GeminiKeyConfig[];
|
||||
@@ -20,16 +23,11 @@ interface GeminiSectionProps {
|
||||
usageDetails: UsageDetail[];
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSaving: boolean;
|
||||
isSwitching: boolean;
|
||||
isModalOpen: boolean;
|
||||
modalIndex: number | null;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onToggle: (index: number, enabled: boolean) => void;
|
||||
onCloseModal: () => void;
|
||||
onSave: (data: GeminiFormState, index: number | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export function GeminiSection({
|
||||
@@ -38,33 +36,34 @@ export function GeminiSection({
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSaving,
|
||||
isSwitching,
|
||||
isModalOpen,
|
||||
modalIndex,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onCloseModal,
|
||||
onSave,
|
||||
}: GeminiSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
|
||||
const actionsDisabled = disableControls || loading || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || isSwitching;
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
const allApiKeys = new Set<string>();
|
||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
||||
allApiKeys.forEach((apiKey) => {
|
||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
||||
|
||||
configs.forEach((config) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const candidateSet = new Set(candidates);
|
||||
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
@@ -99,12 +98,11 @@ export function GeminiSection({
|
||||
/>
|
||||
)}
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData =
|
||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
@@ -169,15 +167,6 @@ export function GeminiSection({
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<GeminiModal
|
||||
isOpen={isModalOpen}
|
||||
editIndex={modalIndex}
|
||||
initialData={initialData}
|
||||
onClose={onCloseModal}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { modelsApi } from '@/services/api';
|
||||
import type { ApiKeyEntry } from '@/types';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import { buildHeaderObject, type HeaderEntry } from '@/utils/headers';
|
||||
import { buildOpenAIModelsEndpoint } from '../utils';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
|
||||
interface OpenAIDiscoveryModalProps {
|
||||
isOpen: boolean;
|
||||
baseUrl: string;
|
||||
headers: HeaderEntry[];
|
||||
apiKeyEntries: ApiKeyEntry[];
|
||||
onClose: () => void;
|
||||
onApply: (selected: ModelInfo[]) => void;
|
||||
}
|
||||
|
||||
export function OpenAIDiscoveryModal({
|
||||
isOpen,
|
||||
baseUrl,
|
||||
headers,
|
||||
apiKeyEntries,
|
||||
onClose,
|
||||
onApply,
|
||||
}: OpenAIDiscoveryModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [endpoint, setEndpoint] = useState('');
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return '';
|
||||
};
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
const filter = search.trim().toLowerCase();
|
||||
if (!filter) return models;
|
||||
return models.filter((model) => {
|
||||
const name = (model.name || '').toLowerCase();
|
||||
const alias = (model.alias || '').toLowerCase();
|
||||
const desc = (model.description || '').toLowerCase();
|
||||
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
|
||||
});
|
||||
}, [models, search]);
|
||||
|
||||
const fetchOpenaiModelDiscovery = useCallback(
|
||||
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
|
||||
const trimmedBaseUrl = baseUrl.trim();
|
||||
if (!trimmedBaseUrl) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const headerObject = buildHeaderObject(headers);
|
||||
const firstKey = apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim();
|
||||
const hasAuthHeader = Boolean(headerObject.Authorization || headerObject['authorization']);
|
||||
const list = await modelsApi.fetchModelsViaApiCall(
|
||||
trimmedBaseUrl,
|
||||
hasAuthHeader ? undefined : firstKey,
|
||||
headerObject
|
||||
);
|
||||
setModels(list);
|
||||
} catch (err: unknown) {
|
||||
if (allowFallback) {
|
||||
try {
|
||||
const list = await modelsApi.fetchModelsViaApiCall(trimmedBaseUrl);
|
||||
setModels(list);
|
||||
return;
|
||||
} catch (fallbackErr: unknown) {
|
||||
const message = getErrorMessage(fallbackErr) || getErrorMessage(err);
|
||||
setModels([]);
|
||||
setError(`${t('ai_providers.openai_models_fetch_error')}: ${message}`);
|
||||
}
|
||||
} else {
|
||||
setModels([]);
|
||||
setError(`${t('ai_providers.openai_models_fetch_error')}: ${getErrorMessage(err)}`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[apiKeyEntries, baseUrl, headers, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setEndpoint(buildOpenAIModelsEndpoint(baseUrl));
|
||||
setModels([]);
|
||||
setSearch('');
|
||||
setSelected(new Set());
|
||||
setError('');
|
||||
void fetchOpenaiModelDiscovery();
|
||||
}, [baseUrl, fetchOpenaiModelDiscovery, isOpen]);
|
||||
|
||||
const toggleSelection = (name: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
const selectedModels = models.filter((model) => selected.has(model.name));
|
||||
onApply(selectedModels);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={t('ai_providers.openai_models_fetch_title')}
|
||||
width={720}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
||||
{t('ai_providers.openai_models_fetch_back')}
|
||||
</Button>
|
||||
<Button onClick={handleApply} disabled={loading}>
|
||||
{t('ai_providers.openai_models_fetch_apply')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="hint" style={{ marginBottom: 8 }}>
|
||||
{t('ai_providers.openai_models_fetch_hint')}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.openai_models_fetch_url_label')}</label>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input className="input" readOnly value={endpoint} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
|
||||
loading={loading}
|
||||
>
|
||||
{t('ai_providers.openai_models_fetch_refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label={t('ai_providers.openai_models_search_label')}
|
||||
placeholder={t('ai_providers.openai_models_search_placeholder')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{loading ? (
|
||||
<div className="hint">{t('ai_providers.openai_models_fetch_loading')}</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className="hint">{t('ai_providers.openai_models_fetch_empty')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className="hint">{t('ai_providers.openai_models_search_empty')}</div>
|
||||
) : (
|
||||
<div className={styles.modelDiscoveryList}>
|
||||
{filteredModels.map((model) => {
|
||||
const checked = selected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
key={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${checked ? styles.modelDiscoveryRowSelected : ''}`}
|
||||
>
|
||||
<input type="checkbox" checked={checked} onChange={() => toggleSelection(model.name)} />
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && <span className={styles.modelDiscoveryAlias}>{model.alias}</span>}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import type { OpenAIProviderConfig, ApiKeyEntry } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '../utils';
|
||||
import type { ModelEntry, OpenAIFormState, ProviderModalProps } from '../types';
|
||||
import { OpenAIDiscoveryModal } from './OpenAIDiscoveryModal';
|
||||
|
||||
const OPENAI_TEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
interface OpenAIModalProps extends ProviderModalProps<OpenAIProviderConfig, OpenAIFormState> {
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const buildEmptyForm = (): OpenAIFormState => ({
|
||||
name: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
headers: [],
|
||||
apiKeyEntries: [buildApiKeyEntry()],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
testModel: undefined,
|
||||
});
|
||||
|
||||
export function OpenAIModal({
|
||||
isOpen,
|
||||
editIndex,
|
||||
initialData,
|
||||
onClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: OpenAIModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const [form, setForm] = useState<OpenAIFormState>(buildEmptyForm);
|
||||
const [discoveryOpen, setDiscoveryOpen] = useState(false);
|
||||
const [testModel, setTestModel] = useState('');
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [testMessage, setTestMessage] = useState('');
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return '';
|
||||
};
|
||||
|
||||
const availableModels = useMemo(
|
||||
() => form.modelEntries.map((entry) => entry.name.trim()).filter(Boolean),
|
||||
[form.modelEntries]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setDiscoveryOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (initialData) {
|
||||
const modelEntries = modelsToEntries(initialData.models);
|
||||
setForm({
|
||||
name: initialData.name,
|
||||
prefix: initialData.prefix ?? '',
|
||||
baseUrl: initialData.baseUrl,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
testModel: initialData.testModel,
|
||||
modelEntries,
|
||||
apiKeyEntries: initialData.apiKeyEntries?.length
|
||||
? initialData.apiKeyEntries
|
||||
: [buildApiKeyEntry()],
|
||||
});
|
||||
const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
|
||||
const initialModel =
|
||||
initialData.testModel && available.includes(initialData.testModel)
|
||||
? initialData.testModel
|
||||
: available[0] || '';
|
||||
setTestModel(initialModel);
|
||||
} else {
|
||||
setForm(buildEmptyForm());
|
||||
setTestModel('');
|
||||
}
|
||||
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
setDiscoveryOpen(false);
|
||||
}, [initialData, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (availableModels.length === 0) {
|
||||
if (testModel) {
|
||||
setTestModel('');
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!testModel || !availableModels.includes(testModel)) {
|
||||
setTestModel(availableModels[0]);
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
}
|
||||
}, [availableModels, isOpen, testModel]);
|
||||
|
||||
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
|
||||
const list = entries.length ? entries : [buildApiKeyEntry()];
|
||||
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
|
||||
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
|
||||
setForm((prev) => ({ ...prev, apiKeyEntries: next }));
|
||||
};
|
||||
|
||||
const removeEntry = (idx: number) => {
|
||||
const next = list.filter((_, i) => i !== idx);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
|
||||
}));
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{list.map((entry, index) => (
|
||||
<div key={index} className="item-row">
|
||||
<div className="item-meta">
|
||||
<Input
|
||||
label={`${t('common.api_key')} #${index + 1}`}
|
||||
value={entry.apiKey}
|
||||
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t('common.proxy_url')}
|
||||
value={entry.proxyUrl ?? ''}
|
||||
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeEntry(index)}
|
||||
disabled={list.length <= 1 || isSaving}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="secondary" size="sm" onClick={addEntry} disabled={isSaving}>
|
||||
{t('ai_providers.openai_keys_add_btn')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const openOpenaiModelDiscovery = () => {
|
||||
const baseUrl = form.baseUrl.trim();
|
||||
if (!baseUrl) {
|
||||
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
|
||||
return;
|
||||
}
|
||||
setDiscoveryOpen(true);
|
||||
};
|
||||
|
||||
const applyOpenaiModelDiscoverySelection = (selectedModels: ModelInfo[]) => {
|
||||
if (!selectedModels.length) {
|
||||
setDiscoveryOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedMap = new Map<string, ModelEntry>();
|
||||
form.modelEntries.forEach((entry) => {
|
||||
const name = entry.name.trim();
|
||||
if (!name) return;
|
||||
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
|
||||
});
|
||||
|
||||
let addedCount = 0;
|
||||
selectedModels.forEach((model) => {
|
||||
const name = model.name.trim();
|
||||
if (!name || mergedMap.has(name)) return;
|
||||
mergedMap.set(name, { name, alias: model.alias ?? '' });
|
||||
addedCount += 1;
|
||||
});
|
||||
|
||||
const mergedEntries = Array.from(mergedMap.values());
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
|
||||
}));
|
||||
|
||||
setDiscoveryOpen(false);
|
||||
if (addedCount > 0) {
|
||||
showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const testOpenaiProviderConnection = async () => {
|
||||
const baseUrl = form.baseUrl.trim();
|
||||
if (!baseUrl) {
|
||||
const message = t('notification.openai_test_url_required');
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl);
|
||||
if (!endpoint) {
|
||||
const message = t('notification.openai_test_url_required');
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const firstKeyEntry = form.apiKeyEntries.find((entry) => entry.apiKey?.trim());
|
||||
if (!firstKeyEntry) {
|
||||
const message = t('notification.openai_test_key_required');
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const modelName = testModel.trim() || availableModels[0] || '';
|
||||
if (!modelName) {
|
||||
const message = t('notification.openai_test_model_required');
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const customHeaders = buildHeaderObject(form.headers);
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...customHeaders,
|
||||
};
|
||||
if (!headers.Authorization && !headers['authorization']) {
|
||||
headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`;
|
||||
}
|
||||
|
||||
setTestStatus('loading');
|
||||
setTestMessage(t('ai_providers.openai_test_running'));
|
||||
|
||||
try {
|
||||
const result = await apiCallApi.request(
|
||||
{
|
||||
method: 'POST',
|
||||
url: endpoint,
|
||||
header: Object.keys(headers).length ? headers : undefined,
|
||||
data: JSON.stringify({
|
||||
model: modelName,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
stream: false,
|
||||
max_tokens: 5,
|
||||
}),
|
||||
},
|
||||
{ timeout: OPENAI_TEST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw new Error(getApiCallErrorMessage(result));
|
||||
}
|
||||
|
||||
setTestStatus('success');
|
||||
setTestMessage(t('ai_providers.openai_test_success'));
|
||||
} catch (err: unknown) {
|
||||
setTestStatus('error');
|
||||
const message = getErrorMessage(err);
|
||||
const errorCode =
|
||||
typeof err === 'object' && err !== null && 'code' in err ? String((err as { code?: string }).code) : '';
|
||||
const isTimeout =
|
||||
errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
|
||||
if (isTimeout) {
|
||||
setTestMessage(t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 }));
|
||||
} else {
|
||||
setTestMessage(`${t('ai_providers.openai_test_failed')}: ${message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={
|
||||
editIndex !== null
|
||||
? t('ai_providers.openai_edit_modal_title')
|
||||
: t('ai_providers.openai_add_modal_title')
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('ai_providers.openai_add_modal_name_label')}
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
placeholder={t('ai_providers.prefix_placeholder')}
|
||||
value={form.prefix ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||
hint={t('ai_providers.prefix_hint')}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.openai_add_modal_url_label')}
|
||||
value={form.baseUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
/>
|
||||
|
||||
<HeaderInputList
|
||||
entries={form.headers}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{editIndex !== null
|
||||
? t('ai_providers.openai_edit_modal_models_label')
|
||||
: t('ai_providers.openai_add_modal_models_label')}
|
||||
</label>
|
||||
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
|
||||
<ModelInputList
|
||||
entries={form.modelEntries}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||
addLabel={t('ai_providers.openai_models_add_btn')}
|
||||
namePlaceholder={t('common.model_name_placeholder')}
|
||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<Button variant="secondary" size="sm" onClick={openOpenaiModelDiscovery} disabled={isSaving}>
|
||||
{t('ai_providers.openai_models_fetch_button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.openai_test_title')}</label>
|
||||
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<select
|
||||
className={`input ${styles.openaiTestSelect}`}
|
||||
value={testModel}
|
||||
onChange={(e) => {
|
||||
setTestModel(e.target.value);
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
}}
|
||||
disabled={isSaving || availableModels.length === 0}
|
||||
>
|
||||
<option value="">
|
||||
{availableModels.length
|
||||
? t('ai_providers.openai_test_select_placeholder')
|
||||
: t('ai_providers.openai_test_select_empty')}
|
||||
</option>
|
||||
{form.modelEntries
|
||||
.filter((entry) => entry.name.trim())
|
||||
.map((entry, idx) => {
|
||||
const name = entry.name.trim();
|
||||
const alias = entry.alias.trim();
|
||||
const label = alias && alias !== name ? `${name} (${alias})` : name;
|
||||
return (
|
||||
<option key={`${name}-${idx}`} value={name}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<Button
|
||||
variant={testStatus === 'error' ? 'danger' : 'secondary'}
|
||||
className={`${styles.openaiTestButton} ${testStatus === 'success' ? styles.openaiTestButtonSuccess : ''}`}
|
||||
onClick={testOpenaiProviderConnection}
|
||||
loading={testStatus === 'loading'}
|
||||
disabled={isSaving || availableModels.length === 0}
|
||||
>
|
||||
{t('ai_providers.openai_test_action')}
|
||||
</Button>
|
||||
</div>
|
||||
{testMessage && (
|
||||
<div
|
||||
className={`status-badge ${
|
||||
testStatus === 'error' ? 'error' : testStatus === 'success' ? 'success' : 'muted'
|
||||
}`}
|
||||
>
|
||||
{testMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
||||
{renderKeyEntries(form.apiKeyEntries)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<OpenAIDiscoveryModal
|
||||
isOpen={discoveryOpen}
|
||||
baseUrl={form.baseUrl}
|
||||
headers={form.headers}
|
||||
apiKeyEntries={form.apiKeyEntries}
|
||||
onClose={() => setDiscoveryOpen(false)}
|
||||
onApply={applyOpenaiModelDiscoverySelection}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,13 +7,16 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import type { OpenAIProviderConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
|
||||
import type { OpenAIFormState } from '../types';
|
||||
import { OpenAIModal } from './OpenAIModal';
|
||||
|
||||
interface OpenAISectionProps {
|
||||
configs: OpenAIProviderConfig[];
|
||||
@@ -21,16 +24,11 @@ interface OpenAISectionProps {
|
||||
usageDetails: UsageDetail[];
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSaving: boolean;
|
||||
isSwitching: boolean;
|
||||
resolvedTheme: string;
|
||||
isModalOpen: boolean;
|
||||
modalIndex: number | null;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onCloseModal: () => void;
|
||||
onSave: (data: OpenAIFormState, index: number | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export function OpenAISection({
|
||||
@@ -39,34 +37,34 @@ export function OpenAISection({
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSaving,
|
||||
isSwitching,
|
||||
resolvedTheme,
|
||||
isModalOpen,
|
||||
modalIndex,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCloseModal,
|
||||
onSave,
|
||||
}: OpenAISectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
||||
const actionsDisabled = disableControls || loading || isSwitching;
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((provider) => {
|
||||
const allKeys = (provider.apiKeyEntries || []).map((entry) => entry.apiKey).filter(Boolean);
|
||||
const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source));
|
||||
const sourceIds = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => sourceIds.add(id));
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => sourceIds.add(id));
|
||||
});
|
||||
|
||||
const filteredDetails = sourceIds.size
|
||||
? usageDetails.filter((detail) => sourceIds.has(detail.source))
|
||||
: [];
|
||||
cache.set(provider.name, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
@@ -96,7 +94,7 @@ export function OpenAISection({
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
renderContent={(item) => {
|
||||
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, maskApiKey);
|
||||
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const apiKeyEntries = item.apiKeyEntries || [];
|
||||
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
|
||||
@@ -130,7 +128,7 @@ export function OpenAISection({
|
||||
</div>
|
||||
<div className={styles.apiKeyEntryList}>
|
||||
{apiKeyEntries.map((entry, entryIndex) => {
|
||||
const entryStats = getStatsBySource(entry.apiKey, keyStats, maskApiKey);
|
||||
const entryStats = getStatsBySource(entry.apiKey, keyStats);
|
||||
return (
|
||||
<div key={entryIndex} className={styles.apiKeyEntryCard}>
|
||||
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
||||
@@ -192,15 +190,6 @@ export function OpenAISection({
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<OpenAIModal
|
||||
isOpen={isModalOpen}
|
||||
editIndex={modalIndex}
|
||||
initialData={initialData}
|
||||
onClose={onCloseModal}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function ProviderList<T>({
|
||||
}: ProviderListProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (loading) {
|
||||
if (loading && items.length === 0) {
|
||||
return <div className="hint">{t('common.loading')}</div>;
|
||||
}
|
||||
|
||||
|
||||
153
src/components/providers/ProviderNav/ProviderNav.module.scss
Normal file
@@ -0,0 +1,153 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.navContainer {
|
||||
position: fixed;
|
||||
left: var(--content-center-x, 50%);
|
||||
bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
pointer-events: auto;
|
||||
width: fit-content;
|
||||
max-width: calc(100vw - 24px);
|
||||
}
|
||||
|
||||
.navList {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
max-width: inherit;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
box-shadow: inset 0 0 0 2px var(--primary-color);
|
||||
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
width 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
height 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 120ms ease;
|
||||
will-change: transform, width, height;
|
||||
}
|
||||
|
||||
.indicatorVisible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.indicatorNoTransition {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, transform 0.15s ease;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.navItem.active {
|
||||
&:hover {
|
||||
background: transparent;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.active {
|
||||
// Active highlight is rendered by the sliding indicator.
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
// 暗色主题适配
|
||||
:global([data-theme='dark']) {
|
||||
.navList {
|
||||
background: rgba(30, 30, 30, 0.7);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.navItem {
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.indicator {
|
||||
background: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
// 小屏幕进一步收紧尺寸
|
||||
@media (max-width: 1200px) {
|
||||
.navContainer {
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
|
||||
.navList {
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.indicator {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
}
|
||||
287
src/components/providers/ProviderNav/ProviderNav.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||
import { useThemeStore } from '@/stores';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import iconCodexLight from '@/assets/icons/codex_light.svg';
|
||||
import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
import iconAmp from '@/assets/icons/amp.svg';
|
||||
import styles from './ProviderNav.module.scss';
|
||||
|
||||
export type ProviderId = 'gemini' | 'codex' | 'claude' | 'vertex' | 'ampcode' | 'openai';
|
||||
|
||||
interface ProviderNavItem {
|
||||
id: ProviderId;
|
||||
label: string;
|
||||
getIcon: (theme: string) => string;
|
||||
}
|
||||
|
||||
const PROVIDERS: ProviderNavItem[] = [
|
||||
{ id: 'gemini', label: 'Gemini', getIcon: () => iconGemini },
|
||||
{ id: 'codex', label: 'Codex', getIcon: (theme) => (theme === 'dark' ? iconCodexDark : iconCodexLight) },
|
||||
{ id: 'claude', label: 'Claude', getIcon: () => iconClaude },
|
||||
{ id: 'vertex', label: 'Vertex', getIcon: () => iconVertex },
|
||||
{ id: 'ampcode', label: 'Ampcode', getIcon: () => iconAmp },
|
||||
{ id: 'openai', label: 'OpenAI', getIcon: (theme) => (theme === 'dark' ? iconOpenaiDark : iconOpenaiLight) },
|
||||
];
|
||||
|
||||
const HEADER_OFFSET = 24;
|
||||
type ScrollContainer = HTMLElement | (Window & typeof globalThis);
|
||||
|
||||
export function ProviderNav() {
|
||||
const location = useLocation();
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
|
||||
const contentScrollerRef = useRef<HTMLElement | null>(null);
|
||||
const navListRef = useRef<HTMLDivElement | null>(null);
|
||||
const navContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const itemRefs = useRef<Record<ProviderId, HTMLButtonElement | null>>({
|
||||
gemini: null,
|
||||
codex: null,
|
||||
claude: null,
|
||||
vertex: null,
|
||||
ampcode: null,
|
||||
openai: null,
|
||||
});
|
||||
const [indicatorRect, setIndicatorRect] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const [indicatorTransitionsEnabled, setIndicatorTransitionsEnabled] = useState(false);
|
||||
const indicatorHasEnabledTransitionsRef = useRef(false);
|
||||
|
||||
// Only show this quick-switch overlay on the AI Providers list page.
|
||||
// Note: The app uses iOS-style stacked page transitions inside `/ai-providers/*`,
|
||||
// so this component can stay mounted while the user is on an edit route.
|
||||
const normalizedPathname =
|
||||
location.pathname.length > 1 && location.pathname.endsWith('/')
|
||||
? location.pathname.slice(0, -1)
|
||||
: location.pathname;
|
||||
const shouldShow = isCurrentLayer && normalizedPathname === '/ai-providers';
|
||||
|
||||
const getHeaderHeight = useCallback(() => {
|
||||
const header = document.querySelector('.main-header') as HTMLElement | null;
|
||||
if (header) return header.getBoundingClientRect().height;
|
||||
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue('--header-height');
|
||||
const value = Number.parseFloat(raw);
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}, []);
|
||||
|
||||
const getContentScroller = useCallback(() => {
|
||||
if (contentScrollerRef.current && document.contains(contentScrollerRef.current)) {
|
||||
return contentScrollerRef.current;
|
||||
}
|
||||
|
||||
const container = document.querySelector('.content') as HTMLElement | null;
|
||||
contentScrollerRef.current = container;
|
||||
return container;
|
||||
}, []);
|
||||
|
||||
const getScrollContainer = useCallback((): ScrollContainer => {
|
||||
// Mobile layout uses document scroll (layout switches at 768px); desktop uses the `.content` scroller.
|
||||
const isMobile = window.matchMedia('(max-width: 768px)').matches;
|
||||
if (isMobile) return window;
|
||||
return getContentScroller() ?? window;
|
||||
}, [getContentScroller]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = getScrollContainer();
|
||||
if (!container) return;
|
||||
|
||||
const isElementScroller = container instanceof HTMLElement;
|
||||
const headerHeight = isElementScroller ? 0 : getHeaderHeight();
|
||||
const containerTop = isElementScroller ? container.getBoundingClientRect().top : 0;
|
||||
const activationLine = containerTop + headerHeight + HEADER_OFFSET + 1;
|
||||
let currentActive: ProviderId | null = null;
|
||||
|
||||
for (const provider of PROVIDERS) {
|
||||
const element = document.getElementById(`provider-${provider.id}`);
|
||||
if (!element) continue;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.top <= activationLine) {
|
||||
currentActive = provider.id;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentActive) break;
|
||||
}
|
||||
|
||||
if (!currentActive) {
|
||||
const firstVisible = PROVIDERS.find((provider) =>
|
||||
document.getElementById(`provider-${provider.id}`)
|
||||
);
|
||||
currentActive = firstVisible?.id ?? null;
|
||||
}
|
||||
|
||||
setActiveProvider(currentActive);
|
||||
}, [getHeaderHeight, getScrollContainer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShow) return;
|
||||
const contentScroller = getContentScroller();
|
||||
|
||||
// Listen to both: desktop scroll happens on `.content`; mobile uses `window`.
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
contentScroller?.addEventListener('scroll', handleScroll, { passive: true });
|
||||
window.addEventListener('resize', handleScroll);
|
||||
const raf = requestAnimationFrame(handleScroll);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
contentScroller?.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [getContentScroller, handleScroll, shouldShow]);
|
||||
|
||||
const updateIndicator = useCallback((providerId: ProviderId | null) => {
|
||||
if (!providerId) {
|
||||
setIndicatorRect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const itemEl = itemRefs.current[providerId];
|
||||
if (!itemEl) return;
|
||||
|
||||
setIndicatorRect({
|
||||
x: itemEl.offsetLeft,
|
||||
y: itemEl.offsetTop,
|
||||
width: itemEl.offsetWidth,
|
||||
height: itemEl.offsetHeight,
|
||||
});
|
||||
|
||||
// Avoid animating from an initial (0,0) state on first paint.
|
||||
if (!indicatorHasEnabledTransitionsRef.current) {
|
||||
indicatorHasEnabledTransitionsRef.current = true;
|
||||
requestAnimationFrame(() => setIndicatorTransitionsEnabled(true));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldShow) return;
|
||||
const raf = requestAnimationFrame(() => updateIndicator(activeProvider));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [activeProvider, shouldShow, updateIndicator]);
|
||||
|
||||
// Expose overlay height to the page, so it can reserve bottom padding and avoid being covered.
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldShow) return;
|
||||
|
||||
const el = navContainerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const updateHeight = () => {
|
||||
const height = el.getBoundingClientRect().height;
|
||||
document.documentElement.style.setProperty('--provider-nav-height', `${height}px`);
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
window.addEventListener('resize', updateHeight);
|
||||
|
||||
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateHeight);
|
||||
ro?.observe(el);
|
||||
|
||||
return () => {
|
||||
ro?.disconnect();
|
||||
window.removeEventListener('resize', updateHeight);
|
||||
document.documentElement.style.removeProperty('--provider-nav-height');
|
||||
};
|
||||
}, [shouldShow]);
|
||||
|
||||
const scrollToProvider = (providerId: ProviderId) => {
|
||||
const container = getScrollContainer();
|
||||
const element = document.getElementById(`provider-${providerId}`);
|
||||
if (!element || !container) return;
|
||||
|
||||
setActiveProvider(providerId);
|
||||
updateIndicator(providerId);
|
||||
|
||||
// Mobile: scroll the document (header is fixed, so offset by header height).
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
const headerHeight = getHeaderHeight();
|
||||
const elementTop = element.getBoundingClientRect().top + window.scrollY;
|
||||
const target = Math.max(0, elementTop - headerHeight - HEADER_OFFSET);
|
||||
window.scrollTo({ top: target, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - HEADER_OFFSET;
|
||||
|
||||
container.scrollTo({ top: scrollTop, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShow) return;
|
||||
const handleResize = () => updateIndicator(activeProvider);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [activeProvider, shouldShow, updateIndicator]);
|
||||
|
||||
const navContent = (
|
||||
<div className={styles.navContainer} ref={navContainerRef}>
|
||||
<div className={styles.navList} ref={navListRef}>
|
||||
<div
|
||||
className={[
|
||||
styles.indicator,
|
||||
indicatorRect ? styles.indicatorVisible : '',
|
||||
indicatorTransitionsEnabled ? '' : styles.indicatorNoTransition,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={
|
||||
(indicatorRect
|
||||
? ({
|
||||
transform: `translate3d(${indicatorRect.x}px, ${indicatorRect.y}px, 0)`,
|
||||
width: indicatorRect.width,
|
||||
height: indicatorRect.height,
|
||||
} satisfies CSSProperties)
|
||||
: undefined) as CSSProperties | undefined
|
||||
}
|
||||
/>
|
||||
{PROVIDERS.map((provider) => {
|
||||
const isActive = activeProvider === provider.id;
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
className={`${styles.navItem} ${isActive ? styles.active : ''}`}
|
||||
ref={(node) => {
|
||||
itemRefs.current[provider.id] = node;
|
||||
}}
|
||||
onClick={() => scrollToProvider(provider.id)}
|
||||
title={provider.label}
|
||||
type="button"
|
||||
aria-label={provider.label}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
<img
|
||||
src={provider.getIcon(resolvedTheme)}
|
||||
alt={provider.label}
|
||||
className={styles.icon}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
if (!shouldShow) return null;
|
||||
|
||||
return createPortal(navContent, document.body);
|
||||
}
|
||||
2
src/components/providers/ProviderNav/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ProviderNav } from './ProviderNav';
|
||||
export type { ProviderId } from './ProviderNav';
|
||||
159
src/components/providers/VertexSection/VertexSection.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource } from '../utils';
|
||||
|
||||
interface VertexSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
}
|
||||
|
||||
export function VertexSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: VertexSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || loading || isSwitching;
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((config) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const candidateSet = new Set(candidates);
|
||||
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
|
||||
{t('ai_providers.vertex_title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||
{t('ai_providers.vertex_add_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ProviderList<ProviderKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
emptyTitle={t('ai_providers.vertex_empty_title')}
|
||||
emptyDescription={t('ai_providers.vertex_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="item-title">
|
||||
{t('ai_providers.vertex_item_title')} #{index + 1}
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
||||
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
||||
</div>
|
||||
{item.prefix && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.baseUrl && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.proxyUrl && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
|
||||
<span className={styles.fieldValue}>{item.proxyUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
{headerEntries.length > 0 && (
|
||||
<div className={styles.headerBadgeList}>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
<span key={key} className={styles.headerBadge}>
|
||||
<strong>{key}:</strong> {value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{item.models?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
<span className={styles.modelCountLabel}>
|
||||
{t('ai_providers.vertex_models_count')}: {item.models.length}
|
||||
</span>
|
||||
{item.models.map((model) => (
|
||||
<span key={`${model.name}-${model.alias || 'default'}`} className={styles.modelTag}>
|
||||
<span className={styles.modelName}>{model.name}</span>
|
||||
{model.alias && (
|
||||
<span className={styles.modelAlias}>{model.alias}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.cardStats}>
|
||||
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||
{t('stats.success')}: {stats.success}
|
||||
</span>
|
||||
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||
{t('stats.failure')}: {stats.failure}
|
||||
</span>
|
||||
</div>
|
||||
<ProviderStatusBar statusData={statusData} />
|
||||
</Fragment>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/providers/VertexSection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { VertexSection } from './VertexSection';
|
||||
@@ -3,8 +3,10 @@ export { ClaudeSection } from './ClaudeSection';
|
||||
export { CodexSection } from './CodexSection';
|
||||
export { GeminiSection } from './GeminiSection';
|
||||
export { OpenAISection } from './OpenAISection';
|
||||
export { VertexSection } from './VertexSection';
|
||||
export { ProviderList } from './ProviderList';
|
||||
export { ProviderStatusBar } from './ProviderStatusBar';
|
||||
export { ProviderNav } from './ProviderNav';
|
||||
export * from './hooks/useProviderStats';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
|
||||
@@ -2,13 +2,6 @@ import type { ApiKeyEntry, GeminiKeyConfig, ProviderKeyConfig } from '@/types';
|
||||
import type { HeaderEntry } from '@/utils/headers';
|
||||
import type { KeyStats, UsageDetail } from '@/utils/usage';
|
||||
|
||||
export type ProviderModal =
|
||||
| { type: 'gemini'; index: number | null }
|
||||
| { type: 'codex'; index: number | null }
|
||||
| { type: 'claude'; index: number | null }
|
||||
| { type: 'ampcode'; index: null }
|
||||
| { type: 'openai'; index: number | null };
|
||||
|
||||
export interface ModelEntry {
|
||||
name: string;
|
||||
alias: string;
|
||||
@@ -31,13 +24,22 @@ export interface AmpcodeFormState {
|
||||
mappingEntries: ModelEntry[];
|
||||
}
|
||||
|
||||
export type GeminiFormState = GeminiKeyConfig & { excludedText: string };
|
||||
export type GeminiFormState = Omit<GeminiKeyConfig, 'headers'> & {
|
||||
headers: HeaderEntry[];
|
||||
excludedText: string;
|
||||
};
|
||||
|
||||
export type ProviderFormState = ProviderKeyConfig & {
|
||||
export type ProviderFormState = Omit<ProviderKeyConfig, 'headers'> & {
|
||||
headers: HeaderEntry[];
|
||||
modelEntries: ModelEntry[];
|
||||
excludedText: string;
|
||||
};
|
||||
|
||||
export type VertexFormState = Omit<ProviderKeyConfig, 'headers' | 'excludedModels'> & {
|
||||
headers: HeaderEntry[];
|
||||
modelEntries: ModelEntry[];
|
||||
};
|
||||
|
||||
export interface ProviderSectionProps<TConfig> {
|
||||
configs: TConfig[];
|
||||
keyStats: KeyStats;
|
||||
@@ -48,12 +50,3 @@ export interface ProviderSectionProps<TConfig> {
|
||||
onDelete: (index: number) => void;
|
||||
onToggle?: (index: number, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ProviderModalProps<TConfig, TPayload = TConfig> {
|
||||
isOpen: boolean;
|
||||
editIndex: number | null;
|
||||
initialData?: TConfig;
|
||||
onClose: () => void;
|
||||
onSave: (data: TPayload, index: number | null) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
|
||||
import type { KeyStatBucket, KeyStats } from '@/utils/usage';
|
||||
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
|
||||
import type { AmpcodeFormState, ModelEntry } from './types';
|
||||
|
||||
export const DISABLE_ALL_MODELS_RULE = '*';
|
||||
@@ -46,7 +46,7 @@ export const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
|
||||
export const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||
if (!trimmed) return '';
|
||||
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
|
||||
return `${trimmed}/models`;
|
||||
};
|
||||
|
||||
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||
@@ -55,40 +55,57 @@ export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||
if (trimmed.endsWith('/chat/completions')) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.endsWith('/v1') ? `${trimmed}/chat/completions` : `${trimmed}/v1/chat/completions`;
|
||||
return `${trimmed}/chat/completions`;
|
||||
};
|
||||
|
||||
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致
|
||||
export const getStatsBySource = (
|
||||
apiKey: string,
|
||||
keyStats: KeyStats,
|
||||
maskFn: (key: string) => string
|
||||
prefix?: string
|
||||
): KeyStatBucket => {
|
||||
const bySource = keyStats.bySource ?? {};
|
||||
const masked = maskFn(apiKey);
|
||||
return bySource[apiKey] || bySource[masked] || { success: 0, failure: 0 };
|
||||
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
|
||||
if (!candidates.length) {
|
||||
return { success: 0, failure: 0 };
|
||||
}
|
||||
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
candidates.forEach((candidate) => {
|
||||
const stats = bySource[candidate];
|
||||
if (!stats) return;
|
||||
success += stats.success;
|
||||
failure += stats.failure;
|
||||
});
|
||||
|
||||
return { success, failure };
|
||||
};
|
||||
|
||||
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
|
||||
export const getOpenAIProviderStats = (
|
||||
apiKeyEntries: ApiKeyEntry[] | undefined,
|
||||
keyStats: KeyStats,
|
||||
maskFn: (key: string) => string
|
||||
providerPrefix?: string
|
||||
): KeyStatBucket => {
|
||||
const bySource = keyStats.bySource ?? {};
|
||||
let totalSuccess = 0;
|
||||
let totalFailure = 0;
|
||||
|
||||
const sourceIds = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
|
||||
(apiKeyEntries || []).forEach((entry) => {
|
||||
const key = entry?.apiKey || '';
|
||||
if (!key) return;
|
||||
const masked = maskFn(key);
|
||||
const stats = bySource[key] || bySource[masked] || { success: 0, failure: 0 };
|
||||
totalSuccess += stats.success;
|
||||
totalFailure += stats.failure;
|
||||
buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
|
||||
});
|
||||
|
||||
return { success: totalSuccess, failure: totalFailure };
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
sourceIds.forEach((id) => {
|
||||
const stats = bySource[id];
|
||||
if (!stats) return;
|
||||
success += stats.success;
|
||||
failure += stats.failure;
|
||||
});
|
||||
|
||||
return { success, failure };
|
||||
};
|
||||
|
||||
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
|
||||
|
||||
@@ -2,28 +2,30 @@
|
||||
* Generic quota section component.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { useQuotaStore, useThemeStore } from '@/stores';
|
||||
import type { AuthFileItem, ResolvedTheme } from '@/types';
|
||||
import { QuotaCard } from './QuotaCard';
|
||||
import type { QuotaStatusState } from './QuotaCard';
|
||||
import { useQuotaLoader } from './useQuotaLoader';
|
||||
import type { QuotaConfig } from './quotaConfigs';
|
||||
import { useGridColumns } from './useGridColumns';
|
||||
import { IconRefreshCw } from '@/components/ui/icons';
|
||||
import styles from '@/pages/QuotaPage.module.scss';
|
||||
|
||||
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
|
||||
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
|
||||
|
||||
const MIN_CARD_PAGE_SIZE = 3;
|
||||
const MAX_CARD_PAGE_SIZE = 30;
|
||||
type ViewMode = 'paged' | 'all';
|
||||
|
||||
const clampCardPageSize = (value: number) =>
|
||||
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
|
||||
const MAX_ITEMS_PER_PAGE = 14;
|
||||
const MAX_SHOW_ALL_THRESHOLD = 30;
|
||||
|
||||
interface QuotaPaginationState<T> {
|
||||
pageSize: number;
|
||||
@@ -40,7 +42,7 @@ interface QuotaPaginationState<T> {
|
||||
|
||||
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSizeState] = useState(() => clampCardPageSize(defaultPageSize));
|
||||
const [pageSize, setPageSizeState] = useState(defaultPageSize);
|
||||
const [loading, setLoadingState] = useState(false);
|
||||
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
|
||||
|
||||
@@ -57,7 +59,7 @@ const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginatio
|
||||
}, [items, currentPage, pageSize]);
|
||||
|
||||
const setPageSize = useCallback((size: number) => {
|
||||
setPageSizeState(clampCardPageSize(size));
|
||||
setPageSizeState(size);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
@@ -107,10 +109,17 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
Record<string, TState>
|
||||
>;
|
||||
|
||||
/* Removed useRef */
|
||||
const [columns, gridRef] = useGridColumns(380); // Min card width 380px matches SCSS
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('paged');
|
||||
const [showTooManyWarning, setShowTooManyWarning] = useState(false);
|
||||
|
||||
const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
|
||||
files,
|
||||
config.filterFn
|
||||
config
|
||||
]);
|
||||
const showAllAllowed = filteredFiles.length <= MAX_SHOW_ALL_THRESHOLD;
|
||||
const effectiveViewMode: ViewMode = viewMode === 'all' && !showAllAllowed ? 'paged' : viewMode;
|
||||
|
||||
const {
|
||||
pageSize,
|
||||
@@ -121,19 +130,59 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
goToPrev,
|
||||
goToNext,
|
||||
loading: sectionLoading,
|
||||
loadingScope,
|
||||
setLoading
|
||||
} = useQuotaPagination(filteredFiles);
|
||||
|
||||
useEffect(() => {
|
||||
if (showAllAllowed) return;
|
||||
if (viewMode !== 'all') return;
|
||||
|
||||
let cancelled = false;
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
setViewMode('paged');
|
||||
setShowTooManyWarning(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [showAllAllowed, viewMode]);
|
||||
|
||||
// Update page size based on view mode and columns
|
||||
useEffect(() => {
|
||||
if (effectiveViewMode === 'all') {
|
||||
setPageSize(Math.max(1, filteredFiles.length));
|
||||
} else {
|
||||
// Paged mode: 3 rows * columns, capped to avoid oversized pages.
|
||||
setPageSize(Math.min(columns * 3, MAX_ITEMS_PER_PAGE));
|
||||
}
|
||||
}, [effectiveViewMode, columns, filteredFiles.length, setPageSize]);
|
||||
|
||||
const { quota, loadQuota } = useQuotaLoader(config);
|
||||
|
||||
const handleRefreshPage = useCallback(() => {
|
||||
loadQuota(pageItems, 'page', setLoading);
|
||||
}, [loadQuota, pageItems, setLoading]);
|
||||
const pendingQuotaRefreshRef = useRef(false);
|
||||
const prevFilesLoadingRef = useRef(loading);
|
||||
|
||||
const handleRefreshAll = useCallback(() => {
|
||||
loadQuota(filteredFiles, 'all', setLoading);
|
||||
}, [loadQuota, filteredFiles, setLoading]);
|
||||
const handleRefresh = useCallback(() => {
|
||||
pendingQuotaRefreshRef.current = true;
|
||||
void triggerHeaderRefresh();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const wasLoading = prevFilesLoadingRef.current;
|
||||
prevFilesLoadingRef.current = loading;
|
||||
|
||||
if (!pendingQuotaRefreshRef.current) return;
|
||||
if (loading) return;
|
||||
if (!wasLoading) return;
|
||||
|
||||
pendingQuotaRefreshRef.current = false;
|
||||
const scope = effectiveViewMode === 'all' ? 'all' : 'page';
|
||||
const targets = effectiveViewMode === 'all' ? filteredFiles : pageItems;
|
||||
if (targets.length === 0) return;
|
||||
loadQuota(targets, scope, setLoading);
|
||||
}, [loading, effectiveViewMode, filteredFiles, pageItems, loadQuota, setLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
@@ -153,28 +202,56 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
});
|
||||
}, [filteredFiles, loading, setQuota]);
|
||||
|
||||
const titleNode = (
|
||||
<div className={styles.titleWrapper}>
|
||||
<span>{t(`${config.i18nPrefix}.title`)}</span>
|
||||
{filteredFiles.length > 0 && (
|
||||
<span className={styles.countBadge}>
|
||||
{filteredFiles.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const isRefreshing = sectionLoading || loading;
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t(`${config.i18nPrefix}.title`)}
|
||||
title={titleNode}
|
||||
extra={
|
||||
<div className={styles.headerActions}>
|
||||
<div className={styles.viewModeToggle}>
|
||||
<Button
|
||||
variant={effectiveViewMode === 'paged' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('paged')}
|
||||
>
|
||||
{t('auth_files.view_mode_paged')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={effectiveViewMode === 'all' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (filteredFiles.length > MAX_SHOW_ALL_THRESHOLD) {
|
||||
setShowTooManyWarning(true);
|
||||
} else {
|
||||
setViewMode('all');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('auth_files.view_mode_all')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefreshPage}
|
||||
disabled={disabled || sectionLoading || pageItems.length === 0}
|
||||
loading={sectionLoading && loadingScope === 'page'}
|
||||
onClick={handleRefresh}
|
||||
disabled={disabled || isRefreshing}
|
||||
loading={isRefreshing}
|
||||
title={t('quota_management.refresh_files_and_quota')}
|
||||
aria-label={t('quota_management.refresh_files_and_quota')}
|
||||
>
|
||||
{t(`${config.i18nPrefix}.refresh_button`)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefreshAll}
|
||||
disabled={disabled || sectionLoading || filteredFiles.length === 0}
|
||||
loading={sectionLoading && loadingScope === 'all'}
|
||||
>
|
||||
{t(`${config.i18nPrefix}.fetch_all`)}
|
||||
{!isRefreshing && <IconRefreshCw size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -186,31 +263,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={config.controlsClassName}>
|
||||
<div className={config.controlClassName}>
|
||||
<label>{t('auth_files.page_size_label')}</label>
|
||||
<input
|
||||
className={styles.pageSizeSelect}
|
||||
type="number"
|
||||
min={MIN_CARD_PAGE_SIZE}
|
||||
max={MAX_CARD_PAGE_SIZE}
|
||||
step={1}
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.valueAsNumber;
|
||||
if (!Number.isFinite(value)) return;
|
||||
setPageSize(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={config.controlClassName}>
|
||||
<label>{t('common.info')}</label>
|
||||
<div className={styles.statsInfo}>
|
||||
{filteredFiles.length} {t('auth_files.files_count')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={config.gridClassName}>
|
||||
<div ref={gridRef} className={config.gridClassName}>
|
||||
{pageItems.map((item) => (
|
||||
<QuotaCard
|
||||
key={item.name}
|
||||
@@ -224,7 +277,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{filteredFiles.length > pageSize && (
|
||||
{filteredFiles.length > pageSize && effectiveViewMode === 'paged' && (
|
||||
<div className={styles.pagination}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -253,6 +306,16 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{showTooManyWarning && (
|
||||
<div className={styles.warningOverlay} onClick={() => setShowTooManyWarning(false)}>
|
||||
<div className={styles.warningModal} onClick={(e) => e.stopPropagation()}>
|
||||
<p>{t('auth_files.too_many_files_warning')}</p>
|
||||
<Button variant="primary" size="sm" onClick={() => setShowTooManyWarning(false)}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
export { QuotaSection } from './QuotaSection';
|
||||
export { QuotaCard } from './QuotaCard';
|
||||
export { useQuotaLoader } from './useQuotaLoader';
|
||||
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
|
||||
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG, KIRO_CONFIG } from './quotaConfigs';
|
||||
export type { QuotaConfig } from './quotaConfigs';
|
||||
|
||||
@@ -10,15 +10,17 @@ import type {
|
||||
AntigravityModelsPayload,
|
||||
AntigravityQuotaState,
|
||||
AuthFileItem,
|
||||
CodexRateLimitInfo,
|
||||
CodexQuotaState,
|
||||
CodexUsageWindow,
|
||||
CodexQuotaWindow,
|
||||
CodexUsagePayload,
|
||||
GeminiCliParsedBucket,
|
||||
GeminiCliQuotaBucketState,
|
||||
GeminiCliQuotaState
|
||||
GeminiCliQuotaState,
|
||||
KiroQuotaState,
|
||||
} from '@/types';
|
||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import {
|
||||
ANTIGRAVITY_QUOTA_URLS,
|
||||
ANTIGRAVITY_REQUEST_HEADERS,
|
||||
@@ -26,7 +28,10 @@ import {
|
||||
CODEX_REQUEST_HEADERS,
|
||||
GEMINI_CLI_QUOTA_URL,
|
||||
GEMINI_CLI_REQUEST_HEADERS,
|
||||
KIRO_QUOTA_URL,
|
||||
KIRO_REQUEST_HEADERS,
|
||||
normalizeAuthIndexValue,
|
||||
normalizeGeminiCliModelId,
|
||||
normalizeNumberValue,
|
||||
normalizePlanType,
|
||||
normalizeQuotaFraction,
|
||||
@@ -34,6 +39,7 @@ import {
|
||||
parseAntigravityPayload,
|
||||
parseCodexUsagePayload,
|
||||
parseGeminiCliQuotaPayload,
|
||||
parseKiroQuotaPayload,
|
||||
resolveCodexChatgptAccountId,
|
||||
resolveCodexPlanType,
|
||||
resolveGeminiCliProjectId,
|
||||
@@ -45,23 +51,29 @@ import {
|
||||
getStatusFromError,
|
||||
isAntigravityFile,
|
||||
isCodexFile,
|
||||
isDisabledAuthFile,
|
||||
isGeminiCliFile,
|
||||
isRuntimeOnlyAuthFile
|
||||
isKiroFile,
|
||||
isRuntimeOnlyAuthFile,
|
||||
} from '@/utils/quota';
|
||||
import type { QuotaRenderHelpers } from './QuotaCard';
|
||||
import styles from '@/pages/QuotaPage.module.scss';
|
||||
|
||||
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
|
||||
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
|
||||
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli' | 'kiro';
|
||||
|
||||
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
|
||||
|
||||
export interface QuotaStore {
|
||||
antigravityQuota: Record<string, AntigravityQuotaState>;
|
||||
codexQuota: Record<string, CodexQuotaState>;
|
||||
geminiCliQuota: Record<string, GeminiCliQuotaState>;
|
||||
kiroQuota: Record<string, KiroQuotaState>;
|
||||
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
|
||||
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
|
||||
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
|
||||
setKiroQuota: (updater: QuotaUpdater<Record<string, KiroQuotaState>>) => void;
|
||||
clearQuotaCache: () => void;
|
||||
}
|
||||
|
||||
@@ -82,6 +94,38 @@ export interface QuotaConfig<TState, TData> {
|
||||
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
||||
}
|
||||
|
||||
const resolveAntigravityProjectId = async (file: AuthFileItem): Promise<string> => {
|
||||
try {
|
||||
const text = await authFilesApi.downloadText(file.name);
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
const topLevel = normalizeStringValue(parsed.project_id ?? parsed.projectId);
|
||||
if (topLevel) return topLevel;
|
||||
|
||||
const installed =
|
||||
parsed.installed && typeof parsed.installed === 'object' && parsed.installed !== null
|
||||
? (parsed.installed as Record<string, unknown>)
|
||||
: null;
|
||||
const installedProjectId = installed
|
||||
? normalizeStringValue(installed.project_id ?? installed.projectId)
|
||||
: null;
|
||||
if (installedProjectId) return installedProjectId;
|
||||
|
||||
const web =
|
||||
parsed.web && typeof parsed.web === 'object' && parsed.web !== null
|
||||
? (parsed.web as Record<string, unknown>)
|
||||
: null;
|
||||
const webProjectId = web ? normalizeStringValue(web.project_id ?? web.projectId) : null;
|
||||
if (webProjectId) return webProjectId;
|
||||
} catch {
|
||||
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||
}
|
||||
|
||||
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||
};
|
||||
|
||||
const fetchAntigravityQuota = async (
|
||||
file: AuthFileItem,
|
||||
t: TFunction
|
||||
@@ -92,6 +136,9 @@ const fetchAntigravityQuota = async (
|
||||
throw new Error(t('antigravity_quota.missing_auth_index'));
|
||||
}
|
||||
|
||||
const projectId = await resolveAntigravityProjectId(file);
|
||||
const requestBody = JSON.stringify({ project: projectId });
|
||||
|
||||
let lastError = '';
|
||||
let lastStatus: number | undefined;
|
||||
let priorityStatus: number | undefined;
|
||||
@@ -104,7 +151,7 @@ const fetchAntigravityQuota = async (
|
||||
method: 'POST',
|
||||
url,
|
||||
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
|
||||
data: '{}'
|
||||
data: requestBody,
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
@@ -151,6 +198,15 @@ const fetchAntigravityQuota = async (
|
||||
};
|
||||
|
||||
const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => {
|
||||
const FIVE_HOUR_SECONDS = 18000;
|
||||
const WEEK_SECONDS = 604800;
|
||||
const WINDOW_META = {
|
||||
codeFiveHour: { id: 'five-hour', labelKey: 'codex_quota.primary_window' },
|
||||
codeWeekly: { id: 'weekly', labelKey: 'codex_quota.secondary_window' },
|
||||
codeReviewFiveHour: { id: 'code-review-five-hour', labelKey: 'codex_quota.code_review_primary_window' },
|
||||
codeReviewWeekly: { id: 'code-review-weekly', labelKey: 'codex_quota.code_review_secondary_window' },
|
||||
} as const;
|
||||
|
||||
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
|
||||
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
|
||||
const windows: CodexQuotaWindow[] = [];
|
||||
@@ -172,30 +228,74 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
|
||||
label: t(labelKey),
|
||||
labelKey,
|
||||
usedPercent,
|
||||
resetLabel
|
||||
resetLabel,
|
||||
});
|
||||
};
|
||||
|
||||
const getWindowSeconds = (window?: CodexUsageWindow | null): number | null => {
|
||||
if (!window) return null;
|
||||
return normalizeNumberValue(window.limit_window_seconds ?? window.limitWindowSeconds);
|
||||
};
|
||||
|
||||
const rawLimitReached = rateLimit?.limit_reached ?? rateLimit?.limitReached;
|
||||
const rawAllowed = rateLimit?.allowed;
|
||||
|
||||
const pickClassifiedWindows = (
|
||||
limitInfo?: CodexRateLimitInfo | null
|
||||
): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => {
|
||||
const rawWindows = [
|
||||
limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null,
|
||||
limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null,
|
||||
];
|
||||
|
||||
let fiveHourWindow: CodexUsageWindow | null = null;
|
||||
let weeklyWindow: CodexUsageWindow | null = null;
|
||||
|
||||
for (const window of rawWindows) {
|
||||
if (!window) continue;
|
||||
const seconds = getWindowSeconds(window);
|
||||
if (seconds === FIVE_HOUR_SECONDS && !fiveHourWindow) {
|
||||
fiveHourWindow = window;
|
||||
} else if (seconds === WEEK_SECONDS && !weeklyWindow) {
|
||||
weeklyWindow = window;
|
||||
}
|
||||
}
|
||||
|
||||
return { fiveHourWindow, weeklyWindow };
|
||||
};
|
||||
|
||||
const rateWindows = pickClassifiedWindows(rateLimit);
|
||||
addWindow(
|
||||
'primary',
|
||||
'codex_quota.primary_window',
|
||||
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
|
||||
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
||||
rateLimit?.allowed
|
||||
WINDOW_META.codeFiveHour.id,
|
||||
WINDOW_META.codeFiveHour.labelKey,
|
||||
rateWindows.fiveHourWindow,
|
||||
rawLimitReached,
|
||||
rawAllowed
|
||||
);
|
||||
addWindow(
|
||||
'secondary',
|
||||
'codex_quota.secondary_window',
|
||||
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
|
||||
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
||||
rateLimit?.allowed
|
||||
WINDOW_META.codeWeekly.id,
|
||||
WINDOW_META.codeWeekly.labelKey,
|
||||
rateWindows.weeklyWindow,
|
||||
rawLimitReached,
|
||||
rawAllowed
|
||||
);
|
||||
|
||||
const codeReviewWindows = pickClassifiedWindows(codeReviewLimit);
|
||||
const codeReviewLimitReached = codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached;
|
||||
const codeReviewAllowed = codeReviewLimit?.allowed;
|
||||
addWindow(
|
||||
WINDOW_META.codeReviewFiveHour.id,
|
||||
WINDOW_META.codeReviewFiveHour.labelKey,
|
||||
codeReviewWindows.fiveHourWindow,
|
||||
codeReviewLimitReached,
|
||||
codeReviewAllowed
|
||||
);
|
||||
addWindow(
|
||||
'code-review',
|
||||
'codex_quota.code_review_window',
|
||||
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
|
||||
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
|
||||
codeReviewLimit?.allowed
|
||||
WINDOW_META.codeReviewWeekly.id,
|
||||
WINDOW_META.codeReviewWeekly.labelKey,
|
||||
codeReviewWindows.weeklyWindow,
|
||||
codeReviewLimitReached,
|
||||
codeReviewAllowed
|
||||
);
|
||||
|
||||
return windows;
|
||||
@@ -219,14 +319,14 @@ const fetchCodexQuota = async (
|
||||
|
||||
const requestHeader: Record<string, string> = {
|
||||
...CODEX_REQUEST_HEADERS,
|
||||
'Chatgpt-Account-Id': accountId
|
||||
'Chatgpt-Account-Id': accountId,
|
||||
};
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'GET',
|
||||
url: CODEX_USAGE_URL,
|
||||
header: requestHeader
|
||||
header: requestHeader,
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
@@ -263,7 +363,7 @@ const fetchGeminiCliQuota = async (
|
||||
method: 'POST',
|
||||
url: GEMINI_CLI_QUOTA_URL,
|
||||
header: { ...GEMINI_CLI_REQUEST_HEADERS },
|
||||
data: JSON.stringify({ project: projectId })
|
||||
data: JSON.stringify({ project: projectId }),
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
@@ -276,13 +376,15 @@ const fetchGeminiCliQuota = async (
|
||||
|
||||
const parsedBuckets = buckets
|
||||
.map((bucket) => {
|
||||
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
|
||||
const modelId = normalizeGeminiCliModelId(bucket.modelId ?? bucket.model_id);
|
||||
if (!modelId) return null;
|
||||
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
|
||||
const remainingFractionRaw = normalizeQuotaFraction(
|
||||
bucket.remainingFraction ?? bucket.remaining_fraction
|
||||
);
|
||||
const remainingAmount = normalizeNumberValue(bucket.remainingAmount ?? bucket.remaining_amount);
|
||||
const remainingAmount = normalizeNumberValue(
|
||||
bucket.remainingAmount ?? bucket.remaining_amount
|
||||
);
|
||||
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
|
||||
let fallbackFraction: number | null = null;
|
||||
if (remainingAmount !== null) {
|
||||
@@ -296,7 +398,7 @@ const fetchGeminiCliQuota = async (
|
||||
tokenType,
|
||||
remainingFraction,
|
||||
remainingAmount,
|
||||
resetTime
|
||||
resetTime,
|
||||
};
|
||||
})
|
||||
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
|
||||
@@ -328,11 +430,7 @@ const renderAntigravityItems = (
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h(
|
||||
'span',
|
||||
{ className: styleMap.quotaModel, title: group.models.join(', ') },
|
||||
group.label
|
||||
),
|
||||
h('span', { className: styleMap.quotaModel, title: group.models.join(', ') }, group.label),
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
@@ -365,7 +463,6 @@ const renderCodexItems = (
|
||||
};
|
||||
|
||||
const planLabel = getPlanLabel(planType);
|
||||
const isFreePlan = normalizePlanType(planType) === 'free';
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
if (planLabel) {
|
||||
@@ -379,17 +476,6 @@ const renderCodexItems = (
|
||||
);
|
||||
}
|
||||
|
||||
if (isFreePlan) {
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'warning', className: styleMap.quotaWarning },
|
||||
t('codex_quota.no_access')
|
||||
)
|
||||
);
|
||||
return h(Fragment, null, ...nodes);
|
||||
}
|
||||
|
||||
if (windows.length === 0) {
|
||||
nodes.push(
|
||||
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows'))
|
||||
@@ -449,7 +535,7 @@ const renderGeminiCliItems = (
|
||||
bucket.remainingAmount === null || bucket.remainingAmount === undefined
|
||||
? null
|
||||
: t('gemini_cli_quota.remaining_amount', {
|
||||
count: bucket.remainingAmount
|
||||
count: bucket.remainingAmount,
|
||||
});
|
||||
const titleBase =
|
||||
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
|
||||
@@ -482,7 +568,7 @@ const renderGeminiCliItems = (
|
||||
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
|
||||
type: 'antigravity',
|
||||
i18nPrefix: 'antigravity_quota',
|
||||
filterFn: (file) => isAntigravityFile(file),
|
||||
filterFn: (file) => isAntigravityFile(file) && !isDisabledAuthFile(file),
|
||||
fetchQuota: fetchAntigravityQuota,
|
||||
storeSelector: (state) => state.antigravityQuota,
|
||||
storeSetter: 'setAntigravityQuota',
|
||||
@@ -492,13 +578,13 @@ export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQ
|
||||
status: 'error',
|
||||
groups: [],
|
||||
error: message,
|
||||
errorStatus: status
|
||||
errorStatus: status,
|
||||
}),
|
||||
cardClassName: styles.antigravityCard,
|
||||
controlsClassName: styles.antigravityControls,
|
||||
controlClassName: styles.antigravityControl,
|
||||
gridClassName: styles.antigravityGrid,
|
||||
renderQuotaItems: renderAntigravityItems
|
||||
renderQuotaItems: renderAntigravityItems,
|
||||
};
|
||||
|
||||
export const CODEX_CONFIG: QuotaConfig<
|
||||
@@ -507,7 +593,7 @@ export const CODEX_CONFIG: QuotaConfig<
|
||||
> = {
|
||||
type: 'codex',
|
||||
i18nPrefix: 'codex_quota',
|
||||
filterFn: (file) => isCodexFile(file),
|
||||
filterFn: (file) => isCodexFile(file) && !isDisabledAuthFile(file),
|
||||
fetchQuota: fetchCodexQuota,
|
||||
storeSelector: (state) => state.codexQuota,
|
||||
storeSetter: 'setCodexQuota',
|
||||
@@ -515,25 +601,26 @@ export const CODEX_CONFIG: QuotaConfig<
|
||||
buildSuccessState: (data) => ({
|
||||
status: 'success',
|
||||
windows: data.windows,
|
||||
planType: data.planType
|
||||
planType: data.planType,
|
||||
}),
|
||||
buildErrorState: (message, status) => ({
|
||||
status: 'error',
|
||||
windows: [],
|
||||
error: message,
|
||||
errorStatus: status
|
||||
errorStatus: status,
|
||||
}),
|
||||
cardClassName: styles.codexCard,
|
||||
controlsClassName: styles.codexControls,
|
||||
controlClassName: styles.codexControl,
|
||||
gridClassName: styles.codexGrid,
|
||||
renderQuotaItems: renderCodexItems
|
||||
renderQuotaItems: renderCodexItems,
|
||||
};
|
||||
|
||||
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
|
||||
type: 'gemini-cli',
|
||||
i18nPrefix: 'gemini_cli_quota',
|
||||
filterFn: (file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file),
|
||||
filterFn: (file) =>
|
||||
isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file) && !isDisabledAuthFile(file),
|
||||
fetchQuota: fetchGeminiCliQuota,
|
||||
storeSelector: (state) => state.geminiCliQuota,
|
||||
storeSetter: 'setGeminiCliQuota',
|
||||
@@ -543,11 +630,299 @@ export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaB
|
||||
status: 'error',
|
||||
buckets: [],
|
||||
error: message,
|
||||
errorStatus: status
|
||||
errorStatus: status,
|
||||
}),
|
||||
cardClassName: styles.geminiCliCard,
|
||||
controlsClassName: styles.geminiCliControls,
|
||||
controlClassName: styles.geminiCliControl,
|
||||
gridClassName: styles.geminiCliGrid,
|
||||
renderQuotaItems: renderGeminiCliItems
|
||||
renderQuotaItems: renderGeminiCliItems,
|
||||
};
|
||||
|
||||
// Kiro quota data structure from API
|
||||
interface KiroQuotaData {
|
||||
// Base quota (原本额度)
|
||||
baseUsage: number | null;
|
||||
baseLimit: number | null;
|
||||
baseRemaining: number | null;
|
||||
// Free trial/bonus quota (赠送额度)
|
||||
bonusUsage: number | null;
|
||||
bonusLimit: number | null;
|
||||
bonusRemaining: number | null;
|
||||
bonusStatus?: string;
|
||||
// Total (合计)
|
||||
currentUsage: number | null;
|
||||
usageLimit: number | null;
|
||||
remainingCredits: number | null;
|
||||
nextReset?: string;
|
||||
subscriptionType?: string;
|
||||
}
|
||||
|
||||
const fetchKiroQuota = async (
|
||||
file: AuthFileItem,
|
||||
t: TFunction
|
||||
): Promise<KiroQuotaData> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
throw new Error(t('kiro_quota.missing_auth_index'));
|
||||
}
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'GET',
|
||||
url: KIRO_QUOTA_URL,
|
||||
header: { ...KIRO_REQUEST_HEADERS }
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||
}
|
||||
|
||||
const payload = parseKiroQuotaPayload(result.body ?? result.bodyText);
|
||||
if (!payload) {
|
||||
throw new Error(t('kiro_quota.empty_data'));
|
||||
}
|
||||
|
||||
// Extract usage data from usageBreakdownList (separating base and bonus)
|
||||
const breakdownList = payload.usageBreakdownList ?? [];
|
||||
let baseLimit = 0;
|
||||
let baseUsage = 0;
|
||||
let bonusLimit = 0;
|
||||
let bonusUsage = 0;
|
||||
let bonusStatus: string | undefined;
|
||||
|
||||
for (const breakdown of breakdownList) {
|
||||
// Add base quota
|
||||
const limit = normalizeNumberValue(breakdown.usageLimitWithPrecision ?? breakdown.usageLimit);
|
||||
const usage = normalizeNumberValue(breakdown.currentUsageWithPrecision ?? breakdown.currentUsage);
|
||||
if (limit !== null) baseLimit += limit;
|
||||
if (usage !== null) baseUsage += usage;
|
||||
|
||||
// Add free trial quota if available (e.g., 500 bonus credits)
|
||||
const freeTrialInfo = breakdown.freeTrialInfo;
|
||||
if (freeTrialInfo) {
|
||||
const freeLimit = normalizeNumberValue(freeTrialInfo.usageLimitWithPrecision ?? freeTrialInfo.usageLimit);
|
||||
const freeUsage = normalizeNumberValue(freeTrialInfo.currentUsageWithPrecision ?? freeTrialInfo.currentUsage);
|
||||
if (freeLimit !== null) bonusLimit += freeLimit;
|
||||
if (freeUsage !== null) bonusUsage += freeUsage;
|
||||
if (freeTrialInfo.freeTrialStatus) {
|
||||
bonusStatus = freeTrialInfo.freeTrialStatus;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalLimit = baseLimit + bonusLimit;
|
||||
const totalUsage = baseUsage + bonusUsage;
|
||||
|
||||
// Calculate next reset time
|
||||
// Note: nextDateReset from Kiro API is in SECONDS (e.g., 1.769904E9 = 1769904000)
|
||||
// JavaScript Date() requires milliseconds, so multiply by 1000
|
||||
let nextReset: string | undefined;
|
||||
if (payload.nextDateReset) {
|
||||
// API returns seconds timestamp (scientific notation like 1.769904E9)
|
||||
const timestampSeconds = payload.nextDateReset;
|
||||
const resetDate = new Date(timestampSeconds * 1000);
|
||||
if (!isNaN(resetDate.getTime())) {
|
||||
nextReset = resetDate.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
// Get subscription type
|
||||
const subscriptionType = payload.subscriptionInfo?.subscriptionTitle ?? payload.subscriptionInfo?.type;
|
||||
|
||||
return {
|
||||
baseUsage,
|
||||
baseLimit,
|
||||
baseRemaining: baseLimit > 0 ? Math.max(0, baseLimit - baseUsage) : null,
|
||||
bonusUsage,
|
||||
bonusLimit,
|
||||
bonusRemaining: bonusLimit > 0 ? Math.max(0, bonusLimit - bonusUsage) : null,
|
||||
bonusStatus,
|
||||
currentUsage: totalUsage,
|
||||
usageLimit: totalLimit,
|
||||
remainingCredits: totalLimit > 0 ? Math.max(0, totalLimit - totalUsage) : null,
|
||||
nextReset,
|
||||
subscriptionType
|
||||
};
|
||||
};
|
||||
|
||||
const renderKiroItems = (
|
||||
quota: KiroQuotaState,
|
||||
t: TFunction,
|
||||
helpers: QuotaRenderHelpers
|
||||
): ReactNode => {
|
||||
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||
const { createElement: h, Fragment } = React;
|
||||
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
// Show subscription type if available
|
||||
if (quota.subscriptionType) {
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'subscription', className: styleMap.codexPlan },
|
||||
h('span', { className: styleMap.codexPlanLabel }, t('kiro_quota.subscription_label')),
|
||||
h('span', { className: styleMap.codexPlanValue }, quota.subscriptionType)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const usageLimit = quota.usageLimit;
|
||||
|
||||
if (usageLimit === null || usageLimit === 0) {
|
||||
nodes.push(
|
||||
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('kiro_quota.empty_data'))
|
||||
);
|
||||
return h(Fragment, null, ...nodes);
|
||||
}
|
||||
|
||||
const resetLabel = formatQuotaResetTime(quota.nextReset);
|
||||
|
||||
// Base quota display (原本额度)
|
||||
const baseLimit = quota.baseLimit;
|
||||
const baseRemaining = quota.baseRemaining;
|
||||
if (baseLimit !== null && baseLimit > 0) {
|
||||
const baseRemainingPercent = baseRemaining !== null && baseLimit > 0
|
||||
? Math.round((baseRemaining / baseLimit) * 100)
|
||||
: 0;
|
||||
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'base-credits', className: styleMap.quotaRow },
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h('span', { className: styleMap.quotaModel }, t('kiro_quota.base_credits_label')),
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
h('span', { className: styleMap.quotaPercent }, `${baseRemainingPercent}%`),
|
||||
baseRemaining !== null
|
||||
? h('span', { className: styleMap.quotaAmount }, t('kiro_quota.remaining_credits', { count: Math.round(baseRemaining) }))
|
||||
: null,
|
||||
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent: baseRemainingPercent, highThreshold: 60, mediumThreshold: 20 })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Bonus quota display (赠送额度)
|
||||
const bonusLimit = quota.bonusLimit;
|
||||
const bonusRemaining = quota.bonusRemaining;
|
||||
if (bonusLimit !== null && bonusLimit > 0) {
|
||||
const bonusRemainingPercent = bonusRemaining !== null && bonusLimit > 0
|
||||
? Math.round((bonusRemaining / bonusLimit) * 100)
|
||||
: 0;
|
||||
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'bonus-credits', className: styleMap.quotaRow },
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h('span', { className: styleMap.quotaModel }, t('kiro_quota.bonus_credits_label')),
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
h('span', { className: styleMap.quotaPercent }, `${bonusRemainingPercent}%`),
|
||||
bonusRemaining !== null
|
||||
? h('span', { className: styleMap.quotaAmount }, t('kiro_quota.remaining_credits', { count: Math.round(bonusRemaining) }))
|
||||
: null
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent: bonusRemainingPercent, highThreshold: 60, mediumThreshold: 20 })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Total credits display (合计)
|
||||
const currentUsage = quota.currentUsage;
|
||||
const remainingCredits = quota.remainingCredits;
|
||||
const totalRemainingPercent = currentUsage !== null && usageLimit > 0
|
||||
? Math.max(0, 100 - Math.round((currentUsage / usageLimit) * 100))
|
||||
: 0;
|
||||
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'total-credits', className: styleMap.quotaRow },
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h('span', { className: styleMap.quotaModel }, t('kiro_quota.total_credits_label')),
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
h('span', { className: styleMap.quotaPercent }, `${totalRemainingPercent}%`),
|
||||
remainingCredits !== null
|
||||
? h('span', { className: styleMap.quotaAmount }, t('kiro_quota.remaining_credits', { count: Math.round(remainingCredits) }))
|
||||
: null
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent: totalRemainingPercent, highThreshold: 60, mediumThreshold: 20 })
|
||||
)
|
||||
);
|
||||
|
||||
return h(Fragment, null, ...nodes);
|
||||
};
|
||||
|
||||
export const KIRO_CONFIG: QuotaConfig<KiroQuotaState, KiroQuotaData> = {
|
||||
type: 'kiro',
|
||||
i18nPrefix: 'kiro_quota',
|
||||
filterFn: (file) => isKiroFile(file),
|
||||
fetchQuota: fetchKiroQuota,
|
||||
storeSelector: (state) => state.kiroQuota,
|
||||
storeSetter: 'setKiroQuota',
|
||||
buildLoadingState: () => ({
|
||||
status: 'loading',
|
||||
baseUsage: null,
|
||||
baseLimit: null,
|
||||
baseRemaining: null,
|
||||
bonusUsage: null,
|
||||
bonusLimit: null,
|
||||
bonusRemaining: null,
|
||||
currentUsage: null,
|
||||
usageLimit: null,
|
||||
remainingCredits: null
|
||||
}),
|
||||
buildSuccessState: (data) => ({
|
||||
status: 'success',
|
||||
baseUsage: data.baseUsage,
|
||||
baseLimit: data.baseLimit,
|
||||
baseRemaining: data.baseRemaining,
|
||||
bonusUsage: data.bonusUsage,
|
||||
bonusLimit: data.bonusLimit,
|
||||
bonusRemaining: data.bonusRemaining,
|
||||
bonusStatus: data.bonusStatus,
|
||||
currentUsage: data.currentUsage,
|
||||
usageLimit: data.usageLimit,
|
||||
remainingCredits: data.remainingCredits,
|
||||
nextReset: data.nextReset,
|
||||
subscriptionType: data.subscriptionType
|
||||
}),
|
||||
buildErrorState: (message, status) => ({
|
||||
status: 'error',
|
||||
baseUsage: null,
|
||||
baseLimit: null,
|
||||
baseRemaining: null,
|
||||
bonusUsage: null,
|
||||
bonusLimit: null,
|
||||
bonusRemaining: null,
|
||||
currentUsage: null,
|
||||
usageLimit: null,
|
||||
remainingCredits: null,
|
||||
error: message,
|
||||
errorStatus: status
|
||||
}),
|
||||
cardClassName: styles.kiroCard,
|
||||
controlsClassName: styles.kiroControls,
|
||||
controlClassName: styles.kiroControl,
|
||||
gridClassName: styles.kiroGrid,
|
||||
renderQuotaItems: renderKiroItems
|
||||
};
|
||||
|
||||
40
src/components/quota/useGridColumns.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to calculate the number of grid columns based on container width and item min-width.
|
||||
* Returns [columns, refCallback].
|
||||
*/
|
||||
export function useGridColumns(
|
||||
itemMinWidth: number,
|
||||
gap: number = 16
|
||||
): [number, (node: HTMLDivElement | null) => void] {
|
||||
const [columns, setColumns] = useState(1);
|
||||
const [element, setElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const refCallback = useCallback((node: HTMLDivElement | null) => {
|
||||
setElement(node);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) return;
|
||||
|
||||
const updateColumns = () => {
|
||||
const containerWidth = element.clientWidth;
|
||||
const effectiveItemWidth = itemMinWidth + gap;
|
||||
const count = Math.floor((containerWidth + gap) / effectiveItemWidth);
|
||||
setColumns(Math.max(1, count));
|
||||
};
|
||||
|
||||
updateColumns();
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateColumns();
|
||||
});
|
||||
|
||||
observer.observe(element);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [element, itemMinWidth, gap]);
|
||||
|
||||
return [columns, refCallback];
|
||||
}
|
||||
175
src/components/ui/AutocompleteInput.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
|
||||
import { IconChevronDown } from './icons';
|
||||
|
||||
interface AutocompleteInputProps {
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: string[] | { value: string; label?: string }[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
className?: string;
|
||||
wrapperClassName?: string;
|
||||
wrapperStyle?: React.CSSProperties;
|
||||
id?: string;
|
||||
rightElement?: ReactNode;
|
||||
}
|
||||
|
||||
export function AutocompleteInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
disabled,
|
||||
hint,
|
||||
error,
|
||||
className = '',
|
||||
wrapperClassName = '',
|
||||
wrapperStyle,
|
||||
id,
|
||||
rightElement
|
||||
}: AutocompleteInputProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const normalizedOptions = options.map(opt =>
|
||||
typeof opt === 'string' ? { value: opt, label: opt } : { value: opt.value, label: opt.label || opt.value }
|
||||
);
|
||||
|
||||
const filteredOptions = normalizedOptions.filter(opt => {
|
||||
const v = value.toLowerCase();
|
||||
return opt.value.toLowerCase().includes(v) || (opt.label && opt.label.toLowerCase().includes(v));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
setIsOpen(true);
|
||||
setHighlightedIndex(-1);
|
||||
};
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
onChange(selectedValue);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
setHighlightedIndex(prev =>
|
||||
prev < filteredOptions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(prev => prev > 0 ? prev - 1 : 0);
|
||||
} else if (e.key === 'Enter') {
|
||||
if (isOpen && highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
|
||||
e.preventDefault();
|
||||
handleSelect(filteredOptions[highlightedIndex].value);
|
||||
} else if (isOpen) {
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
} else if (e.key === 'Tab') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`form-group ${wrapperClassName}`} ref={containerRef} style={wrapperStyle}>
|
||||
{label && <label htmlFor={id}>{label}</label>}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
id={id}
|
||||
className={`input ${className}`.trim()}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
style={{ paddingRight: 32 }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
pointerEvents: disabled ? 'none' : 'auto',
|
||||
cursor: 'pointer',
|
||||
height: '100%'
|
||||
}}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
>
|
||||
{rightElement}
|
||||
<IconChevronDown size={16} style={{ opacity: 0.5, marginLeft: 4 }} />
|
||||
</div>
|
||||
|
||||
{isOpen && filteredOptions.length > 0 && !disabled && (
|
||||
<div className="autocomplete-dropdown" style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 4px)',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)'
|
||||
}}>
|
||||
{filteredOptions.map((opt, index) => (
|
||||
<div
|
||||
key={`${opt.value}-${index}`}
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: index === highlightedIndex ? 'var(--bg-tertiary)' : 'transparent',
|
||||
color: 'var(--text-primary)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
>
|
||||
<span style={{ fontWeight: 500 }}>{opt.value}</span>
|
||||
{opt.label && opt.label !== opt.value && (
|
||||
<span style={{ fontSize: '0.85em', color: 'var(--text-secondary)' }}>{opt.label}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hint && <div className="hint">{hint}</div>}
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export function Button({
|
||||
disabled,
|
||||
...rest
|
||||
}: PropsWithChildren<ButtonProps>) {
|
||||
const hasChildren = children !== null && children !== undefined && children !== false;
|
||||
const classes = [
|
||||
'btn',
|
||||
`btn-${variant}`,
|
||||
@@ -33,7 +34,7 @@ export function Button({
|
||||
return (
|
||||
<button className={classes} disabled={disabled || loading} {...rest}>
|
||||
{loading && <span className="loading-spinner" aria-hidden="true" />}
|
||||
<span>{children}</span>
|
||||
{hasChildren && <span>{children}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,20 @@ import type { PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
interface CardProps {
|
||||
title?: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
extra?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Card({ title, extra, children, className }: PropsWithChildren<CardProps>) {
|
||||
export function Card({ title, subtitle, extra, children, className }: PropsWithChildren<CardProps>) {
|
||||
return (
|
||||
<div className={className ? `card ${className}` : 'card'}>
|
||||
{(title || extra) && (
|
||||
<div className="card-header">
|
||||
<div className="title">{title}</div>
|
||||
<div className="card-title-group">
|
||||
<div className="title">{title}</div>
|
||||
{subtitle && <div className="subtitle">{subtitle}</div>}
|
||||
</div>
|
||||
{extra}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,17 +8,61 @@ interface ModalProps {
|
||||
onClose: () => void;
|
||||
footer?: ReactNode;
|
||||
width?: number | string;
|
||||
className?: string;
|
||||
closeDisabled?: boolean;
|
||||
}
|
||||
|
||||
const CLOSE_ANIMATION_DURATION = 350;
|
||||
const MODAL_LOCK_CLASS = 'modal-open';
|
||||
let activeModalCount = 0;
|
||||
|
||||
const scrollLockSnapshot = {
|
||||
scrollY: 0,
|
||||
contentScrollTop: 0,
|
||||
contentEl: null as HTMLElement | null,
|
||||
bodyPosition: '',
|
||||
bodyTop: '',
|
||||
bodyLeft: '',
|
||||
bodyRight: '',
|
||||
bodyWidth: '',
|
||||
bodyOverflow: '',
|
||||
htmlOverflow: '',
|
||||
};
|
||||
|
||||
const resolveContentScrollContainer = () => {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const contentEl = document.querySelector('.content');
|
||||
return contentEl instanceof HTMLElement ? contentEl : null;
|
||||
};
|
||||
|
||||
const lockScroll = () => {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (activeModalCount === 0) {
|
||||
document.body?.classList.add(MODAL_LOCK_CLASS);
|
||||
document.documentElement?.classList.add(MODAL_LOCK_CLASS);
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
const contentEl = resolveContentScrollContainer();
|
||||
|
||||
scrollLockSnapshot.scrollY = window.scrollY || window.pageYOffset || html.scrollTop || 0;
|
||||
scrollLockSnapshot.contentEl = contentEl;
|
||||
scrollLockSnapshot.contentScrollTop = contentEl?.scrollTop ?? 0;
|
||||
scrollLockSnapshot.bodyPosition = body.style.position;
|
||||
scrollLockSnapshot.bodyTop = body.style.top;
|
||||
scrollLockSnapshot.bodyLeft = body.style.left;
|
||||
scrollLockSnapshot.bodyRight = body.style.right;
|
||||
scrollLockSnapshot.bodyWidth = body.style.width;
|
||||
scrollLockSnapshot.bodyOverflow = body.style.overflow;
|
||||
scrollLockSnapshot.htmlOverflow = html.style.overflow;
|
||||
|
||||
body.classList.add(MODAL_LOCK_CLASS);
|
||||
html.classList.add(MODAL_LOCK_CLASS);
|
||||
|
||||
body.style.position = 'fixed';
|
||||
body.style.top = `-${scrollLockSnapshot.scrollY}px`;
|
||||
body.style.left = '0';
|
||||
body.style.right = '0';
|
||||
body.style.width = '100%';
|
||||
body.style.overflow = 'hidden';
|
||||
html.style.overflow = 'hidden';
|
||||
}
|
||||
activeModalCount += 1;
|
||||
};
|
||||
@@ -27,12 +71,44 @@ const unlockScroll = () => {
|
||||
if (typeof document === 'undefined') return;
|
||||
activeModalCount = Math.max(0, activeModalCount - 1);
|
||||
if (activeModalCount === 0) {
|
||||
document.body?.classList.remove(MODAL_LOCK_CLASS);
|
||||
document.documentElement?.classList.remove(MODAL_LOCK_CLASS);
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
const scrollY = scrollLockSnapshot.scrollY;
|
||||
const contentScrollTop = scrollLockSnapshot.contentScrollTop;
|
||||
const contentEl = scrollLockSnapshot.contentEl;
|
||||
|
||||
body.classList.remove(MODAL_LOCK_CLASS);
|
||||
html.classList.remove(MODAL_LOCK_CLASS);
|
||||
|
||||
body.style.position = scrollLockSnapshot.bodyPosition;
|
||||
body.style.top = scrollLockSnapshot.bodyTop;
|
||||
body.style.left = scrollLockSnapshot.bodyLeft;
|
||||
body.style.right = scrollLockSnapshot.bodyRight;
|
||||
body.style.width = scrollLockSnapshot.bodyWidth;
|
||||
body.style.overflow = scrollLockSnapshot.bodyOverflow;
|
||||
html.style.overflow = scrollLockSnapshot.htmlOverflow;
|
||||
|
||||
if (contentEl) {
|
||||
contentEl.scrollTo({ top: contentScrollTop, left: 0, behavior: 'auto' });
|
||||
}
|
||||
window.scrollTo({ top: scrollY, left: 0, behavior: 'auto' });
|
||||
|
||||
scrollLockSnapshot.scrollY = 0;
|
||||
scrollLockSnapshot.contentScrollTop = 0;
|
||||
scrollLockSnapshot.contentEl = null;
|
||||
}
|
||||
};
|
||||
|
||||
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
footer,
|
||||
width = 520,
|
||||
className,
|
||||
closeDisabled = false,
|
||||
children
|
||||
}: PropsWithChildren<ModalProps>) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -54,19 +130,28 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (open) {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
setIsVisible(true);
|
||||
setIsClosing(false);
|
||||
return;
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
setIsVisible(true);
|
||||
setIsClosing(false);
|
||||
});
|
||||
} else if (isVisible) {
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
startClose(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
startClose(false);
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, isVisible, startClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
@@ -92,12 +177,18 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
|
||||
if (!open && !isVisible) return null;
|
||||
|
||||
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
|
||||
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
|
||||
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}${className ? ` ${className}` : ''}`;
|
||||
|
||||
const modalContent = (
|
||||
<div className={overlayClass}>
|
||||
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
|
||||
<button className="modal-close-floating" onClick={handleClose} aria-label="Close">
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-floating"
|
||||
onClick={closeDisabled ? undefined : handleClose}
|
||||
aria-label="Close"
|
||||
disabled={closeDisabled}
|
||||
>
|
||||
<IconX size={20} />
|
||||
</button>
|
||||
<div className="modal-header">
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Button } from './Button';
|
||||
import { IconX } from './icons';
|
||||
import type { ModelAlias } from '@/types';
|
||||
|
||||
interface ModelEntry {
|
||||
name: string;
|
||||
alias: string;
|
||||
}
|
||||
import type { ModelEntry } from './modelInputListUtils';
|
||||
|
||||
interface ModelInputListProps {
|
||||
entries: ModelEntry[];
|
||||
@@ -17,29 +12,6 @@ interface ModelInputListProps {
|
||||
aliasPlaceholder?: string;
|
||||
}
|
||||
|
||||
export const modelsToEntries = (models?: ModelAlias[]): ModelEntry[] => {
|
||||
if (!Array.isArray(models) || models.length === 0) {
|
||||
return [{ name: '', alias: '' }];
|
||||
}
|
||||
return models.map((m) => ({
|
||||
name: m.name || '',
|
||||
alias: m.alias || ''
|
||||
}));
|
||||
};
|
||||
|
||||
export const entriesToModels = (entries: ModelEntry[]): ModelAlias[] => {
|
||||
return entries
|
||||
.filter((entry) => entry.name.trim())
|
||||
.map((entry) => {
|
||||
const model: ModelAlias = { name: entry.name.trim() };
|
||||
const alias = entry.alias.trim();
|
||||
if (alias && alias !== model.name) {
|
||||
model.alias = alias;
|
||||
}
|
||||
return model;
|
||||
});
|
||||
};
|
||||
|
||||
export function ModelInputList({
|
||||
entries,
|
||||
onChange,
|
||||
|
||||
58
src/components/ui/ToggleSwitch.module.scss
Normal file
@@ -0,0 +1,58 @@
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.labelLeft {
|
||||
.label {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
|
||||
.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.root input {
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.track {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: var(--border-color);
|
||||
border-radius: $radius-full;
|
||||
position: relative;
|
||||
transition: background $transition-fast;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
border-radius: $radius-full;
|
||||
box-shadow: $shadow-sm;
|
||||
transition: transform $transition-fast;
|
||||
}
|
||||
|
||||
.root input:checked + .track {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.root input:checked + .track .thumb {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ChangeEvent, ReactNode } from 'react';
|
||||
import styles from './ToggleSwitch.module.scss';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label?: ReactNode;
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
labelPosition?: 'left' | 'right';
|
||||
}
|
||||
@@ -12,6 +14,7 @@ export function ToggleSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
labelPosition = 'right'
|
||||
}: ToggleSwitchProps) {
|
||||
@@ -19,17 +22,27 @@ export function ToggleSwitch({
|
||||
onChange(event.target.checked);
|
||||
};
|
||||
|
||||
const className = ['switch', labelPosition === 'left' ? 'switch-label-left' : '']
|
||||
const className = [
|
||||
styles.root,
|
||||
labelPosition === 'left' ? styles.labelLeft : '',
|
||||
disabled ? styles.disabled : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<label className={className}>
|
||||
<input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} />
|
||||
<span className="track">
|
||||
<span className="thumb" />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
<span className={styles.track}>
|
||||
<span className={styles.thumb} />
|
||||
</span>
|
||||
{label && <span className="label">{label}</span>}
|
||||
{label && <span className={styles.label}>{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,6 +164,14 @@ export function IconChevronDown({ size = 20, ...props }: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevronLeft({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSearch({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
@@ -314,3 +322,11 @@ export function IconLayoutDashboard({ size = 20, ...props }: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconActivity({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
29
src/components/ui/modelInputListUtils.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { ModelAlias } from '@/types';
|
||||
|
||||
export interface ModelEntry {
|
||||
name: string;
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export const modelsToEntries = (models?: ModelAlias[]): ModelEntry[] => {
|
||||
if (!Array.isArray(models) || models.length === 0) {
|
||||
return [{ name: '', alias: '' }];
|
||||
}
|
||||
return models.map((model) => ({
|
||||
name: model.name || '',
|
||||
alias: model.alias || ''
|
||||
}));
|
||||
};
|
||||
|
||||
export const entriesToModels = (entries: ModelEntry[]): ModelAlias[] => {
|
||||
return entries
|
||||
.filter((entry) => entry.name.trim())
|
||||
.map((entry) => {
|
||||
const model: ModelAlias = { name: entry.name.trim() };
|
||||
const alias = entry.alias.trim();
|
||||
if (alias && alias !== model.name) {
|
||||
model.alias = alias;
|
||||
}
|
||||
return model;
|
||||
});
|
||||
};
|
||||
@@ -39,10 +39,18 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
|
||||
<span className={styles.apiEndpoint}>{api.endpoint}</span>
|
||||
<div className={styles.apiStats}>
|
||||
<span className={styles.apiBadge}>
|
||||
{t('usage_stats.requests_count')}: {api.totalRequests}
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>
|
||||
{t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()}
|
||||
</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{api.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{api.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.apiBadge}>
|
||||
Tokens: {formatTokensInMillions(api.totalTokens)}
|
||||
{t('usage_stats.tokens_count')}: {formatTokensInMillions(api.totalTokens)}
|
||||
</span>
|
||||
{hasPrices && api.totalCost > 0 && (
|
||||
<span className={styles.apiBadge}>
|
||||
@@ -61,7 +69,13 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
|
||||
<div key={model} className={styles.modelRow}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
<span className={styles.modelStat}>
|
||||
{stats.requests} {t('usage_stats.requests_count')}
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{stats.requests.toLocaleString()}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{stats.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{stats.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,8 @@ import styles from '@/pages/UsagePage.module.scss';
|
||||
export interface ModelStat {
|
||||
model: string;
|
||||
requests: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}
|
||||
@@ -38,7 +40,15 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
|
||||
{modelStats.map((stat) => (
|
||||
<tr key={stat.model}>
|
||||
<td className={styles.modelCell}>{stat.model}</td>
|
||||
<td>{stat.requests.toLocaleString()}</td>
|
||||
<td>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{stat.requests.toLocaleString()}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatTokensInMillions(stat.tokens)}</td>
|
||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||
</tr>
|
||||
|
||||
@@ -45,8 +45,8 @@ export function useUsageData(): UseUsageDataReturn {
|
||||
setError('');
|
||||
try {
|
||||
const data = await usageApi.getUsage();
|
||||
const payload = data?.usage ?? data;
|
||||
setUsage(payload);
|
||||
const payload = (data?.usage ?? data) as unknown;
|
||||
setUsage(payload && typeof payload === 'object' ? (payload as UsagePayload) : null);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
|
||||
setError(message);
|
||||
|
||||
@@ -9,3 +9,5 @@ export { useInterval } from './useInterval';
|
||||
export { useMediaQuery } from './useMediaQuery';
|
||||
export { usePagination } from './usePagination';
|
||||
export { useHeaderRefresh } from './useHeaderRefresh';
|
||||
export { useDisableModel } from './useDisableModel';
|
||||
export type { UseDisableModelOptions, UseDisableModelReturn } from './useDisableModel';
|
||||
|
||||
@@ -13,7 +13,7 @@ interface UseApiOptions<T> {
|
||||
successMessage?: string;
|
||||
}
|
||||
|
||||
export function useApi<T = any, Args extends any[] = any[]>(
|
||||
export function useApi<T = unknown, Args extends unknown[] = unknown[]>(
|
||||
apiFunction: (...args: Args) => Promise<T>,
|
||||
options: UseApiOptions<T> = {}
|
||||
) {
|
||||
@@ -38,8 +38,9 @@ export function useApi<T = any, Args extends any[] = any[]>(
|
||||
|
||||
options.onSuccess?.(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorObj = err as Error;
|
||||
} catch (err: unknown) {
|
||||
const errorObj =
|
||||
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error');
|
||||
setError(errorObj);
|
||||
|
||||
if (options.showErrorNotification !== false) {
|
||||
|
||||
199
src/hooks/useDisableModel.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 禁用模型 Hook
|
||||
* 封装禁用模型的状态管理和业务逻辑
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { providersApi } from '@/services/api';
|
||||
import { useDisabledModelsStore } from '@/stores';
|
||||
import {
|
||||
resolveProvider,
|
||||
createDisableState,
|
||||
type DisableState,
|
||||
} from '@/utils/monitor';
|
||||
import type { OpenAIProviderConfig } from '@/types';
|
||||
|
||||
// 不支持禁用的渠道类型(小写)
|
||||
const UNSUPPORTED_PROVIDER_TYPES = ['claude', 'gemini', 'codex', 'vertex'];
|
||||
|
||||
/**
|
||||
* 不支持禁用的提示状态
|
||||
*/
|
||||
export interface UnsupportedDisableState {
|
||||
providerType: string;
|
||||
model: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface UseDisableModelOptions {
|
||||
providerMap: Record<string, string>;
|
||||
providerTypeMap?: Record<string, string>;
|
||||
providerModels?: Record<string, Set<string>>;
|
||||
}
|
||||
|
||||
export interface UseDisableModelReturn {
|
||||
/** 当前禁用状态 */
|
||||
disableState: DisableState | null;
|
||||
/** 不支持禁用的提示状态 */
|
||||
unsupportedState: UnsupportedDisableState | null;
|
||||
/** 是否正在禁用中 */
|
||||
disabling: boolean;
|
||||
/** 开始禁用流程 */
|
||||
handleDisableClick: (source: string, model: string) => void;
|
||||
/** 确认禁用(需要点击3次) */
|
||||
handleConfirmDisable: () => Promise<void>;
|
||||
/** 取消禁用 */
|
||||
handleCancelDisable: () => void;
|
||||
/** 关闭不支持提示 */
|
||||
handleCloseUnsupported: () => void;
|
||||
/** 检查模型是否已禁用 */
|
||||
isModelDisabled: (source: string, model: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用模型 Hook
|
||||
* @param options 配置选项
|
||||
* @returns 禁用模型相关的状态和方法
|
||||
*/
|
||||
export function useDisableModel(options: UseDisableModelOptions): UseDisableModelReturn {
|
||||
const { providerMap, providerTypeMap, providerModels } = options;
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 使用全局 store 管理禁用状态
|
||||
const { addDisabledModel, isDisabled } = useDisabledModelsStore();
|
||||
|
||||
const [disableState, setDisableState] = useState<DisableState | null>(null);
|
||||
const [unsupportedState, setUnsupportedState] = useState<UnsupportedDisableState | null>(null);
|
||||
const [disabling, setDisabling] = useState(false);
|
||||
|
||||
// 开始禁用流程
|
||||
const handleDisableClick = useCallback((source: string, model: string) => {
|
||||
// 首先检查提供商类型是否支持禁用
|
||||
const providerType = providerTypeMap?.[source] || '';
|
||||
const lowerType = providerType.toLowerCase();
|
||||
|
||||
// 如果是不支持的类型,立即显示提示
|
||||
if (lowerType && UNSUPPORTED_PROVIDER_TYPES.includes(lowerType)) {
|
||||
const providerName = resolveProvider(source, providerMap);
|
||||
const displayName = providerName
|
||||
? `${providerName} / ${model}`
|
||||
: `${source.slice(0, 8)}*** / ${model}`;
|
||||
setUnsupportedState({
|
||||
providerType,
|
||||
model,
|
||||
displayName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 支持的类型,进入正常禁用流程
|
||||
setDisableState(createDisableState(source, model, providerMap));
|
||||
}, [providerMap, providerTypeMap]);
|
||||
|
||||
// 确认禁用(需要点击3次)
|
||||
const handleConfirmDisable = useCallback(async () => {
|
||||
if (!disableState) return;
|
||||
|
||||
// 前两次点击只增加步骤
|
||||
if (disableState.step < 3) {
|
||||
setDisableState({
|
||||
...disableState,
|
||||
step: disableState.step + 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 第3次点击,执行禁用
|
||||
setDisabling(true);
|
||||
try {
|
||||
const providerName = resolveProvider(disableState.source, providerMap);
|
||||
if (!providerName) {
|
||||
throw new Error(t('monitor.logs.disable_error_no_provider'));
|
||||
}
|
||||
|
||||
// 获取当前配置
|
||||
const providers = await providersApi.getOpenAIProviders();
|
||||
const targetProvider = providers.find(
|
||||
(p) => p.name && p.name.toLowerCase() === providerName.toLowerCase()
|
||||
);
|
||||
|
||||
if (!targetProvider) {
|
||||
throw new Error(t('monitor.logs.disable_error_provider_not_found', { provider: providerName }));
|
||||
}
|
||||
|
||||
const originalModels = targetProvider.models || [];
|
||||
const modelAlias = disableState.model;
|
||||
|
||||
// 过滤掉匹配的模型
|
||||
const filteredModels = originalModels.filter(
|
||||
(m) => m.alias !== modelAlias && m.name !== modelAlias
|
||||
);
|
||||
|
||||
// 只有当模型确实被过滤掉时才调用 API
|
||||
if (filteredModels.length < originalModels.length) {
|
||||
await providersApi.patchOpenAIProviderByName(targetProvider.name, {
|
||||
models: filteredModels,
|
||||
} as Partial<OpenAIProviderConfig>);
|
||||
}
|
||||
|
||||
// 标记为已禁用(全局状态)
|
||||
addDisabledModel(disableState.source, disableState.model);
|
||||
setDisableState(null);
|
||||
} catch (err) {
|
||||
console.error('禁用模型失败:', err);
|
||||
alert(err instanceof Error ? err.message : t('monitor.logs.disable_error'));
|
||||
} finally {
|
||||
setDisabling(false);
|
||||
}
|
||||
}, [disableState, providerMap, t, addDisabledModel]);
|
||||
|
||||
// 取消禁用
|
||||
const handleCancelDisable = useCallback(() => {
|
||||
setDisableState(null);
|
||||
}, []);
|
||||
|
||||
// 关闭不支持提示
|
||||
const handleCloseUnsupported = useCallback(() => {
|
||||
setUnsupportedState(null);
|
||||
}, []);
|
||||
|
||||
// 检查模型是否已禁用
|
||||
const isModelDisabled = useCallback((source: string, model: string): boolean => {
|
||||
// 首先检查全局状态中是否已禁用
|
||||
if (isDisabled(source, model)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果提供了 providerModels,检查配置中是否已移除
|
||||
if (providerModels) {
|
||||
if (!source || !model) return false;
|
||||
|
||||
// 首先尝试完全匹配
|
||||
if (providerModels[source]) {
|
||||
return !providerModels[source].has(model);
|
||||
}
|
||||
|
||||
// 然后尝试前缀匹配
|
||||
const entries = Object.entries(providerModels);
|
||||
for (const [key, modelSet] of entries) {
|
||||
if (source.startsWith(key) || key.startsWith(source)) {
|
||||
return !modelSet.has(model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [isDisabled, providerModels]);
|
||||
|
||||
return {
|
||||
disableState,
|
||||
unsupportedState,
|
||||
disabling,
|
||||
handleDisableClick,
|
||||
handleConfirmDisable,
|
||||
handleCancelDisable,
|
||||
handleCloseUnsupported,
|
||||
isModelDisabled,
|
||||
};
|
||||
}
|
||||
103
src/hooks/useEdgeSwipeBack.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
type SwipeBackOptions = {
|
||||
enabled?: boolean;
|
||||
edgeSize?: number;
|
||||
threshold?: number;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
type ActiveGesture = {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_EDGE_SIZE = 28;
|
||||
const DEFAULT_THRESHOLD = 90;
|
||||
const VERTICAL_TOLERANCE_RATIO = 1.2;
|
||||
|
||||
export function useEdgeSwipeBack({
|
||||
enabled = true,
|
||||
edgeSize = DEFAULT_EDGE_SIZE,
|
||||
threshold = DEFAULT_THRESHOLD,
|
||||
onBack,
|
||||
}: SwipeBackOptions) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const gestureRef = useRef<ActiveGesture | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const reset = () => {
|
||||
gestureRef.current = null;
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture?.active) return;
|
||||
if (event.pointerId !== gesture.pointerId) return;
|
||||
|
||||
const dx = event.clientX - gesture.startX;
|
||||
const dy = event.clientY - gesture.startY;
|
||||
|
||||
if (Math.abs(dy) > Math.abs(dx) * VERTICAL_TOLERANCE_RATIO) {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture?.active) return;
|
||||
if (event.pointerId !== gesture.pointerId) return;
|
||||
|
||||
const dx = event.clientX - gesture.startX;
|
||||
const dy = event.clientY - gesture.startY;
|
||||
const isHorizontal = Math.abs(dx) > Math.abs(dy) * VERTICAL_TOLERANCE_RATIO;
|
||||
|
||||
reset();
|
||||
|
||||
if (dx >= threshold && isHorizontal) {
|
||||
onBack();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerCancel = (event: PointerEvent) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture?.active) return;
|
||||
if (event.pointerId !== gesture.pointerId) return;
|
||||
reset();
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (event.pointerType !== 'touch') return;
|
||||
if (!event.isPrimary) return;
|
||||
if (event.clientX > edgeSize) return;
|
||||
|
||||
gestureRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
active: true,
|
||||
};
|
||||
};
|
||||
|
||||
el.addEventListener('pointerdown', handlePointerDown, { passive: true });
|
||||
window.addEventListener('pointermove', handlePointerMove, { passive: true });
|
||||
window.addEventListener('pointerup', handlePointerUp, { passive: true });
|
||||
window.addEventListener('pointercancel', handlePointerCancel, { passive: true });
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('pointerdown', handlePointerDown);
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointercancel', handlePointerCancel);
|
||||
};
|
||||
}, [edgeSize, enabled, onBack, threshold]);
|
||||
|
||||
return containerRef;
|
||||
}
|
||||
|
||||
515
src/hooks/useVisualConfig.ts
Normal file
@@ -0,0 +1,515 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import type {
|
||||
PayloadFilterRule,
|
||||
PayloadParamValueType,
|
||||
PayloadRule,
|
||||
VisualConfigValues,
|
||||
} from '@/types/visualConfig';
|
||||
import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig';
|
||||
|
||||
function hasOwn(obj: unknown, key: string): obj is Record<string, unknown> {
|
||||
return obj !== null && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function extractApiKeyValue(raw: unknown): string | null {
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
const record = asRecord(raw);
|
||||
if (!record) return null;
|
||||
|
||||
const candidates = [record['api-key'], record.apiKey, record.key, record.Key];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string') {
|
||||
const trimmed = candidate.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseApiKeysText(raw: unknown): string {
|
||||
if (!Array.isArray(raw)) return '';
|
||||
|
||||
const keys: string[] = [];
|
||||
for (const item of raw) {
|
||||
const key = extractApiKeyValue(item);
|
||||
if (key) keys.push(key);
|
||||
}
|
||||
return keys.join('\n');
|
||||
}
|
||||
|
||||
function ensureRecord(parent: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const existing = asRecord(parent[key]);
|
||||
if (existing) return existing;
|
||||
const next: Record<string, unknown> = {};
|
||||
parent[key] = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
function deleteIfEmpty(parent: Record<string, unknown>, key: string): void {
|
||||
const value = asRecord(parent[key]);
|
||||
if (!value) return;
|
||||
if (Object.keys(value).length === 0) delete parent[key];
|
||||
}
|
||||
|
||||
function setBoolean(obj: Record<string, unknown>, key: string, value: boolean): void {
|
||||
if (value) {
|
||||
obj[key] = true;
|
||||
return;
|
||||
}
|
||||
if (hasOwn(obj, key)) obj[key] = false;
|
||||
}
|
||||
|
||||
function setString(obj: Record<string, unknown>, key: string, value: unknown): void {
|
||||
const safe = typeof value === 'string' ? value : '';
|
||||
const trimmed = safe.trim();
|
||||
if (trimmed !== '') {
|
||||
obj[key] = safe;
|
||||
return;
|
||||
}
|
||||
if (hasOwn(obj, key)) delete obj[key];
|
||||
}
|
||||
|
||||
function setIntFromString(obj: Record<string, unknown>, key: string, value: unknown): void {
|
||||
const safe = typeof value === 'string' ? value : '';
|
||||
const trimmed = safe.trim();
|
||||
if (trimmed === '') {
|
||||
if (hasOwn(obj, key)) delete obj[key];
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
obj[key] = parsed;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasOwn(obj, key)) delete obj[key];
|
||||
}
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
if (typeof structuredClone === 'function') return structuredClone(value);
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function parsePayloadParamValue(raw: unknown): { valueType: PayloadParamValueType; value: string } {
|
||||
if (typeof raw === 'number') {
|
||||
return { valueType: 'number', value: String(raw) };
|
||||
}
|
||||
|
||||
if (typeof raw === 'boolean') {
|
||||
return { valueType: 'boolean', value: String(raw) };
|
||||
}
|
||||
|
||||
if (raw === null || typeof raw === 'object') {
|
||||
try {
|
||||
const json = JSON.stringify(raw, null, 2);
|
||||
return { valueType: 'json', value: json ?? 'null' };
|
||||
} catch {
|
||||
return { valueType: 'json', value: String(raw) };
|
||||
}
|
||||
}
|
||||
|
||||
return { valueType: 'string', value: String(raw ?? '') };
|
||||
}
|
||||
|
||||
const PAYLOAD_PROTOCOL_VALUES = [
|
||||
'openai',
|
||||
'openai-response',
|
||||
'gemini',
|
||||
'claude',
|
||||
'codex',
|
||||
'antigravity',
|
||||
] as const;
|
||||
type PayloadProtocol = (typeof PAYLOAD_PROTOCOL_VALUES)[number];
|
||||
|
||||
function parsePayloadProtocol(raw: unknown): PayloadProtocol | undefined {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
return PAYLOAD_PROTOCOL_VALUES.includes(raw as PayloadProtocol)
|
||||
? (raw as PayloadProtocol)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parsePayloadRules(rules: unknown): PayloadRule[] {
|
||||
if (!Array.isArray(rules)) return [];
|
||||
|
||||
return rules.map((rule, index) => {
|
||||
const record = asRecord(rule) ?? {};
|
||||
|
||||
const modelsRaw = record.models;
|
||||
const models = Array.isArray(modelsRaw)
|
||||
? modelsRaw.map((model, modelIndex) => {
|
||||
const modelRecord = asRecord(model);
|
||||
const nameRaw =
|
||||
typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
|
||||
const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
|
||||
return {
|
||||
id: `model-${index}-${modelIndex}`,
|
||||
name,
|
||||
protocol: parsePayloadProtocol(modelRecord?.protocol),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const paramsRecord = asRecord(record.params);
|
||||
const params = paramsRecord
|
||||
? Object.entries(paramsRecord).map(([path, value], pIndex) => {
|
||||
const parsedValue = parsePayloadParamValue(value);
|
||||
return {
|
||||
id: `param-${index}-${pIndex}`,
|
||||
path,
|
||||
valueType: parsedValue.valueType,
|
||||
value: parsedValue.value,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return { id: `payload-rule-${index}`, models, params };
|
||||
});
|
||||
}
|
||||
|
||||
function parsePayloadFilterRules(rules: unknown): PayloadFilterRule[] {
|
||||
if (!Array.isArray(rules)) return [];
|
||||
|
||||
return rules.map((rule, index) => {
|
||||
const record = asRecord(rule) ?? {};
|
||||
|
||||
const modelsRaw = record.models;
|
||||
const models = Array.isArray(modelsRaw)
|
||||
? modelsRaw.map((model, modelIndex) => {
|
||||
const modelRecord = asRecord(model);
|
||||
const nameRaw =
|
||||
typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
|
||||
const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
|
||||
return {
|
||||
id: `filter-model-${index}-${modelIndex}`,
|
||||
name,
|
||||
protocol: parsePayloadProtocol(modelRecord?.protocol),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const paramsRaw = record.params;
|
||||
const params = Array.isArray(paramsRaw) ? paramsRaw.map(String) : [];
|
||||
|
||||
return { id: `payload-filter-rule-${index}`, models, params };
|
||||
});
|
||||
}
|
||||
|
||||
function serializePayloadRulesForYaml(rules: PayloadRule[]): Array<Record<string, unknown>> {
|
||||
return rules
|
||||
.map((rule) => {
|
||||
const models = (rule.models || [])
|
||||
.filter((m) => m.name?.trim())
|
||||
.map((m) => {
|
||||
const obj: Record<string, unknown> = { name: m.name.trim() };
|
||||
if (m.protocol) obj.protocol = m.protocol;
|
||||
return obj;
|
||||
});
|
||||
|
||||
const params: Record<string, unknown> = {};
|
||||
for (const param of rule.params || []) {
|
||||
if (!param.path?.trim()) continue;
|
||||
let value: unknown = param.value;
|
||||
if (param.valueType === 'number') {
|
||||
const num = Number(param.value);
|
||||
value = Number.isFinite(num) ? num : param.value;
|
||||
} else if (param.valueType === 'boolean') {
|
||||
value = param.value === 'true';
|
||||
} else if (param.valueType === 'json') {
|
||||
try {
|
||||
value = JSON.parse(param.value);
|
||||
} catch {
|
||||
value = param.value;
|
||||
}
|
||||
}
|
||||
params[param.path.trim()] = value;
|
||||
}
|
||||
|
||||
return { models, params };
|
||||
})
|
||||
.filter((rule) => rule.models.length > 0);
|
||||
}
|
||||
|
||||
function serializePayloadFilterRulesForYaml(
|
||||
rules: PayloadFilterRule[]
|
||||
): Array<Record<string, unknown>> {
|
||||
return rules
|
||||
.map((rule) => {
|
||||
const models = (rule.models || [])
|
||||
.filter((m) => m.name?.trim())
|
||||
.map((m) => {
|
||||
const obj: Record<string, unknown> = { name: m.name.trim() };
|
||||
if (m.protocol) obj.protocol = m.protocol;
|
||||
return obj;
|
||||
});
|
||||
|
||||
const params = (Array.isArray(rule.params) ? rule.params : [])
|
||||
.map((path) => String(path).trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return { models, params };
|
||||
})
|
||||
.filter((rule) => rule.models.length > 0);
|
||||
}
|
||||
|
||||
export function useVisualConfig() {
|
||||
const [visualValues, setVisualValuesState] = useState<VisualConfigValues>({
|
||||
...DEFAULT_VISUAL_VALUES,
|
||||
});
|
||||
|
||||
const [baselineValues, setBaselineValues] = useState<VisualConfigValues>({
|
||||
...DEFAULT_VISUAL_VALUES,
|
||||
});
|
||||
|
||||
const visualDirty = useMemo(() => {
|
||||
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues);
|
||||
}, [baselineValues, visualValues]);
|
||||
|
||||
const loadVisualValuesFromYaml = useCallback((yamlContent: string) => {
|
||||
try {
|
||||
const parsedRaw: unknown = parseYaml(yamlContent) || {};
|
||||
const parsed = asRecord(parsedRaw) ?? {};
|
||||
const tls = asRecord(parsed.tls);
|
||||
const remoteManagement = asRecord(parsed['remote-management']);
|
||||
const quotaExceeded = asRecord(parsed['quota-exceeded']);
|
||||
const routing = asRecord(parsed.routing);
|
||||
const payload = asRecord(parsed.payload);
|
||||
const streaming = asRecord(parsed.streaming);
|
||||
|
||||
const newValues: VisualConfigValues = {
|
||||
host: typeof parsed.host === 'string' ? parsed.host : '',
|
||||
port: String(parsed.port ?? ''),
|
||||
|
||||
tlsEnable: Boolean(tls?.enable),
|
||||
tlsCert: typeof tls?.cert === 'string' ? tls.cert : '',
|
||||
tlsKey: typeof tls?.key === 'string' ? tls.key : '',
|
||||
|
||||
rmAllowRemote: Boolean(remoteManagement?.['allow-remote']),
|
||||
rmSecretKey:
|
||||
typeof remoteManagement?.['secret-key'] === 'string' ? remoteManagement['secret-key'] : '',
|
||||
rmDisableControlPanel: Boolean(remoteManagement?.['disable-control-panel']),
|
||||
rmPanelRepo:
|
||||
typeof remoteManagement?.['panel-github-repository'] === 'string'
|
||||
? remoteManagement['panel-github-repository']
|
||||
: typeof remoteManagement?.['panel-repo'] === 'string'
|
||||
? remoteManagement['panel-repo']
|
||||
: '',
|
||||
|
||||
authDir: typeof parsed['auth-dir'] === 'string' ? parsed['auth-dir'] : '',
|
||||
apiKeysText: parseApiKeysText(parsed['api-keys']),
|
||||
|
||||
debug: Boolean(parsed.debug),
|
||||
commercialMode: Boolean(parsed['commercial-mode']),
|
||||
loggingToFile: Boolean(parsed['logging-to-file']),
|
||||
logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] ?? ''),
|
||||
usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']),
|
||||
|
||||
proxyUrl: typeof parsed['proxy-url'] === 'string' ? parsed['proxy-url'] : '',
|
||||
forceModelPrefix: Boolean(parsed['force-model-prefix']),
|
||||
requestRetry: String(parsed['request-retry'] ?? ''),
|
||||
maxRetryInterval: String(parsed['max-retry-interval'] ?? ''),
|
||||
wsAuth: Boolean(parsed['ws-auth']),
|
||||
|
||||
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
|
||||
quotaSwitchPreviewModel: Boolean(
|
||||
quotaExceeded?.['switch-preview-model'] ?? true
|
||||
),
|
||||
|
||||
routingStrategy:
|
||||
routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
|
||||
|
||||
payloadDefaultRules: parsePayloadRules(payload?.default),
|
||||
payloadOverrideRules: parsePayloadRules(payload?.override),
|
||||
payloadFilterRules: parsePayloadFilterRules(payload?.filter),
|
||||
|
||||
streaming: {
|
||||
keepaliveSeconds: String(streaming?.['keepalive-seconds'] ?? ''),
|
||||
bootstrapRetries: String(streaming?.['bootstrap-retries'] ?? ''),
|
||||
nonstreamKeepaliveInterval: String(parsed['nonstream-keepalive-interval'] ?? ''),
|
||||
},
|
||||
};
|
||||
|
||||
setVisualValuesState(newValues);
|
||||
setBaselineValues(deepClone(newValues));
|
||||
} catch {
|
||||
setVisualValuesState({ ...DEFAULT_VISUAL_VALUES });
|
||||
setBaselineValues(deepClone(DEFAULT_VISUAL_VALUES));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyVisualChangesToYaml = useCallback(
|
||||
(currentYaml: string): string => {
|
||||
try {
|
||||
const parsed = (parseYaml(currentYaml) || {}) as Record<string, unknown>;
|
||||
const values = visualValues;
|
||||
|
||||
setString(parsed, 'host', values.host);
|
||||
setIntFromString(parsed, 'port', values.port);
|
||||
|
||||
if (
|
||||
hasOwn(parsed, 'tls') ||
|
||||
values.tlsEnable ||
|
||||
values.tlsCert.trim() ||
|
||||
values.tlsKey.trim()
|
||||
) {
|
||||
const tls = ensureRecord(parsed, 'tls');
|
||||
setBoolean(tls, 'enable', values.tlsEnable);
|
||||
setString(tls, 'cert', values.tlsCert);
|
||||
setString(tls, 'key', values.tlsKey);
|
||||
deleteIfEmpty(parsed, 'tls');
|
||||
}
|
||||
|
||||
if (
|
||||
hasOwn(parsed, 'remote-management') ||
|
||||
values.rmAllowRemote ||
|
||||
values.rmSecretKey.trim() ||
|
||||
values.rmDisableControlPanel ||
|
||||
values.rmPanelRepo.trim()
|
||||
) {
|
||||
const rm = ensureRecord(parsed, 'remote-management');
|
||||
setBoolean(rm, 'allow-remote', values.rmAllowRemote);
|
||||
setString(rm, 'secret-key', values.rmSecretKey);
|
||||
setBoolean(rm, 'disable-control-panel', values.rmDisableControlPanel);
|
||||
setString(rm, 'panel-github-repository', values.rmPanelRepo);
|
||||
if (hasOwn(rm, 'panel-repo')) delete rm['panel-repo'];
|
||||
deleteIfEmpty(parsed, 'remote-management');
|
||||
}
|
||||
|
||||
setString(parsed, 'auth-dir', values.authDir);
|
||||
if (values.apiKeysText !== baselineValues.apiKeysText) {
|
||||
const apiKeys = values.apiKeysText
|
||||
.split('\n')
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean);
|
||||
if (apiKeys.length > 0) {
|
||||
parsed['api-keys'] = apiKeys;
|
||||
} else if (hasOwn(parsed, 'api-keys')) {
|
||||
delete parsed['api-keys'];
|
||||
}
|
||||
}
|
||||
|
||||
setBoolean(parsed, 'debug', values.debug);
|
||||
|
||||
setBoolean(parsed, 'commercial-mode', values.commercialMode);
|
||||
setBoolean(parsed, 'logging-to-file', values.loggingToFile);
|
||||
setIntFromString(parsed, 'logs-max-total-size-mb', values.logsMaxTotalSizeMb);
|
||||
setBoolean(parsed, 'usage-statistics-enabled', values.usageStatisticsEnabled);
|
||||
|
||||
setString(parsed, 'proxy-url', values.proxyUrl);
|
||||
setBoolean(parsed, 'force-model-prefix', values.forceModelPrefix);
|
||||
setIntFromString(parsed, 'request-retry', values.requestRetry);
|
||||
setIntFromString(parsed, 'max-retry-interval', values.maxRetryInterval);
|
||||
setBoolean(parsed, 'ws-auth', values.wsAuth);
|
||||
|
||||
if (hasOwn(parsed, 'quota-exceeded') || !values.quotaSwitchProject || !values.quotaSwitchPreviewModel) {
|
||||
const quota = ensureRecord(parsed, 'quota-exceeded');
|
||||
quota['switch-project'] = values.quotaSwitchProject;
|
||||
quota['switch-preview-model'] = values.quotaSwitchPreviewModel;
|
||||
deleteIfEmpty(parsed, 'quota-exceeded');
|
||||
}
|
||||
|
||||
if (hasOwn(parsed, 'routing') || values.routingStrategy !== 'round-robin') {
|
||||
const routing = ensureRecord(parsed, 'routing');
|
||||
routing.strategy = values.routingStrategy;
|
||||
deleteIfEmpty(parsed, 'routing');
|
||||
}
|
||||
|
||||
const keepaliveSeconds =
|
||||
typeof values.streaming?.keepaliveSeconds === 'string' ? values.streaming.keepaliveSeconds : '';
|
||||
const bootstrapRetries =
|
||||
typeof values.streaming?.bootstrapRetries === 'string' ? values.streaming.bootstrapRetries : '';
|
||||
const nonstreamKeepaliveInterval =
|
||||
typeof values.streaming?.nonstreamKeepaliveInterval === 'string'
|
||||
? values.streaming.nonstreamKeepaliveInterval
|
||||
: '';
|
||||
|
||||
const streamingDefined =
|
||||
hasOwn(parsed, 'streaming') || keepaliveSeconds.trim() || bootstrapRetries.trim();
|
||||
if (streamingDefined) {
|
||||
const streaming = ensureRecord(parsed, 'streaming');
|
||||
setIntFromString(streaming, 'keepalive-seconds', keepaliveSeconds);
|
||||
setIntFromString(streaming, 'bootstrap-retries', bootstrapRetries);
|
||||
deleteIfEmpty(parsed, 'streaming');
|
||||
}
|
||||
|
||||
setIntFromString(parsed, 'nonstream-keepalive-interval', nonstreamKeepaliveInterval);
|
||||
|
||||
if (
|
||||
hasOwn(parsed, 'payload') ||
|
||||
values.payloadDefaultRules.length > 0 ||
|
||||
values.payloadOverrideRules.length > 0 ||
|
||||
values.payloadFilterRules.length > 0
|
||||
) {
|
||||
const payload = ensureRecord(parsed, 'payload');
|
||||
if (values.payloadDefaultRules.length > 0) {
|
||||
payload.default = serializePayloadRulesForYaml(values.payloadDefaultRules);
|
||||
} else if (hasOwn(payload, 'default')) {
|
||||
delete payload.default;
|
||||
}
|
||||
if (values.payloadOverrideRules.length > 0) {
|
||||
payload.override = serializePayloadRulesForYaml(values.payloadOverrideRules);
|
||||
} else if (hasOwn(payload, 'override')) {
|
||||
delete payload.override;
|
||||
}
|
||||
if (values.payloadFilterRules.length > 0) {
|
||||
payload.filter = serializePayloadFilterRulesForYaml(values.payloadFilterRules);
|
||||
} else if (hasOwn(payload, 'filter')) {
|
||||
delete payload.filter;
|
||||
}
|
||||
deleteIfEmpty(parsed, 'payload');
|
||||
}
|
||||
|
||||
return stringifyYaml(parsed, { indent: 2, lineWidth: 120, minContentWidth: 0 });
|
||||
} catch {
|
||||
return currentYaml;
|
||||
}
|
||||
},
|
||||
[baselineValues, visualValues]
|
||||
);
|
||||
|
||||
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
|
||||
setVisualValuesState((prev) => {
|
||||
const next: VisualConfigValues = { ...prev, ...newValues } as VisualConfigValues;
|
||||
if (newValues.streaming) {
|
||||
next.streaming = { ...prev.streaming, ...newValues.streaming };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
visualValues,
|
||||
visualDirty,
|
||||
loadVisualValuesFromYaml,
|
||||
applyVisualChangesToYaml,
|
||||
setVisualValues,
|
||||
};
|
||||
}
|
||||
|
||||
export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [
|
||||
{ value: '', label: '默认' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'openai-response', label: 'OpenAI Response' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'claude', label: 'Claude' },
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
{ value: 'antigravity', label: 'Antigravity' },
|
||||
] as const;
|
||||
|
||||
export const VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS = [
|
||||
{ value: 'string', label: '字符串' },
|
||||
{ value: 'number', label: '数字' },
|
||||
{ value: 'boolean', label: '布尔' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
] as const satisfies ReadonlyArray<{ value: PayloadParamValueType; label: string }>;
|
||||
@@ -6,12 +6,14 @@ import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import zhCN from './locales/zh-CN.json';
|
||||
import en from './locales/en.json';
|
||||
import ru from './locales/ru.json';
|
||||
import { getInitialLanguage } from '@/utils/language';
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
'zh-CN': { translation: zhCN },
|
||||
en: { translation: en }
|
||||
en: { translation: en },
|
||||
ru: { translation: ru }
|
||||
},
|
||||
lng: getInitialLanguage(),
|
||||
fallbackLng: 'zh-CN',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"common": {
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"save": "Save",
|
||||
@@ -71,7 +72,15 @@
|
||||
"submitting": "Connecting...",
|
||||
"error_title": "Login Failed",
|
||||
"error_required": "Please fill in complete connection information",
|
||||
"error_invalid": "Connection failed, please check address and key"
|
||||
"error_invalid": "Connection failed, please check address and key",
|
||||
"error_network": "Network connection failed, please check your network or server address",
|
||||
"error_timeout": "Connection timed out, server not responding",
|
||||
"error_unauthorized": "Authentication failed, invalid management key",
|
||||
"error_forbidden": "Access denied, insufficient permissions",
|
||||
"error_not_found": "Server address invalid or management API not enabled",
|
||||
"error_server": "Internal server error, please try again later",
|
||||
"error_cors": "Cross-origin request blocked, please check server configuration",
|
||||
"error_ssl": "SSL/TLS certificate verification failed"
|
||||
},
|
||||
"header": {
|
||||
"check_connection": "Check Connection",
|
||||
@@ -93,9 +102,10 @@
|
||||
"oauth": "OAuth Login",
|
||||
"quota_management": "Quota Management",
|
||||
"usage_stats": "Usage Statistics",
|
||||
"config_management": "Config Management",
|
||||
"config_management": "Config Panel",
|
||||
"logs": "Logs Viewer",
|
||||
"system_info": "Management Center Info"
|
||||
"system_info": "Management Center Info",
|
||||
"monitor": "Monitor Center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -137,11 +147,22 @@
|
||||
"usage_statistics_enable": "Enable usage statistics",
|
||||
"logging_title": "Logging",
|
||||
"logging_to_file_enable": "Enable logging to file",
|
||||
"logs_max_total_size_title": "Log Size Limit",
|
||||
"logs_max_total_size_label": "Total log size cap (MB):",
|
||||
"logs_max_total_size_hint": "Set to 0 to disable the limit.",
|
||||
"logs_max_total_size_update": "Update",
|
||||
"request_log_title": "Request Logging",
|
||||
"request_log_enable": "Enable request logging",
|
||||
"request_log_warning": "Keep this off unless you need detailed troubleshooting.",
|
||||
"force_model_prefix_enable": "Force model prefix",
|
||||
"ws_auth_title": "WebSocket Authentication",
|
||||
"ws_auth_enable": "Require auth for /ws/*"
|
||||
"ws_auth_enable": "Require auth for /ws/*",
|
||||
"routing_title": "Routing Strategy",
|
||||
"routing_strategy_label": "Routing strategy:",
|
||||
"routing_strategy_hint": "round-robin cycles through keys; fill-first prioritizes the first available key.",
|
||||
"routing_strategy_update": "Update",
|
||||
"routing_strategy_round_robin": "round-robin (cycle)",
|
||||
"routing_strategy_fill_first": "fill-first (prioritize)"
|
||||
},
|
||||
"api_keys": {
|
||||
"title": "API Keys Management",
|
||||
@@ -221,6 +242,27 @@
|
||||
"claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.",
|
||||
"claude_models_add_btn": "Add Model",
|
||||
"claude_models_count": "Models Count",
|
||||
"vertex_title": "Vertex API Configuration",
|
||||
"vertex_add_button": "Add Configuration",
|
||||
"vertex_empty_title": "No Vertex Configuration",
|
||||
"vertex_empty_desc": "Click the button above to add the first configuration",
|
||||
"vertex_item_title": "Vertex Configuration",
|
||||
"vertex_add_modal_title": "Add Vertex API Configuration",
|
||||
"vertex_add_modal_key_label": "API Key:",
|
||||
"vertex_add_modal_key_placeholder": "Please enter Vertex API key",
|
||||
"vertex_add_modal_url_label": "Base URL (Required):",
|
||||
"vertex_add_modal_url_placeholder": "e.g.: https://example.com/api",
|
||||
"vertex_add_modal_proxy_label": "Proxy URL (Optional):",
|
||||
"vertex_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
|
||||
"vertex_edit_modal_title": "Edit Vertex API Configuration",
|
||||
"vertex_edit_modal_key_label": "API Key:",
|
||||
"vertex_edit_modal_url_label": "Base URL (Required):",
|
||||
"vertex_edit_modal_proxy_label": "Proxy URL (Optional):",
|
||||
"vertex_delete_confirm": "Are you sure you want to delete this Vertex configuration?",
|
||||
"vertex_models_label": "Model aliases (alias required):",
|
||||
"vertex_models_add_btn": "Add Mapping",
|
||||
"vertex_models_hint": "Each alias needs both the original model and the alias.",
|
||||
"vertex_models_count": "Alias count",
|
||||
"ampcode_title": "Amp CLI Integration (ampcode)",
|
||||
"ampcode_modal_title": "Configure Ampcode",
|
||||
"ampcode_upstream_url_label": "Upstream URL",
|
||||
@@ -261,12 +303,12 @@
|
||||
"openai_model_name_placeholder": "Model name, e.g. moonshotai/kimi-k2:free",
|
||||
"openai_model_alias_placeholder": "Model alias (optional)",
|
||||
"openai_models_add_btn": "Add Model",
|
||||
"openai_models_fetch_button": "Fetch via /v1/models",
|
||||
"openai_models_fetch_title": "Pick Models from /v1/models",
|
||||
"openai_models_fetch_hint": "Call the /v1/models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.",
|
||||
"openai_models_fetch_button": "Fetch via /models",
|
||||
"openai_models_fetch_title": "Pick Models from /models",
|
||||
"openai_models_fetch_hint": "Call the /models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.",
|
||||
"openai_models_fetch_url_label": "Request URL",
|
||||
"openai_models_fetch_refresh": "Refresh",
|
||||
"openai_models_fetch_loading": "Fetching models from /v1/models...",
|
||||
"openai_models_fetch_loading": "Fetching models from /models...",
|
||||
"openai_models_fetch_empty": "No models returned. Please check the endpoint or auth.",
|
||||
"openai_models_fetch_error": "Failed to fetch models",
|
||||
"openai_models_fetch_back": "Back to edit",
|
||||
@@ -284,7 +326,7 @@
|
||||
"openai_keys_count": "Keys Count",
|
||||
"openai_models_count": "Models Count",
|
||||
"openai_test_title": "Connection Test",
|
||||
"openai_test_hint": "Send a /v1/chat/completions request with the current settings to verify availability.",
|
||||
"openai_test_hint": "Send a /chat/completions request with the current settings to verify availability.",
|
||||
"openai_test_model_placeholder": "Model to test",
|
||||
"openai_test_action": "Run Test",
|
||||
"openai_test_running": "Sending test request...",
|
||||
@@ -292,7 +334,10 @@
|
||||
"openai_test_success": "Test succeeded. The model responded.",
|
||||
"openai_test_failed": "Test failed",
|
||||
"openai_test_select_placeholder": "Choose from current models",
|
||||
"openai_test_select_empty": "No models configured. Add models first"
|
||||
"openai_test_select_empty": "No models configured. Add models first",
|
||||
"search_placeholder": "Search configs (keys, URLs, models...)",
|
||||
"search_empty_title": "No matching configs",
|
||||
"search_empty_desc": "Try a different keyword or clear the search box"
|
||||
},
|
||||
"auth_files": {
|
||||
"title": "Auth Files Management",
|
||||
@@ -312,6 +357,7 @@
|
||||
"delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!",
|
||||
"delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!",
|
||||
"upload_error_json": "Only JSON files are allowed",
|
||||
"upload_error_size": "File size cannot exceed {{maxSize}}",
|
||||
"upload_success": "File uploaded successfully",
|
||||
"download_success": "File downloaded successfully",
|
||||
"delete_success": "File deleted successfully",
|
||||
@@ -327,14 +373,19 @@
|
||||
"search_placeholder": "Filter by name, type, or provider",
|
||||
"page_size_label": "Per page",
|
||||
"page_size_unit": "items",
|
||||
"view_mode_paged": "Paged",
|
||||
"view_mode_all": "Show all",
|
||||
"too_many_files_warning": "Too many credentials. Showing all may cause performance issues, please use paged view.",
|
||||
"filter_all": "All",
|
||||
"filter_qwen": "Qwen",
|
||||
"filter_gemini": "Gemini",
|
||||
"filter_gemini-cli": "GeminiCLI",
|
||||
"filter_kimi": "Kimi",
|
||||
"filter_aistudio": "AIStudio",
|
||||
"filter_claude": "Claude",
|
||||
"filter_codex": "Codex",
|
||||
"filter_antigravity": "Antigravity",
|
||||
"filter_kiro": "Kiro",
|
||||
"filter_iflow": "iFlow",
|
||||
"filter_vertex": "Vertex",
|
||||
"filter_empty": "Empty",
|
||||
@@ -342,10 +393,12 @@
|
||||
"type_qwen": "Qwen",
|
||||
"type_gemini": "Gemini",
|
||||
"type_gemini-cli": "GeminiCLI",
|
||||
"type_kimi": "Kimi",
|
||||
"type_aistudio": "AIStudio",
|
||||
"type_claude": "Claude",
|
||||
"type_codex": "Codex",
|
||||
"type_antigravity": "Antigravity",
|
||||
"type_kiro": "Kiro",
|
||||
"type_iflow": "iFlow",
|
||||
"type_vertex": "Vertex",
|
||||
"type_empty": "Empty",
|
||||
@@ -358,14 +411,28 @@
|
||||
"models_empty_desc": "This credential may not be loaded by the server yet, or no models are bound to it.",
|
||||
"models_unsupported": "This feature is not supported in the current version",
|
||||
"models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
|
||||
"models_excluded_badge": "Excluded",
|
||||
"models_excluded_hint": "This model is excluded by OAuth"
|
||||
"models_excluded_badge": "Disabled",
|
||||
"models_excluded_hint": "This OAuth model is disabled",
|
||||
"status_toggle_label": "Enabled",
|
||||
"status_enabled_success": "\"{{name}}\" enabled",
|
||||
"status_disabled_success": "\"{{name}}\" disabled",
|
||||
"prefix_proxy_button": "Edit prefix/proxy_url",
|
||||
"prefix_proxy_loading": "Loading credential...",
|
||||
"prefix_proxy_source_label": "Credential JSON",
|
||||
"prefix_label": "prefix",
|
||||
"proxy_url_label": "proxy_url",
|
||||
"prefix_placeholder": "",
|
||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
|
||||
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully",
|
||||
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
|
||||
"quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}"
|
||||
},
|
||||
"antigravity_quota": {
|
||||
"title": "Antigravity Quota",
|
||||
"empty_title": "No Antigravity Auth Files",
|
||||
"empty_desc": "Upload an Antigravity credential to view remaining quota.",
|
||||
"idle": "Not loaded. Click Refresh Button.",
|
||||
"idle": "Click here to refresh quota",
|
||||
"loading": "Loading quota...",
|
||||
"load_failed": "Failed to load quota: {{message}}",
|
||||
"missing_auth_index": "Auth file missing auth_index",
|
||||
@@ -377,7 +444,7 @@
|
||||
"title": "Codex Quota",
|
||||
"empty_title": "No Codex Auth Files",
|
||||
"empty_desc": "Upload a Codex credential to view quota.",
|
||||
"idle": "Not loaded. Click Refresh Button.",
|
||||
"idle": "Click here to refresh quota",
|
||||
"loading": "Loading quota...",
|
||||
"load_failed": "Failed to load quota: {{message}}",
|
||||
"missing_auth_index": "Auth file missing auth_index",
|
||||
@@ -388,7 +455,8 @@
|
||||
"fetch_all": "Fetch All",
|
||||
"primary_window": "5-hour limit",
|
||||
"secondary_window": "Weekly limit",
|
||||
"code_review_window": "Code review limit",
|
||||
"code_review_primary_window": "Code review 5-hour limit",
|
||||
"code_review_secondary_window": "Code review weekly limit",
|
||||
"plan_label": "Plan",
|
||||
"plan_plus": "Plus",
|
||||
"plan_team": "Team",
|
||||
@@ -398,7 +466,7 @@
|
||||
"title": "Gemini CLI Quota",
|
||||
"empty_title": "No Gemini CLI Auth Files",
|
||||
"empty_desc": "Upload a Gemini CLI credential to view remaining quota.",
|
||||
"idle": "Not loaded. Click Refresh Button.",
|
||||
"idle": "Click here to refresh quota",
|
||||
"loading": "Loading quota...",
|
||||
"load_failed": "Failed to load quota: {{message}}",
|
||||
"missing_auth_index": "Auth file missing auth_index",
|
||||
@@ -408,6 +476,23 @@
|
||||
"fetch_all": "Fetch All",
|
||||
"remaining_amount": "Remaining {{count}}"
|
||||
},
|
||||
"kiro_quota": {
|
||||
"title": "Kiro Quota",
|
||||
"empty_title": "No Kiro Auth Files",
|
||||
"empty_desc": "Upload a Kiro credential to view remaining quota.",
|
||||
"idle": "Not loaded. Click Refresh Button.",
|
||||
"loading": "Loading quota...",
|
||||
"load_failed": "Failed to load quota: {{message}}",
|
||||
"missing_auth_index": "Auth file missing auth_index",
|
||||
"empty_data": "No quota data available",
|
||||
"refresh_button": "Refresh Quota",
|
||||
"fetch_all": "Fetch All",
|
||||
"subscription_label": "Subscription",
|
||||
"base_credits_label": "Base Credits",
|
||||
"bonus_credits_label": "Bonus Credits",
|
||||
"total_credits_label": "Total Credits",
|
||||
"remaining_credits": "Remaining {{count}}"
|
||||
},
|
||||
"vertex_import": {
|
||||
"title": "Vertex JSON Login",
|
||||
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
|
||||
@@ -428,41 +513,105 @@
|
||||
"result_file": "Persisted file"
|
||||
},
|
||||
"oauth_excluded": {
|
||||
"title": "OAuth Excluded Models",
|
||||
"description": "Per-provider exclusions are shown as cards; click edit to adjust. Wildcards * are supported and the scope follows the auth file filter.",
|
||||
"add": "Add Exclusion",
|
||||
"add_title": "Add provider exclusion",
|
||||
"edit_title": "Edit exclusions for {{provider}}",
|
||||
"title": "OAuth Model Disablement",
|
||||
"description": "Per-provider model disablement is shown as cards; click a card to edit or delete. Wildcards * are supported and the scope follows the auth file filter.",
|
||||
"add": "Add Disablement",
|
||||
"add_title": "Add provider model disablement",
|
||||
"edit_title": "Edit model disablement for {{provider}}",
|
||||
"refresh": "Refresh",
|
||||
"refreshing": "Refreshing...",
|
||||
"provider_label": "Provider",
|
||||
"provider_auto": "Follow current filter",
|
||||
"provider_placeholder": "e.g. gemini-cli",
|
||||
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
|
||||
"models_label": "Models to exclude",
|
||||
"models_placeholder": "gpt-4.1-mini\n*-preview",
|
||||
"models_hint": "Separate by commas or new lines; saving an empty list removes that provider. * wildcards are supported.",
|
||||
"models_label": "Models to disable",
|
||||
"models_loading": "Loading models...",
|
||||
"models_unsupported": "Current CPA version does not support fetching model lists.",
|
||||
"models_loaded": "{{count}} models loaded. Check the models to disable.",
|
||||
"no_models_available": "No models available for this provider.",
|
||||
"save": "Save/Update",
|
||||
"saving": "Saving...",
|
||||
"save_success": "Excluded models updated",
|
||||
"save_failed": "Failed to update excluded models",
|
||||
"save_success": "Model disablement updated",
|
||||
"save_failed": "Failed to update model disablement",
|
||||
"delete": "Delete Provider",
|
||||
"delete_confirm": "Delete the exclusion list for {{provider}}?",
|
||||
"delete_success": "Exclusion list removed",
|
||||
"delete_failed": "Failed to delete exclusion list",
|
||||
"delete_confirm": "Delete model disablement for {{provider}}?",
|
||||
"delete_success": "Provider model disablement removed",
|
||||
"delete_failed": "Failed to delete model disablement",
|
||||
"deleting": "Deleting...",
|
||||
"no_models": "No excluded models",
|
||||
"model_count": "{{count}} models excluded",
|
||||
"list_empty_all": "No exclusions yet—use “Add Exclusion” to create one.",
|
||||
"list_empty_filtered": "No exclusions in this scope; click “Add Exclusion” to add.",
|
||||
"disconnected": "Connect to the server to view exclusions",
|
||||
"load_failed": "Failed to load exclusion list",
|
||||
"no_models": "No disabled models configured",
|
||||
"model_count": "{{count}} models disabled",
|
||||
"list_empty_all": "No provider model disablement yet; click “Add Disablement” to create one.",
|
||||
"list_empty_filtered": "No disabled items in this scope; click “Add Disablement” to add.",
|
||||
"disconnected": "Connect to the server to view model disablement",
|
||||
"load_failed": "Failed to load model disablement",
|
||||
"provider_required": "Please enter a provider first",
|
||||
"scope_all": "Scope: All providers",
|
||||
"scope_provider": "Scope: {{provider}}",
|
||||
"upgrade_required": "Current CPA version does not support OAuth model disablement. Please upgrade.",
|
||||
"upgrade_required_title": "Please upgrade CLI Proxy API",
|
||||
"upgrade_required_desc": "The current server version does not support fetching OAuth model disablement. Please upgrade to the latest CPA (CLI Proxy API) version and try again."
|
||||
},
|
||||
"oauth_model_alias": {
|
||||
"title": "OAuth Model Aliases",
|
||||
"add": "Add Alias",
|
||||
"add_title": "Add provider model aliases",
|
||||
"provider_label": "Provider",
|
||||
"provider_placeholder": "e.g. gemini-cli / vertex",
|
||||
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
|
||||
"model_source_loading": "Loading models...",
|
||||
"model_source_unsupported": "The current CPA version does not support fetching model lists (manual input still works).",
|
||||
"model_source_loaded": "{{count}} models loaded. Use the dropdown in 'Source model name', or type custom values. Saving an empty list removes that provider. Enable 'Keep original' to keep the original name while adding the alias.",
|
||||
"alias_label": "Model aliases",
|
||||
"alias_name_placeholder": "Source model name",
|
||||
"alias_placeholder": "Alias (required)",
|
||||
"alias_fork_label": "Keep original",
|
||||
"add_alias": "Add alias",
|
||||
"save": "Save/Update",
|
||||
"save_success": "Model aliases updated",
|
||||
"save_failed": "Failed to update model aliases",
|
||||
"delete": "Delete Provider",
|
||||
"delete_confirm": "Delete model aliases for {{provider}}?",
|
||||
"delete_link_title": "Unlink mapping",
|
||||
"delete_link_confirm": "Unlink mapping from <code>{{sourceModel}}</code> ({{provider}}) to alias <code>{{alias}}</code>?",
|
||||
"delete_alias_title": "Delete Alias",
|
||||
"delete_alias_confirm": "Delete alias <code>{{alias}}</code> and unmap all associated models?",
|
||||
"delete_success": "Model aliases removed",
|
||||
"delete_failed": "Failed to delete model aliases",
|
||||
"no_models": "No model aliases",
|
||||
"model_count": "{{count}} aliases",
|
||||
"list_empty_all": "No model aliases yet—use “Add Alias” to create one.",
|
||||
"chart_title": "All mappings overview",
|
||||
"diagram_providers": "Providers",
|
||||
"diagram_source_models": "Source Models",
|
||||
"diagram_aliases": "Aliases",
|
||||
"diagram_expand": "Expand",
|
||||
"diagram_collapse": "Collapse",
|
||||
"diagram_add_alias": "Add Alias",
|
||||
"diagram_rename": "Rename",
|
||||
"diagram_rename_alias_title": "Rename alias",
|
||||
"diagram_rename_alias_label": "New alias name",
|
||||
"diagram_rename_placeholder": "Enter alias name...",
|
||||
"diagram_delete_link": "Unlink from {{provider}} / {{name}}",
|
||||
"diagram_delete_alias": "Delete alias",
|
||||
"diagram_please_enter_alias": "Please enter an alias name.",
|
||||
"diagram_alias_exists": "This alias already exists.",
|
||||
"diagram_add_alias_title": "Add alias",
|
||||
"diagram_add_alias_label": "Alias name",
|
||||
"diagram_add_placeholder": "Enter new alias name...",
|
||||
"diagram_rename_btn": "Rename",
|
||||
"diagram_add_btn": "Add",
|
||||
"diagram_settings": "Settings",
|
||||
"diagram_settings_title": "Alias settings — {{alias}}",
|
||||
"diagram_settings_source_title": "Source model settings",
|
||||
"diagram_settings_empty": "No mappings for this alias yet.",
|
||||
"diagram_tap_hint": "On touch devices: tap a source model, then tap an alias to link.",
|
||||
"view_mode": "View mode",
|
||||
"view_mode_diagram": "Diagram",
|
||||
"view_mode_list": "List",
|
||||
"provider_required": "Please enter a provider first",
|
||||
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
|
||||
"upgrade_required_title": "Please upgrade CLI Proxy API",
|
||||
"upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version."
|
||||
"upgrade_required_desc": "The current server does not support the OAuth model aliases API. Please upgrade to the latest CLI Proxy API (CPA) version."
|
||||
},
|
||||
"auth_login": {
|
||||
"codex_oauth_title": "Codex OAuth",
|
||||
@@ -513,6 +662,17 @@
|
||||
"gemini_cli_oauth_status_error": "Authentication failed:",
|
||||
"gemini_cli_oauth_start_error": "Failed to start Gemini CLI OAuth:",
|
||||
"gemini_cli_oauth_polling_error": "Failed to check authentication status:",
|
||||
"kimi_oauth_title": "Kimi OAuth",
|
||||
"kimi_oauth_button": "Start Kimi Login",
|
||||
"kimi_oauth_hint": "Login to Kimi service through OAuth device flow, automatically obtain and save authentication files.",
|
||||
"kimi_oauth_url_label": "Authorization URL:",
|
||||
"kimi_open_link": "Open Link",
|
||||
"kimi_copy_link": "Copy Link",
|
||||
"kimi_oauth_status_waiting": "Waiting for authentication...",
|
||||
"kimi_oauth_status_success": "Authentication successful!",
|
||||
"kimi_oauth_status_error": "Authentication failed:",
|
||||
"kimi_oauth_start_error": "Failed to start Kimi OAuth:",
|
||||
"kimi_oauth_polling_error": "Failed to check authentication status:",
|
||||
"qwen_oauth_title": "Qwen OAuth",
|
||||
"qwen_oauth_button": "Start Qwen Login",
|
||||
"qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.",
|
||||
@@ -548,7 +708,7 @@
|
||||
"iflow_oauth_polling_error": "Failed to check authentication status:",
|
||||
"iflow_cookie_title": "iFlow Cookie Login",
|
||||
"iflow_cookie_label": "Cookie Value:",
|
||||
"iflow_cookie_placeholder": "Paste browser cookie, e.g. sessionid=...;",
|
||||
"iflow_cookie_placeholder": "Enter the BXAuth value, starting with BXAuth=",
|
||||
"iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.",
|
||||
"iflow_cookie_key_hint": "Note: Create a key on the platform first.",
|
||||
"iflow_cookie_button": "Submit Cookie Login",
|
||||
@@ -563,7 +723,26 @@
|
||||
"iflow_cookie_result_expired": "Expires At",
|
||||
"iflow_cookie_result_path": "Saved Path",
|
||||
"iflow_cookie_result_type": "Type",
|
||||
"remote_access_disabled": "This login method is not available for remote access. Please access from localhost."
|
||||
"remote_access_disabled": "This login method is not available for remote access. Please access from localhost.",
|
||||
"kiro_oauth_title": "Kiro OAuth",
|
||||
"kiro_oauth_hint": "Login to Kiro service via AWS SSO, supporting Builder ID and Identity Center (IDC), or import refreshToken from Kiro IDE directly.",
|
||||
"kiro_builder_id_label": "AWS Builder ID Login",
|
||||
"kiro_builder_id_hint": "Login with AWS Builder ID account, suitable for individual developers.",
|
||||
"kiro_builder_id_button": "Login with Builder ID",
|
||||
"kiro_idc_label": "AWS Identity Center (IDC) Login",
|
||||
"kiro_idc_hint": "Login with enterprise AWS Identity Center, requires Start URL and Region.",
|
||||
"kiro_idc_start_url_label": "Start URL",
|
||||
"kiro_idc_start_url_placeholder": "https://your-org.awsapps.com/start",
|
||||
"kiro_idc_region_label": "Region (Optional)",
|
||||
"kiro_idc_region_placeholder": "us-east-1",
|
||||
"kiro_idc_button": "Login with IDC",
|
||||
"kiro_token_import_label": "Token Import",
|
||||
"kiro_token_import_hint": "Import refreshToken from Kiro IDE, can be found in Kiro IDE's auth file.",
|
||||
"kiro_token_placeholder": "Paste refreshToken",
|
||||
"kiro_token_import_button": "Import Token",
|
||||
"kiro_token_required": "Please enter refreshToken first",
|
||||
"kiro_token_import_success": "Kiro Token imported successfully",
|
||||
"kiro_token_import_error": "Kiro Token import failed:"
|
||||
},
|
||||
"usage_stats": {
|
||||
"title": "Usage Statistics",
|
||||
@@ -671,6 +850,8 @@
|
||||
"loaded_lines": "Loaded: {{count}} lines",
|
||||
"filtered_lines": "Filtered: {{count}} lines",
|
||||
"hide_management_logs": "Hide {{prefix}} logs",
|
||||
"show_raw_logs": "Show Raw Logs",
|
||||
"show_raw_logs_hint": "Show original log text for easier multi-line copy",
|
||||
"search_placeholder": "Search logs by content or keyword",
|
||||
"search_empty_title": "No matching logs found",
|
||||
"search_empty_desc": "Try a different keyword or clear the filters.",
|
||||
@@ -683,11 +864,11 @@
|
||||
"upgrade_required_desc": "The current server version does not support the logs viewing feature. Please upgrade to the latest version of CLI Proxy API to use this feature."
|
||||
},
|
||||
"config_management": {
|
||||
"title": "Config Management",
|
||||
"title": "Config Panel",
|
||||
"editor_title": "Configuration File",
|
||||
"reload": "Reload",
|
||||
"save": "Save",
|
||||
"description": "View and edit the server-side config.yaml file. Validate the syntax before saving.",
|
||||
"description": "Edit config.yaml via visual editor or source file",
|
||||
"status_idle": "Waiting for action",
|
||||
"status_loading": "Loading configuration...",
|
||||
"status_loaded": "Configuration loaded",
|
||||
@@ -704,15 +885,153 @@
|
||||
"search_button": "Search",
|
||||
"search_no_results": "No results",
|
||||
"search_prev": "Previous",
|
||||
"search_next": "Next"
|
||||
"search_next": "Next",
|
||||
"tabs": {
|
||||
"visual": "Visual Editor",
|
||||
"source": "Source File Editor"
|
||||
},
|
||||
"visual": {
|
||||
"sections": {
|
||||
"server": {
|
||||
"title": "Server Configuration",
|
||||
"description": "Basic server settings",
|
||||
"host": "Host Address",
|
||||
"port": "Port"
|
||||
},
|
||||
"tls": {
|
||||
"title": "TLS/SSL Configuration",
|
||||
"description": "HTTPS secure connection settings",
|
||||
"enable": "Enable TLS",
|
||||
"enable_desc": "Enable HTTPS secure connection",
|
||||
"cert": "Certificate File Path",
|
||||
"key": "Private Key File Path"
|
||||
},
|
||||
"remote": {
|
||||
"title": "Remote Management",
|
||||
"description": "Remote access and control panel settings",
|
||||
"allow_remote": "Allow Remote Access",
|
||||
"allow_remote_desc": "Allow management access from other hosts",
|
||||
"disable_panel": "Disable Control Panel",
|
||||
"disable_panel_desc": "Disable the built-in web control panel",
|
||||
"secret_key": "Management Key",
|
||||
"secret_key_placeholder": "Set management key",
|
||||
"panel_repo": "Panel Repository"
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authentication Configuration",
|
||||
"description": "API keys and authentication directory settings",
|
||||
"auth_dir": "Auth Directory (auth-dir)",
|
||||
"auth_dir_hint": "Directory path for authentication files (supports ~)"
|
||||
},
|
||||
"system": {
|
||||
"title": "System Configuration",
|
||||
"description": "Debug, logging, statistics, and performance settings",
|
||||
"debug": "Debug Mode",
|
||||
"debug_desc": "Enable verbose debug logging",
|
||||
"commercial_mode": "Commercial Mode",
|
||||
"commercial_mode_desc": "Disable high-overhead middleware to support high concurrency",
|
||||
"logging_to_file": "Log to File",
|
||||
"logging_to_file_desc": "Save logs to files",
|
||||
"usage_statistics": "Usage Statistics",
|
||||
"usage_statistics_desc": "Collect usage statistics",
|
||||
"logs_max_size": "Log File Size Limit (MB)"
|
||||
},
|
||||
"network": {
|
||||
"title": "Network Configuration",
|
||||
"description": "Proxy, retry, and routing settings",
|
||||
"proxy_url": "Proxy URL",
|
||||
"request_retry": "Request Retry Count",
|
||||
"max_retry_interval": "Max Retry Interval (seconds)",
|
||||
"routing_strategy": "Routing Strategy",
|
||||
"routing_strategy_hint": "Select credential selection strategy",
|
||||
"strategy_round_robin": "Round Robin",
|
||||
"strategy_fill_first": "Fill First",
|
||||
"force_model_prefix": "Force Model Prefix",
|
||||
"force_model_prefix_desc": "Unprefixed model requests only use credentials without prefix",
|
||||
"ws_auth": "WebSocket Authentication",
|
||||
"ws_auth_desc": "Enable WebSocket authentication (/v1/ws)"
|
||||
},
|
||||
"quota": {
|
||||
"title": "Quota Fallback",
|
||||
"description": "Fallback strategy when quota is exceeded",
|
||||
"switch_project": "Switch Project",
|
||||
"switch_project_desc": "Automatically switch to another project when quota is exceeded",
|
||||
"switch_preview_model": "Switch to Preview Model",
|
||||
"switch_preview_model_desc": "Switch to preview model version when quota is exceeded"
|
||||
},
|
||||
"streaming": {
|
||||
"title": "Streaming Configuration",
|
||||
"description": "Keepalive and bootstrap retry settings",
|
||||
"keepalive_seconds": "Keepalive Seconds",
|
||||
"keepalive_hint": "Set to 0 or leave empty to disable keepalive",
|
||||
"bootstrap_retries": "Bootstrap Retries",
|
||||
"bootstrap_hint": "Number of retries during stream startup (before first byte)",
|
||||
"nonstream_keepalive": "Non-stream Keepalive Interval (seconds)",
|
||||
"nonstream_keepalive_hint": "Send blank lines every N seconds for non-streaming responses to prevent idle timeout, set to 0 or leave empty to disable",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"payload": {
|
||||
"title": "Payload Configuration",
|
||||
"description": "Default values, override rules, and filter rules",
|
||||
"default_rules": "Default Rules",
|
||||
"default_rules_desc": "Use these default values when parameters are not specified in the request",
|
||||
"override_rules": "Override Rules",
|
||||
"override_rules_desc": "Force override parameter values in the request",
|
||||
"filter_rules": "Filter Rules",
|
||||
"filter_rules_desc": "Pre-filter upstream request body via JSON Path, automatically remove non-compliant/redundant parameters (Request Sanitization)"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"label": "API Keys List (api-keys)",
|
||||
"add": "Add API Key",
|
||||
"empty": "No API keys",
|
||||
"hint": "Each entry represents an API key (consistent with 'API Key Management' page style)",
|
||||
"edit_title": "Edit API Key",
|
||||
"add_title": "Add API Key",
|
||||
"input_label": "API Key",
|
||||
"input_placeholder": "Paste your API key",
|
||||
"input_hint": "This only modifies the local config file content, it will not sync to the API Key Management interface",
|
||||
"error_empty": "Please enter an API key",
|
||||
"error_invalid": "API key contains invalid characters"
|
||||
},
|
||||
"payload_rules": {
|
||||
"rule": "Rule",
|
||||
"models": "Applicable Models",
|
||||
"model_name": "Model Name",
|
||||
"provider_type": "Provider Type",
|
||||
"add_model": "Add Model",
|
||||
"params": "Parameter Settings",
|
||||
"remove_params": "Remove Parameters",
|
||||
"json_path": "JSON Path (e.g., temperature)",
|
||||
"json_path_filter": "JSON Path (gjson/sjson), e.g., generationConfig.thinkingConfig.thinkingBudget",
|
||||
"param_type": "Parameter Type",
|
||||
"add_param": "Add Parameter",
|
||||
"no_rules": "No rules",
|
||||
"add_rule": "Add Rule",
|
||||
"value_string": "String value",
|
||||
"value_number": "Number value (e.g., 0.7)",
|
||||
"value_boolean": "true or false",
|
||||
"value_json": "JSON value",
|
||||
"value_default": "Value"
|
||||
},
|
||||
"common": {
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"update": "Update",
|
||||
"add": "Add"
|
||||
}
|
||||
}
|
||||
},
|
||||
"quota_management": {
|
||||
"title": "Quota Management",
|
||||
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
|
||||
"refresh_files": "Refresh auth files"
|
||||
"refresh_files": "Refresh auth files",
|
||||
"refresh_files_and_quota": "Refresh files & quota"
|
||||
},
|
||||
"system_info": {
|
||||
"title": "Management Center Info",
|
||||
"about_title": "CLI Proxy API Management Center",
|
||||
"connection_status_title": "Connection Status",
|
||||
"api_status_label": "API Status:",
|
||||
"config_status_label": "Config Status:",
|
||||
@@ -722,9 +1041,9 @@
|
||||
"not_loaded": "Not Loaded",
|
||||
"seconds_ago": "seconds ago",
|
||||
"models_title": "Available Models",
|
||||
"models_desc": "Shows the /v1/models response and uses saved API keys for auth automatically.",
|
||||
"models_desc": "Shows the /models response and uses saved API keys for auth automatically.",
|
||||
"models_loading": "Loading available models...",
|
||||
"models_empty": "No models returned by /v1/models",
|
||||
"models_empty": "No models returned by /models",
|
||||
"models_error": "Failed to load model list",
|
||||
"models_count": "{{count}} available models",
|
||||
"version_check_title": "Update Check",
|
||||
@@ -761,12 +1080,16 @@
|
||||
"quota_switch_preview_updated": "Preview model switch settings updated",
|
||||
"usage_statistics_updated": "Usage statistics settings updated",
|
||||
"logging_to_file_updated": "Logging settings updated",
|
||||
"logs_max_total_size_updated": "Log size limit updated",
|
||||
"request_log_updated": "Request logging setting updated",
|
||||
"force_model_prefix_updated": "Model prefix setting updated",
|
||||
"ws_auth_updated": "WebSocket authentication setting updated",
|
||||
"routing_strategy_updated": "Routing strategy updated",
|
||||
"login_storage_cleared": "Local login data cleared",
|
||||
"api_key_added": "API key added successfully",
|
||||
"api_key_updated": "API key updated successfully",
|
||||
"api_key_deleted": "API key deleted successfully",
|
||||
"api_key_invalid_chars": "API key can only contain letters, numbers, and symbols",
|
||||
"gemini_key_added": "Gemini key added successfully",
|
||||
"gemini_key_updated": "Gemini key updated successfully",
|
||||
"gemini_key_deleted": "Gemini key deleted successfully",
|
||||
@@ -780,6 +1103,10 @@
|
||||
"claude_config_added": "Claude configuration added successfully",
|
||||
"claude_config_updated": "Claude configuration updated successfully",
|
||||
"claude_config_deleted": "Claude configuration deleted successfully",
|
||||
"vertex_config_added": "Vertex configuration added successfully",
|
||||
"vertex_config_updated": "Vertex configuration updated successfully",
|
||||
"vertex_config_deleted": "Vertex configuration deleted successfully",
|
||||
"vertex_base_url_required": "Please enter the Vertex Base URL",
|
||||
"config_enabled": "Configuration enabled",
|
||||
"config_disabled": "Configuration disabled",
|
||||
"field_required": "Required fields cannot be empty",
|
||||
@@ -809,12 +1136,15 @@
|
||||
"gemini_api_key": "Gemini API key",
|
||||
"codex_api_key": "Codex API key",
|
||||
"claude_api_key": "Claude API key",
|
||||
"commercial_mode_restart_required": "Commercial mode setting changed. Please restart the service for it to take effect",
|
||||
"copy_failed": "Copy failed",
|
||||
"link_copied": "Link copied to clipboard"
|
||||
},
|
||||
"language": {
|
||||
"switch": "Language",
|
||||
"chinese": "中文",
|
||||
"english": "English"
|
||||
"english": "English",
|
||||
"russian": "Русский"
|
||||
},
|
||||
"theme": {
|
||||
"switch": "Theme",
|
||||
@@ -833,5 +1163,170 @@
|
||||
"build_date": "Build Time",
|
||||
"version": "Management UI Version",
|
||||
"author": "Author"
|
||||
},
|
||||
"monitor": {
|
||||
"title": "Monitor Center",
|
||||
"time_range": "Time Range",
|
||||
"today": "Today",
|
||||
"last_n_days": "Last {{n}} Days",
|
||||
"api_filter": "API Query",
|
||||
"api_filter_placeholder": "Query API data",
|
||||
"apply": "Apply",
|
||||
"no_data": "No data available",
|
||||
"requests": "Requests",
|
||||
"kpi": {
|
||||
"requests": "Requests",
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"rate": "Success Rate",
|
||||
"tokens": "Tokens",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"reasoning": "Reasoning",
|
||||
"cached": "Cached",
|
||||
"avg_tpm": "Avg TPM",
|
||||
"avg_rpm": "Avg RPM",
|
||||
"avg_rpd": "Avg RPD",
|
||||
"tokens_per_minute": "Tokens per minute",
|
||||
"requests_per_minute": "Requests per minute",
|
||||
"requests_per_day": "Requests per day"
|
||||
},
|
||||
"distribution": {
|
||||
"title": "Model Usage Distribution",
|
||||
"by_requests": "By Requests",
|
||||
"by_tokens": "By Tokens",
|
||||
"requests": "Requests",
|
||||
"tokens": "Tokens",
|
||||
"request_share": "Request Share",
|
||||
"token_share": "Token Share"
|
||||
},
|
||||
"trend": {
|
||||
"title": "Daily Usage Trend",
|
||||
"subtitle": "Requests and Token usage trend",
|
||||
"requests": "Requests",
|
||||
"input_tokens": "Input Tokens",
|
||||
"output_tokens": "Output Tokens",
|
||||
"reasoning_tokens": "Reasoning Tokens",
|
||||
"cached_tokens": "Cached Tokens"
|
||||
},
|
||||
"hourly": {
|
||||
"last_6h": "Last 6 Hours",
|
||||
"last_12h": "Last 12 Hours",
|
||||
"last_24h": "Last 24 Hours",
|
||||
"all": "All",
|
||||
"requests": "Requests",
|
||||
"success_rate": "Success Rate"
|
||||
},
|
||||
"hourly_model": {
|
||||
"title": "Hourly Model Request Distribution",
|
||||
"models": "Models"
|
||||
},
|
||||
"hourly_token": {
|
||||
"title": "Hourly Token Usage",
|
||||
"subtitle": "By Hour",
|
||||
"total": "Total Tokens",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"reasoning": "Reasoning",
|
||||
"cached": "Cached"
|
||||
},
|
||||
"channel": {
|
||||
"title": "Channel Statistics",
|
||||
"subtitle": "Grouped by source channel",
|
||||
"click_hint": "Click row to expand model details",
|
||||
"all_channels": "All Channels",
|
||||
"all_models": "All Models",
|
||||
"all_status": "All Status",
|
||||
"only_success": "Success Only",
|
||||
"only_failed": "Failed Only",
|
||||
"header_name": "Channel",
|
||||
"header_count": "Requests",
|
||||
"header_rate": "Success Rate",
|
||||
"header_recent": "Recent Status",
|
||||
"header_time": "Last Request",
|
||||
"model_details": "Model Details",
|
||||
"model": "Model",
|
||||
"success": "Success",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"time": {
|
||||
"today": "Today",
|
||||
"last_n_days": "{{n}} Days",
|
||||
"custom": "Custom",
|
||||
"to": "to",
|
||||
"apply": "Apply"
|
||||
},
|
||||
"failure": {
|
||||
"title": "Failure Analysis",
|
||||
"subtitle": "Locate issues by source channel",
|
||||
"click_hint": "Click row to expand details",
|
||||
"no_failures": "No failure data",
|
||||
"header_name": "Channel",
|
||||
"header_count": "Failures",
|
||||
"header_time": "Last Failure",
|
||||
"header_models": "Top Failed Models",
|
||||
"all_failed_models": "All Failed Models"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Request Logs",
|
||||
"total_count": "{{count}} records",
|
||||
"sort_hint": "Auto sorted by time desc",
|
||||
"scroll_hint": "Scroll to browse all data",
|
||||
"virtual_scroll_info": "Showing {{visible}} rows, {{total}} records total",
|
||||
"all_apis": "All APIs",
|
||||
"all_models": "All Models",
|
||||
"all_sources": "All Sources",
|
||||
"all_status": "All Status",
|
||||
"all_provider_types": "All Providers",
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"last_update": "Last Update",
|
||||
"manual_refresh": "Manual Refresh",
|
||||
"refresh_5s": "5s Refresh",
|
||||
"refresh_10s": "10s Refresh",
|
||||
"refresh_15s": "15s Refresh",
|
||||
"refresh_30s": "30s Refresh",
|
||||
"refresh_60s": "60s Refresh",
|
||||
"refresh_in_seconds": "Refresh in {{seconds}}s",
|
||||
"refreshing": "Refreshing...",
|
||||
"header_auth": "Auth Index",
|
||||
"header_api": "API",
|
||||
"header_request_type": "Type",
|
||||
"header_model": "Model",
|
||||
"header_source": "Source",
|
||||
"header_status": "Status",
|
||||
"header_recent": "Recent Status",
|
||||
"header_rate": "Success Rate",
|
||||
"header_count": "Requests",
|
||||
"header_input": "Input",
|
||||
"header_output": "Output",
|
||||
"header_total": "Total Tokens",
|
||||
"header_time": "Time",
|
||||
"header_actions": "Actions",
|
||||
"showing": "Showing {{start}}-{{end}} of {{total}}",
|
||||
"page_info": "Page {{current}}/{{total}}",
|
||||
"first_page": "First",
|
||||
"prev_page": "Prev",
|
||||
"next_page": "Next",
|
||||
"last_page": "Last",
|
||||
"disable": "Disable",
|
||||
"disable_model": "Disable this model",
|
||||
"disabled": "Disabled",
|
||||
"removed": "Removed",
|
||||
"disabling": "Disabling...",
|
||||
"disable_confirm_title": "Confirm Disable Model",
|
||||
"disable_error": "Disable failed",
|
||||
"disable_error_no_provider": "Cannot identify provider",
|
||||
"disable_error_provider_not_found": "Provider config not found: {{provider}}",
|
||||
"disable_not_supported": "{{provider}} provider does not support disable operation",
|
||||
"disable_unsupported_title": "Auto-disable Not Supported",
|
||||
"disable_unsupported_desc": "{{providerType}} type providers do not support auto-disable feature.",
|
||||
"disable_unsupported_guide_title": "Manual Operation Guide",
|
||||
"disable_unsupported_guide_step1": "1. Go to the \"AI Providers\" page",
|
||||
"disable_unsupported_guide_step2": "2. Find the corresponding {{providerType}} configuration",
|
||||
"disable_unsupported_guide_step3": "3. Edit the config and remove model \"{{model}}\"",
|
||||
"disable_unsupported_guide_step4": "4. Save the configuration to apply changes",
|
||||
"disable_unsupported_close": "Got it"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1130
src/i18n/locales/ru.json
Normal file
@@ -2,6 +2,7 @@
|
||||
"common": {
|
||||
"login": "登录",
|
||||
"logout": "登出",
|
||||
"back": "返回",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"save": "保存",
|
||||
@@ -71,7 +72,15 @@
|
||||
"submitting": "连接中...",
|
||||
"error_title": "登录失败",
|
||||
"error_required": "请填写完整的连接信息",
|
||||
"error_invalid": "连接失败,请检查地址和密钥"
|
||||
"error_invalid": "连接失败,请检查地址和密钥",
|
||||
"error_network": "网络连接失败,请检查网络或服务器地址",
|
||||
"error_timeout": "连接超时,服务器无响应",
|
||||
"error_unauthorized": "认证失败,管理密钥无效",
|
||||
"error_forbidden": "访问被拒绝,权限不足",
|
||||
"error_not_found": "服务器地址无效或管理接口未启用",
|
||||
"error_server": "服务器内部错误,请稍后重试",
|
||||
"error_cors": "跨域请求被阻止,请检查服务器配置",
|
||||
"error_ssl": "SSL/TLS 证书验证失败"
|
||||
},
|
||||
"header": {
|
||||
"check_connection": "检查连接",
|
||||
@@ -93,9 +102,10 @@
|
||||
"oauth": "OAuth 登录",
|
||||
"quota_management": "配额管理",
|
||||
"usage_stats": "使用统计",
|
||||
"config_management": "配置管理",
|
||||
"config_management": "配置面板",
|
||||
"logs": "日志查看",
|
||||
"system_info": "中心信息"
|
||||
"system_info": "中心信息",
|
||||
"monitor": "监控中心"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "仪表盘",
|
||||
@@ -137,11 +147,22 @@
|
||||
"usage_statistics_enable": "启用使用统计",
|
||||
"logging_title": "日志记录",
|
||||
"logging_to_file_enable": "启用日志记录到文件",
|
||||
"logs_max_total_size_title": "日志容量限制",
|
||||
"logs_max_total_size_label": "日志总大小上限 (MB):",
|
||||
"logs_max_total_size_hint": "设置为 0 表示不限制。",
|
||||
"logs_max_total_size_update": "更新",
|
||||
"request_log_title": "请求日志",
|
||||
"request_log_enable": "启用请求日志",
|
||||
"request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。",
|
||||
"force_model_prefix_enable": "强制模型前缀",
|
||||
"ws_auth_title": "WebSocket 鉴权",
|
||||
"ws_auth_enable": "启用 /ws/* 鉴权"
|
||||
"ws_auth_enable": "启用 /ws/* 鉴权",
|
||||
"routing_title": "路由策略",
|
||||
"routing_strategy_label": "路由策略:",
|
||||
"routing_strategy_hint": "round-robin 为轮询,fill-first 为优先填充。",
|
||||
"routing_strategy_update": "更新",
|
||||
"routing_strategy_round_robin": "round-robin (轮询)",
|
||||
"routing_strategy_fill_first": "fill-first (优先填充)"
|
||||
},
|
||||
"api_keys": {
|
||||
"title": "API 密钥管理",
|
||||
@@ -221,6 +242,27 @@
|
||||
"claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。",
|
||||
"claude_models_add_btn": "添加模型",
|
||||
"claude_models_count": "模型数量",
|
||||
"vertex_title": "Vertex API 配置",
|
||||
"vertex_add_button": "添加配置",
|
||||
"vertex_empty_title": "暂无Vertex配置",
|
||||
"vertex_empty_desc": "点击上方按钮添加第一个配置",
|
||||
"vertex_item_title": "Vertex配置",
|
||||
"vertex_add_modal_title": "添加Vertex API配置",
|
||||
"vertex_add_modal_key_label": "API密钥:",
|
||||
"vertex_add_modal_key_placeholder": "请输入Vertex API密钥",
|
||||
"vertex_add_modal_url_label": "Base URL (必填):",
|
||||
"vertex_add_modal_url_placeholder": "例如: https://example.com/api",
|
||||
"vertex_add_modal_proxy_label": "代理 URL (可选):",
|
||||
"vertex_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
|
||||
"vertex_edit_modal_title": "编辑Vertex API配置",
|
||||
"vertex_edit_modal_key_label": "API密钥:",
|
||||
"vertex_edit_modal_url_label": "Base URL (必填):",
|
||||
"vertex_edit_modal_proxy_label": "代理 URL (可选):",
|
||||
"vertex_delete_confirm": "确定要删除这个Vertex配置吗?",
|
||||
"vertex_models_label": "模型别名 (别名必填):",
|
||||
"vertex_models_add_btn": "添加映射",
|
||||
"vertex_models_hint": "每条别名需要填写原模型与别名。",
|
||||
"vertex_models_count": "别名数量",
|
||||
"ampcode_title": "Amp CLI 集成 (ampcode)",
|
||||
"ampcode_modal_title": "配置 Ampcode",
|
||||
"ampcode_upstream_url_label": "Upstream URL",
|
||||
@@ -261,12 +303,12 @@
|
||||
"openai_model_name_placeholder": "模型名称,如 moonshotai/kimi-k2:free",
|
||||
"openai_model_alias_placeholder": "模型别名 (可选)",
|
||||
"openai_models_add_btn": "添加模型",
|
||||
"openai_models_fetch_button": "从 /v1/models 获取",
|
||||
"openai_models_fetch_title": "从 /v1/models 选择模型",
|
||||
"openai_models_fetch_hint": "使用上方 Base URL 调用 /v1/models 端点,附带首个 API Key(Bearer)与自定义请求头。",
|
||||
"openai_models_fetch_button": "从 /models 获取",
|
||||
"openai_models_fetch_title": "从 /models 选择模型",
|
||||
"openai_models_fetch_hint": "使用上方 Base URL 调用 /models 端点,附带首个 API Key(Bearer)与自定义请求头。",
|
||||
"openai_models_fetch_url_label": "请求地址",
|
||||
"openai_models_fetch_refresh": "重新获取",
|
||||
"openai_models_fetch_loading": "正在从 /v1/models 获取模型列表...",
|
||||
"openai_models_fetch_loading": "正在从 /models 获取模型列表...",
|
||||
"openai_models_fetch_empty": "未获取到模型,请检查端点或鉴权信息。",
|
||||
"openai_models_fetch_error": "获取模型失败",
|
||||
"openai_models_fetch_back": "返回编辑",
|
||||
@@ -284,7 +326,7 @@
|
||||
"openai_keys_count": "密钥数量",
|
||||
"openai_models_count": "模型数量",
|
||||
"openai_test_title": "连通性测试",
|
||||
"openai_test_hint": "使用当前配置向 /v1/chat/completions 请求,验证是否可用。",
|
||||
"openai_test_hint": "使用当前配置向 /chat/completions 请求,验证是否可用。",
|
||||
"openai_test_model_placeholder": "选择或输入要测试的模型",
|
||||
"openai_test_action": "发送测试",
|
||||
"openai_test_running": "正在发送测试请求...",
|
||||
@@ -292,7 +334,10 @@
|
||||
"openai_test_success": "测试成功,模型可用。",
|
||||
"openai_test_failed": "测试失败",
|
||||
"openai_test_select_placeholder": "从当前模型列表选择",
|
||||
"openai_test_select_empty": "当前未配置模型,请先添加模型"
|
||||
"openai_test_select_empty": "当前未配置模型,请先添加模型",
|
||||
"search_placeholder": "搜索配置(密钥、地址、模型等)",
|
||||
"search_empty_title": "没有匹配的配置",
|
||||
"search_empty_desc": "请尝试更换关键字或清空搜索框"
|
||||
},
|
||||
"auth_files": {
|
||||
"title": "认证文件管理",
|
||||
@@ -312,6 +357,7 @@
|
||||
"delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!",
|
||||
"delete_filtered_confirm": "确定要删除筛选出的 {{type}} 认证文件吗?此操作不可恢复!",
|
||||
"upload_error_json": "只能上传JSON文件",
|
||||
"upload_error_size": "文件大小不能超过 {{maxSize}}",
|
||||
"upload_success": "文件上传成功",
|
||||
"download_success": "文件下载成功",
|
||||
"delete_success": "文件删除成功",
|
||||
@@ -327,14 +373,19 @@
|
||||
"search_placeholder": "输入名称、类型或提供方关键字",
|
||||
"page_size_label": "单页数量",
|
||||
"page_size_unit": "个/页",
|
||||
"view_mode_paged": "按页显示",
|
||||
"view_mode_all": "显示全部",
|
||||
"too_many_files_warning": "您的凭证总数过多,全部加载会导致页面卡顿,请保持单页浏览。",
|
||||
"filter_all": "全部",
|
||||
"filter_qwen": "Qwen",
|
||||
"filter_gemini": "Gemini",
|
||||
"filter_gemini-cli": "GeminiCLI",
|
||||
"filter_kimi": "Kimi",
|
||||
"filter_aistudio": "AIStudio",
|
||||
"filter_claude": "Claude",
|
||||
"filter_codex": "Codex",
|
||||
"filter_antigravity": "Antigravity",
|
||||
"filter_kiro": "Kiro",
|
||||
"filter_iflow": "iFlow",
|
||||
"filter_vertex": "Vertex",
|
||||
"filter_empty": "空文件",
|
||||
@@ -342,10 +393,12 @@
|
||||
"type_qwen": "Qwen",
|
||||
"type_gemini": "Gemini",
|
||||
"type_gemini-cli": "GeminiCLI",
|
||||
"type_kimi": "Kimi",
|
||||
"type_aistudio": "AIStudio",
|
||||
"type_claude": "Claude",
|
||||
"type_codex": "Codex",
|
||||
"type_antigravity": "Antigravity",
|
||||
"type_kiro": "Kiro",
|
||||
"type_iflow": "iFlow",
|
||||
"type_vertex": "Vertex",
|
||||
"type_empty": "空文件",
|
||||
@@ -358,14 +411,28 @@
|
||||
"models_empty_desc": "该认证凭证可能尚未被服务器加载或没有绑定任何模型",
|
||||
"models_unsupported": "当前版本不支持此功能",
|
||||
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
|
||||
"models_excluded_badge": "已排除",
|
||||
"models_excluded_hint": "此模型已被 OAuth 排除"
|
||||
"models_excluded_badge": "已禁用",
|
||||
"models_excluded_hint": "此 OAuth 模型已被禁用",
|
||||
"status_toggle_label": "启用",
|
||||
"status_enabled_success": "已启用 \"{{name}}\"",
|
||||
"status_disabled_success": "已停用 \"{{name}}\"",
|
||||
"prefix_proxy_button": "配置 prefix/proxy_url",
|
||||
"prefix_proxy_loading": "正在加载凭证文件...",
|
||||
"prefix_proxy_source_label": "凭证 JSON",
|
||||
"prefix_label": "prefix",
|
||||
"proxy_url_label": "proxy_url",
|
||||
"prefix_placeholder": "",
|
||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
|
||||
"prefix_proxy_saved_success": "已更新 \"{{name}}\"",
|
||||
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
|
||||
"quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}"
|
||||
},
|
||||
"antigravity_quota": {
|
||||
"title": "Antigravity 额度",
|
||||
"empty_title": "暂无 Antigravity 认证",
|
||||
"empty_desc": "上传 Antigravity 认证文件后即可查看额度。",
|
||||
"idle": "尚未加载额度,请点击刷新按钮。",
|
||||
"idle": "点击此处刷新额度",
|
||||
"loading": "正在加载额度...",
|
||||
"load_failed": "额度获取失败:{{message}}",
|
||||
"missing_auth_index": "认证文件缺少 auth_index",
|
||||
@@ -377,7 +444,7 @@
|
||||
"title": "Codex 额度",
|
||||
"empty_title": "暂无 Codex 认证",
|
||||
"empty_desc": "上传 Codex 认证文件后即可查看额度。",
|
||||
"idle": "尚未加载额度,请点击刷新按钮。",
|
||||
"idle": "点击此处刷新额度",
|
||||
"loading": "正在加载额度...",
|
||||
"load_failed": "额度获取失败:{{message}}",
|
||||
"missing_auth_index": "认证文件缺少 auth_index",
|
||||
@@ -388,7 +455,8 @@
|
||||
"fetch_all": "获取全部",
|
||||
"primary_window": "5 小时限额",
|
||||
"secondary_window": "周限额",
|
||||
"code_review_window": "代码审查限额",
|
||||
"code_review_primary_window": "代码审查 5 小时限额",
|
||||
"code_review_secondary_window": "代码审查周限额",
|
||||
"plan_label": "套餐",
|
||||
"plan_plus": "Plus",
|
||||
"plan_team": "Team",
|
||||
@@ -398,7 +466,7 @@
|
||||
"title": "Gemini CLI 额度",
|
||||
"empty_title": "暂无 Gemini CLI 认证",
|
||||
"empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。",
|
||||
"idle": "尚未加载额度,请点击刷新按钮。",
|
||||
"idle": "点击此处刷新额度",
|
||||
"loading": "正在加载额度...",
|
||||
"load_failed": "额度获取失败:{{message}}",
|
||||
"missing_auth_index": "认证文件缺少 auth_index",
|
||||
@@ -408,6 +476,23 @@
|
||||
"fetch_all": "获取全部",
|
||||
"remaining_amount": "剩余 {{count}}"
|
||||
},
|
||||
"kiro_quota": {
|
||||
"title": "Kiro 额度",
|
||||
"empty_title": "暂无 Kiro 认证",
|
||||
"empty_desc": "上传 Kiro 认证文件后即可查看额度。",
|
||||
"idle": "尚未加载额度,请点击刷新按钮。",
|
||||
"loading": "正在加载额度...",
|
||||
"load_failed": "额度获取失败:{{message}}",
|
||||
"missing_auth_index": "认证文件缺少 auth_index",
|
||||
"empty_data": "暂无额度数据",
|
||||
"refresh_button": "刷新额度",
|
||||
"fetch_all": "获取全部",
|
||||
"subscription_label": "订阅类型",
|
||||
"base_credits_label": "基础额度",
|
||||
"bonus_credits_label": "赠送额度",
|
||||
"total_credits_label": "合计额度",
|
||||
"remaining_credits": "剩余 {{count}}"
|
||||
},
|
||||
"vertex_import": {
|
||||
"title": "Vertex JSON 登录",
|
||||
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
|
||||
@@ -428,41 +513,105 @@
|
||||
"result_file": "存储文件"
|
||||
},
|
||||
"oauth_excluded": {
|
||||
"title": "OAuth 排除列表",
|
||||
"title": "OAuth 模型禁用",
|
||||
"description": "按提供商分列展示,点击卡片编辑或删除;支持 * 通配符,范围跟随上方的配置文件过滤标签。",
|
||||
"add": "新增排除",
|
||||
"add_title": "新增提供商排除列表",
|
||||
"edit_title": "编辑 {{provider}} 的排除列表",
|
||||
"add": "新增禁用",
|
||||
"add_title": "新增提供商模型禁用",
|
||||
"edit_title": "编辑 {{provider}} 的模型禁用",
|
||||
"refresh": "刷新",
|
||||
"refreshing": "刷新中...",
|
||||
"provider_label": "提供商",
|
||||
"provider_auto": "跟随当前过滤",
|
||||
"provider_placeholder": "例如 gemini-cli / openai",
|
||||
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
|
||||
"models_label": "排除的模型",
|
||||
"models_placeholder": "gpt-4.1-mini\n*-preview",
|
||||
"models_hint": "逗号或换行分隔;留空保存将删除该提供商记录;支持 * 通配符。",
|
||||
"models_label": "禁用的模型",
|
||||
"models_loading": "正在加载模型列表...",
|
||||
"models_unsupported": "当前 CPA 版本不支持获取模型列表。",
|
||||
"models_loaded": "已加载 {{count}} 个模型,勾选要禁用的模型。",
|
||||
"no_models_available": "该提供商暂无可用模型列表。",
|
||||
"save": "保存/更新",
|
||||
"saving": "正在保存...",
|
||||
"save_success": "排除列表已更新",
|
||||
"save_failed": "更新排除列表失败",
|
||||
"save_success": "模型禁用已更新",
|
||||
"save_failed": "更新模型禁用失败",
|
||||
"delete": "删除提供商",
|
||||
"delete_confirm": "确定要删除 {{provider}} 的排除列表吗?",
|
||||
"delete_success": "已删除该提供商的排除列表",
|
||||
"delete_failed": "删除排除列表失败",
|
||||
"delete_confirm": "确定要删除 {{provider}} 的模型禁用吗?",
|
||||
"delete_success": "已删除该提供商的模型禁用",
|
||||
"delete_failed": "删除模型禁用失败",
|
||||
"deleting": "正在删除...",
|
||||
"no_models": "未配置排除模型",
|
||||
"model_count": "排除 {{count}} 个模型",
|
||||
"list_empty_all": "暂无任何提供商的排除列表,点击“新增排除”创建。",
|
||||
"list_empty_filtered": "当前筛选下没有排除项,点击“新增排除”添加。",
|
||||
"disconnected": "请先连接服务器以查看排除列表",
|
||||
"load_failed": "加载排除列表失败",
|
||||
"no_models": "未配置禁用模型",
|
||||
"model_count": "禁用 {{count}} 个模型",
|
||||
"list_empty_all": "暂无任何提供商的模型禁用,点击“新增禁用”创建。",
|
||||
"list_empty_filtered": "当前筛选下没有禁用项,点击“新增禁用”添加。",
|
||||
"disconnected": "请先连接服务器以查看模型禁用",
|
||||
"load_failed": "加载模型禁用失败",
|
||||
"provider_required": "请先填写提供商名称",
|
||||
"scope_all": "当前范围:全局(显示所有提供商)",
|
||||
"scope_provider": "当前范围:{{provider}}",
|
||||
"upgrade_required": "当前 CPA 版本不支持模型排除列表,请升级 CPA 版本",
|
||||
"upgrade_required": "当前 CPA 版本不支持 OAuth 模型禁用,请升级 CPA 版本",
|
||||
"upgrade_required_title": "需要升级 CPA 版本",
|
||||
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
||||
"upgrade_required_desc": "当前服务器版本不支持获取 OAuth 模型禁用功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
||||
},
|
||||
"oauth_model_alias": {
|
||||
"title": "OAuth 模型别名",
|
||||
"add": "新增别名",
|
||||
"add_title": "新增提供商模型别名",
|
||||
"provider_label": "提供商",
|
||||
"provider_placeholder": "例如 gemini-cli / vertex",
|
||||
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
|
||||
"model_source_loading": "正在加载模型列表...",
|
||||
"model_source_unsupported": "当前 CPA 版本不支持获取模型列表(仍可手动输入)。",
|
||||
"model_source_loaded": "已加载 {{count}} 个模型,可在“原模型名称”中下拉选择;也可手动输入。留空保存将删除该提供商记录;开启“保留原名”会在保留原模型名的同时新增别名。",
|
||||
"alias_label": "模型别名",
|
||||
"alias_name_placeholder": "原模型名称",
|
||||
"alias_placeholder": "别名 (必填)",
|
||||
"alias_fork_label": "保留原名",
|
||||
"add_alias": "添加别名",
|
||||
"save": "保存/更新",
|
||||
"save_success": "模型别名已更新",
|
||||
"save_failed": "更新模型别名失败",
|
||||
"delete": "删除提供商",
|
||||
"delete_confirm": "确定要删除 {{provider}} 的模型别名吗?",
|
||||
"delete_link_title": "取消链接",
|
||||
"delete_link_confirm": "确定取消 <code>{{sourceModel}}</code>({{provider}})到别名 <code>{{alias}}</code> 的映射?",
|
||||
"delete_alias_title": "删除别名",
|
||||
"delete_alias_confirm": "确定删除别名 <code>{{alias}}</code> 并取消所有关联模型的映射?",
|
||||
"delete_success": "已删除该提供商的模型别名",
|
||||
"delete_failed": "删除模型别名失败",
|
||||
"no_models": "未配置模型别名",
|
||||
"model_count": "{{count}} 条别名",
|
||||
"list_empty_all": "暂无任何提供商的模型别名,点击“新增别名”创建。",
|
||||
"chart_title": "全部映射概览",
|
||||
"diagram_providers": "提供商",
|
||||
"diagram_source_models": "源模型",
|
||||
"diagram_aliases": "别名",
|
||||
"diagram_expand": "展开",
|
||||
"diagram_collapse": "收起",
|
||||
"diagram_add_alias": "添加别名",
|
||||
"diagram_rename": "重命名",
|
||||
"diagram_rename_alias_title": "重命名别名",
|
||||
"diagram_rename_alias_label": "新别名名称",
|
||||
"diagram_rename_placeholder": "输入别名名称...",
|
||||
"diagram_delete_link": "取消链接 {{provider}} / {{name}}",
|
||||
"diagram_delete_alias": "删除别名",
|
||||
"diagram_please_enter_alias": "请输入别名名称。",
|
||||
"diagram_alias_exists": "该别名已存在。",
|
||||
"diagram_add_alias_title": "添加别名",
|
||||
"diagram_add_alias_label": "别名名称",
|
||||
"diagram_add_placeholder": "输入新别名名称...",
|
||||
"diagram_rename_btn": "重命名",
|
||||
"diagram_add_btn": "添加",
|
||||
"diagram_settings": "设置",
|
||||
"diagram_settings_title": "别名设置 — {{alias}}",
|
||||
"diagram_settings_source_title": "源模型设置",
|
||||
"diagram_settings_empty": "该别名暂无映射。",
|
||||
"diagram_tap_hint": "触摸设备上:先点选源模型,再点选别名即可建立映射。",
|
||||
"view_mode": "视图模式",
|
||||
"view_mode_diagram": "概览",
|
||||
"view_mode_list": "管理",
|
||||
"provider_required": "请先填写提供商名称",
|
||||
"upgrade_required": "当前 CPA 版本不支持模型别名功能,请升级 CPA 版本",
|
||||
"upgrade_required_title": "需要升级 CPA 版本",
|
||||
"upgrade_required_desc": "当前服务器版本不支持 OAuth 模型别名功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
||||
},
|
||||
"auth_login": {
|
||||
"codex_oauth_title": "Codex OAuth",
|
||||
@@ -513,6 +662,17 @@
|
||||
"gemini_cli_oauth_status_error": "认证失败:",
|
||||
"gemini_cli_oauth_start_error": "启动 Gemini CLI OAuth 失败:",
|
||||
"gemini_cli_oauth_polling_error": "检查认证状态失败:",
|
||||
"kimi_oauth_title": "Kimi OAuth",
|
||||
"kimi_oauth_button": "开始 Kimi 登录",
|
||||
"kimi_oauth_hint": "通过设备授权流程登录 Kimi 服务,自动获取并保存认证文件。",
|
||||
"kimi_oauth_url_label": "授权链接:",
|
||||
"kimi_open_link": "打开链接",
|
||||
"kimi_copy_link": "复制链接",
|
||||
"kimi_oauth_status_waiting": "等待认证中...",
|
||||
"kimi_oauth_status_success": "认证成功!",
|
||||
"kimi_oauth_status_error": "认证失败:",
|
||||
"kimi_oauth_start_error": "启动 Kimi OAuth 失败:",
|
||||
"kimi_oauth_polling_error": "检查认证状态失败:",
|
||||
"qwen_oauth_title": "Qwen OAuth",
|
||||
"qwen_oauth_button": "开始 Qwen 登录",
|
||||
"qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。",
|
||||
@@ -548,7 +708,7 @@
|
||||
"iflow_oauth_polling_error": "检查认证状态失败:",
|
||||
"iflow_cookie_title": "iFlow Cookie 登录",
|
||||
"iflow_cookie_label": "Cookie 内容:",
|
||||
"iflow_cookie_placeholder": "粘贴浏览器中的 Cookie,例如 sessionid=...;",
|
||||
"iflow_cookie_placeholder": "填入BXAuth值 以BXAuth=开头",
|
||||
"iflow_cookie_hint": "直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。",
|
||||
"iflow_cookie_key_hint": "提示:需在平台上先创建 Key。",
|
||||
"iflow_cookie_button": "提交 Cookie 登录",
|
||||
@@ -563,7 +723,26 @@
|
||||
"iflow_cookie_result_expired": "过期时间",
|
||||
"iflow_cookie_result_path": "保存路径",
|
||||
"iflow_cookie_result_type": "类型",
|
||||
"remote_access_disabled": "远程访问不支持此登录方式,请从本地 (localhost) 访问"
|
||||
"remote_access_disabled": "远程访问不支持此登录方式,请从本地 (localhost) 访问",
|
||||
"kiro_oauth_title": "Kiro OAuth",
|
||||
"kiro_oauth_hint": "通过 AWS SSO 登录 Kiro 服务,支持 Builder ID 和 Identity Center (IDC) 两种方式,或直接导入 Kiro IDE 的 refreshToken。",
|
||||
"kiro_builder_id_label": "AWS Builder ID 登录",
|
||||
"kiro_builder_id_hint": "使用 AWS Builder ID 账号登录,适用于个人开发者。",
|
||||
"kiro_builder_id_button": "使用 Builder ID 登录",
|
||||
"kiro_idc_label": "AWS Identity Center (IDC) 登录",
|
||||
"kiro_idc_hint": "使用企业 AWS Identity Center 登录,需要提供 Start URL 和 Region。",
|
||||
"kiro_idc_start_url_label": "Start URL",
|
||||
"kiro_idc_start_url_placeholder": "https://your-org.awsapps.com/start",
|
||||
"kiro_idc_region_label": "Region (可选)",
|
||||
"kiro_idc_region_placeholder": "us-east-1",
|
||||
"kiro_idc_button": "使用 IDC 登录",
|
||||
"kiro_token_import_label": "Token 导入",
|
||||
"kiro_token_import_hint": "从 Kiro IDE 导入 refreshToken,可在 Kiro IDE 的认证文件中找到。",
|
||||
"kiro_token_placeholder": "粘贴 refreshToken",
|
||||
"kiro_token_import_button": "导入 Token",
|
||||
"kiro_token_required": "请先填写 refreshToken",
|
||||
"kiro_token_import_success": "Kiro Token 导入成功",
|
||||
"kiro_token_import_error": "Kiro Token 导入失败:"
|
||||
},
|
||||
"usage_stats": {
|
||||
"title": "使用统计",
|
||||
@@ -671,6 +850,8 @@
|
||||
"loaded_lines": "已载入 {{count}} 行",
|
||||
"filtered_lines": "已过滤 {{count}} 行",
|
||||
"hide_management_logs": "屏蔽 {{prefix}} 日志",
|
||||
"show_raw_logs": "显示原始日志",
|
||||
"show_raw_logs_hint": "直接显示原始日志文本,方便多行复制",
|
||||
"search_placeholder": "搜索日志内容或关键字",
|
||||
"search_empty_title": "未找到匹配的日志",
|
||||
"search_empty_desc": "尝试更换关键字或清空筛选条件。",
|
||||
@@ -683,11 +864,11 @@
|
||||
"upgrade_required_desc": "当前服务器版本不支持日志查看功能,请升级到最新版本的 CLI Proxy API 以使用此功能。"
|
||||
},
|
||||
"config_management": {
|
||||
"title": "配置管理",
|
||||
"title": "配置面板",
|
||||
"editor_title": "配置文件",
|
||||
"reload": "重新加载",
|
||||
"save": "保存",
|
||||
"description": "查看并编辑服务器上的 config.yaml 配置文件。保存前请确认语法正确。",
|
||||
"description": "通过可视化或者源文件方式编辑 config.yaml 配置文件",
|
||||
"status_idle": "等待操作",
|
||||
"status_loading": "加载配置中...",
|
||||
"status_loaded": "配置已加载",
|
||||
@@ -704,15 +885,153 @@
|
||||
"search_button": "搜索",
|
||||
"search_no_results": "无结果",
|
||||
"search_prev": "上一个",
|
||||
"search_next": "下一个"
|
||||
"search_next": "下一个",
|
||||
"tabs": {
|
||||
"visual": "可视化编辑",
|
||||
"source": "源文件编辑"
|
||||
},
|
||||
"visual": {
|
||||
"sections": {
|
||||
"server": {
|
||||
"title": "服务器配置",
|
||||
"description": "基础服务器设置",
|
||||
"host": "主机地址",
|
||||
"port": "端口"
|
||||
},
|
||||
"tls": {
|
||||
"title": "TLS/SSL 配置",
|
||||
"description": "HTTPS 安全连接设置",
|
||||
"enable": "启用 TLS",
|
||||
"enable_desc": "启用 HTTPS 安全连接",
|
||||
"cert": "证书文件路径",
|
||||
"key": "私钥文件路径"
|
||||
},
|
||||
"remote": {
|
||||
"title": "远程管理",
|
||||
"description": "远程访问和控制面板设置",
|
||||
"allow_remote": "允许远程访问",
|
||||
"allow_remote_desc": "允许从其他主机访问管理接口",
|
||||
"disable_panel": "禁用控制面板",
|
||||
"disable_panel_desc": "禁用内置的 Web 控制面板",
|
||||
"secret_key": "管理密钥",
|
||||
"secret_key_placeholder": "设置管理密钥",
|
||||
"panel_repo": "面板仓库"
|
||||
},
|
||||
"auth": {
|
||||
"title": "认证配置",
|
||||
"description": "API 密钥与认证文件目录设置",
|
||||
"auth_dir": "认证文件目录 (auth-dir)",
|
||||
"auth_dir_hint": "存放认证文件的目录路径(支持 ~)"
|
||||
},
|
||||
"system": {
|
||||
"title": "系统配置",
|
||||
"description": "调试、日志、统计与性能调试设置",
|
||||
"debug": "调试模式",
|
||||
"debug_desc": "启用详细的调试日志",
|
||||
"commercial_mode": "商业模式",
|
||||
"commercial_mode_desc": "禁用高开销中间件以支持高并发",
|
||||
"logging_to_file": "写入日志文件",
|
||||
"logging_to_file_desc": "将日志保存到文件",
|
||||
"usage_statistics": "使用统计",
|
||||
"usage_statistics_desc": "收集使用统计信息",
|
||||
"logs_max_size": "日志文件大小限制 (MB)"
|
||||
},
|
||||
"network": {
|
||||
"title": "网络配置",
|
||||
"description": "代理、重试和路由设置",
|
||||
"proxy_url": "代理 URL",
|
||||
"request_retry": "请求重试次数",
|
||||
"max_retry_interval": "最大重试间隔 (秒)",
|
||||
"routing_strategy": "路由策略",
|
||||
"routing_strategy_hint": "选择凭据选择策略",
|
||||
"strategy_round_robin": "轮询 (Round Robin)",
|
||||
"strategy_fill_first": "填充优先 (Fill First)",
|
||||
"force_model_prefix": "强制模型前缀",
|
||||
"force_model_prefix_desc": "未带前缀的模型请求只使用无前缀凭据",
|
||||
"ws_auth": "WebSocket 认证",
|
||||
"ws_auth_desc": "启用 WebSocket 连接认证 (/v1/ws)"
|
||||
},
|
||||
"quota": {
|
||||
"title": "配额回退",
|
||||
"description": "配额耗尽时的回退策略",
|
||||
"switch_project": "切换项目",
|
||||
"switch_project_desc": "配额耗尽时自动切换到其他项目",
|
||||
"switch_preview_model": "切换预览模型",
|
||||
"switch_preview_model_desc": "配额耗尽时切换到预览版本模型"
|
||||
},
|
||||
"streaming": {
|
||||
"title": "流式传输配置",
|
||||
"description": "Keepalive 与 bootstrap 重试设置",
|
||||
"keepalive_seconds": "Keepalive 秒数",
|
||||
"keepalive_hint": "设置为 0 或留空表示禁用 keepalive",
|
||||
"bootstrap_retries": "Bootstrap 重试次数",
|
||||
"bootstrap_hint": "流式传输启动时(首包前)的重试次数",
|
||||
"nonstream_keepalive": "非流式 Keepalive 间隔 (秒)",
|
||||
"nonstream_keepalive_hint": "非流式响应时每隔 N 秒发送空行以防止空闲超时,设置为 0 或留空表示禁用",
|
||||
"disabled": "已禁用"
|
||||
},
|
||||
"payload": {
|
||||
"title": "Payload 配置",
|
||||
"description": "默认值、覆盖规则与过滤规则",
|
||||
"default_rules": "默认规则",
|
||||
"default_rules_desc": "当请求中未指定参数时,使用这些默认值",
|
||||
"override_rules": "覆盖规则",
|
||||
"override_rules_desc": "强制覆盖请求中的参数值",
|
||||
"filter_rules": "过滤规则",
|
||||
"filter_rules_desc": "通过 JSON Path 预过滤上游请求体,自动剔除不合规/冗余参数(Request Sanitization)"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"label": "API 密钥列表 (api-keys)",
|
||||
"add": "添加 API 密钥",
|
||||
"empty": "暂无 API 密钥",
|
||||
"hint": "每个条目代表一个 API 密钥(与 「API 密钥管理」 页面样式一致)",
|
||||
"edit_title": "编辑 API 密钥",
|
||||
"add_title": "添加 API 密钥",
|
||||
"input_label": "API 密钥",
|
||||
"input_placeholder": "粘贴你的 API 密钥",
|
||||
"input_hint": "此处仅修改本地配置文件内容,不会自动同步到 API 密钥管理接口",
|
||||
"error_empty": "请输入 API 密钥",
|
||||
"error_invalid": "API 密钥包含无效字符"
|
||||
},
|
||||
"payload_rules": {
|
||||
"rule": "规则",
|
||||
"models": "适用模型",
|
||||
"model_name": "模型名称",
|
||||
"provider_type": "供应商类型",
|
||||
"add_model": "添加模型",
|
||||
"params": "参数设置",
|
||||
"remove_params": "移除参数",
|
||||
"json_path": "JSON 路径 (如 temperature)",
|
||||
"json_path_filter": "JSON 路径 (gjson/sjson),如 generationConfig.thinkingConfig.thinkingBudget",
|
||||
"param_type": "参数类型",
|
||||
"add_param": "添加参数",
|
||||
"no_rules": "暂无规则",
|
||||
"add_rule": "添加规则",
|
||||
"value_string": "字符串值",
|
||||
"value_number": "数字值 (如 0.7)",
|
||||
"value_boolean": "true 或 false",
|
||||
"value_json": "JSON 值",
|
||||
"value_default": "值"
|
||||
},
|
||||
"common": {
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"cancel": "取消",
|
||||
"update": "更新",
|
||||
"add": "添加"
|
||||
}
|
||||
}
|
||||
},
|
||||
"quota_management": {
|
||||
"title": "配额管理",
|
||||
"description": "集中查看 OAuth 额度与剩余情况",
|
||||
"refresh_files": "刷新认证文件"
|
||||
"refresh_files": "刷新认证文件",
|
||||
"refresh_files_and_quota": "刷新认证文件&额度"
|
||||
},
|
||||
"system_info": {
|
||||
"title": "管理中心信息",
|
||||
"about_title": "CLI Proxy API Management Center",
|
||||
"connection_status_title": "连接状态",
|
||||
"api_status_label": "API 状态:",
|
||||
"config_status_label": "配置状态:",
|
||||
@@ -722,9 +1041,9 @@
|
||||
"not_loaded": "未加载",
|
||||
"seconds_ago": "秒前",
|
||||
"models_title": "可用模型列表",
|
||||
"models_desc": "展示 /v1/models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。",
|
||||
"models_desc": "展示 /models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。",
|
||||
"models_loading": "正在加载可用模型...",
|
||||
"models_empty": "未从 /v1/models 获取到模型数据",
|
||||
"models_empty": "未从 /models 获取到模型数据",
|
||||
"models_error": "获取模型列表失败",
|
||||
"models_count": "可用模型 {{count}} 个",
|
||||
"version_check_title": "版本检查",
|
||||
@@ -761,12 +1080,16 @@
|
||||
"quota_switch_preview_updated": "预览模型切换设置已更新",
|
||||
"usage_statistics_updated": "使用统计设置已更新",
|
||||
"logging_to_file_updated": "日志记录设置已更新",
|
||||
"logs_max_total_size_updated": "日志容量设置已更新",
|
||||
"request_log_updated": "请求日志设置已更新",
|
||||
"force_model_prefix_updated": "模型前缀设置已更新",
|
||||
"ws_auth_updated": "WebSocket 鉴权设置已更新",
|
||||
"routing_strategy_updated": "路由策略已更新",
|
||||
"login_storage_cleared": "本地登录信息已清理",
|
||||
"api_key_added": "API密钥添加成功",
|
||||
"api_key_updated": "API密钥更新成功",
|
||||
"api_key_deleted": "API密钥删除成功",
|
||||
"api_key_invalid_chars": "API密钥仅支持英文字母、数字和符号",
|
||||
"gemini_key_added": "Gemini密钥添加成功",
|
||||
"gemini_key_updated": "Gemini密钥更新成功",
|
||||
"gemini_key_deleted": "Gemini密钥删除成功",
|
||||
@@ -780,6 +1103,10 @@
|
||||
"claude_config_added": "Claude配置添加成功",
|
||||
"claude_config_updated": "Claude配置更新成功",
|
||||
"claude_config_deleted": "Claude配置删除成功",
|
||||
"vertex_config_added": "Vertex配置添加成功",
|
||||
"vertex_config_updated": "Vertex配置更新成功",
|
||||
"vertex_config_deleted": "Vertex配置删除成功",
|
||||
"vertex_base_url_required": "请填写Vertex Base URL",
|
||||
"config_enabled": "配置已启用",
|
||||
"config_disabled": "配置已停用",
|
||||
"field_required": "必填字段不能为空",
|
||||
@@ -809,12 +1136,15 @@
|
||||
"gemini_api_key": "Gemini API密钥",
|
||||
"codex_api_key": "Codex API密钥",
|
||||
"claude_api_key": "Claude API密钥",
|
||||
"commercial_mode_restart_required": "商业模式开关已变更,请重启服务后生效",
|
||||
"copy_failed": "复制失败",
|
||||
"link_copied": "已复制"
|
||||
},
|
||||
"language": {
|
||||
"switch": "语言",
|
||||
"chinese": "中文",
|
||||
"english": "English"
|
||||
"english": "English",
|
||||
"russian": "Русский"
|
||||
},
|
||||
"theme": {
|
||||
"switch": "主题",
|
||||
@@ -833,5 +1163,170 @@
|
||||
"build_date": "构建时间",
|
||||
"version": "管理中心版本",
|
||||
"author": "作者"
|
||||
},
|
||||
"monitor": {
|
||||
"title": "监控中心",
|
||||
"time_range": "时间范围",
|
||||
"today": "今天",
|
||||
"last_n_days": "最近 {{n}} 天",
|
||||
"api_filter": "API 查询",
|
||||
"api_filter_placeholder": "查询对应 API 数据",
|
||||
"apply": "查看",
|
||||
"no_data": "暂无数据",
|
||||
"requests": "请求",
|
||||
"kpi": {
|
||||
"requests": "请求数",
|
||||
"success": "成功",
|
||||
"failed": "失败",
|
||||
"rate": "成功率",
|
||||
"tokens": "Tokens",
|
||||
"input": "输入",
|
||||
"output": "输出",
|
||||
"reasoning": "思考",
|
||||
"cached": "缓存",
|
||||
"avg_tpm": "平均 TPM",
|
||||
"avg_rpm": "平均 RPM",
|
||||
"avg_rpd": "日均 RPD",
|
||||
"tokens_per_minute": "每分钟 Token",
|
||||
"requests_per_minute": "每分钟请求",
|
||||
"requests_per_day": "每日请求数"
|
||||
},
|
||||
"distribution": {
|
||||
"title": "模型用量分布",
|
||||
"by_requests": "按请求数",
|
||||
"by_tokens": "按 Token 数",
|
||||
"requests": "请求",
|
||||
"tokens": "Token",
|
||||
"request_share": "请求占比",
|
||||
"token_share": "Token 占比"
|
||||
},
|
||||
"trend": {
|
||||
"title": "每日用量趋势",
|
||||
"subtitle": "请求数与 Token 用量趋势",
|
||||
"requests": "请求数",
|
||||
"input_tokens": "输入 Token",
|
||||
"output_tokens": "输出 Token",
|
||||
"reasoning_tokens": "思考 Token",
|
||||
"cached_tokens": "缓存 Token"
|
||||
},
|
||||
"hourly": {
|
||||
"last_6h": "最近 6 小时",
|
||||
"last_12h": "最近 12 小时",
|
||||
"last_24h": "最近 24 小时",
|
||||
"all": "全部",
|
||||
"requests": "请求数",
|
||||
"success_rate": "成功率"
|
||||
},
|
||||
"hourly_model": {
|
||||
"title": "每小时模型请求分布",
|
||||
"models": "模型"
|
||||
},
|
||||
"hourly_token": {
|
||||
"title": "每小时 Token 用量",
|
||||
"subtitle": "按小时显示",
|
||||
"total": "总 Token",
|
||||
"input": "输入",
|
||||
"output": "输出",
|
||||
"reasoning": "思考",
|
||||
"cached": "缓存"
|
||||
},
|
||||
"channel": {
|
||||
"title": "渠道统计",
|
||||
"subtitle": "按来源渠道分类",
|
||||
"click_hint": "单击行展开模型详情",
|
||||
"all_channels": "全部渠道",
|
||||
"all_models": "全部模型",
|
||||
"all_status": "全部状态",
|
||||
"only_success": "仅成功",
|
||||
"only_failed": "仅失败",
|
||||
"header_name": "渠道",
|
||||
"header_count": "请求数",
|
||||
"header_rate": "成功率",
|
||||
"header_recent": "最近请求状态",
|
||||
"header_time": "最近请求时间",
|
||||
"model_details": "模型详情",
|
||||
"model": "模型",
|
||||
"success": "成功",
|
||||
"failed": "失败"
|
||||
},
|
||||
"time": {
|
||||
"today": "今天",
|
||||
"last_n_days": "{{n}} 天",
|
||||
"custom": "自定义",
|
||||
"to": "至",
|
||||
"apply": "应用"
|
||||
},
|
||||
"failure": {
|
||||
"title": "失败来源分析",
|
||||
"subtitle": "从来源渠道定位异常",
|
||||
"click_hint": "单击行展开详情",
|
||||
"no_failures": "暂无失败数据",
|
||||
"header_name": "渠道",
|
||||
"header_count": "失败数",
|
||||
"header_time": "最近失败",
|
||||
"header_models": "主要失败模型",
|
||||
"all_failed_models": "所有失败模型"
|
||||
},
|
||||
"logs": {
|
||||
"title": "请求日志",
|
||||
"total_count": "共 {{count}} 条",
|
||||
"sort_hint": "自动按时间倒序",
|
||||
"scroll_hint": "滚动浏览全部数据",
|
||||
"virtual_scroll_info": "当前显示 {{visible}} 行,共 {{total}} 条记录",
|
||||
"all_apis": "全部请求 API",
|
||||
"all_models": "全部请求模型",
|
||||
"all_sources": "全部请求渠道",
|
||||
"all_status": "全部请求状态",
|
||||
"all_provider_types": "全部请求类型",
|
||||
"success": "成功",
|
||||
"failed": "失败",
|
||||
"last_update": "最后更新",
|
||||
"manual_refresh": "手动刷新",
|
||||
"refresh_5s": "5秒刷新",
|
||||
"refresh_10s": "10秒刷新",
|
||||
"refresh_15s": "15秒刷新",
|
||||
"refresh_30s": "30秒刷新",
|
||||
"refresh_60s": "60秒刷新",
|
||||
"refresh_in_seconds": "{{seconds}}秒后刷新",
|
||||
"refreshing": "刷新中...",
|
||||
"header_auth": "认证索引",
|
||||
"header_api": "请求 API",
|
||||
"header_request_type": "请求类型",
|
||||
"header_model": "请求模型",
|
||||
"header_source": "请求渠道",
|
||||
"header_status": "请求状态",
|
||||
"header_recent": "最近请求状态",
|
||||
"header_rate": "成功率",
|
||||
"header_count": "请求数",
|
||||
"header_input": "输入",
|
||||
"header_output": "输出",
|
||||
"header_total": "总 Token",
|
||||
"header_time": "时间",
|
||||
"header_actions": "操作",
|
||||
"showing": "显示 {{start}}-{{end}} 条,共 {{total}} 条",
|
||||
"page_info": "第 {{current}}/{{total}} 页",
|
||||
"first_page": "首页",
|
||||
"prev_page": "上一页",
|
||||
"next_page": "下一页",
|
||||
"last_page": "末页",
|
||||
"disable": "禁用",
|
||||
"disable_model": "禁用此模型",
|
||||
"disabled": "已禁用",
|
||||
"removed": "已移除",
|
||||
"disabling": "禁用中...",
|
||||
"disable_confirm_title": "确认禁用模型",
|
||||
"disable_error": "禁用失败",
|
||||
"disable_error_no_provider": "无法识别渠道",
|
||||
"disable_error_provider_not_found": "未找到渠道配置:{{provider}}",
|
||||
"disable_not_supported": "{{provider}} 渠道不支持禁用操作",
|
||||
"disable_unsupported_title": "不支持自动禁用",
|
||||
"disable_unsupported_desc": "{{providerType}} 类型的渠道暂不支持自动禁用功能。",
|
||||
"disable_unsupported_guide_title": "手动操作指南",
|
||||
"disable_unsupported_guide_step1": "1. 前往「AI 提供商」页面",
|
||||
"disable_unsupported_guide_step2": "2. 找到对应的 {{providerType}} 配置",
|
||||
"disable_unsupported_guide_step3": "3. 编辑配置,移除模型「{{model}}」",
|
||||
"disable_unsupported_guide_step4": "4. 保存配置即可生效",
|
||||
"disable_unsupported_close": "我知道了"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
312
src/pages/AiProvidersAmpcodeEditPage.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { ampcodeApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { AmpcodeConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '@/components/providers/utils';
|
||||
import type { AmpcodeFormState } from '@/components/providers';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
|
||||
type LocationState = { fromAiProviders?: boolean } | null;
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return '';
|
||||
};
|
||||
|
||||
export function AiProvidersAmpcodeEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
const [form, setForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [mappingsDirty, setMappingsDirty] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const initializedRef = useRef(false);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const title = useMemo(() => t('ai_providers.ampcode_modal_title'), [t]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
const state = location.state as LocationState;
|
||||
if (state?.fromAiProviders) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
navigate('/ai-providers', { replace: true });
|
||||
}, [location.state, navigate]);
|
||||
|
||||
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleBack();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleBack]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
|
||||
setLoading(true);
|
||||
setLoaded(false);
|
||||
setMappingsDirty(false);
|
||||
setError('');
|
||||
setForm(buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null));
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const ampcode = await ampcodeApi.getAmpcode();
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
setLoaded(true);
|
||||
updateConfigValue('ampcode', ampcode);
|
||||
clearCache('ampcode');
|
||||
setForm(buildAmpcodeFormState(ampcode));
|
||||
} catch (err: unknown) {
|
||||
if (!mountedRef.current) return;
|
||||
setError(getErrorMessage(err) || t('notification.refresh_failed'));
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [clearCache, t, updateConfigValue]);
|
||||
|
||||
const clearAmpcodeUpstreamApiKey = async () => {
|
||||
showConfirmation({
|
||||
title: t('ai_providers.ampcode_clear_upstream_api_key_title', {
|
||||
defaultValue: 'Clear Upstream API Key',
|
||||
}),
|
||||
message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'),
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await ampcodeApi.clearUpstreamApiKey();
|
||||
const previous = config?.ampcode ?? {};
|
||||
const next: AmpcodeConfig = { ...previous };
|
||||
delete next.upstreamApiKey;
|
||||
updateConfigValue('ampcode', next);
|
||||
clearCache('ampcode');
|
||||
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
setError(message);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const performSaveAmpcode = async () => {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const upstreamUrl = form.upstreamUrl.trim();
|
||||
const overrideKey = form.upstreamApiKey.trim();
|
||||
const modelMappings = entriesToAmpcodeMappings(form.mappingEntries);
|
||||
|
||||
if (upstreamUrl) {
|
||||
await ampcodeApi.updateUpstreamUrl(upstreamUrl);
|
||||
} else {
|
||||
await ampcodeApi.clearUpstreamUrl();
|
||||
}
|
||||
|
||||
await ampcodeApi.updateForceModelMappings(form.forceModelMappings);
|
||||
|
||||
if (loaded || mappingsDirty) {
|
||||
if (modelMappings.length) {
|
||||
await ampcodeApi.saveModelMappings(modelMappings);
|
||||
} else {
|
||||
await ampcodeApi.clearModelMappings();
|
||||
}
|
||||
}
|
||||
|
||||
if (overrideKey) {
|
||||
await ampcodeApi.updateUpstreamApiKey(overrideKey);
|
||||
}
|
||||
|
||||
const previous = config?.ampcode ?? {};
|
||||
const next: AmpcodeConfig = {
|
||||
upstreamUrl: upstreamUrl || undefined,
|
||||
forceModelMappings: form.forceModelMappings,
|
||||
};
|
||||
|
||||
if (previous.upstreamApiKey) {
|
||||
next.upstreamApiKey = previous.upstreamApiKey;
|
||||
}
|
||||
|
||||
if (Array.isArray(previous.modelMappings)) {
|
||||
next.modelMappings = previous.modelMappings;
|
||||
}
|
||||
|
||||
if (overrideKey) {
|
||||
next.upstreamApiKey = overrideKey;
|
||||
}
|
||||
|
||||
if (loaded || mappingsDirty) {
|
||||
if (modelMappings.length) {
|
||||
next.modelMappings = modelMappings;
|
||||
} else {
|
||||
delete next.modelMappings;
|
||||
}
|
||||
}
|
||||
|
||||
updateConfigValue('ampcode', next);
|
||||
clearCache('ampcode');
|
||||
showNotification(t('notification.ampcode_updated'), 'success');
|
||||
handleBack();
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
setError(message);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAmpcode = async () => {
|
||||
if (!loaded && mappingsDirty) {
|
||||
showConfirmation({
|
||||
title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }),
|
||||
message: t('ai_providers.ampcode_mappings_overwrite_confirm'),
|
||||
variant: 'secondary',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: performSaveAmpcode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await performSaveAmpcode();
|
||||
};
|
||||
|
||||
const canSave = !disableControls && !saving && !loading;
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
ref={swipeRef}
|
||||
contentClassName={layoutStyles.content}
|
||||
title={title}
|
||||
onBack={handleBack}
|
||||
backLabel={t('common.back')}
|
||||
backAriaLabel={t('common.back')}
|
||||
rightAction={
|
||||
<Button size="sm" onClick={() => void saveAmpcode()} loading={saving} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
}
|
||||
isLoading={loading}
|
||||
loadingLabel={t('common.loading')}
|
||||
>
|
||||
<Card>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
<Input
|
||||
label={t('ai_providers.ampcode_upstream_url_label')}
|
||||
placeholder={t('ai_providers.ampcode_upstream_url_placeholder')}
|
||||
value={form.upstreamUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))}
|
||||
disabled={loading || saving || disableControls}
|
||||
hint={t('ai_providers.ampcode_upstream_url_hint')}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.ampcode_upstream_api_key_label')}
|
||||
placeholder={t('ai_providers.ampcode_upstream_api_key_placeholder')}
|
||||
type="password"
|
||||
value={form.upstreamApiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))}
|
||||
disabled={loading || saving || disableControls}
|
||||
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
marginTop: -8,
|
||||
marginBottom: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div className="hint" style={{ margin: 0 }}>
|
||||
{t('ai_providers.ampcode_upstream_api_key_current', {
|
||||
key: config?.ampcode?.upstreamApiKey
|
||||
? maskApiKey(config.ampcode.upstreamApiKey)
|
||||
: t('common.not_set'),
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => void clearAmpcodeUpstreamApiKey()}
|
||||
disabled={loading || saving || disableControls || !config?.ampcode?.upstreamApiKey}
|
||||
>
|
||||
{t('ai_providers.ampcode_clear_upstream_api_key')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.ampcode_force_model_mappings_label')}
|
||||
checked={form.forceModelMappings}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, forceModelMappings: value }))}
|
||||
disabled={loading || saving || disableControls}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.ampcode_force_model_mappings_hint')}</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.ampcode_model_mappings_label')}</label>
|
||||
<ModelInputList
|
||||
entries={form.mappingEntries}
|
||||
onChange={(entries) => {
|
||||
setMappingsDirty(true);
|
||||
setForm((prev) => ({ ...prev, mappingEntries: entries }));
|
||||
}}
|
||||
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
|
||||
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
|
||||
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
|
||||
disabled={loading || saving || disableControls}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
|
||||
</div>
|
||||
</Card>
|
||||
</SecondaryScreenShell>
|
||||
);
|
||||
}
|
||||
278
src/pages/AiProvidersClaudeEditPage.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||
import type { ProviderFormState } from '@/components/providers';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
|
||||
type LocationState = { fromAiProviders?: boolean } | null;
|
||||
|
||||
const buildEmptyForm = (): ProviderFormState => ({
|
||||
apiKey: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
proxyUrl: '',
|
||||
headers: [],
|
||||
models: [],
|
||||
excludedModels: [],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
excludedText: '',
|
||||
});
|
||||
|
||||
const parseIndexParam = (value: string | undefined) => {
|
||||
if (!value) return null;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
export function AiProvidersClaudeEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const params = useParams<{ index?: string }>();
|
||||
|
||||
const { showNotification } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
const [configs, setConfigs] = useState<ProviderKeyConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
|
||||
|
||||
const hasIndexParam = typeof params.index === 'string';
|
||||
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
|
||||
const invalidIndexParam = hasIndexParam && editIndex === null;
|
||||
|
||||
const initialData = useMemo(() => {
|
||||
if (editIndex === null) return undefined;
|
||||
return configs[editIndex];
|
||||
}, [configs, editIndex]);
|
||||
|
||||
const invalidIndex = editIndex !== null && !initialData;
|
||||
|
||||
const title =
|
||||
editIndex !== null
|
||||
? t('ai_providers.claude_edit_modal_title')
|
||||
: t('ai_providers.claude_add_modal_title');
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
const state = location.state as LocationState;
|
||||
if (state?.fromAiProviders) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
navigate('/ai-providers', { replace: true });
|
||||
}, [location.state, navigate]);
|
||||
|
||||
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleBack();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleBack]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
fetchConfig('claude-api-key')
|
||||
.then((value) => {
|
||||
if (cancelled) return;
|
||||
setConfigs(Array.isArray(value) ? (value as ProviderKeyConfig[]) : []);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
setError(message || t('notification.refresh_failed'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchConfig, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
if (initialData) {
|
||||
setForm({
|
||||
...initialData,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
modelEntries: modelsToEntries(initialData.models),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
}, [initialData, loading]);
|
||||
|
||||
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload: ProviderKeyConfig = {
|
||||
apiKey: form.apiKey.trim(),
|
||||
prefix: form.prefix?.trim() || undefined,
|
||||
baseUrl: (form.baseUrl ?? '').trim() || undefined,
|
||||
proxyUrl: form.proxyUrl?.trim() || undefined,
|
||||
headers: buildHeaderObject(form.headers),
|
||||
models: form.modelEntries
|
||||
.map((entry) => {
|
||||
const name = entry.name.trim();
|
||||
if (!name) return null;
|
||||
const alias = entry.alias.trim();
|
||||
return { name, alias: alias || name };
|
||||
})
|
||||
.filter(Boolean) as ProviderKeyConfig['models'],
|
||||
excludedModels: parseExcludedModels(form.excludedText),
|
||||
};
|
||||
|
||||
const nextList =
|
||||
editIndex !== null
|
||||
? configs.map((item, idx) => (idx === editIndex ? payload : item))
|
||||
: [...configs, payload];
|
||||
|
||||
await providersApi.saveClaudeConfigs(nextList);
|
||||
updateConfigValue('claude-api-key', nextList);
|
||||
clearCache('claude-api-key');
|
||||
showNotification(
|
||||
editIndex !== null ? t('notification.claude_config_updated') : t('notification.claude_config_added'),
|
||||
'success'
|
||||
);
|
||||
handleBack();
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
setError(message);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
canSave,
|
||||
clearCache,
|
||||
configs,
|
||||
editIndex,
|
||||
form,
|
||||
handleBack,
|
||||
showNotification,
|
||||
t,
|
||||
updateConfigValue,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
ref={swipeRef}
|
||||
contentClassName={layoutStyles.content}
|
||||
title={title}
|
||||
onBack={handleBack}
|
||||
backLabel={t('common.back')}
|
||||
backAriaLabel={t('common.back')}
|
||||
rightAction={
|
||||
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
}
|
||||
isLoading={loading}
|
||||
loadingLabel={t('common.loading')}
|
||||
>
|
||||
<Card>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{invalidIndexParam || invalidIndex ? (
|
||||
<div className="hint">Invalid provider index.</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
label={t('ai_providers.claude_add_modal_key_label')}
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
placeholder={t('ai_providers.prefix_placeholder')}
|
||||
value={form.prefix ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||
hint={t('ai_providers.prefix_hint')}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.claude_add_modal_url_label')}
|
||||
value={form.baseUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.claude_add_modal_proxy_label')}
|
||||
value={form.proxyUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<HeaderInputList
|
||||
entries={form.headers}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.claude_models_label')}</label>
|
||||
<ModelInputList
|
||||
entries={form.modelEntries}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||
addLabel={t('ai_providers.claude_models_add_btn')}
|
||||
namePlaceholder={t('common.model_name_placeholder')}
|
||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||
value={form.excludedText}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||
rows={4}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</SecondaryScreenShell>
|
||||
);
|
||||
}
|
||||
267
src/pages/AiProvidersCodexEditPage.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { entriesToModels } from '@/components/ui/modelInputListUtils';
|
||||
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||
import type { ProviderFormState } from '@/components/providers';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
|
||||
type LocationState = { fromAiProviders?: boolean } | null;
|
||||
|
||||
const buildEmptyForm = (): ProviderFormState => ({
|
||||
apiKey: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
proxyUrl: '',
|
||||
headers: [],
|
||||
models: [],
|
||||
excludedModels: [],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
excludedText: '',
|
||||
});
|
||||
|
||||
const parseIndexParam = (value: string | undefined) => {
|
||||
if (!value) return null;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
export function AiProvidersCodexEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const params = useParams<{ index?: string }>();
|
||||
|
||||
const { showNotification } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
const [configs, setConfigs] = useState<ProviderKeyConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
|
||||
|
||||
const hasIndexParam = typeof params.index === 'string';
|
||||
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
|
||||
const invalidIndexParam = hasIndexParam && editIndex === null;
|
||||
|
||||
const initialData = useMemo(() => {
|
||||
if (editIndex === null) return undefined;
|
||||
return configs[editIndex];
|
||||
}, [configs, editIndex]);
|
||||
|
||||
const invalidIndex = editIndex !== null && !initialData;
|
||||
|
||||
const title =
|
||||
editIndex !== null ? t('ai_providers.codex_edit_modal_title') : t('ai_providers.codex_add_modal_title');
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
const state = location.state as LocationState;
|
||||
if (state?.fromAiProviders) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
navigate('/ai-providers', { replace: true });
|
||||
}, [location.state, navigate]);
|
||||
|
||||
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleBack();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleBack]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
fetchConfig('codex-api-key')
|
||||
.then((value) => {
|
||||
if (cancelled) return;
|
||||
setConfigs(Array.isArray(value) ? (value as ProviderKeyConfig[]) : []);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
setError(message || t('notification.refresh_failed'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchConfig, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
if (initialData) {
|
||||
setForm({
|
||||
...initialData,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
modelEntries: (initialData.models || []).map((model) => ({
|
||||
name: model.name,
|
||||
alias: model.alias ?? '',
|
||||
})),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
}, [initialData, loading]);
|
||||
|
||||
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
|
||||
const trimmedBaseUrl = (form.baseUrl ?? '').trim();
|
||||
const baseUrl = trimmedBaseUrl || undefined;
|
||||
if (!baseUrl) {
|
||||
showNotification(t('notification.codex_base_url_required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload: ProviderKeyConfig = {
|
||||
apiKey: form.apiKey.trim(),
|
||||
prefix: form.prefix?.trim() || undefined,
|
||||
baseUrl,
|
||||
proxyUrl: form.proxyUrl?.trim() || undefined,
|
||||
headers: buildHeaderObject(form.headers),
|
||||
models: entriesToModels(form.modelEntries),
|
||||
excludedModels: parseExcludedModels(form.excludedText),
|
||||
};
|
||||
|
||||
const nextList =
|
||||
editIndex !== null
|
||||
? configs.map((item, idx) => (idx === editIndex ? payload : item))
|
||||
: [...configs, payload];
|
||||
|
||||
await providersApi.saveCodexConfigs(nextList);
|
||||
updateConfigValue('codex-api-key', nextList);
|
||||
clearCache('codex-api-key');
|
||||
showNotification(
|
||||
editIndex !== null ? t('notification.codex_config_updated') : t('notification.codex_config_added'),
|
||||
'success'
|
||||
);
|
||||
handleBack();
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
setError(message);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
canSave,
|
||||
clearCache,
|
||||
configs,
|
||||
editIndex,
|
||||
form,
|
||||
handleBack,
|
||||
showNotification,
|
||||
t,
|
||||
updateConfigValue,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
ref={swipeRef}
|
||||
contentClassName={layoutStyles.content}
|
||||
title={title}
|
||||
onBack={handleBack}
|
||||
backLabel={t('common.back')}
|
||||
backAriaLabel={t('common.back')}
|
||||
rightAction={
|
||||
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
}
|
||||
isLoading={loading}
|
||||
loadingLabel={t('common.loading')}
|
||||
>
|
||||
<Card>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{invalidIndexParam || invalidIndex ? (
|
||||
<div className="hint">Invalid provider index.</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
label={t('ai_providers.codex_add_modal_key_label')}
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
placeholder={t('ai_providers.prefix_placeholder')}
|
||||
value={form.prefix ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||
hint={t('ai_providers.prefix_hint')}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.codex_add_modal_url_label')}
|
||||
value={form.baseUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.codex_add_modal_proxy_label')}
|
||||
value={form.proxyUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<HeaderInputList
|
||||
entries={form.headers}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||
value={form.excludedText}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||
rows={4}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</SecondaryScreenShell>
|
||||
);
|
||||
}
|
||||
5
src/pages/AiProvidersEditLayout.module.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.content {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
246
src/pages/AiProvidersGeminiEditPage.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { GeminiKeyConfig } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||
import type { GeminiFormState } from '@/components/providers';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
|
||||
type LocationState = { fromAiProviders?: boolean } | null;
|
||||
|
||||
const buildEmptyForm = (): GeminiFormState => ({
|
||||
apiKey: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
headers: [],
|
||||
excludedModels: [],
|
||||
excludedText: '',
|
||||
});
|
||||
|
||||
const parseIndexParam = (value: string | undefined) => {
|
||||
if (!value) return null;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
export function AiProvidersGeminiEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const params = useParams<{ index?: string }>();
|
||||
|
||||
const { showNotification } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
const [configs, setConfigs] = useState<GeminiKeyConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<GeminiFormState>(() => buildEmptyForm());
|
||||
|
||||
const hasIndexParam = typeof params.index === 'string';
|
||||
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
|
||||
const invalidIndexParam = hasIndexParam && editIndex === null;
|
||||
|
||||
const initialData = useMemo(() => {
|
||||
if (editIndex === null) return undefined;
|
||||
return configs[editIndex];
|
||||
}, [configs, editIndex]);
|
||||
|
||||
const invalidIndex = editIndex !== null && !initialData;
|
||||
|
||||
const title =
|
||||
editIndex !== null ? t('ai_providers.gemini_edit_modal_title') : t('ai_providers.gemini_add_modal_title');
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
const state = location.state as LocationState;
|
||||
if (state?.fromAiProviders) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
navigate('/ai-providers', { replace: true });
|
||||
}, [location.state, navigate]);
|
||||
|
||||
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleBack();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleBack]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
fetchConfig('gemini-api-key')
|
||||
.then((value) => {
|
||||
if (cancelled) return;
|
||||
setConfigs(Array.isArray(value) ? (value as GeminiKeyConfig[]) : []);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
setError(message || t('notification.refresh_failed'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchConfig, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
if (initialData) {
|
||||
setForm({
|
||||
...initialData,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
}, [initialData, loading]);
|
||||
|
||||
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload: GeminiKeyConfig = {
|
||||
apiKey: form.apiKey.trim(),
|
||||
prefix: form.prefix?.trim() || undefined,
|
||||
baseUrl: form.baseUrl?.trim() || undefined,
|
||||
headers: buildHeaderObject(form.headers),
|
||||
excludedModels: parseExcludedModels(form.excludedText),
|
||||
};
|
||||
|
||||
const nextList =
|
||||
editIndex !== null
|
||||
? configs.map((item, idx) => (idx === editIndex ? payload : item))
|
||||
: [...configs, payload];
|
||||
|
||||
await providersApi.saveGeminiKeys(nextList);
|
||||
updateConfigValue('gemini-api-key', nextList);
|
||||
clearCache('gemini-api-key');
|
||||
showNotification(
|
||||
editIndex !== null ? t('notification.gemini_key_updated') : t('notification.gemini_key_added'),
|
||||
'success'
|
||||
);
|
||||
handleBack();
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
setError(message);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
canSave,
|
||||
clearCache,
|
||||
configs,
|
||||
editIndex,
|
||||
form,
|
||||
handleBack,
|
||||
showNotification,
|
||||
t,
|
||||
updateConfigValue,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
ref={swipeRef}
|
||||
contentClassName={layoutStyles.content}
|
||||
title={title}
|
||||
onBack={handleBack}
|
||||
backLabel={t('common.back')}
|
||||
backAriaLabel={t('common.back')}
|
||||
rightAction={
|
||||
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
}
|
||||
isLoading={loading}
|
||||
loadingLabel={t('common.loading')}
|
||||
>
|
||||
<Card>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{invalidIndexParam || invalidIndex ? (
|
||||
<div className="hint">Invalid provider index.</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
label={t('ai_providers.gemini_add_modal_key_label')}
|
||||
placeholder={t('ai_providers.gemini_add_modal_key_placeholder')}
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
placeholder={t('ai_providers.prefix_placeholder')}
|
||||
value={form.prefix ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||
hint={t('ai_providers.prefix_hint')}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.gemini_base_url_label')}
|
||||
placeholder={t('ai_providers.gemini_base_url_placeholder')}
|
||||
value={form.baseUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<HeaderInputList
|
||||
entries={form.headers}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||
value={form.excludedText}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||
rows={4}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</SecondaryScreenShell>
|
||||
);
|
||||
}
|
||||
370
src/pages/AiProvidersOpenAIEditLayout.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore, useOpenAIEditDraftStore } from '@/stores';
|
||||
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import type { ApiKeyEntry, OpenAIProviderConfig } from '@/types';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { buildApiKeyEntry } from '@/components/providers/utils';
|
||||
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
|
||||
|
||||
type LocationState = { fromAiProviders?: boolean } | null;
|
||||
|
||||
export type OpenAIEditOutletContext = {
|
||||
hasIndexParam: boolean;
|
||||
editIndex: number | null;
|
||||
invalidIndexParam: boolean;
|
||||
invalidIndex: boolean;
|
||||
disableControls: boolean;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
form: OpenAIFormState;
|
||||
setForm: Dispatch<SetStateAction<OpenAIFormState>>;
|
||||
testModel: string;
|
||||
setTestModel: Dispatch<SetStateAction<string>>;
|
||||
testStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
setTestStatus: Dispatch<SetStateAction<'idle' | 'loading' | 'success' | 'error'>>;
|
||||
testMessage: string;
|
||||
setTestMessage: Dispatch<SetStateAction<string>>;
|
||||
availableModels: string[];
|
||||
handleBack: () => void;
|
||||
handleSave: () => Promise<void>;
|
||||
mergeDiscoveredModels: (selectedModels: ModelInfo[]) => void;
|
||||
};
|
||||
|
||||
const buildEmptyForm = (): OpenAIFormState => ({
|
||||
name: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
headers: [],
|
||||
apiKeyEntries: [buildApiKeyEntry()],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
testModel: undefined,
|
||||
});
|
||||
|
||||
const parseIndexParam = (value: string | undefined) => {
|
||||
if (!value) return null;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return '';
|
||||
};
|
||||
|
||||
export function AiProvidersOpenAIEditLayout() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
|
||||
const params = useParams<{ index?: string }>();
|
||||
const hasIndexParam = typeof params.index === 'string';
|
||||
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
|
||||
const invalidIndexParam = hasIndexParam && editIndex === null;
|
||||
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
const isCacheValid = useConfigStore((state) => state.isCacheValid);
|
||||
|
||||
const [providers, setProviders] = useState<OpenAIProviderConfig[]>(
|
||||
() => config?.openaiCompatibility ?? []
|
||||
);
|
||||
const [loading, setLoading] = useState(
|
||||
() => !isCacheValid('openai-compatibility')
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const draftKey = useMemo(() => {
|
||||
if (invalidIndexParam) return `openai:invalid:${params.index ?? 'unknown'}`;
|
||||
if (editIndex === null) return 'openai:new';
|
||||
return `openai:${editIndex}`;
|
||||
}, [editIndex, invalidIndexParam, params.index]);
|
||||
|
||||
const draft = useOpenAIEditDraftStore((state) => state.drafts[draftKey]);
|
||||
const ensureDraft = useOpenAIEditDraftStore((state) => state.ensureDraft);
|
||||
const initDraft = useOpenAIEditDraftStore((state) => state.initDraft);
|
||||
const clearDraft = useOpenAIEditDraftStore((state) => state.clearDraft);
|
||||
const setDraftForm = useOpenAIEditDraftStore((state) => state.setDraftForm);
|
||||
const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel);
|
||||
const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus);
|
||||
const setDraftTestMessage = useOpenAIEditDraftStore((state) => state.setDraftTestMessage);
|
||||
|
||||
const form = draft?.form ?? buildEmptyForm();
|
||||
const testModel = draft?.testModel ?? '';
|
||||
const testStatus = draft?.testStatus ?? 'idle';
|
||||
const testMessage = draft?.testMessage ?? '';
|
||||
|
||||
const setForm: Dispatch<SetStateAction<OpenAIFormState>> = useCallback(
|
||||
(action) => {
|
||||
setDraftForm(draftKey, action);
|
||||
},
|
||||
[draftKey, setDraftForm]
|
||||
);
|
||||
|
||||
const setTestModel: Dispatch<SetStateAction<string>> = useCallback(
|
||||
(action) => {
|
||||
setDraftTestModel(draftKey, action);
|
||||
},
|
||||
[draftKey, setDraftTestModel]
|
||||
);
|
||||
|
||||
const setTestStatus: Dispatch<SetStateAction<'idle' | 'loading' | 'success' | 'error'>> =
|
||||
useCallback(
|
||||
(action) => {
|
||||
setDraftTestStatus(draftKey, action);
|
||||
},
|
||||
[draftKey, setDraftTestStatus]
|
||||
);
|
||||
|
||||
const setTestMessage: Dispatch<SetStateAction<string>> = useCallback(
|
||||
(action) => {
|
||||
setDraftTestMessage(draftKey, action);
|
||||
},
|
||||
[draftKey, setDraftTestMessage]
|
||||
);
|
||||
|
||||
const initialData = useMemo(() => {
|
||||
if (editIndex === null) return undefined;
|
||||
return providers[editIndex];
|
||||
}, [editIndex, providers]);
|
||||
|
||||
const invalidIndex = editIndex !== null && !initialData;
|
||||
|
||||
const availableModels = useMemo(
|
||||
() => form.modelEntries.map((entry) => entry.name.trim()).filter(Boolean),
|
||||
[form.modelEntries]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
ensureDraft(draftKey);
|
||||
}, [draftKey, ensureDraft]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
clearDraft(draftKey);
|
||||
const state = location.state as LocationState;
|
||||
if (state?.fromAiProviders) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
navigate('/ai-providers', { replace: true });
|
||||
}, [clearDraft, draftKey, location.state, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const hasValidCache = isCacheValid('openai-compatibility');
|
||||
if (!hasValidCache) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
fetchConfig('openai-compatibility')
|
||||
.then((value) => {
|
||||
if (cancelled) return;
|
||||
setProviders(Array.isArray(value) ? (value as OpenAIProviderConfig[]) : []);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message = getErrorMessage(err) || t('notification.refresh_failed');
|
||||
showNotification(`${t('notification.load_failed')}: ${message}`, 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchConfig, isCacheValid, showNotification, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (draft?.initialized) return;
|
||||
|
||||
if (initialData) {
|
||||
const modelEntries = modelsToEntries(initialData.models);
|
||||
const seededForm: OpenAIFormState = {
|
||||
name: initialData.name,
|
||||
prefix: initialData.prefix ?? '',
|
||||
baseUrl: initialData.baseUrl,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
testModel: initialData.testModel,
|
||||
modelEntries,
|
||||
apiKeyEntries: initialData.apiKeyEntries?.length
|
||||
? initialData.apiKeyEntries
|
||||
: [buildApiKeyEntry()],
|
||||
};
|
||||
|
||||
const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
|
||||
const initialTestModel =
|
||||
initialData.testModel && available.includes(initialData.testModel)
|
||||
? initialData.testModel
|
||||
: available[0] || '';
|
||||
initDraft(draftKey, {
|
||||
form: seededForm,
|
||||
testModel: initialTestModel,
|
||||
testStatus: 'idle',
|
||||
testMessage: '',
|
||||
});
|
||||
} else {
|
||||
initDraft(draftKey, {
|
||||
form: buildEmptyForm(),
|
||||
testModel: '',
|
||||
testStatus: 'idle',
|
||||
testMessage: '',
|
||||
});
|
||||
}
|
||||
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
if (availableModels.length === 0) {
|
||||
if (testModel) {
|
||||
setTestModel('');
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!testModel || !availableModels.includes(testModel)) {
|
||||
setTestModel(availableModels[0]);
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
}
|
||||
}, [availableModels, loading, setTestMessage, setTestModel, setTestStatus, testModel]);
|
||||
|
||||
const mergeDiscoveredModels = useCallback(
|
||||
(selectedModels: ModelInfo[]) => {
|
||||
if (!selectedModels.length) return;
|
||||
|
||||
let addedCount = 0;
|
||||
setForm((prev) => {
|
||||
const mergedMap = new Map<string, ModelEntry>();
|
||||
prev.modelEntries.forEach((entry) => {
|
||||
const name = entry.name.trim();
|
||||
if (!name) return;
|
||||
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
|
||||
});
|
||||
|
||||
selectedModels.forEach((model) => {
|
||||
const name = model.name.trim();
|
||||
if (!name || mergedMap.has(name)) return;
|
||||
mergedMap.set(name, { name, alias: model.alias ?? '' });
|
||||
addedCount += 1;
|
||||
});
|
||||
|
||||
const mergedEntries = Array.from(mergedMap.values());
|
||||
return {
|
||||
...prev,
|
||||
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
|
||||
};
|
||||
});
|
||||
|
||||
if (addedCount > 0) {
|
||||
showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success');
|
||||
}
|
||||
},
|
||||
[setForm, showNotification, t]
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const name = form.name.trim();
|
||||
const baseUrl = form.baseUrl.trim();
|
||||
|
||||
if (!name || !baseUrl) {
|
||||
showNotification(t('notification.openai_provider_required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: OpenAIProviderConfig = {
|
||||
name,
|
||||
prefix: form.prefix?.trim() || undefined,
|
||||
baseUrl,
|
||||
headers: buildHeaderObject(form.headers),
|
||||
apiKeyEntries: form.apiKeyEntries.map((entry: ApiKeyEntry) => ({
|
||||
apiKey: entry.apiKey.trim(),
|
||||
proxyUrl: entry.proxyUrl?.trim() || undefined,
|
||||
headers: entry.headers,
|
||||
})),
|
||||
};
|
||||
const resolvedTestModel = testModel.trim();
|
||||
if (resolvedTestModel) payload.testModel = resolvedTestModel;
|
||||
const models = entriesToModels(form.modelEntries);
|
||||
if (models.length) payload.models = models;
|
||||
|
||||
const nextList =
|
||||
editIndex !== null
|
||||
? providers.map((item, idx) => (idx === editIndex ? payload : item))
|
||||
: [...providers, payload];
|
||||
|
||||
await providersApi.saveOpenAIProviders(nextList);
|
||||
setProviders(nextList);
|
||||
updateConfigValue('openai-compatibility', nextList);
|
||||
clearCache('openai-compatibility');
|
||||
showNotification(
|
||||
editIndex !== null
|
||||
? t('notification.openai_provider_updated')
|
||||
: t('notification.openai_provider_added'),
|
||||
'success'
|
||||
);
|
||||
handleBack();
|
||||
} catch (err: unknown) {
|
||||
showNotification(`${t('notification.update_failed')}: ${getErrorMessage(err)}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
clearCache,
|
||||
editIndex,
|
||||
form,
|
||||
handleBack,
|
||||
providers,
|
||||
testModel,
|
||||
showNotification,
|
||||
t,
|
||||
updateConfigValue,
|
||||
]);
|
||||
|
||||
const resolvedLoading = !draft?.initialized;
|
||||
|
||||
return (
|
||||
<Outlet
|
||||
context={{
|
||||
hasIndexParam,
|
||||
editIndex,
|
||||
invalidIndexParam,
|
||||
invalidIndex,
|
||||
disableControls,
|
||||
loading: resolvedLoading,
|
||||
saving,
|
||||
form,
|
||||
setForm,
|
||||
testModel,
|
||||
setTestModel,
|
||||
testStatus,
|
||||
setTestStatus,
|
||||
testMessage,
|
||||
setTestMessage,
|
||||
availableModels,
|
||||
handleBack,
|
||||
handleSave,
|
||||
mergeDiscoveredModels,
|
||||
} satisfies OpenAIEditOutletContext}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
374
src/pages/AiProvidersOpenAIEditPage.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import type { ApiKeyEntry } from '@/types';
|
||||
import { buildHeaderObject } from '@/utils/headers';
|
||||
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '@/components/providers/utils';
|
||||
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
|
||||
import styles from './AiProvidersPage.module.scss';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
|
||||
const OPENAI_TEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return '';
|
||||
};
|
||||
|
||||
export function AiProvidersOpenAIEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const {
|
||||
hasIndexParam,
|
||||
invalidIndexParam,
|
||||
invalidIndex,
|
||||
disableControls,
|
||||
loading,
|
||||
saving,
|
||||
form,
|
||||
setForm,
|
||||
testModel,
|
||||
setTestModel,
|
||||
testStatus,
|
||||
setTestStatus,
|
||||
testMessage,
|
||||
setTestMessage,
|
||||
availableModels,
|
||||
handleBack,
|
||||
handleSave,
|
||||
} = useOutletContext<OpenAIEditOutletContext>();
|
||||
|
||||
const title = hasIndexParam
|
||||
? t('ai_providers.openai_edit_modal_title')
|
||||
: t('ai_providers.openai_add_modal_title');
|
||||
|
||||
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleBack();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleBack]);
|
||||
|
||||
const canSave = !disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex;
|
||||
|
||||
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
|
||||
const list = entries.length ? entries : [buildApiKeyEntry()];
|
||||
|
||||
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
|
||||
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
|
||||
setForm((prev) => ({ ...prev, apiKeyEntries: next }));
|
||||
};
|
||||
|
||||
const removeEntry = (idx: number) => {
|
||||
const next = list.filter((_, i) => i !== idx);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
|
||||
}));
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{list.map((entry, index) => (
|
||||
<div key={index} className="item-row">
|
||||
<div className="item-meta">
|
||||
<Input
|
||||
label={`${t('common.api_key')} #${index + 1}`}
|
||||
value={entry.apiKey}
|
||||
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
|
||||
disabled={saving || disableControls}
|
||||
/>
|
||||
<Input
|
||||
label={t('common.proxy_url')}
|
||||
value={entry.proxyUrl ?? ''}
|
||||
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
|
||||
disabled={saving || disableControls}
|
||||
/>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeEntry(index)}
|
||||
disabled={saving || disableControls || list.length <= 1}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={addEntry}
|
||||
disabled={saving || disableControls}
|
||||
>
|
||||
{t('ai_providers.openai_keys_add_btn')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const openOpenaiModelDiscovery = () => {
|
||||
const baseUrl = form.baseUrl.trim();
|
||||
if (!baseUrl) {
|
||||
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
|
||||
return;
|
||||
}
|
||||
navigate('models');
|
||||
};
|
||||
|
||||
const testOpenaiProviderConnection = async () => {
|
||||
const baseUrl = form.baseUrl.trim();
|
||||
if (!baseUrl) {
|
||||
const message = t('notification.openai_test_url_required');
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl);
|
||||
if (!endpoint) {
|
||||
const message = t('notification.openai_test_url_required');
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const firstKeyEntry = form.apiKeyEntries.find((entry) => entry.apiKey?.trim());
|
||||
if (!firstKeyEntry) {
|
||||
const message = t('notification.openai_test_key_required');
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const modelName = testModel.trim() || availableModels[0] || '';
|
||||
if (!modelName) {
|
||||
const message = t('notification.openai_test_model_required');
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const customHeaders = buildHeaderObject(form.headers);
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...customHeaders,
|
||||
};
|
||||
if (!headers.Authorization && !headers['authorization']) {
|
||||
headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`;
|
||||
}
|
||||
|
||||
setTestStatus('loading');
|
||||
setTestMessage(t('ai_providers.openai_test_running'));
|
||||
|
||||
try {
|
||||
const result = await apiCallApi.request(
|
||||
{
|
||||
method: 'POST',
|
||||
url: endpoint,
|
||||
header: Object.keys(headers).length ? headers : undefined,
|
||||
data: JSON.stringify({
|
||||
model: modelName,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
stream: false,
|
||||
max_tokens: 5,
|
||||
}),
|
||||
},
|
||||
{ timeout: OPENAI_TEST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw new Error(getApiCallErrorMessage(result));
|
||||
}
|
||||
|
||||
setTestStatus('success');
|
||||
setTestMessage(t('ai_providers.openai_test_success'));
|
||||
} catch (err: unknown) {
|
||||
setTestStatus('error');
|
||||
const message = getErrorMessage(err);
|
||||
const errorCode =
|
||||
typeof err === 'object' && err !== null && 'code' in err
|
||||
? String((err as { code?: string }).code)
|
||||
: '';
|
||||
const isTimeout = errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
|
||||
if (isTimeout) {
|
||||
setTestMessage(
|
||||
t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
|
||||
);
|
||||
} else {
|
||||
setTestMessage(`${t('ai_providers.openai_test_failed')}: ${message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
ref={swipeRef}
|
||||
contentClassName={layoutStyles.content}
|
||||
title={title}
|
||||
onBack={handleBack}
|
||||
backLabel={t('common.back')}
|
||||
backAriaLabel={t('common.back')}
|
||||
rightAction={
|
||||
<Button size="sm" onClick={() => void handleSave()} loading={saving} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
}
|
||||
isLoading={loading}
|
||||
loadingLabel={t('common.loading')}
|
||||
>
|
||||
<Card>
|
||||
{invalidIndexParam || invalidIndex ? (
|
||||
<div className="hint">Invalid provider index.</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
label={t('ai_providers.openai_add_modal_name_label')}
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
disabled={saving || disableControls}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
placeholder={t('ai_providers.prefix_placeholder')}
|
||||
value={form.prefix ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||
hint={t('ai_providers.prefix_hint')}
|
||||
disabled={saving || disableControls}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.openai_add_modal_url_label')}
|
||||
value={form.baseUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
disabled={saving || disableControls}
|
||||
/>
|
||||
|
||||
<HeaderInputList
|
||||
entries={form.headers}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
disabled={saving || disableControls}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{hasIndexParam
|
||||
? t('ai_providers.openai_edit_modal_models_label')
|
||||
: t('ai_providers.openai_add_modal_models_label')}
|
||||
</label>
|
||||
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
|
||||
<ModelInputList
|
||||
entries={form.modelEntries}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||
addLabel={t('ai_providers.openai_models_add_btn')}
|
||||
namePlaceholder={t('common.model_name_placeholder')}
|
||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||
disabled={saving || disableControls}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={openOpenaiModelDiscovery}
|
||||
disabled={saving || disableControls}
|
||||
>
|
||||
{t('ai_providers.openai_models_fetch_button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.openai_test_title')}</label>
|
||||
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<select
|
||||
className={`input ${styles.openaiTestSelect}`}
|
||||
value={testModel}
|
||||
onChange={(e) => {
|
||||
setTestModel(e.target.value);
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
}}
|
||||
disabled={saving || disableControls || availableModels.length === 0}
|
||||
>
|
||||
<option value="">
|
||||
{availableModels.length
|
||||
? t('ai_providers.openai_test_select_placeholder')
|
||||
: t('ai_providers.openai_test_select_empty')}
|
||||
</option>
|
||||
{form.modelEntries
|
||||
.filter((entry) => entry.name.trim())
|
||||
.map((entry, idx) => {
|
||||
const name = entry.name.trim();
|
||||
const alias = entry.alias.trim();
|
||||
const label = alias && alias !== name ? `${name} (${alias})` : name;
|
||||
return (
|
||||
<option key={`${name}-${idx}`} value={name}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<Button
|
||||
variant={testStatus === 'error' ? 'danger' : 'secondary'}
|
||||
className={`${styles.openaiTestButton} ${
|
||||
testStatus === 'success' ? styles.openaiTestButtonSuccess : ''
|
||||
}`}
|
||||
onClick={() => void testOpenaiProviderConnection()}
|
||||
loading={testStatus === 'loading'}
|
||||
disabled={saving || disableControls || availableModels.length === 0}
|
||||
>
|
||||
{t('ai_providers.openai_test_action')}
|
||||
</Button>
|
||||
</div>
|
||||
{testMessage && (
|
||||
<div
|
||||
className={`status-badge ${
|
||||
testStatus === 'error'
|
||||
? 'error'
|
||||
: testStatus === 'success'
|
||||
? 'success'
|
||||
: 'muted'
|
||||
}`}
|
||||
>
|
||||
{testMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
||||
{renderKeyEntries(form.apiKeyEntries)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</SecondaryScreenShell>
|
||||
);
|
||||
}
|
||||
223
src/pages/AiProvidersOpenAIModelsPage.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { modelsApi } from '@/services/api';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import { buildHeaderObject } from '@/utils/headers';
|
||||
import { buildOpenAIModelsEndpoint } from '@/components/providers/utils';
|
||||
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
|
||||
import styles from './AiProvidersPage.module.scss';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return '';
|
||||
};
|
||||
|
||||
export function AiProvidersOpenAIModelsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
disableControls,
|
||||
loading: initialLoading,
|
||||
saving,
|
||||
form,
|
||||
mergeDiscoveredModels,
|
||||
} = useOutletContext<OpenAIEditOutletContext>();
|
||||
|
||||
const [endpoint, setEndpoint] = useState('');
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
const filter = search.trim().toLowerCase();
|
||||
if (!filter) return models;
|
||||
return models.filter((model) => {
|
||||
const name = (model.name || '').toLowerCase();
|
||||
const alias = (model.alias || '').toLowerCase();
|
||||
const desc = (model.description || '').toLowerCase();
|
||||
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
|
||||
});
|
||||
}, [models, search]);
|
||||
|
||||
const fetchOpenaiModelDiscovery = useCallback(
|
||||
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
|
||||
const trimmedBaseUrl = form.baseUrl.trim();
|
||||
if (!trimmedBaseUrl) return;
|
||||
|
||||
setFetching(true);
|
||||
setError('');
|
||||
try {
|
||||
const headerObject = buildHeaderObject(form.headers);
|
||||
const firstKey = form.apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim();
|
||||
const hasAuthHeader = Boolean(headerObject.Authorization || headerObject['authorization']);
|
||||
const list = await modelsApi.fetchModelsViaApiCall(
|
||||
trimmedBaseUrl,
|
||||
hasAuthHeader ? undefined : firstKey,
|
||||
headerObject
|
||||
);
|
||||
setModels(list);
|
||||
} catch (err: unknown) {
|
||||
if (allowFallback) {
|
||||
try {
|
||||
const list = await modelsApi.fetchModelsViaApiCall(trimmedBaseUrl);
|
||||
setModels(list);
|
||||
return;
|
||||
} catch (fallbackErr: unknown) {
|
||||
const message = getErrorMessage(fallbackErr) || getErrorMessage(err);
|
||||
setModels([]);
|
||||
setError(`${t('ai_providers.openai_models_fetch_error')}: ${message}`);
|
||||
}
|
||||
} else {
|
||||
setModels([]);
|
||||
setError(`${t('ai_providers.openai_models_fetch_error')}: ${getErrorMessage(err)}`);
|
||||
}
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
},
|
||||
[form.apiKeyEntries, form.baseUrl, form.headers, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLoading) return;
|
||||
setEndpoint(buildOpenAIModelsEndpoint(form.baseUrl));
|
||||
setModels([]);
|
||||
setSearch('');
|
||||
setSelected(new Set());
|
||||
setError('');
|
||||
void fetchOpenaiModelDiscovery();
|
||||
}, [fetchOpenaiModelDiscovery, form.baseUrl, initialLoading]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
navigate(-1);
|
||||
}, [navigate]);
|
||||
|
||||
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleBack();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleBack]);
|
||||
|
||||
const toggleSelection = (name: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
const selectedModels = models.filter((model) => selected.has(model.name));
|
||||
if (selectedModels.length) {
|
||||
mergeDiscoveredModels(selectedModels);
|
||||
}
|
||||
handleBack();
|
||||
};
|
||||
|
||||
const canApply = !disableControls && !saving && !fetching;
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
ref={swipeRef}
|
||||
contentClassName={layoutStyles.content}
|
||||
title={t('ai_providers.openai_models_fetch_title')}
|
||||
onBack={handleBack}
|
||||
backLabel={t('common.back')}
|
||||
backAriaLabel={t('common.back')}
|
||||
rightAction={
|
||||
<Button size="sm" onClick={handleApply} disabled={!canApply}>
|
||||
{t('ai_providers.openai_models_fetch_apply')}
|
||||
</Button>
|
||||
}
|
||||
isLoading={initialLoading}
|
||||
loadingLabel={t('common.loading')}
|
||||
>
|
||||
<Card>
|
||||
<div className="hint" style={{ marginBottom: 8 }}>
|
||||
{t('ai_providers.openai_models_fetch_hint')}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.openai_models_fetch_url_label')}</label>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input className="input" readOnly value={endpoint} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
|
||||
loading={fetching}
|
||||
disabled={disableControls || saving}
|
||||
>
|
||||
{t('ai_providers.openai_models_fetch_refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label={t('ai_providers.openai_models_search_label')}
|
||||
placeholder={t('ai_providers.openai_models_search_placeholder')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
disabled={fetching}
|
||||
/>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{fetching ? (
|
||||
<div className="hint">{t('ai_providers.openai_models_fetch_loading')}</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className="hint">{t('ai_providers.openai_models_fetch_empty')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className="hint">{t('ai_providers.openai_models_search_empty')}</div>
|
||||
) : (
|
||||
<div className={styles.modelDiscoveryList}>
|
||||
{filteredModels.map((model) => {
|
||||
const checked = selected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
key={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${
|
||||
checked ? styles.modelDiscoveryRowSelected : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleSelection(model.name)}
|
||||
/>
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</SecondaryScreenShell>
|
||||
);
|
||||
}
|
||||
@@ -20,13 +20,62 @@
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-xl 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
flex: 0 1 320px;
|
||||
min-width: 200px;
|
||||
|
||||
@include mobile {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
:global(.form-group) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.searchEmpty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-xl * 2;
|
||||
text-align: center;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px dashed var(--border-primary);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.searchEmptyTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.searchEmptyDesc {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xl;
|
||||
padding-bottom: calc(
|
||||
var(--provider-nav-height, 60px) + 12px + env(safe-area-inset-bottom) + #{$spacing-md}
|
||||
);
|
||||
}
|
||||
|
||||
.section {
|
||||
|
||||