Compare commits
64 Commits
v3.6.1
...
feat/add-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a87daebe14 | ||
|
|
ec04132303 | ||
|
|
bbb356a948 | ||
|
|
f582bd58b1 | ||
|
|
a7f1461a33 | ||
|
|
b9412ece0b | ||
|
|
ec303544ca | ||
|
|
023726c59d | ||
|
|
956e723781 | ||
|
|
461ba6f418 | ||
|
|
8d2c067814 | ||
|
|
a00eb764f7 | ||
|
|
67bd8f5c11 | ||
|
|
3051743bd3 | ||
|
|
6b5752db24 | ||
|
|
ec1ae7073f | ||
|
|
883cf0346b | ||
|
|
1805ed586e | ||
|
|
98a1305684 | ||
|
|
f79efb86cd | ||
|
|
ed59420a83 | ||
|
|
bfc27349b3 | ||
|
|
4fc7413ffa | ||
|
|
12112e9d7d | ||
|
|
6a6980c82c | ||
|
|
031ea3a58f | ||
|
|
9d431cc7ae | ||
|
|
685a1138e4 | ||
|
|
154ff4c819 | ||
|
|
2540f6ba08 | ||
|
|
d32ceb9b80 | ||
|
|
e11c7d84cd | ||
|
|
ea8f2095e2 | ||
|
|
09f80d82bc | ||
|
|
2f18d6ec00 | ||
|
|
fafca841cb | ||
|
|
f4b8aed29a | ||
|
|
9663b4251e | ||
|
|
9e8abf5f26 | ||
|
|
32a6de074c | ||
|
|
ac09551563 | ||
|
|
7ae2a9f556 | ||
|
|
c985db8f3d | ||
|
|
c7b235bb98 | ||
|
|
1616c63c0b | ||
|
|
146b42fb68 | ||
|
|
0ea434a485 | ||
|
|
21fd7cc9fd | ||
|
|
434c64f38d | ||
|
|
6d8e822f8d | ||
|
|
2fae8c9275 | ||
|
|
30c763ffe3 | ||
|
|
e4d7999294 | ||
|
|
34f7139fda | ||
|
|
a85f24f616 | ||
|
|
b9743a463d | ||
|
|
2f02514a14 | ||
|
|
75866044bd | ||
|
|
155532ea8c | ||
|
|
346f916048 | ||
|
|
8a05e7bd3d | ||
|
|
32a2ba5ef6 | ||
|
|
4502b2f973 | ||
|
|
6cb930b4ec |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,6 +9,11 @@ release/
|
|||||||
.npmrc
|
.npmrc
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
GEMINI.md
|
||||||
/.claude
|
/.claude
|
||||||
|
/.codex
|
||||||
|
/.gemini
|
||||||
|
/.cc-switch
|
||||||
|
/.idea
|
||||||
/.vscode
|
/.vscode
|
||||||
vitest-report.json
|
vitest-report.json
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -3,8 +3,12 @@
|
|||||||
# Claude Code & Codex Provider Switcher
|
# Claude Code & Codex Provider Switcher
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
|
[](https://github.com/trending/typescript)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
|
|
||||||
|
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
|
||||||
English | [中文](README_ZH.md) | [Changelog](CHANGELOG.md)
|
English | [中文](README_ZH.md) | [Changelog](CHANGELOG.md)
|
||||||
|
|
||||||
@@ -39,11 +43,11 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Current Version: v3.6.1 | [Full Changelog](CHANGELOG.md)
|
### Current Version: v3.6.2 | [Full Changelog](CHANGELOG.md)
|
||||||
|
|
||||||
**Core Capabilities**
|
**Core Capabilities**
|
||||||
|
|
||||||
- **Provider Management**: One-click switching between Claude Code & Codex API configurations
|
- **Provider Management**: One-click switching between Claude Code, Codex, and Gemini API configurations
|
||||||
- **MCP Integration**: Centralized MCP server management with stdio/http support and real-time sync
|
- **MCP Integration**: Centralized MCP server management with stdio/http support and real-time sync
|
||||||
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
|
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
|
||||||
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
|
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
|
||||||
@@ -111,8 +115,8 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
|
|||||||
2. **Switch Provider**:
|
2. **Switch Provider**:
|
||||||
- Main UI: Select provider → Click "Enable"
|
- Main UI: Select provider → Click "Enable"
|
||||||
- System Tray: Click provider name directly (instant effect)
|
- System Tray: Click provider name directly (instant effect)
|
||||||
3. **Takes Effect**: Restart terminal or Claude Code/Codex to apply changes
|
3. **Takes Effect**: Restart your terminal or Claude Code / Codex / Gemini clients to apply changes
|
||||||
4. **Back to Official**: Select "Official Login" preset, restart terminal, then use `/login` (Claude) or official login flow (Codex)
|
4. **Back to Official**: Select the "Official Login" preset (Claude/Codex) or "Google Official" preset (Gemini), restart the corresponding client, then follow its login/OAuth flow
|
||||||
|
|
||||||
### MCP Management
|
### MCP Management
|
||||||
|
|
||||||
@@ -133,7 +137,13 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
|
|||||||
|
|
||||||
- Live config: `~/.codex/auth.json` (required) + `config.toml` (optional)
|
- Live config: `~/.codex/auth.json` (required) + `config.toml` (optional)
|
||||||
- API key field: `OPENAI_API_KEY` in `auth.json`
|
- API key field: `OPENAI_API_KEY` in `auth.json`
|
||||||
- MCP servers: `~/.codex/config.toml` → `[mcp.servers]`
|
- MCP servers: `~/.codex/config.toml` → `[mcp_servers]` tables
|
||||||
|
|
||||||
|
**Gemini**
|
||||||
|
|
||||||
|
- Live config: `~/.gemini/.env` (API key) + `~/.gemini/settings.json` (auth type for quick switching)
|
||||||
|
- API key field: `GEMINI_API_KEY` inside `.env`
|
||||||
|
- Tray quick switch: each provider switch rewrites `~/.gemini/.env` so the Gemini CLI picks up the new credentials immediately
|
||||||
|
|
||||||
**CC Switch Storage**
|
**CC Switch Storage**
|
||||||
|
|
||||||
@@ -340,7 +350,7 @@ Before submitting PRs, please ensure:
|
|||||||
- Pass type check: `pnpm typecheck`
|
- Pass type check: `pnpm typecheck`
|
||||||
- Pass format check: `pnpm format:check`
|
- Pass format check: `pnpm format:check`
|
||||||
- Pass unit tests: `pnpm test:unit`
|
- Pass unit tests: `pnpm test:unit`
|
||||||
- Functional PRs should be discussed in the issue area first
|
- 💡 For new features, please open an issue for discussion before submitting a PR
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
|
|||||||
26
README_ZH.md
26
README_ZH.md
@@ -3,8 +3,12 @@
|
|||||||
# Claude Code & Codex 供应商管理器
|
# Claude Code & Codex 供应商管理器
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
|
[](https://github.com/trending/typescript)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
|
|
||||||
|
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
|
||||||
[English](README.md) | 中文 | [更新日志](CHANGELOG.md)
|
[English](README.md) | 中文 | [更新日志](CHANGELOG.md)
|
||||||
|
|
||||||
@@ -39,11 +43,11 @@ CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编
|
|||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
### 当前版本:v3.6.1 | [完整更新日志](CHANGELOG.md)
|
### 当前版本:v3.6.2 | [完整更新日志](CHANGELOG.md)
|
||||||
|
|
||||||
**核心功能**
|
**核心功能**
|
||||||
|
|
||||||
- **供应商管理**:一键切换 Claude Code 与 Codex 的 API 配置
|
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
|
||||||
- **MCP 集成**:集中管理 MCP 服务器,支持 stdio/http 类型和实时同步
|
- **MCP 集成**:集中管理 MCP 服务器,支持 stdio/http 类型和实时同步
|
||||||
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
|
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
|
||||||
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
|
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
|
||||||
@@ -111,8 +115,8 @@ brew upgrade --cask cc-switch
|
|||||||
2. **切换供应商**:
|
2. **切换供应商**:
|
||||||
- 主界面:选择供应商 → 点击"启用"
|
- 主界面:选择供应商 → 点击"启用"
|
||||||
- 系统托盘:直接点击供应商名称(立即生效)
|
- 系统托盘:直接点击供应商名称(立即生效)
|
||||||
3. **生效方式**:重启终端或 Claude Code/Codex 以应用更改
|
3. **生效方式**:重启终端或 Claude Code / Codex / Gemini 客户端以应用更改
|
||||||
4. **恢复官方登录**:选择"官方登录"预设,重启终端后使用 `/login`(Claude)或官方登录流程(Codex)
|
4. **恢复官方登录**:选择"官方登录"预设(Claude/Codex)或"Google 官方"预设(Gemini),重启对应客户端后按照其登录/OAuth 流程操作
|
||||||
|
|
||||||
### MCP 管理
|
### MCP 管理
|
||||||
|
|
||||||
@@ -133,7 +137,13 @@ brew upgrade --cask cc-switch
|
|||||||
|
|
||||||
- Live 配置:`~/.codex/auth.json`(必需)+ `config.toml`(可选)
|
- Live 配置:`~/.codex/auth.json`(必需)+ `config.toml`(可选)
|
||||||
- API key 字段:`auth.json` 中的 `OPENAI_API_KEY`
|
- API key 字段:`auth.json` 中的 `OPENAI_API_KEY`
|
||||||
- MCP 服务器:`~/.codex/config.toml` → `[mcp.servers]`
|
- MCP 服务器:`~/.codex/config.toml` → `[mcp_servers]` 表
|
||||||
|
|
||||||
|
**Gemini**
|
||||||
|
|
||||||
|
- Live 配置:`~/.gemini/.env`(API Key)+ `~/.gemini/settings.json`(保存认证模式,支持托盘快速切换)
|
||||||
|
- API key 字段:`.env` 文件中的 `GEMINI_API_KEY`
|
||||||
|
- 托盘快速切换:每次切换供应商都会重写 `~/.gemini/.env`,Gemini CLI 无需额外操作即可使用新配置
|
||||||
|
|
||||||
**CC Switch 存储**
|
**CC Switch 存储**
|
||||||
|
|
||||||
@@ -159,7 +169,7 @@ brew upgrade --cask cc-switch
|
|||||||
│ 前端 (React + TS) │
|
│ 前端 (React + TS) │
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
│ │ Components │ │ Hooks │ │ TanStack Query │ │
|
│ │ Components │ │ Hooks │ │ TanStack Query │ │
|
||||||
│ │ (UI) │──│ (业务逻辑) │──│ (缓存/同步) │ │
|
│ │ (UI) │──│ (业务逻辑) │──│ (缓存/同步) │ │
|
||||||
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||||
└────────────────────────┬────────────────────────────────────┘
|
└────────────────────────┬────────────────────────────────────┘
|
||||||
│ Tauri IPC
|
│ Tauri IPC
|
||||||
@@ -167,7 +177,7 @@ brew upgrade --cask cc-switch
|
|||||||
│ 后端 (Tauri + Rust) │
|
│ 后端 (Tauri + Rust) │
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
│ │ Commands │ │ Services │ │ Models/Config │ │
|
│ │ Commands │ │ Services │ │ Models/Config │ │
|
||||||
│ │ (API 层) │──│ (业务层) │──│ (数据) │ │
|
│ │ (API 层) │──│ (业务层) │──│ (数据) │ │
|
||||||
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
@@ -340,7 +350,7 @@ pnpm test:unit --coverage
|
|||||||
- 通过类型检查:`pnpm typecheck`
|
- 通过类型检查:`pnpm typecheck`
|
||||||
- 通过格式检查:`pnpm format:check`
|
- 通过格式检查:`pnpm format:check`
|
||||||
- 通过单元测试:`pnpm test:unit`
|
- 通过单元测试:`pnpm test:unit`
|
||||||
- 功能性 PR 请先经过 issue 区讨论
|
- 💡 新功能开发前,欢迎先开 issue 讨论实现方案
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
|
|||||||
551
deplink.html
Normal file
551
deplink.html
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CC Switch 深链接测试</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 2px solid #ecf0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card:hover {
|
||||||
|
border-color: #3498db;
|
||||||
|
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card h3 {
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card .description {
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deep-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(52, 152, 219, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deep-link:hover {
|
||||||
|
background: linear-gradient(135deg, #2980b9 0%, #1f6391 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deep-link:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
background: #fff3cd;
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box h4 {
|
||||||
|
color: #856404;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box ul {
|
||||||
|
list-style: disc;
|
||||||
|
margin-left: 20px;
|
||||||
|
color: #856404;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generator-section {
|
||||||
|
background: #e8f4f8;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 30px;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generator-section h2 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-bottom: 2px solid #3498db;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background: linear-gradient(135deg, #27ae60 0%, #229954 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(39, 174, 96, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: linear-gradient(135deg, #229954 0%, #1e8449 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.4);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-box {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
border: 2px solid #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-box strong {
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-text {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 12px 0;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #2c3e50;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy:hover {
|
||||||
|
background: linear-gradient(135deg, #8e44ad 0%, #7d3c98 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-claude {
|
||||||
|
background: #e8f4f8;
|
||||||
|
color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-codex {
|
||||||
|
background: #fef5e7;
|
||||||
|
color: #f39c12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-gemini {
|
||||||
|
background: #fdeef4;
|
||||||
|
color: #e91e63;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generator-section {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🔗 CC Switch 深链接测试</h1>
|
||||||
|
<p>点击下方链接测试深链接导入功能</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<!-- Claude 示例 -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Claude Code 供应商</h2>
|
||||||
|
|
||||||
|
<div class="link-card">
|
||||||
|
<h3>
|
||||||
|
<span class="app-badge badge-claude">Claude</span>
|
||||||
|
Claude Official (官方)
|
||||||
|
</h3>
|
||||||
|
<p class="description">
|
||||||
|
导入 Claude 官方 API 配置。使用官方端点 api.anthropic.com,默认模型 claude-haiku-4.1。
|
||||||
|
</p>
|
||||||
|
<a href="ccswitch://v1/import?resource=provider&app=claude&name=Claude%20Official&homepage=https%3A%2F%2Fclaude.ai&endpoint=https%3A%2F%2Fapi.anthropic.com%2Fv1&apiKey=sk-ant-test-demo-key-12345&model=claude-haiku-4.1¬es=%E5%AE%98%E6%96%B9%E6%B5%8B%E8%AF%95%E9%85%8D%E7%BD%AE"
|
||||||
|
class="deep-link">
|
||||||
|
📥 导入 Claude Official
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="link-card">
|
||||||
|
<h3>
|
||||||
|
<span class="app-badge badge-claude">Claude</span>
|
||||||
|
Claude 测试环境
|
||||||
|
</h3>
|
||||||
|
<p class="description">
|
||||||
|
公司内部测试环境配置示例。包含备注信息,方便区分不同环境。默认模型 claude-haiku-4.1。
|
||||||
|
</p>
|
||||||
|
<a href="ccswitch://v1/import?resource=provider&app=claude&name=%E5%85%AC%E5%8F%B8%E6%B5%8B%E8%AF%95%E7%8E%AF%E5%A2%83&homepage=https%3A%2F%2Ftest.company.com&endpoint=https%3A%2F%2Fapi-test.company.com%2Fv1&apiKey=sk-ant-test-company-key&model=claude-haiku-4.1¬es=%E5%85%AC%E5%8F%B8%E5%86%85%E9%83%A8%E6%B5%8B%E8%AF%95%E7%8E%AF%E5%A2%83%EF%BC%8C%E4%BB%85%E4%BE%9B%E5%BC%80%E5%8F%91%E4%BD%BF%E7%94%A8"
|
||||||
|
class="deep-link">
|
||||||
|
📥 导入测试环境
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Codex 示例 -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Codex 供应商</h2>
|
||||||
|
|
||||||
|
<div class="link-card">
|
||||||
|
<h3>
|
||||||
|
<span class="app-badge badge-codex">Codex</span>
|
||||||
|
OpenAI Official (官方)
|
||||||
|
</h3>
|
||||||
|
<p class="description">
|
||||||
|
导入 OpenAI 官方 API 配置。使用官方端点 api.openai.com,默认模型 gpt-5.1。
|
||||||
|
</p>
|
||||||
|
<a href="ccswitch://v1/import?resource=provider&app=codex&name=OpenAI%20Official&homepage=https%3A%2F%2Fopenai.com&endpoint=https%3A%2F%2Fapi.openai.com%2Fv1&apiKey=sk-test-demo-openai-key-67890&model=gpt-5.1¬es=OpenAI%20%E5%AE%98%E6%96%B9%E6%9C%8D%E5%8A%A1"
|
||||||
|
class="deep-link">
|
||||||
|
📥 导入 OpenAI Official
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="link-card">
|
||||||
|
<h3>
|
||||||
|
<span class="app-badge badge-codex">Codex</span>
|
||||||
|
Azure OpenAI
|
||||||
|
</h3>
|
||||||
|
<p class="description">
|
||||||
|
Azure 部署的 OpenAI 服务示例。适合企业用户使用 Azure 云服务。默认模型 gpt-5.1。
|
||||||
|
</p>
|
||||||
|
<a href="ccswitch://v1/import?resource=provider&app=codex&name=Azure%20OpenAI&homepage=https%3A%2F%2Fazure.microsoft.com%2Fopenai&endpoint=https%3A%2F%2Fyour-resource.openai.azure.com%2F&apiKey=azure-test-api-key-xyz&model=gpt-5.1¬es=Azure%20%E4%BC%81%E4%B8%9A%E7%89%88%E6%9C%AC"
|
||||||
|
class="deep-link">
|
||||||
|
📥 导入 Azure OpenAI
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gemini 示例 -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Gemini 供应商</h2>
|
||||||
|
|
||||||
|
<div class="link-card">
|
||||||
|
<h3>
|
||||||
|
<span class="app-badge badge-gemini">Gemini</span>
|
||||||
|
Google Gemini Official
|
||||||
|
</h3>
|
||||||
|
<p class="description">
|
||||||
|
导入 Google Gemini 官方 API 配置。默认模型 gemini-3-pro-preview。
|
||||||
|
</p>
|
||||||
|
<a href="ccswitch://v1/import?resource=provider&app=gemini&name=Google%20Gemini&homepage=https%3A%2F%2Fai.google.dev&endpoint=https%3A%2F%2Fgenerativelanguage.googleapis.com%2Fv1beta&apiKey=AIzaSy-test-demo-key-abc123&model=gemini-3-pro-preview¬es=Google%20AI%20%E5%AE%98%E6%96%B9%E6%9C%8D%E5%8A%A1"
|
||||||
|
class="deep-link">
|
||||||
|
📥 导入 Google Gemini
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="link-card">
|
||||||
|
<h3>
|
||||||
|
<span class="app-badge badge-gemini">Gemini</span>
|
||||||
|
Gemini 测试环境
|
||||||
|
</h3>
|
||||||
|
<p class="description">
|
||||||
|
公司内部 Gemini 测试环境配置示例。用于验证 Gemini 相关深链接导入流程,请求地址为:https://api-gemini-test.company.com/v1beta。默认模型 gemini-3-pro-preview。
|
||||||
|
</p>
|
||||||
|
<a href="ccswitch://v1/import?resource=provider&app=gemini&name=%E5%85%AC%E5%8F%B8%20Gemini%20%E6%B5%8B%E8%AF%95%E7%8E%AF%E5%A2%83&homepage=https%3A%2F%2Fgemini-test.company.com&endpoint=https%3A%2F%2Fapi-gemini-test.company.com%2Fv1beta&apiKey=sk-gemini-test-company-key&model=gemini-3-pro-preview¬es=%E5%85%AC%E5%8F%B8%E5%86%85%E9%83%A8%20Gemini%20%E6%B5%8B%E8%AF%95%E7%8E%AF%E5%A2%83%EF%BC%8C%E4%BB%85%E4%BE%9B%E5%BC%80%E5%8F%91%E4%BD%BF%E7%94%A8"
|
||||||
|
class="deep-link">
|
||||||
|
📥 导入 Gemini 测试环境
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 注意事项 -->
|
||||||
|
<div class="info-box">
|
||||||
|
<h4>⚠️ 使用注意事项</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>首次点击</strong>:浏览器会询问是否允许打开 CC Switch,请点击"允许"或"打开"</li>
|
||||||
|
<li><strong>macOS 用户</strong>:可能需要在"系统设置" → "隐私与安全性"中允许应用</li>
|
||||||
|
<li><strong>测试 API Key</strong>:示例中的 API Key 仅用于测试格式,无法实际使用</li>
|
||||||
|
<li><strong>导入确认</strong>:点击链接后会弹出确认对话框,API Key 会被掩码显示(前4位+****)</li>
|
||||||
|
<li><strong>编辑配置</strong>:导入后可以在 CC Switch 中随时编辑或删除配置</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 深链接生成器 -->
|
||||||
|
<div class="generator-section">
|
||||||
|
<h2>🛠️ 深链接生成器</h2>
|
||||||
|
<p style="color: #7f8c8d; margin-bottom: 24px;">填写下方表单,生成您自己的深链接</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>应用类型 *</label>
|
||||||
|
<select id="app">
|
||||||
|
<option value="claude">Claude Code</option>
|
||||||
|
<option value="codex">Codex</option>
|
||||||
|
<option value="gemini">Gemini</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>供应商名称 *</label>
|
||||||
|
<input type="text" id="name" placeholder="例如: Claude Official">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>官网地址 *</label>
|
||||||
|
<input type="url" id="homepage" placeholder="https://example.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>API 端点 *</label>
|
||||||
|
<input type="url" id="endpoint" placeholder="https://api.example.com/v1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>API Key *</label>
|
||||||
|
<input type="text" id="apiKey" placeholder="sk-xxxxx 或 AIzaSyXXXXX">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>模型(可选)</label>
|
||||||
|
<input type="text" id="model" placeholder="例如: claude-haiku-4.1, gpt-5.1, gemini-3-pro-preview">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>备注(可选)</label>
|
||||||
|
<textarea id="notes" rows="2" placeholder="例如: 公司专用账号"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn" onclick="generateLink()">🚀 生成深链接</button>
|
||||||
|
|
||||||
|
<div id="result" style="display: none;">
|
||||||
|
<div class="result-box">
|
||||||
|
<strong>✅ 生成的深链接:</strong>
|
||||||
|
<div class="result-text" id="linkText"></div>
|
||||||
|
<button class="btn btn-copy" onclick="copyLink()">📋 复制链接</button>
|
||||||
|
<a id="testLink" class="deep-link" style="text-decoration: none;">
|
||||||
|
🧪 测试链接
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function generateLink() {
|
||||||
|
const app = document.getElementById('app').value;
|
||||||
|
const name = document.getElementById('name').value.trim();
|
||||||
|
const homepage = document.getElementById('homepage').value.trim();
|
||||||
|
const endpoint = document.getElementById('endpoint').value.trim();
|
||||||
|
const apiKey = document.getElementById('apiKey').value.trim();
|
||||||
|
const model = document.getElementById('model').value.trim();
|
||||||
|
const notes = document.getElementById('notes').value.trim();
|
||||||
|
|
||||||
|
// 验证必填字段
|
||||||
|
if (!name || !homepage || !endpoint || !apiKey) {
|
||||||
|
alert('❌ 请填写所有必填字段(标记 * 的字段)!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 URL 格式
|
||||||
|
try {
|
||||||
|
new URL(homepage);
|
||||||
|
new URL(endpoint);
|
||||||
|
} catch (e) {
|
||||||
|
alert('❌ 请输入有效的 URL 格式(需包含 http:// 或 https://)!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建参数
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
resource: 'provider',
|
||||||
|
app: app,
|
||||||
|
name: name,
|
||||||
|
homepage: homepage,
|
||||||
|
endpoint: endpoint,
|
||||||
|
apiKey: apiKey
|
||||||
|
});
|
||||||
|
|
||||||
|
if (model) {
|
||||||
|
params.append('model', model);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notes) {
|
||||||
|
params.append('notes', notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deepLink = `ccswitch://v1/import?${params.toString()}`;
|
||||||
|
|
||||||
|
// 显示结果
|
||||||
|
document.getElementById('linkText').textContent = deepLink;
|
||||||
|
document.getElementById('testLink').href = deepLink;
|
||||||
|
document.getElementById('result').style.display = 'block';
|
||||||
|
|
||||||
|
// 滚动到结果区域
|
||||||
|
document.getElementById('result').scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'nearest'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyLink() {
|
||||||
|
const linkText = document.getElementById('linkText').textContent;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(linkText).then(() => {
|
||||||
|
const btn = event.target;
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = '✅ 已复制!';
|
||||||
|
btn.style.background = 'linear-gradient(135deg, #27ae60 0%, #229954 100%)';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = originalText;
|
||||||
|
btn.style.background = '';
|
||||||
|
}, 2000);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('复制失败:', err);
|
||||||
|
alert('❌ 复制失败,请手动复制链接');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阻止表单默认提交行为
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const inputs = document.querySelectorAll('input, textarea, select');
|
||||||
|
inputs.forEach(input => {
|
||||||
|
input.addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') {
|
||||||
|
e.preventDefault();
|
||||||
|
generateLink();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
1309
docs/CODEX_MCP_RAW_TOML_PLAN.md
Normal file
1309
docs/CODEX_MCP_RAW_TOML_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
863
docs/v3.7.0-unified-mcp-refactor.md
Normal file
863
docs/v3.7.0-unified-mcp-refactor.md
Normal file
@@ -0,0 +1,863 @@
|
|||||||
|
# v3.7.0 统一 MCP 管理重构计划
|
||||||
|
|
||||||
|
## 📋 项目概述
|
||||||
|
|
||||||
|
**目标**:将原有的按应用分离的 MCP 管理(Claude/Codex/Gemini 各自独立管理)重构为统一管理面板,每个 MCP 服务器通过多选框控制应用到哪些客户端。
|
||||||
|
|
||||||
|
**版本**:v3.6.2 → v3.7.0
|
||||||
|
|
||||||
|
**开始时间**:2025-11-14
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 核心需求
|
||||||
|
|
||||||
|
### 原有架构(v3.6.x)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Claude面板 │ │ Codex面板 │ │ Gemini面板 │
|
||||||
|
│ MCP管理 │ │ MCP管理 │ │ MCP管理 │
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
↓ ↓ ↓
|
||||||
|
mcp.claude mcp.codex mcp.gemini
|
||||||
|
{servers} {servers} {servers}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 新架构(v3.7.0)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────┐
|
||||||
|
│ 统一 MCP 管理面板 │
|
||||||
|
│ ┌────────┬────────┬────────┬────┐ │
|
||||||
|
│ │ 服务器 │ Claude │ Codex │Gem │ │
|
||||||
|
│ ├────────┼────────┼────────┼────┤ │
|
||||||
|
│ │ mcp-1 │ ✓ │ ✓ │ │ │
|
||||||
|
│ │ mcp-2 │ ✓ │ │ ✓ │ │
|
||||||
|
│ └────────┴────────┴────────┴────┘ │
|
||||||
|
└───────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
mcp.servers
|
||||||
|
{
|
||||||
|
"mcp-1": {
|
||||||
|
apps: {claude: true, codex: true, gemini: false}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 技术架构
|
||||||
|
|
||||||
|
### 数据结构设计
|
||||||
|
|
||||||
|
#### 新增:McpApps(应用启用状态)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct McpApps {
|
||||||
|
pub claude: bool,
|
||||||
|
pub codex: bool,
|
||||||
|
pub gemini: bool,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 更新:McpServer(统一服务器定义)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct McpServer {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub server: serde_json::Value, // 连接配置(stdio/http)
|
||||||
|
pub apps: McpApps, // 新增:标记应用到哪些客户端
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
pub docs: Option<String>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 更新:McpRoot(新旧结构并存)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct McpRoot {
|
||||||
|
// v3.7.0 新结构
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub servers: Option<HashMap<String, McpServer>>,
|
||||||
|
|
||||||
|
// v3.6.x 旧结构(保留用于迁移)
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
|
pub claude: McpConfig,
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
|
pub codex: McpConfig,
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
|
pub gemini: McpConfig,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 迁移策略
|
||||||
|
|
||||||
|
```
|
||||||
|
旧配置 (v3.6.x) 新配置 (v3.7.0)
|
||||||
|
───────────────── ─────────────────
|
||||||
|
mcp: mcp:
|
||||||
|
claude: servers:
|
||||||
|
servers: mcp-fetch:
|
||||||
|
mcp-fetch: {...} → id: "mcp-fetch"
|
||||||
|
codex: server: {...}
|
||||||
|
servers: apps:
|
||||||
|
mcp-filesystem: {...} claude: true
|
||||||
|
codex: true
|
||||||
|
gemini: false
|
||||||
|
```
|
||||||
|
|
||||||
|
**迁移逻辑**:
|
||||||
|
1. 检测 `mcp.servers` 是否存在
|
||||||
|
2. 若不存在,从 `mcp.claude/codex/gemini.servers` 收集所有服务器
|
||||||
|
3. 合并同 id 服务器的 apps 字段
|
||||||
|
4. 清空旧结构字段
|
||||||
|
5. 保存配置(自动触发)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 开发进度
|
||||||
|
|
||||||
|
### Phase 1: 后端数据结构与迁移 ✅ 已完成
|
||||||
|
|
||||||
|
#### 1.1 修改数据结构(app_config.rs)✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/app_config.rs`
|
||||||
|
|
||||||
|
**变更**:
|
||||||
|
- ✅ 新增 `McpApps` 结构体(lines 30-62)
|
||||||
|
- ✅ 新增 `McpServer` 结构体(lines 64-79)
|
||||||
|
- ✅ 更新 `McpRoot` 支持新旧结构(lines 81-96)
|
||||||
|
- ✅ 添加辅助方法:`is_enabled_for`, `set_enabled_for`, `enabled_apps`
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
|
||||||
|
#### 1.2 实现迁移逻辑 ✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/app_config.rs`
|
||||||
|
|
||||||
|
**实现**:
|
||||||
|
- ✅ `migrate_mcp_to_unified()` 方法(lines 380-509)
|
||||||
|
- 从旧结构收集所有服务器
|
||||||
|
- 按 id 合并重复服务器
|
||||||
|
- 处理冲突(合并 apps 字段)
|
||||||
|
- 清空旧结构
|
||||||
|
- ✅ 集成到 `MultiAppConfig::load()` 方法(lines 252-257)
|
||||||
|
- 自动检测并执行迁移
|
||||||
|
- 迁移后保存配置
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: 后端服务层重构 ✅ 已完成
|
||||||
|
|
||||||
|
#### 2.1 重写 McpService ✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/services/mcp.rs`
|
||||||
|
|
||||||
|
**新增方法**:
|
||||||
|
- ✅ `get_all_servers()` - 获取所有服务器(lines 13-27)
|
||||||
|
- ✅ `upsert_server()` - 添加/更新服务器(lines 30-52)
|
||||||
|
- ✅ `delete_server()` - 删除服务器(lines 55-75)
|
||||||
|
- ✅ `toggle_app()` - 切换应用启用状态(lines 78-111)
|
||||||
|
- ✅ `sync_all_enabled()` - 同步所有启用的服务器(lines 180-188)
|
||||||
|
|
||||||
|
**兼容层方法**(已废弃):
|
||||||
|
- ✅ `get_servers()` - 按应用过滤服务器(lines 196-210)
|
||||||
|
- ✅ `set_enabled()` - 委托到 toggle_app(lines 213-222)
|
||||||
|
- ✅ `sync_enabled()` - 同步特定应用(lines 225-236)
|
||||||
|
- ✅ `import_from_claude/codex/gemini()` - 导入包装(lines 239-266)
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
|
||||||
|
#### 2.2 新增同步函数(mcp.rs)✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/mcp.rs`
|
||||||
|
|
||||||
|
**新增函数**(lines 800-965):
|
||||||
|
- ✅ `json_server_to_toml_table()` - JSON → TOML 转换助手(lines 828-889)
|
||||||
|
- ✅ `sync_single_server_to_claude()` - 同步单个服务器到 Claude(lines 800-814)
|
||||||
|
- ✅ `remove_server_from_claude()` - 从 Claude 移除服务器(lines 817-826)
|
||||||
|
- ✅ `sync_single_server_to_codex()` - 同步单个服务器到 Codex(lines 891-936)
|
||||||
|
- ✅ `remove_server_from_codex()` - 从 Codex 移除服务器(lines 939-965)
|
||||||
|
- ✅ `sync_single_server_to_gemini()` - 同步单个服务器到 Gemini(lines 967-977)
|
||||||
|
- ✅ `remove_server_from_gemini()` - 从 Gemini 移除服务器(lines 980-989)
|
||||||
|
|
||||||
|
**关键修复**:
|
||||||
|
- ✅ 修复 toml_edit 类型转换(使用手动构建而非 serde 转换)
|
||||||
|
- ✅ 修复 get_codex_config_path() 调用(返回 PathBuf 而非 Result)
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
**修复提交**:`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
|
||||||
|
|
||||||
|
#### 2.3 新增 Tauri Commands ✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/commands/mcp.rs`
|
||||||
|
|
||||||
|
**新增命令**(lines 147-196):
|
||||||
|
- ✅ `get_mcp_servers()` - 获取所有服务器(lines 154-159)
|
||||||
|
- ✅ `upsert_mcp_server()` - 添加/更新服务器(lines 162-168)
|
||||||
|
- ✅ `delete_mcp_server()` - 删除服务器(lines 171-177)
|
||||||
|
- ✅ `toggle_mcp_app()` - 切换应用状态(lines 180-189)
|
||||||
|
- ✅ `sync_all_mcp_servers()` - 同步所有服务器(lines 192-195)
|
||||||
|
|
||||||
|
**更新旧命令**(兼容层):
|
||||||
|
- ✅ `upsert_mcp_server_in_config()` - 转换为统一结构(lines 68-131)
|
||||||
|
- ✅ `delete_mcp_server_in_config()` - 忽略 app 参数(lines 134-141)
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
**修复提交**:`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
|
||||||
|
|
||||||
|
#### 2.4 注册新命令(lib.rs)✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/lib.rs`
|
||||||
|
|
||||||
|
**变更**:
|
||||||
|
- ✅ 导出 `McpServer` 类型(line 21)
|
||||||
|
- ✅ 导出新增的 mcp 同步函数(lines 26-31)
|
||||||
|
- ✅ 注册 5 个新命令到 invoke_handler(lines 550-555)
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
|
||||||
|
#### 2.5 添加缺失的函数(claude_mcp.rs & gemini_mcp.rs)✅
|
||||||
|
|
||||||
|
**文件**:
|
||||||
|
- `src-tauri/src/claude_mcp.rs` (lines 234-253)
|
||||||
|
- `src-tauri/src/gemini_mcp.rs` (lines 160-179)
|
||||||
|
|
||||||
|
**新增**:
|
||||||
|
- ✅ `read_mcp_servers_map()` - 读取现有 MCP 服务器映射
|
||||||
|
|
||||||
|
**提交**:`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
|
||||||
|
|
||||||
|
#### 2.6 编译验证 ✅
|
||||||
|
|
||||||
|
**状态**:✅ 编译成功
|
||||||
|
- ⚠️ 16 个警告(8 个废弃警告 + 8 个未使用函数警告 - 预期内)
|
||||||
|
- ✅ 0 个错误
|
||||||
|
|
||||||
|
**提交**:`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: 前端开发 ⚠️ 部分完成
|
||||||
|
|
||||||
|
#### 3.1 TypeScript 类型定义 ✅
|
||||||
|
|
||||||
|
**文件**:`src/types.ts`
|
||||||
|
|
||||||
|
**变更**:
|
||||||
|
- ✅ 新增 `McpApps` 接口(lines 129-133)
|
||||||
|
- ✅ 更新 `McpServer` 接口(lines 136-149)
|
||||||
|
- 新增 `apps: McpApps` 字段
|
||||||
|
- `name` 改为必填
|
||||||
|
- 标记 `enabled` 为废弃
|
||||||
|
- ✅ 新增 `McpServersMap` 类型别名(line 152)
|
||||||
|
- ✅ 保持向后兼容(保留 `enabled`, `source` 等旧字段)
|
||||||
|
|
||||||
|
**提交**:`ac09551` - "feat(frontend): add unified MCP types and API layer for v3.7.0"
|
||||||
|
|
||||||
|
#### 3.2 API 层更新 ✅
|
||||||
|
|
||||||
|
**文件**:`src/lib/api/mcp.ts`
|
||||||
|
|
||||||
|
**新增方法**(lines 99-141):
|
||||||
|
- ✅ `getAllServers()` - 获取所有服务器(lines 106-108)
|
||||||
|
- ✅ `upsertUnifiedServer()` - 添加/更新服务器(lines 113-115)
|
||||||
|
- ✅ `deleteUnifiedServer()` - 删除服务器(lines 120-122)
|
||||||
|
- ✅ `toggleApp()` - 切换应用状态(lines 127-133)
|
||||||
|
- ✅ `syncAllServers()` - 同步所有服务器(lines 138-140)
|
||||||
|
|
||||||
|
**导入更新**:
|
||||||
|
- ✅ 导入 `McpServersMap` 类型(line 6)
|
||||||
|
|
||||||
|
**提交**:`ac09551` - "feat(frontend): add unified MCP types and API layer for v3.7.0"
|
||||||
|
|
||||||
|
#### 3.3 React Query Hooks 📝 待开发
|
||||||
|
|
||||||
|
**计划文件**:`src/hooks/useMcp.ts`
|
||||||
|
|
||||||
|
**需要实现的 Hooks**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 查询 hooks
|
||||||
|
export function useAllMcpServers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['mcp', 'all'],
|
||||||
|
queryFn: () => mcpApi.getAllServers(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 变更 hooks
|
||||||
|
export function useUpsertMcpServer() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (server: McpServer) => mcpApi.upsertUnifiedServer(server),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToggleMcpApp() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ serverId, app, enabled }: {
|
||||||
|
serverId: string;
|
||||||
|
app: AppId;
|
||||||
|
enabled: boolean;
|
||||||
|
}) => mcpApi.toggleApp(serverId, app, enabled),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteMcpServer() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => mcpApi.deleteUnifiedServer(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSyncAllMcpServers() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => mcpApi.syncAllServers(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**:
|
||||||
|
- `@tanstack/react-query` (已安装)
|
||||||
|
- `src/lib/api/mcp.ts` (✅ 已完成)
|
||||||
|
- `src/types.ts` (✅ 已完成)
|
||||||
|
|
||||||
|
#### 3.4 统一 MCP 面板组件 📝 待开发
|
||||||
|
|
||||||
|
**计划文件**:`src/components/mcp/UnifiedMcpPanel.tsx`
|
||||||
|
|
||||||
|
**组件结构**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UnifiedMcpPanelProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnifiedMcpPanel({ className }: UnifiedMcpPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: servers, isLoading } = useAllMcpServers();
|
||||||
|
const toggleApp = useToggleMcpApp();
|
||||||
|
const deleteServer = useDeleteMcpServer();
|
||||||
|
const syncAll = useSyncAllMcpServers();
|
||||||
|
|
||||||
|
// 组件实现...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI 设计**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ MCP 服务器管理 ┌──────────┐ │
|
||||||
|
│ │ 添加服务器 │ │
|
||||||
|
│ ┌─────┐ ┌──────────────┐ ┌─────────┐ └──────────┘ │
|
||||||
|
│ │ 搜索 │ │ 导入自...▼ │ │ 同步全部 │ │
|
||||||
|
│ └─────┘ └──────────────┘ └─────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────┐ │
|
||||||
|
│ │ 名称 │ Claude │ Codex │ Gemini │操作│ │
|
||||||
|
│ ├─────────────────────────────────────────────┤ │
|
||||||
|
│ │ mcp-fetch │ ✓ │ ✓ │ │ ⚙️ │ │
|
||||||
|
│ │ filesystem │ ✓ │ │ ✓ │ ⚙️ │ │
|
||||||
|
│ │ brave-search │ │ ✓ │ ✓ │ ⚙️ │ │
|
||||||
|
│ └─────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**功能特性**:
|
||||||
|
- 📋 服务器列表展示(名称、描述、标签)
|
||||||
|
- ☑️ 三个复选框控制应用启用状态(Claude/Codex/Gemini)
|
||||||
|
- ➕ 添加新服务器(表单模态框)
|
||||||
|
- ✏️ 编辑服务器(表单模态框)
|
||||||
|
- 🗑️ 删除服务器(确认对话框)
|
||||||
|
- 📥 导入功能(从 Claude/Codex/Gemini 导入)
|
||||||
|
- 🔄 同步全部(手动触发同步到 live 配置)
|
||||||
|
- 🔍 搜索过滤
|
||||||
|
- 🏷️ 标签过滤
|
||||||
|
|
||||||
|
**子组件**:
|
||||||
|
|
||||||
|
1. **McpServerTable** (`McpServerTable.tsx`)
|
||||||
|
- 服务器列表表格
|
||||||
|
- 应用复选框
|
||||||
|
- 操作按钮(编辑、删除)
|
||||||
|
|
||||||
|
2. **McpServerFormModal** (`McpServerFormModal.tsx`)
|
||||||
|
- 添加/编辑表单
|
||||||
|
- stdio/http 类型切换
|
||||||
|
- 应用选择(多选)
|
||||||
|
- 元信息编辑(描述、标签、链接)
|
||||||
|
|
||||||
|
3. **McpImportDialog** (`McpImportDialog.tsx`)
|
||||||
|
- 选择导入来源(Claude/Codex/Gemini)
|
||||||
|
- 服务器预览
|
||||||
|
- 批量导入
|
||||||
|
|
||||||
|
**依赖组件**(来自 shadcn/ui):
|
||||||
|
- `Table`, `TableBody`, `TableCell`, `TableHead`, `TableHeader`, `TableRow`
|
||||||
|
- `Checkbox`
|
||||||
|
- `Button`
|
||||||
|
- `Dialog`, `DialogContent`, `DialogHeader`, `DialogTitle`
|
||||||
|
- `Input`, `Textarea`, `Label`
|
||||||
|
- `Select`, `SelectContent`, `SelectItem`, `SelectTrigger`, `SelectValue`
|
||||||
|
- `Badge`
|
||||||
|
- `Tooltip`
|
||||||
|
|
||||||
|
#### 3.5 主界面集成 📝 待开发
|
||||||
|
|
||||||
|
**文件**:`src/App.tsx`
|
||||||
|
|
||||||
|
**变更计划**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 原有代码(v3.6.x)
|
||||||
|
{currentApp === 'claude' && <ClaudeMcpPanel />}
|
||||||
|
{currentApp === 'codex' && <CodexMcpPanel />}
|
||||||
|
{currentApp === 'gemini' && <GeminiMcpPanel />}
|
||||||
|
|
||||||
|
// 新代码(v3.7.0)
|
||||||
|
<UnifiedMcpPanel />
|
||||||
|
```
|
||||||
|
|
||||||
|
**移除的组件**:
|
||||||
|
- `ClaudeMcpPanel.tsx`
|
||||||
|
- `CodexMcpPanel.tsx`
|
||||||
|
- `GeminiMcpPanel.tsx`
|
||||||
|
|
||||||
|
**注意**:保留旧组件文件备份,以便回滚
|
||||||
|
|
||||||
|
#### 3.6 国际化文本更新 📝 待开发
|
||||||
|
|
||||||
|
**文件**:
|
||||||
|
- `src/locales/zh/translation.json`
|
||||||
|
- `src/locales/en/translation.json`
|
||||||
|
|
||||||
|
**需要添加的翻译键**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcp": {
|
||||||
|
"unifiedPanel": {
|
||||||
|
"title": "MCP 服务器管理 / MCP Server Management",
|
||||||
|
"addServer": "添加服务器 / Add Server",
|
||||||
|
"editServer": "编辑服务器 / Edit Server",
|
||||||
|
"deleteServer": "删除服务器 / Delete Server",
|
||||||
|
"deleteConfirm": "确定要删除此服务器吗?/ Are you sure to delete this server?",
|
||||||
|
"syncAll": "同步全部 / Sync All",
|
||||||
|
"syncAllSuccess": "已同步所有启用的服务器 / All enabled servers synced",
|
||||||
|
"importFrom": "导入自... / Import from...",
|
||||||
|
"search": "搜索服务器... / Search servers...",
|
||||||
|
"noServers": "暂无服务器 / No servers yet",
|
||||||
|
"enabledApps": "启用的应用 / Enabled Apps",
|
||||||
|
"apps": {
|
||||||
|
"claude": "Claude",
|
||||||
|
"codex": "Codex",
|
||||||
|
"gemini": "Gemini"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"id": "服务器 ID / Server ID",
|
||||||
|
"name": "显示名称 / Display Name",
|
||||||
|
"type": "类型 / Type",
|
||||||
|
"stdio": "本地进程 / Local Process",
|
||||||
|
"http": "远程服务 / Remote Service",
|
||||||
|
"command": "命令 / Command",
|
||||||
|
"args": "参数 / Arguments",
|
||||||
|
"env": "环境变量 / Environment Variables",
|
||||||
|
"cwd": "工作目录 / Working Directory",
|
||||||
|
"url": "URL",
|
||||||
|
"headers": "请求头 / Headers",
|
||||||
|
"description": "描述 / Description",
|
||||||
|
"tags": "标签 / Tags",
|
||||||
|
"homepage": "主页 / Homepage",
|
||||||
|
"docs": "文档 / Documentation",
|
||||||
|
"selectApps": "选择应用 / Select Apps",
|
||||||
|
"selectAppsHint": "勾选此服务器要应用到哪些客户端 / Check which clients this server applies to"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"name": "名称 / Name",
|
||||||
|
"type": "类型 / Type",
|
||||||
|
"apps": "应用 / Apps",
|
||||||
|
"actions": "操作 / Actions",
|
||||||
|
"edit": "编辑 / Edit",
|
||||||
|
"delete": "删除 / Delete"
|
||||||
|
},
|
||||||
|
"import": {
|
||||||
|
"title": "导入 MCP 服务器 / Import MCP Servers",
|
||||||
|
"fromClaude": "从 Claude 导入 / Import from Claude",
|
||||||
|
"fromCodex": "从 Codex 导入 / Import from Codex",
|
||||||
|
"fromGemini": "从 Gemini 导入 / Import from Gemini",
|
||||||
|
"success": "成功导入 {{count}} 个服务器 / Successfully imported {{count}} server(s)",
|
||||||
|
"noServersFound": "未找到可导入的服务器 / No servers found to import"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 迁移流程
|
||||||
|
|
||||||
|
### 用户体验
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 用户升级到 v3.7.0
|
||||||
|
↓
|
||||||
|
2. 首次启动应用
|
||||||
|
↓
|
||||||
|
3. 后端自动执行迁移
|
||||||
|
- 检测旧结构 (mcp.claude/codex/gemini.servers)
|
||||||
|
- 合并到统一结构 (mcp.servers)
|
||||||
|
- 保存迁移后的配置
|
||||||
|
- 日志记录迁移详情
|
||||||
|
↓
|
||||||
|
4. 前端加载新面板
|
||||||
|
- 显示所有服务器
|
||||||
|
- 三个复选框显示各应用启用状态
|
||||||
|
↓
|
||||||
|
5. 用户无缝使用
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据完整性保证
|
||||||
|
|
||||||
|
1. **迁移前验证**:
|
||||||
|
- ✅ 校验旧结构合法性
|
||||||
|
- ✅ 记录迁移前状态
|
||||||
|
|
||||||
|
2. **迁移中处理**:
|
||||||
|
- ✅ 合并同 id 服务器的 apps 字段
|
||||||
|
- ✅ 处理 id 冲突(保留第一个,记录警告)
|
||||||
|
- ✅ 保留所有元信息(描述、标签、链接)
|
||||||
|
|
||||||
|
3. **迁移后清理**:
|
||||||
|
- ✅ 清空旧结构(claude/codex/gemini)
|
||||||
|
- ✅ 自动保存新配置
|
||||||
|
- ✅ 日志记录迁移完成
|
||||||
|
|
||||||
|
4. **回滚机制**:
|
||||||
|
- 配置文件有备份(`config.v1.backup.<timestamp>.json`)
|
||||||
|
- 迁移失败时可手动回滚
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 测试计划
|
||||||
|
|
||||||
|
### 后端测试 ✅ 已验证
|
||||||
|
|
||||||
|
- [x] 编译测试(cargo check)
|
||||||
|
- [x] 数据结构序列化/反序列化
|
||||||
|
- [ ] 迁移逻辑单元测试
|
||||||
|
- [ ] 服务层方法测试
|
||||||
|
- [ ] 同步函数测试
|
||||||
|
|
||||||
|
### 前端测试 ⏳ 待进行
|
||||||
|
|
||||||
|
- [ ] TypeScript 类型检查
|
||||||
|
- [ ] API 调用测试
|
||||||
|
- [ ] 组件渲染测试
|
||||||
|
- [ ] 用户交互测试
|
||||||
|
- [ ] 国际化文本检查
|
||||||
|
|
||||||
|
### 集成测试 ⏳ 待进行
|
||||||
|
|
||||||
|
- [ ] 完整迁移流程测试
|
||||||
|
- [ ] 从空配置启动
|
||||||
|
- [ ] 从 v3.6.x 配置升级
|
||||||
|
- [ ] 多服务器合并场景
|
||||||
|
- [ ] 冲突处理验证
|
||||||
|
- [ ] 多应用同步测试
|
||||||
|
- [ ] 启用单个应用
|
||||||
|
- [ ] 启用多个应用
|
||||||
|
- [ ] 动态切换应用
|
||||||
|
- [ ] 同步到 live 配置验证
|
||||||
|
- [ ] 边界情况测试
|
||||||
|
- [ ] 空服务器列表
|
||||||
|
- [ ] 超长服务器名称
|
||||||
|
- [ ] 特殊字符处理
|
||||||
|
- [ ] 并发操作
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 交付清单
|
||||||
|
|
||||||
|
### 代码文件
|
||||||
|
|
||||||
|
#### 后端(Rust)✅ 已完成
|
||||||
|
|
||||||
|
- [x] `src-tauri/src/app_config.rs` - 数据结构定义与迁移
|
||||||
|
- [x] `src-tauri/src/services/mcp.rs` - 服务层重构
|
||||||
|
- [x] `src-tauri/src/mcp.rs` - 同步函数实现
|
||||||
|
- [x] `src-tauri/src/commands/mcp.rs` - Tauri 命令
|
||||||
|
- [x] `src-tauri/src/lib.rs` - 命令注册
|
||||||
|
- [x] `src-tauri/src/claude_mcp.rs` - Claude MCP 操作
|
||||||
|
- [x] `src-tauri/src/gemini_mcp.rs` - Gemini MCP 操作
|
||||||
|
|
||||||
|
#### 前端(TypeScript/React)⚠️ 部分完成
|
||||||
|
|
||||||
|
- [x] `src/types.ts` - 类型定义更新
|
||||||
|
- [x] `src/lib/api/mcp.ts` - API 层更新
|
||||||
|
- [ ] `src/hooks/useMcp.ts` - React Query Hooks
|
||||||
|
- [ ] `src/components/mcp/UnifiedMcpPanel.tsx` - 统一面板组件
|
||||||
|
- [ ] `src/components/mcp/McpServerTable.tsx` - 服务器表格
|
||||||
|
- [ ] `src/components/mcp/McpServerFormModal.tsx` - 表单模态框
|
||||||
|
- [ ] `src/components/mcp/McpImportDialog.tsx` - 导入对话框
|
||||||
|
- [ ] `src/App.tsx` - 主界面集成
|
||||||
|
- [ ] `src/locales/zh/translation.json` - 中文翻译
|
||||||
|
- [ ] `src/locales/en/translation.json` - 英文翻译
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
|
||||||
|
- [x] 本重构计划文档 (`docs/v3.7.0-unified-mcp-refactor.md`)
|
||||||
|
- [ ] 用户升级指南 (`docs/upgrade-to-v3.7.0.md`)
|
||||||
|
- [ ] API 变更说明 (`docs/api-changes-v3.7.0.md`)
|
||||||
|
|
||||||
|
### Git 提交记录 ✅
|
||||||
|
|
||||||
|
- [x] `c7b235b` - feat(mcp): implement unified MCP management for v3.7.0
|
||||||
|
- [x] `7ae2a9f` - fix(mcp): resolve compilation errors and add backward compatibility
|
||||||
|
- [x] `ac09551` - feat(frontend): add unified MCP types and API layer for v3.7.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 下一步行动
|
||||||
|
|
||||||
|
### 立即任务(优先级 P0)
|
||||||
|
|
||||||
|
1. ⬜ **实现 useMcp Hook**
|
||||||
|
- 文件:`src/hooks/useMcp.ts`
|
||||||
|
- 估时:1-2 小时
|
||||||
|
- 依赖:API 层(已完成)
|
||||||
|
|
||||||
|
2. ⬜ **创建 UnifiedMcpPanel 核心组件**
|
||||||
|
- 文件:`src/components/mcp/UnifiedMcpPanel.tsx`
|
||||||
|
- 估时:3-4 小时
|
||||||
|
- 依赖:useMcp Hook
|
||||||
|
|
||||||
|
3. ⬜ **添加国际化文本**
|
||||||
|
- 文件:`src/locales/{zh,en}/translation.json`
|
||||||
|
- 估时:30 分钟
|
||||||
|
|
||||||
|
4. ⬜ **集成到主界面**
|
||||||
|
- 文件:`src/App.tsx`
|
||||||
|
- 估时:30 分钟
|
||||||
|
- 依赖:UnifiedMcpPanel 组件
|
||||||
|
|
||||||
|
### 次要任务(优先级 P1)
|
||||||
|
|
||||||
|
5. ⬜ **实现子组件**
|
||||||
|
- McpServerTable
|
||||||
|
- McpServerFormModal
|
||||||
|
- McpImportDialog
|
||||||
|
- 估时:4-6 小时
|
||||||
|
|
||||||
|
6. ⬜ **编写测试用例**
|
||||||
|
- 后端单元测试
|
||||||
|
- 前端组件测试
|
||||||
|
- 集成测试
|
||||||
|
- 估时:6-8 小时
|
||||||
|
|
||||||
|
7. ⬜ **编写用户文档**
|
||||||
|
- 升级指南
|
||||||
|
- API 变更说明
|
||||||
|
- 估时:2-3 小时
|
||||||
|
|
||||||
|
### 优化任务(优先级 P2)
|
||||||
|
|
||||||
|
8. ⬜ **性能优化**
|
||||||
|
- 服务器列表虚拟滚动
|
||||||
|
- 批量操作优化
|
||||||
|
- 估时:2-3 小时
|
||||||
|
|
||||||
|
9. ⬜ **用户体验增强**
|
||||||
|
- 添加加载状态
|
||||||
|
- 添加错误提示
|
||||||
|
- 添加操作确认
|
||||||
|
- 估时:2-3 小时
|
||||||
|
|
||||||
|
10. ⬜ **代码清理**
|
||||||
|
- 移除旧的分应用面板组件
|
||||||
|
- 清理废弃代码
|
||||||
|
- 代码格式化
|
||||||
|
- 估时:1-2 小时
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 技术亮点
|
||||||
|
|
||||||
|
### 1. 平滑迁移机制
|
||||||
|
|
||||||
|
- ✅ 自动检测旧配置并迁移
|
||||||
|
- ✅ 新旧结构并存(过渡期)
|
||||||
|
- ✅ 无需用户手动操作
|
||||||
|
- ✅ 保留所有历史数据
|
||||||
|
|
||||||
|
### 2. 向后兼容
|
||||||
|
|
||||||
|
- ✅ 旧命令继续可用(带废弃警告)
|
||||||
|
- ✅ 前端可增量更新
|
||||||
|
- ✅ 渐进式重构策略
|
||||||
|
|
||||||
|
### 3. 类型安全
|
||||||
|
|
||||||
|
- ✅ Rust 强类型保证数据完整性
|
||||||
|
- ✅ TypeScript 类型定义与后端一致
|
||||||
|
- ✅ serde 序列化/反序列化自动处理
|
||||||
|
|
||||||
|
### 4. 清晰的架构分层
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend (React)
|
||||||
|
↓ (Tauri IPC)
|
||||||
|
Commands Layer
|
||||||
|
↓
|
||||||
|
Services Layer
|
||||||
|
↓
|
||||||
|
Data Layer (Config + Live Sync)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. SSOT 原则
|
||||||
|
|
||||||
|
- 单一配置源:`~/.cc-switch/config.json`
|
||||||
|
- 统一管理:`mcp.servers` 字段
|
||||||
|
- 按需同步:写入各应用 live 配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 参考资源
|
||||||
|
|
||||||
|
### 内部文档
|
||||||
|
|
||||||
|
- [项目 README](../README.md)
|
||||||
|
- [CLAUDE.md](../CLAUDE.md) - Claude Code 工作指南
|
||||||
|
- [架构文档](../CLAUDE.md#架构概述)
|
||||||
|
|
||||||
|
### 相关 Issues/PRs
|
||||||
|
|
||||||
|
- 无(新功能开发)
|
||||||
|
|
||||||
|
### 技术栈文档
|
||||||
|
|
||||||
|
- [Tauri 2.0](https://tauri.app/v1/guides/)
|
||||||
|
- [React 18](https://react.dev/)
|
||||||
|
- [TanStack Query](https://tanstack.com/query/latest)
|
||||||
|
- [shadcn/ui](https://ui.shadcn.com/)
|
||||||
|
- [serde](https://serde.rs/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 变更日志
|
||||||
|
|
||||||
|
### 2025-11-14
|
||||||
|
|
||||||
|
- ✅ 完成后端 Phase 1 & 2(数据结构、服务层、命令层)
|
||||||
|
- ✅ 修复所有编译错误
|
||||||
|
- ✅ 完成前端类型定义和 API 层
|
||||||
|
- ✅ 创建本重构计划文档
|
||||||
|
|
||||||
|
### 待更新...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👥 团队协作
|
||||||
|
|
||||||
|
**开发者**:Claude Code (AI Assistant) + User
|
||||||
|
|
||||||
|
**审查者**:User
|
||||||
|
|
||||||
|
**测试者**:User
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 风险与对策
|
||||||
|
|
||||||
|
### 风险 1:迁移数据丢失
|
||||||
|
|
||||||
|
**概率**:低
|
||||||
|
**影响**:高
|
||||||
|
**对策**:
|
||||||
|
- ✅ 迁移前自动备份配置
|
||||||
|
- ✅ 详细日志记录
|
||||||
|
- ✅ 测试各种边界情况
|
||||||
|
|
||||||
|
### 风险 2:性能问题(大量服务器)
|
||||||
|
|
||||||
|
**概率**:中
|
||||||
|
**影响**:中
|
||||||
|
**对策**:
|
||||||
|
- ⬜ 实现虚拟滚动
|
||||||
|
- ⬜ 分页或懒加载
|
||||||
|
- ⬜ 性能测试
|
||||||
|
|
||||||
|
### 风险 3:兼容性问题
|
||||||
|
|
||||||
|
**概率**:中
|
||||||
|
**影响**:中
|
||||||
|
**对策**:
|
||||||
|
- ✅ 保留旧命令兼容层
|
||||||
|
- ✅ 前端增量更新
|
||||||
|
- ⬜ 多版本测试
|
||||||
|
|
||||||
|
### 风险 4:用户学习成本
|
||||||
|
|
||||||
|
**概率**:低
|
||||||
|
**影响**:低
|
||||||
|
**对策**:
|
||||||
|
- ⬜ 清晰的 UI 设计
|
||||||
|
- ⬜ 详细的升级指南
|
||||||
|
- ⬜ 操作提示和引导
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 预期收益
|
||||||
|
|
||||||
|
### 用户体验提升
|
||||||
|
|
||||||
|
- ⭐ **简化操作**:不再需要在不同应用面板切换
|
||||||
|
- ⭐ **统一视图**:一目了然看到所有 MCP 配置
|
||||||
|
- ⭐ **灵活配置**:轻松控制每个 MCP 应用到哪些客户端
|
||||||
|
|
||||||
|
### 代码质量提升
|
||||||
|
|
||||||
|
- ⭐ **架构优化**:统一数据源,消除冗余
|
||||||
|
- ⭐ **维护性**:单一面板组件,代码更简洁
|
||||||
|
- ⭐ **扩展性**:未来添加新应用(如 Cursor)更容易
|
||||||
|
|
||||||
|
### 性能提升
|
||||||
|
|
||||||
|
- ⭐ **减少重复加载**:统一管理减少配置文件读写
|
||||||
|
- ⭐ **更快同步**:批量操作更高效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 联系方式
|
||||||
|
|
||||||
|
**问题反馈**:[GitHub Issues](https://github.com/jasonyoungyang/cc-switch/issues)
|
||||||
|
|
||||||
|
**功能建议**:[GitHub Discussions](https://github.com/jasonyoungyang/cc-switch/discussions)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本**:v1.0
|
||||||
|
**最后更新**:2025-11-14
|
||||||
|
**状态**:🟡 开发中(后端完成 ✅,前端进行中 ⚠️)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.6.1",
|
"version": "3.6.2",
|
||||||
"description": "Claude Code & Codex 供应商切换工具",
|
"description": "Claude Code & Codex 供应商切换工具",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm tauri dev",
|
"dev": "pnpm tauri dev",
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
"@codemirror/lint": "^6.8.5",
|
"@codemirror/lint": "^6.8.5",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||||
"@tailwindcss/vite": "^4.1.13",
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
"@tanstack/react-query": "^5.90.3",
|
"@tanstack/react-query": "^5.90.3",
|
||||||
"@tauri-apps/api": "^2.8.0",
|
"@tauri-apps/api": "^2.8.0",
|
||||||
@@ -75,5 +77,6 @@
|
|||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||||
}
|
}
|
||||||
|
|||||||
131
pnpm-lock.yaml
generated
131
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
|||||||
'@codemirror/lang-json':
|
'@codemirror/lang-json':
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.2
|
version: 6.0.2
|
||||||
|
'@codemirror/lang-markdown':
|
||||||
|
specifier: ^6.5.0
|
||||||
|
version: 6.5.0
|
||||||
'@codemirror/lint':
|
'@codemirror/lint':
|
||||||
specifier: ^6.8.5
|
specifier: ^6.8.5
|
||||||
version: 6.8.5
|
version: 6.8.5
|
||||||
@@ -62,6 +65,9 @@ importers:
|
|||||||
'@radix-ui/react-tabs':
|
'@radix-ui/react-tabs':
|
||||||
specifier: ^1.1.13
|
specifier: ^1.1.13
|
||||||
version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@radix-ui/react-visually-hidden':
|
||||||
|
specifier: ^1.2.4
|
||||||
|
version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.1.13
|
specifier: ^4.1.13
|
||||||
version: 4.1.13(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))
|
version: 4.1.13(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))
|
||||||
@@ -280,12 +286,21 @@ packages:
|
|||||||
'@codemirror/commands@6.8.1':
|
'@codemirror/commands@6.8.1':
|
||||||
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
|
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
|
||||||
|
|
||||||
|
'@codemirror/lang-css@6.3.1':
|
||||||
|
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
|
||||||
|
|
||||||
|
'@codemirror/lang-html@6.4.11':
|
||||||
|
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
|
||||||
|
|
||||||
'@codemirror/lang-javascript@6.2.4':
|
'@codemirror/lang-javascript@6.2.4':
|
||||||
resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
|
resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
|
||||||
|
|
||||||
'@codemirror/lang-json@6.0.2':
|
'@codemirror/lang-json@6.0.2':
|
||||||
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
|
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
|
||||||
|
|
||||||
|
'@codemirror/lang-markdown@6.5.0':
|
||||||
|
resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
|
||||||
|
|
||||||
'@codemirror/language@6.11.3':
|
'@codemirror/language@6.11.3':
|
||||||
resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==}
|
resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==}
|
||||||
|
|
||||||
@@ -573,9 +588,15 @@ packages:
|
|||||||
'@lezer/common@1.2.3':
|
'@lezer/common@1.2.3':
|
||||||
resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==}
|
resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==}
|
||||||
|
|
||||||
|
'@lezer/css@1.3.0':
|
||||||
|
resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==}
|
||||||
|
|
||||||
'@lezer/highlight@1.2.1':
|
'@lezer/highlight@1.2.1':
|
||||||
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
|
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
|
||||||
|
|
||||||
|
'@lezer/html@1.3.12':
|
||||||
|
resolution: {integrity: sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==}
|
||||||
|
|
||||||
'@lezer/javascript@1.5.4':
|
'@lezer/javascript@1.5.4':
|
||||||
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
|
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
|
||||||
|
|
||||||
@@ -585,6 +606,9 @@ packages:
|
|||||||
'@lezer/lr@1.4.2':
|
'@lezer/lr@1.4.2':
|
||||||
resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==}
|
resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==}
|
||||||
|
|
||||||
|
'@lezer/markdown@1.6.0':
|
||||||
|
resolution: {integrity: sha512-AXb98u3M6BEzTnreBnGtQaF7xFTiMA92Dsy5tqEjpacbjRxDSFdN4bKJo9uvU4cEEOS7D2B9MT7kvDgOEIzJSw==}
|
||||||
|
|
||||||
'@marijn/find-cluster-break@1.0.2':
|
'@marijn/find-cluster-break@1.0.2':
|
||||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||||
|
|
||||||
@@ -821,6 +845,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-primitive@2.1.4':
|
||||||
|
resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-roving-focus@1.1.11':
|
'@radix-ui/react-roving-focus@1.1.11':
|
||||||
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
|
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -856,6 +893,15 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-slot@1.2.4':
|
||||||
|
resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-switch@1.2.6':
|
'@radix-ui/react-switch@1.2.6':
|
||||||
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
|
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -967,6 +1013,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-visually-hidden@1.2.4':
|
||||||
|
resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/rect@1.1.1':
|
'@radix-ui/rect@1.1.1':
|
||||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||||
|
|
||||||
@@ -2468,6 +2527,26 @@ snapshots:
|
|||||||
'@codemirror/view': 6.38.2
|
'@codemirror/view': 6.38.2
|
||||||
'@lezer/common': 1.2.3
|
'@lezer/common': 1.2.3
|
||||||
|
|
||||||
|
'@codemirror/lang-css@6.3.1':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/autocomplete': 6.18.7
|
||||||
|
'@codemirror/language': 6.11.3
|
||||||
|
'@codemirror/state': 6.5.2
|
||||||
|
'@lezer/common': 1.2.3
|
||||||
|
'@lezer/css': 1.3.0
|
||||||
|
|
||||||
|
'@codemirror/lang-html@6.4.11':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/autocomplete': 6.18.7
|
||||||
|
'@codemirror/lang-css': 6.3.1
|
||||||
|
'@codemirror/lang-javascript': 6.2.4
|
||||||
|
'@codemirror/language': 6.11.3
|
||||||
|
'@codemirror/state': 6.5.2
|
||||||
|
'@codemirror/view': 6.38.2
|
||||||
|
'@lezer/common': 1.2.3
|
||||||
|
'@lezer/css': 1.3.0
|
||||||
|
'@lezer/html': 1.3.12
|
||||||
|
|
||||||
'@codemirror/lang-javascript@6.2.4':
|
'@codemirror/lang-javascript@6.2.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.18.7
|
'@codemirror/autocomplete': 6.18.7
|
||||||
@@ -2483,6 +2562,16 @@ snapshots:
|
|||||||
'@codemirror/language': 6.11.3
|
'@codemirror/language': 6.11.3
|
||||||
'@lezer/json': 1.0.3
|
'@lezer/json': 1.0.3
|
||||||
|
|
||||||
|
'@codemirror/lang-markdown@6.5.0':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/autocomplete': 6.18.7
|
||||||
|
'@codemirror/lang-html': 6.4.11
|
||||||
|
'@codemirror/language': 6.11.3
|
||||||
|
'@codemirror/state': 6.5.2
|
||||||
|
'@codemirror/view': 6.38.2
|
||||||
|
'@lezer/common': 1.2.3
|
||||||
|
'@lezer/markdown': 1.6.0
|
||||||
|
|
||||||
'@codemirror/language@6.11.3':
|
'@codemirror/language@6.11.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/state': 6.5.2
|
'@codemirror/state': 6.5.2
|
||||||
@@ -2713,10 +2802,22 @@ snapshots:
|
|||||||
|
|
||||||
'@lezer/common@1.2.3': {}
|
'@lezer/common@1.2.3': {}
|
||||||
|
|
||||||
|
'@lezer/css@1.3.0':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.2.3
|
||||||
|
'@lezer/highlight': 1.2.1
|
||||||
|
'@lezer/lr': 1.4.2
|
||||||
|
|
||||||
'@lezer/highlight@1.2.1':
|
'@lezer/highlight@1.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@lezer/common': 1.2.3
|
'@lezer/common': 1.2.3
|
||||||
|
|
||||||
|
'@lezer/html@1.3.12':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.2.3
|
||||||
|
'@lezer/highlight': 1.2.1
|
||||||
|
'@lezer/lr': 1.4.2
|
||||||
|
|
||||||
'@lezer/javascript@1.5.4':
|
'@lezer/javascript@1.5.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@lezer/common': 1.2.3
|
'@lezer/common': 1.2.3
|
||||||
@@ -2733,6 +2834,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@lezer/common': 1.2.3
|
'@lezer/common': 1.2.3
|
||||||
|
|
||||||
|
'@lezer/markdown@1.6.0':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.2.3
|
||||||
|
'@lezer/highlight': 1.2.1
|
||||||
|
|
||||||
'@marijn/find-cluster-break@1.0.2': {}
|
'@marijn/find-cluster-break@1.0.2': {}
|
||||||
|
|
||||||
'@mswjs/interceptors@0.40.0':
|
'@mswjs/interceptors@0.40.0':
|
||||||
@@ -2968,6 +3074,15 @@ snapshots:
|
|||||||
'@types/react': 18.3.23
|
'@types/react': 18.3.23
|
||||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||||
|
|
||||||
|
'@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-slot': 1.2.4(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.23
|
||||||
|
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||||
|
|
||||||
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -3021,6 +3136,13 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.23
|
'@types/react': 18.3.23
|
||||||
|
|
||||||
|
'@radix-ui/react-slot@1.2.4(@types/react@18.3.23)(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
react: 18.3.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.23
|
||||||
|
|
||||||
'@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -3115,6 +3237,15 @@ snapshots:
|
|||||||
'@types/react': 18.3.23
|
'@types/react': 18.3.23
|
||||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||||
|
|
||||||
|
'@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.23
|
||||||
|
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||||
|
|
||||||
'@radix-ui/rect@1.1.1': {}
|
'@radix-ui/rect@1.1.1': {}
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||||
|
|||||||
425
src-tauri/Cargo.lock
generated
425
src-tauri/Cargo.lock
generated
@@ -17,6 +17,17 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.7.8"
|
version = "0.7.8"
|
||||||
@@ -484,6 +495,25 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bzip2"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
|
||||||
|
dependencies = [
|
||||||
|
"bzip2-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bzip2-sys"
|
||||||
|
version = "0.1.13+1.0.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cairo-rs"
|
name = "cairo-rs"
|
||||||
version = "0.18.5"
|
version = "0.18.5"
|
||||||
@@ -558,13 +588,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb"
|
checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.6.1"
|
version = "3.6.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -576,8 +609,11 @@ dependencies = [
|
|||||||
"rquickjs",
|
"rquickjs",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
|
"serial_test",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-deep-link",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
@@ -585,10 +621,14 @@ dependencies = [
|
|||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml 0.8.2",
|
"toml 0.8.2",
|
||||||
"toml_edit 0.22.27",
|
"toml_edit 0.22.27",
|
||||||
|
"url",
|
||||||
|
"winreg 0.52.0",
|
||||||
|
"zip 2.4.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -644,6 +684,16 @@ dependencies = [
|
|||||||
"windows-link 0.2.0",
|
"windows-link 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -663,6 +713,32 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-random"
|
||||||
|
version = "0.1.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||||
|
dependencies = [
|
||||||
|
"const-random-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-random-macro"
|
||||||
|
version = "0.1.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.16",
|
||||||
|
"once_cell",
|
||||||
|
"tiny-keccak",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "constant_time_eq"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -728,6 +804,21 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc"
|
||||||
|
version = "3.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675"
|
||||||
|
dependencies = [
|
||||||
|
"crc-catalog",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc-catalog"
|
||||||
|
version = "2.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -752,6 +843,12 @@ version = "0.8.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crunchy"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -834,6 +931,12 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deflate64"
|
||||||
|
version = "0.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.4"
|
version = "0.5.4"
|
||||||
@@ -876,6 +979,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -981,6 +1085,15 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dlv-list"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||||
|
dependencies = [
|
||||||
|
"const-random",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "downcast-rs"
|
name = "downcast-rs"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -1034,7 +1147,7 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
"toml 0.9.7",
|
"toml 0.9.7",
|
||||||
"vswhom",
|
"vswhom",
|
||||||
"winreg",
|
"winreg 0.55.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1669,6 +1782,12 @@ dependencies = [
|
|||||||
"ahash",
|
"ahash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@@ -1699,6 +1818,15 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.29.1"
|
version = "0.29.1"
|
||||||
@@ -1745,6 +1873,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-range"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
@@ -1992,6 +2126,15 @@ dependencies = [
|
|||||||
"cfb",
|
"cfb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "io-uring"
|
name = "io-uring"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@@ -2089,6 +2232,16 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jobserver"
|
||||||
|
version = "0.1.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.3",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.81"
|
version = "0.3.81"
|
||||||
@@ -2247,6 +2400,27 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lzma-rs"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"crc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lzma-sys"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2776,6 +2950,16 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ordered-multimap"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||||
|
dependencies = [
|
||||||
|
"dlv-list",
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ordered-stream"
|
name = "ordered-stream"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2860,6 +3044,16 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pbkdf2"
|
||||||
|
version = "0.12.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
"hmac",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -3621,6 +3815,16 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rust-ini"
|
||||||
|
version = "0.21.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"ordered-multimap",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust_decimal"
|
name = "rust_decimal"
|
||||||
version = "1.38.0"
|
version = "1.38.0"
|
||||||
@@ -3727,6 +3931,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scc"
|
||||||
|
version = "2.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
|
||||||
|
dependencies = [
|
||||||
|
"sdd",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -3790,6 +4003,12 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sdd"
|
||||||
|
version = "3.0.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "seahash"
|
name = "seahash"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@@ -3962,6 +4181,44 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_yaml"
|
||||||
|
version = "0.9.34+deprecated"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap 2.11.4",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
"unsafe-libyaml",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serial_test"
|
||||||
|
version = "3.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9"
|
||||||
|
dependencies = [
|
||||||
|
"futures",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"parking_lot",
|
||||||
|
"scc",
|
||||||
|
"serial_test_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serial_test_derive"
|
||||||
|
version = "3.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.106",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serialize-to-javascript"
|
name = "serialize-to-javascript"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -3994,6 +4251,17 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -4320,6 +4588,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
|
"http-range",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -4435,6 +4704,27 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-deep-link"
|
||||||
|
version = "2.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73"
|
||||||
|
dependencies = [
|
||||||
|
"dunce",
|
||||||
|
"plist",
|
||||||
|
"rust-ini",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-utils",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
"windows-registry",
|
||||||
|
"windows-result 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-dialog"
|
name = "tauri-plugin-dialog"
|
||||||
version = "2.4.0"
|
version = "2.4.0"
|
||||||
@@ -4589,7 +4879,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
"zip",
|
"zip 4.6.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4789,6 +5079,15 @@ dependencies = [
|
|||||||
"time-core",
|
"time-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny-keccak"
|
||||||
|
version = "2.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||||
|
dependencies = [
|
||||||
|
"crunchy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -5162,6 +5461,12 @@ version = "1.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unsafe-libyaml"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -5712,6 +6017,17 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-registry"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.1.3",
|
||||||
|
"windows-result 0.3.4",
|
||||||
|
"windows-strings 0.4.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -6081,6 +6397,16 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winreg"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winreg"
|
name = "winreg"
|
||||||
version = "0.55.0"
|
version = "0.55.0"
|
||||||
@@ -6188,6 +6514,15 @@ dependencies = [
|
|||||||
"rustix",
|
"rustix",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xz2"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
|
||||||
|
dependencies = [
|
||||||
|
"lzma-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -6319,6 +6654,20 @@ name = "zeroize"
|
|||||||
version = "1.8.2"
|
version = "1.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize_derive"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.106",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
@@ -6353,6 +6702,36 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "2.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||||
|
dependencies = [
|
||||||
|
"aes",
|
||||||
|
"arbitrary",
|
||||||
|
"bzip2",
|
||||||
|
"constant_time_eq",
|
||||||
|
"crc32fast",
|
||||||
|
"crossbeam-utils",
|
||||||
|
"deflate64",
|
||||||
|
"displaydoc",
|
||||||
|
"flate2",
|
||||||
|
"getrandom 0.3.3",
|
||||||
|
"hmac",
|
||||||
|
"indexmap 2.11.4",
|
||||||
|
"lzma-rs",
|
||||||
|
"memchr",
|
||||||
|
"pbkdf2",
|
||||||
|
"sha1",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"time",
|
||||||
|
"xz2",
|
||||||
|
"zeroize",
|
||||||
|
"zopfli",
|
||||||
|
"zstd",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zip"
|
name = "zip"
|
||||||
version = "4.6.1"
|
version = "4.6.1"
|
||||||
@@ -6365,6 +6744,46 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zopfli"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"crc32fast",
|
||||||
|
"log",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-safe",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-safe"
|
||||||
|
version = "7.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-sys"
|
||||||
|
version = "2.0.16+zstd.1.5.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zvariant"
|
name = "zvariant"
|
||||||
version = "5.7.0"
|
version = "5.7.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.6.1"
|
version = "3.6.2"
|
||||||
description = "Claude Code & Codex 供应商配置管理工具"
|
description = "Claude Code & Codex 供应商配置管理工具"
|
||||||
authors = ["Jason Young"]
|
authors = ["Jason Young"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -25,14 +25,15 @@ tauri-build = { version = "2.4.0", features = [] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
chrono = "0.4"
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
tauri = { version = "2.8.2", features = ["tray-icon"] }
|
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset"] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-store = "2"
|
tauri-plugin-store = "2"
|
||||||
|
tauri-plugin-deep-link = "2"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
toml_edit = "0.22"
|
toml_edit = "0.22"
|
||||||
@@ -42,10 +43,18 @@ futures = "0.3"
|
|||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
|
anyhow = "1.0"
|
||||||
|
zip = "2.2"
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
tempfile = "3"
|
||||||
|
url = "2.5"
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
winreg = "0.52"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = "0.5"
|
objc2 = "0.5"
|
||||||
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
||||||
@@ -57,3 +66,7 @@ lto = "thin"
|
|||||||
opt-level = "s"
|
opt-level = "s"
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
strip = "symbols"
|
strip = "symbols"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serial_test = "3"
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
19
src-tauri/Info.plist
Normal file
19
src-tauri/Info.plist
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- 注册 ccswitch:// 自定义 URL 协议,用于深链接导入 -->
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>CC Switch Deep Link</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>ccswitch</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
||||||
@@ -2,7 +2,77 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
/// MCP 配置:单客户端维度(claude 或 codex 下的一组服务器)
|
use crate::services::skill::SkillStore;
|
||||||
|
|
||||||
|
/// MCP 服务器应用状态(标记应用到哪些客户端)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct McpApps {
|
||||||
|
#[serde(default)]
|
||||||
|
pub claude: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub codex: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gemini: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl McpApps {
|
||||||
|
/// 检查指定应用是否启用
|
||||||
|
pub fn is_enabled_for(&self, app: &AppType) -> bool {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => self.claude,
|
||||||
|
AppType::Codex => self.codex,
|
||||||
|
AppType::Gemini => self.gemini,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置指定应用的启用状态
|
||||||
|
pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => self.claude = enabled,
|
||||||
|
AppType::Codex => self.codex = enabled,
|
||||||
|
AppType::Gemini => self.gemini = enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取所有启用的应用列表
|
||||||
|
pub fn enabled_apps(&self) -> Vec<AppType> {
|
||||||
|
let mut apps = Vec::new();
|
||||||
|
if self.claude {
|
||||||
|
apps.push(AppType::Claude);
|
||||||
|
}
|
||||||
|
if self.codex {
|
||||||
|
apps.push(AppType::Codex);
|
||||||
|
}
|
||||||
|
if self.gemini {
|
||||||
|
apps.push(AppType::Gemini);
|
||||||
|
}
|
||||||
|
apps
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否所有应用都未启用
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
!self.claude && !self.codex && !self.gemini
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP 服务器定义(v3.7.0 统一结构)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct McpServer {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub server: serde_json::Value,
|
||||||
|
pub apps: McpApps,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub docs: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP 配置:单客户端维度(v3.6.x 及以前,保留用于向后兼容)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct McpConfig {
|
pub struct McpConfig {
|
||||||
/// 以 id 为键的服务器定义(宽松 JSON 对象,包含 enabled/source 等 UI 辅助字段)
|
/// 以 id 为键的服务器定义(宽松 JSON 对象,包含 enabled/source 等 UI 辅助字段)
|
||||||
@@ -10,25 +80,72 @@ pub struct McpConfig {
|
|||||||
pub servers: HashMap<String, serde_json::Value>,
|
pub servers: HashMap<String, serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MCP 根:按客户端分开维护(无历史兼容压力,直接以 v2 结构落地)
|
impl McpConfig {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
/// 检查配置是否为空
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.servers.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP 根配置(v3.7.0 新旧结构并存)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct McpRoot {
|
pub struct McpRoot {
|
||||||
#[serde(default)]
|
/// 统一的 MCP 服务器存储(v3.7.0+)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub servers: Option<HashMap<String, McpServer>>,
|
||||||
|
|
||||||
|
/// 旧的分应用存储(v3.6.x 及以前,保留用于迁移)
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
pub claude: McpConfig,
|
pub claude: McpConfig,
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
pub codex: McpConfig,
|
pub codex: McpConfig,
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
|
pub gemini: McpConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for McpRoot {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
// v3.7.0+ 默认使用新的统一结构(空 HashMap)
|
||||||
|
servers: Some(HashMap::new()),
|
||||||
|
// 旧结构保持空,仅用于反序列化旧配置时的迁移
|
||||||
|
claude: McpConfig::default(),
|
||||||
|
codex: McpConfig::default(),
|
||||||
|
gemini: McpConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt 配置:单客户端维度
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct PromptConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub prompts: HashMap<String, crate::prompt::Prompt>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt 根:按客户端分开维护
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct PromptRoot {
|
||||||
|
#[serde(default)]
|
||||||
|
pub claude: PromptConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub codex: PromptConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gemini: PromptConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
use crate::prompt_files::prompt_file_path;
|
||||||
use crate::provider::ProviderManager;
|
use crate::provider::ProviderManager;
|
||||||
|
|
||||||
/// 应用类型
|
/// 应用类型
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum AppType {
|
pub enum AppType {
|
||||||
Claude,
|
Claude,
|
||||||
Codex,
|
Codex,
|
||||||
|
Gemini, // 新增
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppType {
|
impl AppType {
|
||||||
@@ -36,6 +153,7 @@ impl AppType {
|
|||||||
match self {
|
match self {
|
||||||
AppType::Claude => "claude",
|
AppType::Claude => "claude",
|
||||||
AppType::Codex => "codex",
|
AppType::Codex => "codex",
|
||||||
|
AppType::Gemini => "gemini", // 新增
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,15 +166,49 @@ impl FromStr for AppType {
|
|||||||
match normalized.as_str() {
|
match normalized.as_str() {
|
||||||
"claude" => Ok(AppType::Claude),
|
"claude" => Ok(AppType::Claude),
|
||||||
"codex" => Ok(AppType::Codex),
|
"codex" => Ok(AppType::Codex),
|
||||||
|
"gemini" => Ok(AppType::Gemini), // 新增
|
||||||
other => Err(AppError::localized(
|
other => Err(AppError::localized(
|
||||||
"unsupported_app",
|
"unsupported_app",
|
||||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex。"),
|
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
|
||||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex."),
|
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 通用配置片段(按应用分治)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct CommonConfigSnippets {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub claude: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub codex: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gemini: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonConfigSnippets {
|
||||||
|
/// 获取指定应用的通用配置片段
|
||||||
|
pub fn get(&self, app: &AppType) -> Option<&String> {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => self.claude.as_ref(),
|
||||||
|
AppType::Codex => self.codex.as_ref(),
|
||||||
|
AppType::Gemini => self.gemini.as_ref(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置指定应用的通用配置片段
|
||||||
|
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => self.claude = snippet,
|
||||||
|
AppType::Codex => self.codex = snippet,
|
||||||
|
AppType::Gemini => self.gemini = snippet,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 多应用配置结构(向后兼容)
|
/// 多应用配置结构(向后兼容)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MultiAppConfig {
|
pub struct MultiAppConfig {
|
||||||
@@ -68,6 +220,18 @@ pub struct MultiAppConfig {
|
|||||||
/// MCP 配置(按客户端分治)
|
/// MCP 配置(按客户端分治)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mcp: McpRoot,
|
pub mcp: McpRoot,
|
||||||
|
/// Prompt 配置(按客户端分治)
|
||||||
|
#[serde(default)]
|
||||||
|
pub prompts: PromptRoot,
|
||||||
|
/// Claude Skills 配置
|
||||||
|
#[serde(default)]
|
||||||
|
pub skills: SkillStore,
|
||||||
|
/// 通用配置片段(按应用分治)
|
||||||
|
#[serde(default)]
|
||||||
|
pub common_config_snippets: CommonConfigSnippets,
|
||||||
|
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub claude_common_config_snippet: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_version() -> u32 {
|
fn default_version() -> u32 {
|
||||||
@@ -79,11 +243,16 @@ impl Default for MultiAppConfig {
|
|||||||
let mut apps = HashMap::new();
|
let mut apps = HashMap::new();
|
||||||
apps.insert("claude".to_string(), ProviderManager::default());
|
apps.insert("claude".to_string(), ProviderManager::default());
|
||||||
apps.insert("codex".to_string(), ProviderManager::default());
|
apps.insert("codex".to_string(), ProviderManager::default());
|
||||||
|
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
version: 2,
|
version: 2,
|
||||||
apps,
|
apps,
|
||||||
mcp: McpRoot::default(),
|
mcp: McpRoot::default(),
|
||||||
|
prompts: PromptRoot::default(),
|
||||||
|
skills: SkillStore::default(),
|
||||||
|
common_config_snippets: CommonConfigSnippets::default(),
|
||||||
|
claude_common_config_snippet: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,8 +263,12 @@ impl MultiAppConfig {
|
|||||||
let config_path = get_app_config_path();
|
let config_path = get_app_config_path();
|
||||||
|
|
||||||
if !config_path.exists() {
|
if !config_path.exists() {
|
||||||
log::info!("配置文件不存在,创建新的多应用配置");
|
log::info!("配置文件不存在,创建新的多应用配置并自动导入提示词");
|
||||||
return Ok(Self::default());
|
// 使用新的方法,支持自动导入提示词
|
||||||
|
let config = Self::default_with_auto_import()?;
|
||||||
|
// 立即保存到磁盘
|
||||||
|
config.save()?;
|
||||||
|
return Ok(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试读取文件
|
// 尝试读取文件
|
||||||
@@ -121,8 +294,73 @@ impl MultiAppConfig {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let has_skills_in_config = value
|
||||||
|
.as_object()
|
||||||
|
.is_some_and(|map| map.contains_key("skills"));
|
||||||
|
|
||||||
// 解析 v2 结构
|
// 解析 v2 结构
|
||||||
serde_json::from_value::<Self>(value).map_err(|e| AppError::json(&config_path, e))
|
let mut config: Self =
|
||||||
|
serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
|
||||||
|
let mut updated = false;
|
||||||
|
|
||||||
|
if !has_skills_in_config {
|
||||||
|
let skills_path = get_app_config_dir().join("skills.json");
|
||||||
|
if skills_path.exists() {
|
||||||
|
match std::fs::read_to_string(&skills_path) {
|
||||||
|
Ok(content) => match serde_json::from_str::<SkillStore>(&content) {
|
||||||
|
Ok(store) => {
|
||||||
|
config.skills = store;
|
||||||
|
updated = true;
|
||||||
|
log::info!("已从旧版 skills.json 导入 Claude Skills 配置");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("解析旧版 skills.json 失败: {e}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("读取旧版 skills.json 失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 gemini 应用存在(兼容旧配置文件)
|
||||||
|
if !config.apps.contains_key("gemini") {
|
||||||
|
config
|
||||||
|
.apps
|
||||||
|
.insert("gemini".to_string(), ProviderManager::default());
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行 MCP 迁移(v3.6.x → v3.7.0)
|
||||||
|
let migrated = config.migrate_mcp_to_unified()?;
|
||||||
|
if migrated {
|
||||||
|
log::info!("MCP 配置已迁移到 v3.7.0 统一结构,保存配置...");
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对于已经存在的配置文件,如果此前版本还没有 Prompt 功能,
|
||||||
|
// 且 prompts 仍然是空的,则尝试自动导入现有提示词文件。
|
||||||
|
let imported_prompts = config.maybe_auto_import_prompts_for_existing_config()?;
|
||||||
|
if imported_prompts {
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude
|
||||||
|
if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
|
||||||
|
log::info!(
|
||||||
|
"迁移通用配置:claude_common_config_snippet → common_config_snippets.claude"
|
||||||
|
);
|
||||||
|
config.common_config_snippets.claude = Some(old_claude_snippet);
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated {
|
||||||
|
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
|
||||||
|
config.save()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存配置到文件
|
/// 保存配置到文件
|
||||||
@@ -132,7 +370,7 @@ impl MultiAppConfig {
|
|||||||
if config_path.exists() {
|
if config_path.exists() {
|
||||||
let backup_path = get_app_config_dir().join("config.json.bak");
|
let backup_path = get_app_config_dir().join("config.json.bak");
|
||||||
if let Err(e) = copy_file(&config_path, &backup_path) {
|
if let Err(e) = copy_file(&config_path, &backup_path) {
|
||||||
log::warn!("备份 config.json 到 .bak 失败: {}", e);
|
log::warn!("备份 config.json 到 .bak 失败: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +401,7 @@ impl MultiAppConfig {
|
|||||||
match app {
|
match app {
|
||||||
AppType::Claude => &self.mcp.claude,
|
AppType::Claude => &self.mcp.claude,
|
||||||
AppType::Codex => &self.mcp.codex,
|
AppType::Codex => &self.mcp.codex,
|
||||||
|
AppType::Gemini => &self.mcp.gemini,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +410,451 @@ impl MultiAppConfig {
|
|||||||
match app {
|
match app {
|
||||||
AppType::Claude => &mut self.mcp.claude,
|
AppType::Claude => &mut self.mcp.claude,
|
||||||
AppType::Codex => &mut self.mcp.codex,
|
AppType::Codex => &mut self.mcp.codex,
|
||||||
|
AppType::Gemini => &mut self.mcp.gemini,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建默认配置并自动导入已存在的提示词文件
|
||||||
|
fn default_with_auto_import() -> Result<Self, AppError> {
|
||||||
|
log::info!("首次启动,创建默认配置并检测提示词文件");
|
||||||
|
|
||||||
|
let mut config = Self::default();
|
||||||
|
|
||||||
|
// 为每个应用尝试自动导入提示词
|
||||||
|
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
|
||||||
|
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
|
||||||
|
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已存在配置文件时的 Prompt 自动导入逻辑
|
||||||
|
///
|
||||||
|
/// 适用于「老版本已经生成过 config.json,但当时还没有 Prompt 功能」的升级场景。
|
||||||
|
/// 判定规则:
|
||||||
|
/// - 仅当所有应用的 prompts 都为空时才尝试导入(避免打扰已经在使用 Prompt 功能的用户)
|
||||||
|
/// - 每个应用最多导入一次,对应各自的提示词文件(如 CLAUDE.md/AGENTS.md/GEMINI.md)
|
||||||
|
///
|
||||||
|
/// 返回值:
|
||||||
|
/// - Ok(true) 表示至少有一个应用成功导入了提示词
|
||||||
|
/// - Ok(false) 表示无需导入或未导入任何内容
|
||||||
|
fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result<bool, AppError> {
|
||||||
|
// 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入
|
||||||
|
if !self.prompts.claude.prompts.is_empty()
|
||||||
|
|| !self.prompts.codex.prompts.is_empty()
|
||||||
|
|| !self.prompts.gemini.prompts.is_empty()
|
||||||
|
{
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
|
||||||
|
|
||||||
|
let mut imported = false;
|
||||||
|
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||||
|
// 复用已有的单应用导入逻辑
|
||||||
|
if Self::auto_import_prompt_if_exists(self, app)? {
|
||||||
|
imported = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(imported)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查并自动导入单个应用的提示词文件
|
||||||
|
///
|
||||||
|
/// 返回值:
|
||||||
|
/// - Ok(true) 表示成功导入了非空文件
|
||||||
|
/// - Ok(false) 表示未导入(文件不存在、内容为空或读取失败)
|
||||||
|
fn auto_import_prompt_if_exists(config: &mut Self, app: AppType) -> Result<bool, AppError> {
|
||||||
|
let file_path = prompt_file_path(&app)?;
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if !file_path.exists() {
|
||||||
|
log::debug!("提示词文件不存在,跳过自动导入: {file_path:?}");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取文件内容
|
||||||
|
let content = match std::fs::read_to_string(&file_path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}");
|
||||||
|
return Ok(false); // 失败时不中断,继续处理其他应用
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查内容是否为空
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
log::debug!("提示词文件内容为空,跳过导入: {file_path:?}");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("发现提示词文件,自动导入: {file_path:?}");
|
||||||
|
|
||||||
|
// 创建提示词对象
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
let id = format!("auto-imported-{timestamp}");
|
||||||
|
let prompt = crate::prompt::Prompt {
|
||||||
|
id: id.clone(),
|
||||||
|
name: format!(
|
||||||
|
"Auto-imported Prompt {}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M")
|
||||||
|
),
|
||||||
|
content,
|
||||||
|
description: Some("Automatically imported on first launch".to_string()),
|
||||||
|
enabled: true, // 自动启用
|
||||||
|
created_at: Some(timestamp),
|
||||||
|
updated_at: Some(timestamp),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 插入到对应的应用配置中
|
||||||
|
let prompts = match app {
|
||||||
|
AppType::Claude => &mut config.prompts.claude.prompts,
|
||||||
|
AppType::Codex => &mut config.prompts.codex.prompts,
|
||||||
|
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||||
|
};
|
||||||
|
|
||||||
|
prompts.insert(id, prompt);
|
||||||
|
|
||||||
|
log::info!("自动导入完成: {}", app.as_str());
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 v3.6.x 的分应用 MCP 结构迁移到 v3.7.0 的统一结构
|
||||||
|
///
|
||||||
|
/// 迁移策略:
|
||||||
|
/// 1. 检查是否已经迁移(mcp.servers 是否存在)
|
||||||
|
/// 2. 收集所有应用的 MCP,按 ID 去重合并
|
||||||
|
/// 3. 生成统一的 McpServer 结构,标记应用到哪些客户端
|
||||||
|
/// 4. 清空旧的分应用配置
|
||||||
|
pub fn migrate_mcp_to_unified(&mut self) -> Result<bool, AppError> {
|
||||||
|
// 检查是否已经是新结构
|
||||||
|
if self.mcp.servers.is_some() {
|
||||||
|
log::debug!("MCP 配置已是统一结构,跳过迁移");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("检测到旧版 MCP 配置格式,开始迁移到 v3.7.0 统一结构...");
|
||||||
|
|
||||||
|
let mut unified_servers: HashMap<String, McpServer> = HashMap::new();
|
||||||
|
let mut conflicts = Vec::new();
|
||||||
|
|
||||||
|
// 收集所有应用的 MCP
|
||||||
|
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||||
|
let old_servers = match app {
|
||||||
|
AppType::Claude => &self.mcp.claude.servers,
|
||||||
|
AppType::Codex => &self.mcp.codex.servers,
|
||||||
|
AppType::Gemini => &self.mcp.gemini.servers,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (id, entry) in old_servers {
|
||||||
|
let enabled = entry
|
||||||
|
.get("enabled")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if let Some(existing) = unified_servers.get_mut(id) {
|
||||||
|
// 该 ID 已存在,合并 apps 字段
|
||||||
|
existing.apps.set_enabled_for(&app, enabled);
|
||||||
|
|
||||||
|
// 检测配置冲突(同 ID 但配置不同)
|
||||||
|
if existing.server != *entry.get("server").unwrap_or(&serde_json::json!({})) {
|
||||||
|
conflicts.push(format!(
|
||||||
|
"MCP '{id}' 在 {} 和之前的应用中配置不同,将使用首次遇到的配置",
|
||||||
|
app.as_str()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 首次遇到该 MCP,创建新条目
|
||||||
|
let name = entry
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(id)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let server = entry
|
||||||
|
.get("server")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(serde_json::json!({}));
|
||||||
|
|
||||||
|
let description = entry
|
||||||
|
.get("description")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let homepage = entry
|
||||||
|
.get("homepage")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let docs = entry
|
||||||
|
.get("docs")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let tags = entry
|
||||||
|
.get("tags")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut apps = McpApps::default();
|
||||||
|
apps.set_enabled_for(&app, enabled);
|
||||||
|
|
||||||
|
unified_servers.insert(
|
||||||
|
id.clone(),
|
||||||
|
McpServer {
|
||||||
|
id: id.clone(),
|
||||||
|
name,
|
||||||
|
server,
|
||||||
|
apps,
|
||||||
|
description,
|
||||||
|
homepage,
|
||||||
|
docs,
|
||||||
|
tags,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录冲突警告
|
||||||
|
if !conflicts.is_empty() {
|
||||||
|
log::warn!("MCP 迁移过程中检测到配置冲突:");
|
||||||
|
for conflict in &conflicts {
|
||||||
|
log::warn!(" - {conflict}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"MCP 迁移完成,共迁移 {} 个服务器{}",
|
||||||
|
unified_servers.len(),
|
||||||
|
if !conflicts.is_empty() {
|
||||||
|
format!("(存在 {} 个冲突)", conflicts.len())
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 替换为新结构
|
||||||
|
self.mcp.servers = Some(unified_servers);
|
||||||
|
|
||||||
|
// 清空旧的分应用配置
|
||||||
|
self.mcp.claude = McpConfig::default();
|
||||||
|
self.mcp.codex = McpConfig::default();
|
||||||
|
self.mcp.gemini = McpConfig::default();
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
struct TempHome {
|
||||||
|
#[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期
|
||||||
|
dir: TempDir,
|
||||||
|
original_home: Option<String>,
|
||||||
|
original_userprofile: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempHome {
|
||||||
|
fn new() -> Self {
|
||||||
|
let dir = TempDir::new().expect("failed to create temp home");
|
||||||
|
let original_home = env::var("HOME").ok();
|
||||||
|
let original_userprofile = env::var("USERPROFILE").ok();
|
||||||
|
|
||||||
|
env::set_var("HOME", dir.path());
|
||||||
|
env::set_var("USERPROFILE", dir.path());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
dir,
|
||||||
|
original_home,
|
||||||
|
original_userprofile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempHome {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
match &self.original_home {
|
||||||
|
Some(value) => env::set_var("HOME", value),
|
||||||
|
None => env::remove_var("HOME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
match &self.original_userprofile {
|
||||||
|
Some(value) => env::set_var("USERPROFILE", value),
|
||||||
|
None => env::remove_var("USERPROFILE"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_prompt_file(app: AppType, content: &str) {
|
||||||
|
let path = crate::prompt_files::prompt_file_path(&app).expect("prompt path");
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("create parent dir");
|
||||||
|
}
|
||||||
|
fs::write(path, content).expect("write prompt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn auto_imports_existing_prompt_when_config_missing() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
write_prompt_file(AppType::Claude, "# hello");
|
||||||
|
|
||||||
|
let config = MultiAppConfig::load().expect("load config");
|
||||||
|
|
||||||
|
assert_eq!(config.prompts.claude.prompts.len(), 1);
|
||||||
|
let prompt = config
|
||||||
|
.prompts
|
||||||
|
.claude
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.expect("prompt exists");
|
||||||
|
assert!(prompt.enabled);
|
||||||
|
assert_eq!(prompt.content, "# hello");
|
||||||
|
|
||||||
|
let config_path = crate::config::get_app_config_path();
|
||||||
|
assert!(
|
||||||
|
config_path.exists(),
|
||||||
|
"auto import should persist config to disk"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn skips_empty_prompt_files_during_import() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
write_prompt_file(AppType::Claude, " \n ");
|
||||||
|
|
||||||
|
let config = MultiAppConfig::load().expect("load config");
|
||||||
|
assert!(
|
||||||
|
config.prompts.claude.prompts.is_empty(),
|
||||||
|
"empty files must be ignored"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn auto_import_happens_only_once() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
write_prompt_file(AppType::Claude, "first version");
|
||||||
|
|
||||||
|
let first = MultiAppConfig::load().expect("load config");
|
||||||
|
assert_eq!(first.prompts.claude.prompts.len(), 1);
|
||||||
|
let claude_prompt = first
|
||||||
|
.prompts
|
||||||
|
.claude
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.expect("prompt exists")
|
||||||
|
.content
|
||||||
|
.clone();
|
||||||
|
assert_eq!(claude_prompt, "first version");
|
||||||
|
|
||||||
|
// 覆盖文件内容,但保留 config.json
|
||||||
|
write_prompt_file(AppType::Claude, "second version");
|
||||||
|
let second = MultiAppConfig::load().expect("load config again");
|
||||||
|
|
||||||
|
assert_eq!(second.prompts.claude.prompts.len(), 1);
|
||||||
|
let prompt = second
|
||||||
|
.prompts
|
||||||
|
.claude
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.expect("prompt exists");
|
||||||
|
assert_eq!(
|
||||||
|
prompt.content, "first version",
|
||||||
|
"should not re-import when config already exists"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn auto_imports_gemini_prompt_on_first_launch() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
write_prompt_file(AppType::Gemini, "# Gemini Prompt\n\nTest content");
|
||||||
|
|
||||||
|
let config = MultiAppConfig::load().expect("load config");
|
||||||
|
|
||||||
|
assert_eq!(config.prompts.gemini.prompts.len(), 1);
|
||||||
|
let prompt = config
|
||||||
|
.prompts
|
||||||
|
.gemini
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.expect("gemini prompt exists");
|
||||||
|
assert!(prompt.enabled, "gemini prompt should be enabled");
|
||||||
|
assert_eq!(prompt.content, "# Gemini Prompt\n\nTest content");
|
||||||
|
assert_eq!(
|
||||||
|
prompt.description,
|
||||||
|
Some("Automatically imported on first launch".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn auto_imports_all_three_apps_prompts() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
write_prompt_file(AppType::Claude, "# Claude prompt");
|
||||||
|
write_prompt_file(AppType::Codex, "# Codex prompt");
|
||||||
|
write_prompt_file(AppType::Gemini, "# Gemini prompt");
|
||||||
|
|
||||||
|
let config = MultiAppConfig::load().expect("load config");
|
||||||
|
|
||||||
|
// 验证所有三个应用的提示词都被导入
|
||||||
|
assert_eq!(config.prompts.claude.prompts.len(), 1);
|
||||||
|
assert_eq!(config.prompts.codex.prompts.len(), 1);
|
||||||
|
assert_eq!(config.prompts.gemini.prompts.len(), 1);
|
||||||
|
|
||||||
|
// 验证所有提示词都被启用
|
||||||
|
assert!(
|
||||||
|
config
|
||||||
|
.prompts
|
||||||
|
.claude
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.enabled
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
config
|
||||||
|
.prompts
|
||||||
|
.codex
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.enabled
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
config
|
||||||
|
.prompts
|
||||||
|
.gemini
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.enabled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ fn read_override_from_store(app: &tauri::AppHandle) -> Option<PathBuf> {
|
|||||||
let store = match app.store_builder("app_paths.json").build() {
|
let store = match app.store_builder("app_paths.json").build() {
|
||||||
Ok(store) => store,
|
Ok(store) => store,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("无法创建 Store: {}", e);
|
log::warn!("无法创建 Store: {e}");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -46,21 +46,17 @@ fn read_override_from_store(app: &tauri::AppHandle) -> Option<PathBuf> {
|
|||||||
|
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Store 中配置的 app_config_dir 不存在: {:?}\n\
|
"Store 中配置的 app_config_dir 不存在: {path:?}\n\
|
||||||
将使用默认路径。",
|
将使用默认路径。"
|
||||||
path
|
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("使用 Store 中的 app_config_dir: {:?}", path);
|
log::info!("使用 Store 中的 app_config_dir: {path:?}");
|
||||||
Some(path)
|
Some(path)
|
||||||
}
|
}
|
||||||
Some(_) => {
|
Some(_) => {
|
||||||
log::warn!(
|
log::warn!("Store 中的 {STORE_KEY_APP_CONFIG_DIR} 类型不正确,应为字符串");
|
||||||
"Store 中的 {} 类型不正确,应为字符串",
|
|
||||||
STORE_KEY_APP_CONFIG_DIR
|
|
||||||
);
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
@@ -82,14 +78,14 @@ pub fn set_app_config_dir_to_store(
|
|||||||
let store = app
|
let store = app
|
||||||
.store_builder("app_paths.json")
|
.store_builder("app_paths.json")
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| AppError::Message(format!("创建 Store 失败: {}", e)))?;
|
.map_err(|e| AppError::Message(format!("创建 Store 失败: {e}")))?;
|
||||||
|
|
||||||
match path {
|
match path {
|
||||||
Some(p) => {
|
Some(p) => {
|
||||||
let trimmed = p.trim();
|
let trimmed = p.trim();
|
||||||
if !trimmed.is_empty() {
|
if !trimmed.is_empty() {
|
||||||
store.set(STORE_KEY_APP_CONFIG_DIR, Value::String(trimmed.to_string()));
|
store.set(STORE_KEY_APP_CONFIG_DIR, Value::String(trimmed.to_string()));
|
||||||
log::info!("已将 app_config_dir 写入 Store: {}", trimmed);
|
log::info!("已将 app_config_dir 写入 Store: {trimmed}");
|
||||||
} else {
|
} else {
|
||||||
store.delete(STORE_KEY_APP_CONFIG_DIR);
|
store.delete(STORE_KEY_APP_CONFIG_DIR);
|
||||||
log::info!("已从 Store 中删除 app_config_dir 配置");
|
log::info!("已从 Store 中删除 app_config_dir 配置");
|
||||||
@@ -103,7 +99,7 @@ pub fn set_app_config_dir_to_store(
|
|||||||
|
|
||||||
store
|
store
|
||||||
.save()
|
.save()
|
||||||
.map_err(|e| AppError::Message(format!("保存 Store 失败: {}", e)))?;
|
.map_err(|e| AppError::Message(format!("保存 Store 失败: {e}")))?;
|
||||||
|
|
||||||
refresh_app_config_dir_override(app);
|
refresh_app_config_dir_override(app);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ fn ensure_mcp_override_migrated() {
|
|||||||
|
|
||||||
if let Some(parent) = new_path.parent() {
|
if let Some(parent) = new_path.parent() {
|
||||||
if let Err(err) = fs::create_dir_all(parent) {
|
if let Err(err) = fs::create_dir_all(parent) {
|
||||||
log::warn!("创建 MCP 目录失败: {}", err);
|
log::warn!("创建 MCP 目录失败: {err}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,9 +118,10 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
|
|||||||
let t_opt = spec.get("type").and_then(|x| x.as_str());
|
let t_opt = spec.get("type").and_then(|x| x.as_str());
|
||||||
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理)
|
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理)
|
||||||
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
|
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
|
||||||
if !(is_stdio || is_http) {
|
let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
|
||||||
|
if !(is_stdio || is_http || is_sse) {
|
||||||
return Err(AppError::McpValidation(
|
return Err(AppError::McpValidation(
|
||||||
"MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio)".into(),
|
"MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio)".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,13 +135,15 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// http 类型必须有 url
|
// http/sse 类型必须有 url
|
||||||
if is_http {
|
if is_http || is_sse {
|
||||||
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
||||||
if url.is_empty() {
|
if url.is_empty() {
|
||||||
return Err(AppError::McpValidation(
|
return Err(AppError::McpValidation(if is_http {
|
||||||
"http 类型的 MCP 服务器缺少 url 字段".into(),
|
"http 类型的 MCP 服务器缺少 url 字段".into()
|
||||||
));
|
} else {
|
||||||
|
"sse 类型的 MCP 服务器缺少 url 字段".into()
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +234,23 @@ pub fn validate_command_in_path(cmd: &str) -> Result<bool, AppError> {
|
|||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读取 ~/.claude.json 中的 mcpServers 映射
|
||||||
|
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(std::collections::HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = read_json_value(&path)?;
|
||||||
|
let servers = root
|
||||||
|
.get("mcpServers")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(servers)
|
||||||
|
}
|
||||||
|
|
||||||
/// 将给定的启用 MCP 服务器映射写入到用户级 ~/.claude.json 的 mcpServers 字段
|
/// 将给定的启用 MCP 服务器映射写入到用户级 ~/.claude.json 的 mcpServers 字段
|
||||||
/// 仅覆盖 mcpServers,其他字段保持不变
|
/// 仅覆盖 mcpServers,其他字段保持不变
|
||||||
pub fn set_mcp_servers_map(
|
pub fn set_mcp_servers_map(
|
||||||
@@ -250,14 +270,13 @@ pub fn set_mcp_servers_map(
|
|||||||
map.clone()
|
map.clone()
|
||||||
} else {
|
} else {
|
||||||
return Err(AppError::McpValidation(format!(
|
return Err(AppError::McpValidation(format!(
|
||||||
"MCP 服务器 '{}' 不是对象",
|
"MCP 服务器 '{id}' 不是对象"
|
||||||
id
|
|
||||||
)));
|
)));
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(server_val) = obj.remove("server") {
|
if let Some(server_val) = obj.remove("server") {
|
||||||
let server_obj = server_val.as_object().cloned().ok_or_else(|| {
|
let server_obj = server_val.as_object().cloned().ok_or_else(|| {
|
||||||
AppError::McpValidation(format!("MCP 服务器 '{}' server 字段不是对象", id))
|
AppError::McpValidation(format!("MCP 服务器 '{id}' server 字段不是对象"))
|
||||||
})?;
|
})?;
|
||||||
obj = server_obj;
|
obj = server_obj;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ pub fn write_claude_config() -> Result<bool, AppError> {
|
|||||||
if changed || !path.exists() {
|
if changed || !path.exists() {
|
||||||
let serialized = serde_json::to_string_pretty(&obj)
|
let serialized = serde_json::to_string_pretty(&obj)
|
||||||
.map_err(|e| AppError::JsonSerialize { source: e })?;
|
.map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
fs::write(&path, format!("{}\n", serialized)).map_err(|e| AppError::io(&path, e))?;
|
fs::write(&path, format!("{serialized}\n")).map_err(|e| AppError::io(&path, e))?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
} else {
|
} else {
|
||||||
Ok(false)
|
Ok(false)
|
||||||
@@ -114,7 +114,7 @@ pub fn clear_claude_config() -> Result<bool, AppError> {
|
|||||||
|
|
||||||
let serialized =
|
let serialized =
|
||||||
serde_json::to_string_pretty(&value).map_err(|e| AppError::JsonSerialize { source: e })?;
|
serde_json::to_string_pretty(&value).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
fs::write(&path, format!("{}\n", serialized)).map_err(|e| AppError::io(&path, e))?;
|
fs::write(&path, format!("{serialized}\n")).map_err(|e| AppError::io(&path, e))?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ pub fn get_codex_provider_paths(
|
|||||||
.map(sanitize_provider_name)
|
.map(sanitize_provider_name)
|
||||||
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
||||||
|
|
||||||
let auth_path = get_codex_config_dir().join(format!("auth-{}.json", base_name));
|
let auth_path = get_codex_config_dir().join(format!("auth-{base_name}.json"));
|
||||||
let config_path = get_codex_config_dir().join(format!("config-{}.toml", base_name));
|
let config_path = get_codex_config_dir().join(format!("config-{base_name}.toml"));
|
||||||
|
|
||||||
(auth_path, config_path)
|
(auth_path, config_path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
|||||||
|
|
||||||
Ok(ConfigStatus { exists, path })
|
Ok(ConfigStatus { exists, path })
|
||||||
}
|
}
|
||||||
|
AppType::Gemini => {
|
||||||
|
let env_path = crate::gemini_config::get_gemini_env_path();
|
||||||
|
let exists = env_path.exists();
|
||||||
|
let path = crate::gemini_config::get_gemini_dir()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(ConfigStatus { exists, path })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +53,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
|||||||
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||||
AppType::Claude => config::get_claude_config_dir(),
|
AppType::Claude => config::get_claude_config_dir(),
|
||||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||||
|
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(dir.to_string_lossy().to_string())
|
Ok(dir.to_string_lossy().to_string())
|
||||||
@@ -55,16 +65,17 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
|
|||||||
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||||
AppType::Claude => config::get_claude_config_dir(),
|
AppType::Claude => config::get_claude_config_dir(),
|
||||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||||
|
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if !config_dir.exists() {
|
if !config_dir.exists() {
|
||||||
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
|
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
handle
|
handle
|
||||||
.opener()
|
.opener()
|
||||||
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
||||||
.map_err(|e| format!("打开文件夹失败: {}", e))?;
|
.map_err(|e| format!("打开文件夹失败: {e}"))?;
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
@@ -87,14 +98,14 @@ pub async fn pick_directory(
|
|||||||
builder.blocking_pick_folder()
|
builder.blocking_pick_folder()
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("弹出目录选择器失败: {}", e))?;
|
.map_err(|e| format!("弹出目录选择器失败: {e}"))?;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Some(file_path) => {
|
Some(file_path) => {
|
||||||
let resolved = file_path
|
let resolved = file_path
|
||||||
.simplified()
|
.simplified()
|
||||||
.into_path()
|
.into_path()
|
||||||
.map_err(|e| format!("解析选择的目录失败: {}", e))?;
|
.map_err(|e| format!("解析选择的目录失败: {e}"))?;
|
||||||
Ok(Some(resolved.to_string_lossy().to_string()))
|
Ok(Some(resolved.to_string_lossy().to_string()))
|
||||||
}
|
}
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
@@ -114,13 +125,116 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
|
|||||||
let config_dir = config::get_app_config_dir();
|
let config_dir = config::get_app_config_dir();
|
||||||
|
|
||||||
if !config_dir.exists() {
|
if !config_dir.exists() {
|
||||||
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
|
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
handle
|
handle
|
||||||
.opener()
|
.opener()
|
||||||
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
||||||
.map_err(|e| format!("打开文件夹失败: {}", e))?;
|
.map_err(|e| format!("打开文件夹失败: {e}"))?;
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取 Claude 通用配置片段(已废弃,使用 get_common_config_snippet)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_claude_common_config_snippet(
|
||||||
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let guard = state
|
||||||
|
.config
|
||||||
|
.read()
|
||||||
|
.map_err(|e| format!("读取配置锁失败: {e}"))?;
|
||||||
|
Ok(guard.common_config_snippets.claude.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_claude_common_config_snippet(
|
||||||
|
snippet: String,
|
||||||
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut guard = state
|
||||||
|
.config
|
||||||
|
.write()
|
||||||
|
.map_err(|e| format!("写入配置锁失败: {e}"))?;
|
||||||
|
|
||||||
|
// 验证是否为有效的 JSON(如果不为空)
|
||||||
|
if !snippet.trim().is_empty() {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||||
|
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
guard.common_config_snippets.claude = if snippet.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(snippet)
|
||||||
|
};
|
||||||
|
|
||||||
|
guard.save().map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取通用配置片段(统一接口)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_common_config_snippet(
|
||||||
|
app_type: String,
|
||||||
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
|
||||||
|
|
||||||
|
let guard = state
|
||||||
|
.config
|
||||||
|
.read()
|
||||||
|
.map_err(|e| format!("读取配置锁失败: {e}"))?;
|
||||||
|
|
||||||
|
Ok(guard.common_config_snippets.get(&app).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置通用配置片段(统一接口)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_common_config_snippet(
|
||||||
|
app_type: String,
|
||||||
|
snippet: String,
|
||||||
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
|
||||||
|
|
||||||
|
let mut guard = state
|
||||||
|
.config
|
||||||
|
.write()
|
||||||
|
.map_err(|e| format!("写入配置锁失败: {e}"))?;
|
||||||
|
|
||||||
|
// 验证格式(根据应用类型)
|
||||||
|
if !snippet.trim().is_empty() {
|
||||||
|
match app {
|
||||||
|
AppType::Claude | AppType::Gemini => {
|
||||||
|
// 验证 JSON 格式
|
||||||
|
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||||
|
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
|
||||||
|
}
|
||||||
|
AppType::Codex => {
|
||||||
|
// TOML 格式暂不验证(或可使用 toml crate)
|
||||||
|
// 注意:TOML 验证较为复杂,暂时跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard.common_config_snippets.set(
|
||||||
|
&app,
|
||||||
|
if snippet.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(snippet)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
guard.save().map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
29
src-tauri/src/commands/deeplink.rs
Normal file
29
src-tauri/src/commands/deeplink.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
use crate::deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
|
||||||
|
use crate::store::AppState;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
/// Parse a deep link URL and return the parsed request for frontend confirmation
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn parse_deeplink(url: String) -> Result<DeepLinkImportRequest, String> {
|
||||||
|
log::info!("Parsing deep link URL: {url}");
|
||||||
|
parse_deeplink_url(&url).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a provider from a deep link request (after user confirmation)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn import_from_deeplink(
|
||||||
|
state: State<AppState>,
|
||||||
|
request: DeepLinkImportRequest,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
log::info!(
|
||||||
|
"Importing provider from deep link: {} for app {}",
|
||||||
|
request.name,
|
||||||
|
request.app
|
||||||
|
);
|
||||||
|
|
||||||
|
let provider_id = import_provider_from_deeplink(&state, request).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
log::info!("Successfully imported provider with ID: {provider_id}");
|
||||||
|
|
||||||
|
Ok(provider_id)
|
||||||
|
}
|
||||||
22
src-tauri/src/commands/env.rs
Normal file
22
src-tauri/src/commands/env.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
use crate::services::env_checker::{check_env_conflicts as check_conflicts, EnvConflict};
|
||||||
|
use crate::services::env_manager::{
|
||||||
|
delete_env_vars as delete_vars, restore_from_backup, BackupInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Check environment variable conflicts for a specific app
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn check_env_conflicts(app: String) -> Result<Vec<EnvConflict>, String> {
|
||||||
|
check_conflicts(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete environment variables with backup
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_env_vars(conflicts: Vec<EnvConflict>) -> Result<BackupInfo, String> {
|
||||||
|
delete_vars(conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore environment variables from backup file
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn restore_env_backup(backup_path: String) -> Result<(), String> {
|
||||||
|
restore_from_backup(backup_path)
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ pub async fn export_config_to_file(
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("导出配置失败: {}", e))?
|
.map_err(|e| format!("导出配置失败: {e}"))?
|
||||||
.map_err(|e: AppError| e.to_string())
|
.map_err(|e: AppError| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ pub async fn import_config_from_file(
|
|||||||
ConfigService::load_config_for_import(&path_buf)
|
ConfigService::load_config_for_import(&path_buf)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("导入配置失败: {}", e))?
|
.map_err(|e| format!("导入配置失败: {e}"))?
|
||||||
.map_err(|e: AppError| e.to_string())?;
|
.map_err(|e: AppError| e.to_string())?;
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pub struct McpConfigResponse {
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
|
||||||
pub async fn get_mcp_config(
|
pub async fn get_mcp_config(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
app: String,
|
||||||
@@ -66,6 +67,7 @@ pub async fn get_mcp_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 在 config.json 中新增或更新一个 MCP 服务器定义
|
/// 在 config.json 中新增或更新一个 MCP 服务器定义
|
||||||
|
/// [已废弃] 该命令仍然使用旧的分应用API,会转换为统一结构
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn upsert_mcp_server_in_config(
|
pub async fn upsert_mcp_server_in_config(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -74,8 +76,59 @@ pub async fn upsert_mcp_server_in_config(
|
|||||||
spec: serde_json::Value,
|
spec: serde_json::Value,
|
||||||
sync_other_side: Option<bool>,
|
sync_other_side: Option<bool>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
|
use crate::app_config::McpServer;
|
||||||
|
|
||||||
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
McpService::upsert_server(&state, app_ty, &id, spec, sync_other_side.unwrap_or(false))
|
|
||||||
|
// 读取现有的服务器(如果存在)
|
||||||
|
let existing_server = {
|
||||||
|
let cfg = state.config.read().map_err(|e| e.to_string())?;
|
||||||
|
if let Some(servers) = &cfg.mcp.servers {
|
||||||
|
servers.get(&id).cloned()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建新的统一服务器结构
|
||||||
|
let mut new_server = if let Some(mut existing) = existing_server {
|
||||||
|
// 更新现有服务器
|
||||||
|
existing.server = spec.clone();
|
||||||
|
existing.apps.set_enabled_for(&app_ty, true);
|
||||||
|
existing
|
||||||
|
} else {
|
||||||
|
// 创建新服务器
|
||||||
|
let mut apps = crate::app_config::McpApps::default();
|
||||||
|
apps.set_enabled_for(&app_ty, true);
|
||||||
|
|
||||||
|
// 尝试从 spec 中提取 name,否则使用 id
|
||||||
|
let name = spec
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(&id)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
McpServer {
|
||||||
|
id: id.clone(),
|
||||||
|
name,
|
||||||
|
server: spec,
|
||||||
|
apps,
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
docs: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果 sync_other_side 为 true,也启用其他应用
|
||||||
|
if sync_other_side.unwrap_or(false) {
|
||||||
|
new_server.apps.claude = true;
|
||||||
|
new_server.apps.codex = true;
|
||||||
|
new_server.apps.gemini = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
McpService::upsert_server(&state, new_server)
|
||||||
|
.map(|_| true)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,15 +136,15 @@ pub async fn upsert_mcp_server_in_config(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_mcp_server_in_config(
|
pub async fn delete_mcp_server_in_config(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
_app: String, // 参数保留用于向后兼容,但在统一结构中不再需要
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
McpService::delete_server(&state, &id).map_err(|e| e.to_string())
|
||||||
McpService::delete_server(&state, app_ty, &id).map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置启用状态并同步到客户端配置
|
/// 设置启用状态并同步到客户端配置
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
|
||||||
pub async fn set_mcp_enabled(
|
pub async fn set_mcp_enabled(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
app: String,
|
||||||
@@ -102,30 +155,43 @@ pub async fn set_mcp_enabled(
|
|||||||
McpService::set_enabled(&state, app_ty, &id, enabled).map_err(|e| e.to_string())
|
McpService::set_enabled(&state, app_ty, &id, enabled).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 手动同步:将启用的 MCP 投影到 ~/.claude.json
|
// ============================================================================
|
||||||
|
// v3.7.0 新增:统一 MCP 管理命令
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
use crate::app_config::McpServer;
|
||||||
|
|
||||||
|
/// 获取所有 MCP 服务器(统一结构)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result<bool, String> {
|
pub async fn get_mcp_servers(
|
||||||
McpService::sync_enabled(&state, AppType::Claude)
|
state: State<'_, AppState>,
|
||||||
.map(|_| true)
|
) -> Result<HashMap<String, McpServer>, String> {
|
||||||
.map_err(|e| e.to_string())
|
McpService::get_all_servers(&state).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml
|
/// 添加或更新 MCP 服务器
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn sync_enabled_mcp_to_codex(state: State<'_, AppState>) -> Result<bool, String> {
|
pub async fn upsert_mcp_server(
|
||||||
McpService::sync_enabled(&state, AppType::Codex)
|
state: State<'_, AppState>,
|
||||||
.map(|_| true)
|
server: McpServer,
|
||||||
.map_err(|e| e.to_string())
|
) -> Result<(), String> {
|
||||||
|
McpService::upsert_server(&state, server).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 ~/.claude.json 导入 MCP 定义到 config.json
|
/// 删除 MCP 服务器
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn import_mcp_from_claude(state: State<'_, AppState>) -> Result<usize, String> {
|
pub async fn delete_mcp_server(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||||
McpService::import_from_claude(&state).map_err(|e| e.to_string())
|
McpService::delete_server(&state, &id).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 ~/.codex/config.toml 导入 MCP 定义到 config.json
|
/// 切换 MCP 服务器在指定应用的启用状态
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn import_mcp_from_codex(state: State<'_, AppState>) -> Result<usize, String> {
|
pub async fn toggle_mcp_app(
|
||||||
McpService::import_from_codex(&state).map_err(|e| e.to_string())
|
state: State<'_, AppState>,
|
||||||
|
server_id: String,
|
||||||
|
app: String,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String>
|
|||||||
let url = if url.starts_with("http://") || url.starts_with("https://") {
|
let url = if url.starts_with("http://") || url.starts_with("https://") {
|
||||||
url
|
url
|
||||||
} else {
|
} else {
|
||||||
format!("https://{}", url)
|
format!("https://{url}")
|
||||||
};
|
};
|
||||||
|
|
||||||
app.opener()
|
app.opener()
|
||||||
.open_url(&url, None::<String>)
|
.open_url(&url, None::<String>)
|
||||||
.map_err(|e| format!("打开链接失败: {}", e))?;
|
.map_err(|e| format!("打开链接失败: {e}"))?;
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ pub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {
|
|||||||
"https://github.com/farion1231/cc-switch/releases/latest",
|
"https://github.com/farion1231/cc-switch/releases/latest",
|
||||||
None::<String>,
|
None::<String>,
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("打开更新页面失败: {}", e))?;
|
.map_err(|e| format!("打开更新页面失败: {e}"))?;
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ pub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {
|
|||||||
/// 判断是否为便携版(绿色版)运行
|
/// 判断是否为便携版(绿色版)运行
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn is_portable_mode() -> Result<bool, String> {
|
pub async fn is_portable_mode() -> Result<bool, String> {
|
||||||
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {}", e))?;
|
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {e}"))?;
|
||||||
if let Some(dir) = exe_path.parent() {
|
if let Some(dir) = exe_path.parent() {
|
||||||
Ok(dir.join("portable.ini").is_file())
|
Ok(dir.join("portable.ini").is_file())
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
|
mod deeplink;
|
||||||
|
mod env;
|
||||||
mod import_export;
|
mod import_export;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod misc;
|
mod misc;
|
||||||
mod plugin;
|
mod plugin;
|
||||||
|
mod prompt;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
pub mod skill;
|
||||||
|
|
||||||
pub use config::*;
|
pub use config::*;
|
||||||
|
pub use deeplink::*;
|
||||||
|
pub use env::*;
|
||||||
pub use import_export::*;
|
pub use import_export::*;
|
||||||
pub use mcp::*;
|
pub use mcp::*;
|
||||||
pub use misc::*;
|
pub use misc::*;
|
||||||
pub use plugin::*;
|
pub use plugin::*;
|
||||||
|
pub use prompt::*;
|
||||||
pub use provider::*;
|
pub use provider::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
|
pub use skill::*;
|
||||||
|
|||||||
64
src-tauri/src/commands/prompt.rs
Normal file
64
src-tauri/src/commands/prompt.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::prompt::Prompt;
|
||||||
|
use crate::services::PromptService;
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_prompts(
|
||||||
|
app: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<HashMap<String, Prompt>, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::get_prompts(&state, app_type).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn upsert_prompt(
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
prompt: Prompt,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::upsert_prompt(&state, app_type, &id, prompt).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_prompt(
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::delete_prompt(&state, app_type, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn enable_prompt(
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::enable_prompt(&state, app_type, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_prompt_from_file(
|
||||||
|
app: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::import_from_file(&state, app_type).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_current_prompt_file_content(app: String) -> Result<Option<String>, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::get_current_file_content(app_type).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
163
src-tauri/src/commands/skill.rs
Normal file
163
src-tauri/src/commands/skill.rs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
use crate::services::skill::SkillState;
|
||||||
|
use crate::services::{Skill, SkillRepo, SkillService};
|
||||||
|
use crate::store::AppState;
|
||||||
|
use chrono::Utc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
pub struct SkillServiceState(pub Arc<SkillService>);
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_skills(
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<Skill>, String> {
|
||||||
|
let repos = {
|
||||||
|
let config = app_state.config.read().map_err(|e| e.to_string())?;
|
||||||
|
config.skills.repos.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.list_skills(repos)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn install_skill(
|
||||||
|
directory: String,
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
// 先在不持有写锁的情况下收集仓库与技能信息
|
||||||
|
let repos = {
|
||||||
|
let config = app_state.config.read().map_err(|e| e.to_string())?;
|
||||||
|
config.skills.repos.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let skills = service
|
||||||
|
.0
|
||||||
|
.list_skills(repos)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let skill = skills
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
|
||||||
|
.ok_or_else(|| "技能不存在".to_string())?;
|
||||||
|
|
||||||
|
if !skill.installed {
|
||||||
|
let repo = SkillRepo {
|
||||||
|
owner: skill
|
||||||
|
.repo_owner
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "缺少仓库信息".to_string())?,
|
||||||
|
name: skill
|
||||||
|
.repo_name
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "缺少仓库信息".to_string())?,
|
||||||
|
branch: skill
|
||||||
|
.repo_branch
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "main".to_string()),
|
||||||
|
enabled: true,
|
||||||
|
skills_path: None, // 安装时使用默认路径
|
||||||
|
};
|
||||||
|
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.install_skill(directory.clone(), repo)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
config.skills.skills.insert(
|
||||||
|
directory.clone(),
|
||||||
|
SkillState {
|
||||||
|
installed: true,
|
||||||
|
installed_at: Utc::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
app_state.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn uninstall_skill(
|
||||||
|
directory: String,
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.uninstall_skill(directory.clone())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
config.skills.skills.remove(&directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
app_state.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_skill_repos(
|
||||||
|
_service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<SkillRepo>, String> {
|
||||||
|
let config = app_state.config.read().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(config.skills.repos.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn add_skill_repo(
|
||||||
|
repo: SkillRepo,
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
{
|
||||||
|
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.add_repo(&mut config.skills, repo)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
app_state.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn remove_skill_repo(
|
||||||
|
owner: String,
|
||||||
|
name: String,
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
{
|
||||||
|
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.remove_repo(&mut config.skills, owner, name)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
app_state.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let parent = dir.parent().unwrap_or_else(|| Path::new(""));
|
let parent = dir.parent().unwrap_or_else(|| Path::new(""));
|
||||||
Some(parent.join(format!("{}.json", file_name)))
|
Some(parent.join(format!("{file_name}.json")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级
|
/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级
|
||||||
@@ -95,7 +95,7 @@ pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>)
|
|||||||
.map(sanitize_provider_name)
|
.map(sanitize_provider_name)
|
||||||
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
||||||
|
|
||||||
get_claude_config_dir().join(format!("settings-{}.json", base_name))
|
get_claude_config_dir().join(format!("settings-{base_name}.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取 JSON 配置文件
|
/// 读取 JSON 配置文件
|
||||||
@@ -149,7 +149,7 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
|
|||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
tmp.push(format!("{}.tmp.{}", file_name, ts));
|
tmp.push(format!("{file_name}.tmp.{ts}"));
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
|
let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
|
||||||
|
|||||||
457
src-tauri/src/deeplink.rs
Normal file
457
src-tauri/src/deeplink.rs
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
/// Deep link import functionality for CC Switch
|
||||||
|
///
|
||||||
|
/// This module implements the ccswitch:// protocol for importing provider configurations
|
||||||
|
/// via deep links. See docs/ccswitch-deeplink-design.md for detailed design.
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::services::ProviderService;
|
||||||
|
use crate::store::AppState;
|
||||||
|
use crate::AppType;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// Deep link import request model
|
||||||
|
/// Represents a parsed ccswitch:// URL ready for processing
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeepLinkImportRequest {
|
||||||
|
/// Protocol version (e.g., "v1")
|
||||||
|
pub version: String,
|
||||||
|
/// Resource type to import (e.g., "provider")
|
||||||
|
pub resource: String,
|
||||||
|
/// Target application (claude/codex/gemini)
|
||||||
|
pub app: String,
|
||||||
|
/// Provider name
|
||||||
|
pub name: String,
|
||||||
|
/// Provider homepage URL
|
||||||
|
pub homepage: String,
|
||||||
|
/// API endpoint/base URL
|
||||||
|
pub endpoint: String,
|
||||||
|
/// API key
|
||||||
|
pub api_key: String,
|
||||||
|
/// Optional model name
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
|
/// Optional notes/description
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a ccswitch:// URL into a DeepLinkImportRequest
|
||||||
|
///
|
||||||
|
/// Expected format:
|
||||||
|
/// ccswitch://v1/import?resource=provider&app=claude&name=...&homepage=...&endpoint=...&apiKey=...
|
||||||
|
pub fn parse_deeplink_url(url_str: &str) -> Result<DeepLinkImportRequest, AppError> {
|
||||||
|
// Parse URL
|
||||||
|
let url = Url::parse(url_str)
|
||||||
|
.map_err(|e| AppError::InvalidInput(format!("Invalid deep link URL: {e}")))?;
|
||||||
|
|
||||||
|
// Validate scheme
|
||||||
|
let scheme = url.scheme();
|
||||||
|
if scheme != "ccswitch" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid scheme: expected 'ccswitch', got '{scheme}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract version from host
|
||||||
|
let version = url
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing version in URL host".to_string()))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Validate version
|
||||||
|
if version != "v1" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Unsupported protocol version: {version}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract path (should be "/import")
|
||||||
|
let path = url.path();
|
||||||
|
if path != "/import" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid path: expected '/import', got '{path}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse query parameters
|
||||||
|
let params: HashMap<String, String> = url.query_pairs().into_owned().collect();
|
||||||
|
|
||||||
|
// Extract and validate resource type
|
||||||
|
let resource = params
|
||||||
|
.get("resource")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'resource' parameter".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
if resource != "provider" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Unsupported resource type: {resource}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract required fields
|
||||||
|
let app = params
|
||||||
|
.get("app")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Validate app type
|
||||||
|
if app != "claude" && app != "codex" && app != "gemini" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid app type: must be 'claude', 'codex', or 'gemini', got '{app}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = params
|
||||||
|
.get("name")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let homepage = params
|
||||||
|
.get("homepage")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'homepage' parameter".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let endpoint = params
|
||||||
|
.get("endpoint")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'endpoint' parameter".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let api_key = params
|
||||||
|
.get("apiKey")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'apiKey' parameter".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Validate URLs
|
||||||
|
validate_url(&homepage, "homepage")?;
|
||||||
|
validate_url(&endpoint, "endpoint")?;
|
||||||
|
|
||||||
|
// Extract optional fields
|
||||||
|
let model = params.get("model").cloned();
|
||||||
|
let notes = params.get("notes").cloned();
|
||||||
|
|
||||||
|
Ok(DeepLinkImportRequest {
|
||||||
|
version,
|
||||||
|
resource,
|
||||||
|
app,
|
||||||
|
name,
|
||||||
|
homepage,
|
||||||
|
endpoint,
|
||||||
|
api_key,
|
||||||
|
model,
|
||||||
|
notes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that a string is a valid HTTP(S) URL
|
||||||
|
fn validate_url(url_str: &str, field_name: &str) -> Result<(), AppError> {
|
||||||
|
let url = Url::parse(url_str)
|
||||||
|
.map_err(|e| AppError::InvalidInput(format!("Invalid URL for '{field_name}': {e}")))?;
|
||||||
|
|
||||||
|
let scheme = url.scheme();
|
||||||
|
if scheme != "http" && scheme != "https" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid URL scheme for '{field_name}': must be http or https, got '{scheme}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a provider from a deep link request
|
||||||
|
///
|
||||||
|
/// This function:
|
||||||
|
/// 1. Validates the request
|
||||||
|
/// 2. Converts it to a Provider structure
|
||||||
|
/// 3. Delegates to ProviderService for actual import
|
||||||
|
pub fn import_provider_from_deeplink(
|
||||||
|
state: &AppState,
|
||||||
|
request: DeepLinkImportRequest,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
// Parse app type
|
||||||
|
let app_type = AppType::from_str(&request.app)
|
||||||
|
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", request.app)))?;
|
||||||
|
|
||||||
|
// Build provider configuration based on app type
|
||||||
|
let mut provider = build_provider_from_request(&app_type, &request)?;
|
||||||
|
|
||||||
|
// Generate a unique ID for the provider using timestamp + sanitized name
|
||||||
|
// This is similar to how frontend generates IDs
|
||||||
|
let timestamp = chrono::Utc::now().timestamp_millis();
|
||||||
|
let sanitized_name = request
|
||||||
|
.name
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
||||||
|
.collect::<String>()
|
||||||
|
.to_lowercase();
|
||||||
|
provider.id = format!("{sanitized_name}-{timestamp}");
|
||||||
|
|
||||||
|
let provider_id = provider.id.clone();
|
||||||
|
|
||||||
|
// Use ProviderService to add the provider
|
||||||
|
ProviderService::add(state, app_type, provider)?;
|
||||||
|
|
||||||
|
Ok(provider_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a Provider structure from a deep link request
|
||||||
|
fn build_provider_from_request(
|
||||||
|
app_type: &AppType,
|
||||||
|
request: &DeepLinkImportRequest,
|
||||||
|
) -> Result<Provider, AppError> {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
let settings_config = match app_type {
|
||||||
|
AppType::Claude => {
|
||||||
|
// Claude configuration structure
|
||||||
|
let mut env = serde_json::Map::new();
|
||||||
|
env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!(request.api_key));
|
||||||
|
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(request.endpoint));
|
||||||
|
|
||||||
|
// Add model if provided (use as default model)
|
||||||
|
if let Some(model) = &request.model {
|
||||||
|
env.insert("ANTHROPIC_MODEL".to_string(), json!(model));
|
||||||
|
}
|
||||||
|
|
||||||
|
json!({ "env": env })
|
||||||
|
}
|
||||||
|
AppType::Codex => {
|
||||||
|
// Codex configuration structure
|
||||||
|
// For Codex, we store auth.json (JSON) and config.toml (TOML string) in settings_config。
|
||||||
|
//
|
||||||
|
// 这里尽量与前端 `getCodexCustomTemplate` 的默认模板保持一致,
|
||||||
|
// 再根据深链接参数注入 base_url / model,避免出现“只有 base_url 行”的极简配置,
|
||||||
|
// 让通过 UI 新建和通过深链接导入的 Codex 自定义供应商行为一致。
|
||||||
|
|
||||||
|
// 1. 生成一个适合作为 model_provider 名的安全标识
|
||||||
|
// 规则尽量与前端 codexProviderPresets.generateThirdPartyConfig 保持一致:
|
||||||
|
// - 转小写
|
||||||
|
// - 非 [a-z0-9_] 统一替换为下划线
|
||||||
|
// - 去掉首尾下划线
|
||||||
|
// - 若结果为空,则使用 "custom"
|
||||||
|
let clean_provider_name = {
|
||||||
|
let raw: String = request.name.chars().filter(|c| !c.is_control()).collect();
|
||||||
|
let lower = raw.to_lowercase();
|
||||||
|
let mut key: String = lower
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'a'..='z' | '0'..='9' | '_' => c,
|
||||||
|
_ => '_',
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 去掉首尾下划线
|
||||||
|
while key.starts_with('_') {
|
||||||
|
key.remove(0);
|
||||||
|
}
|
||||||
|
while key.ends_with('_') {
|
||||||
|
key.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.is_empty() {
|
||||||
|
"custom".to_string()
|
||||||
|
} else {
|
||||||
|
key
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 模型名称:优先使用 deeplink 中的 model,否则退回到 Codex 默认模型
|
||||||
|
let model_name = request
|
||||||
|
.model
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("gpt-5-codex")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// 3. 端点:与 UI 中 Base URL 处理方式保持一致,去掉结尾多余的斜杠
|
||||||
|
let endpoint = request.endpoint.trim().trim_end_matches('/').to_string();
|
||||||
|
|
||||||
|
// 4. 组装 config.toml 内容
|
||||||
|
// 使用 Rust 1.58+ 的内联格式化语法,避免 clippy::uninlined_format_args 警告
|
||||||
|
let config_toml = format!(
|
||||||
|
r#"model_provider = "{clean_provider_name}"
|
||||||
|
model = "{model_name}"
|
||||||
|
model_reasoning_effort = "high"
|
||||||
|
disable_response_storage = true
|
||||||
|
|
||||||
|
[model_providers.{clean_provider_name}]
|
||||||
|
name = "{clean_provider_name}"
|
||||||
|
base_url = "{endpoint}"
|
||||||
|
wire_api = "responses"
|
||||||
|
requires_openai_auth = true
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"auth": {
|
||||||
|
"OPENAI_API_KEY": request.api_key,
|
||||||
|
},
|
||||||
|
"config": config_toml
|
||||||
|
})
|
||||||
|
}
|
||||||
|
AppType::Gemini => {
|
||||||
|
// Gemini configuration structure (.env format)
|
||||||
|
let mut env = serde_json::Map::new();
|
||||||
|
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
|
||||||
|
env.insert(
|
||||||
|
"GOOGLE_GEMINI_BASE_URL".to_string(),
|
||||||
|
json!(request.endpoint),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add model if provided
|
||||||
|
if let Some(model) = &request.model {
|
||||||
|
env.insert("GEMINI_MODEL".to_string(), json!(model));
|
||||||
|
}
|
||||||
|
|
||||||
|
json!({ "env": env })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider = Provider {
|
||||||
|
id: String::new(), // Will be generated by ProviderService
|
||||||
|
name: request.name.clone(),
|
||||||
|
settings_config,
|
||||||
|
website_url: Some(request.homepage.clone()),
|
||||||
|
category: None,
|
||||||
|
created_at: None,
|
||||||
|
sort_index: None,
|
||||||
|
notes: request.notes.clone(),
|
||||||
|
meta: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_valid_claude_deeplink() {
|
||||||
|
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test-123";
|
||||||
|
|
||||||
|
let request = parse_deeplink_url(url).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(request.version, "v1");
|
||||||
|
assert_eq!(request.resource, "provider");
|
||||||
|
assert_eq!(request.app, "claude");
|
||||||
|
assert_eq!(request.name, "Test Provider");
|
||||||
|
assert_eq!(request.homepage, "https://example.com");
|
||||||
|
assert_eq!(request.endpoint, "https://api.example.com");
|
||||||
|
assert_eq!(request.api_key, "sk-test-123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_deeplink_with_notes() {
|
||||||
|
let url = "ccswitch://v1/import?resource=provider&app=codex&name=Codex&homepage=https%3A%2F%2Fcodex.com&endpoint=https%3A%2F%2Fapi.codex.com&apiKey=key123¬es=Test%20notes";
|
||||||
|
|
||||||
|
let request = parse_deeplink_url(url).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(request.notes, Some("Test notes".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_invalid_scheme() {
|
||||||
|
let url = "https://v1/import?resource=provider&app=claude&name=Test";
|
||||||
|
|
||||||
|
let result = parse_deeplink_url(url);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("Invalid scheme"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_unsupported_version() {
|
||||||
|
let url = "ccswitch://v2/import?resource=provider&app=claude&name=Test";
|
||||||
|
|
||||||
|
let result = parse_deeplink_url(url);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("Unsupported protocol version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_missing_required_field() {
|
||||||
|
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test";
|
||||||
|
|
||||||
|
let result = parse_deeplink_url(url);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("Missing 'homepage' parameter"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_invalid_url() {
|
||||||
|
let result = validate_url("not-a-url", "test");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_invalid_scheme() {
|
||||||
|
let result = validate_url("ftp://example.com", "test");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("must be http or https"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_gemini_provider_with_model() {
|
||||||
|
let request = DeepLinkImportRequest {
|
||||||
|
version: "v1".to_string(),
|
||||||
|
resource: "provider".to_string(),
|
||||||
|
app: "gemini".to_string(),
|
||||||
|
name: "Test Gemini".to_string(),
|
||||||
|
homepage: "https://example.com".to_string(),
|
||||||
|
endpoint: "https://api.example.com".to_string(),
|
||||||
|
api_key: "test-api-key".to_string(),
|
||||||
|
model: Some("gemini-2.0-flash".to_string()),
|
||||||
|
notes: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
||||||
|
|
||||||
|
// Verify provider basic info
|
||||||
|
assert_eq!(provider.name, "Test Gemini");
|
||||||
|
assert_eq!(
|
||||||
|
provider.website_url,
|
||||||
|
Some("https://example.com".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify settings_config structure
|
||||||
|
let env = provider.settings_config["env"].as_object().unwrap();
|
||||||
|
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
|
||||||
|
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
|
||||||
|
assert_eq!(env["GEMINI_MODEL"], "gemini-2.0-flash");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_gemini_provider_without_model() {
|
||||||
|
let request = DeepLinkImportRequest {
|
||||||
|
version: "v1".to_string(),
|
||||||
|
resource: "provider".to_string(),
|
||||||
|
app: "gemini".to_string(),
|
||||||
|
name: "Test Gemini".to_string(),
|
||||||
|
homepage: "https://example.com".to_string(),
|
||||||
|
endpoint: "https://api.example.com".to_string(),
|
||||||
|
api_key: "test-api-key".to_string(),
|
||||||
|
model: None,
|
||||||
|
notes: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
||||||
|
|
||||||
|
// Verify settings_config structure
|
||||||
|
let env = provider.settings_config["env"].as_object().unwrap();
|
||||||
|
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
|
||||||
|
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
|
||||||
|
// Model should not be present
|
||||||
|
assert!(env.get("GEMINI_MODEL").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
633
src-tauri/src/gemini_config.rs
Normal file
633
src-tauri/src/gemini_config.rs
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
use crate::config::write_text_file;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// 获取 Gemini 配置目录路径(支持设置覆盖)
|
||||||
|
pub fn get_gemini_dir() -> PathBuf {
|
||||||
|
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
||||||
|
return custom;
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs::home_dir()
|
||||||
|
.expect("无法获取用户主目录")
|
||||||
|
.join(".gemini")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Gemini .env 文件路径
|
||||||
|
pub fn get_gemini_env_path() -> PathBuf {
|
||||||
|
get_gemini_dir().join(".env")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 .env 文件内容为键值对
|
||||||
|
///
|
||||||
|
/// 此函数宽松地解析 .env 文件,跳过无效行。
|
||||||
|
/// 对于需要严格验证的场景,请使用 `parse_env_file_strict`。
|
||||||
|
pub fn parse_env_file(content: &str) -> HashMap<String, String> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
|
||||||
|
// 跳过空行和注释
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 KEY=VALUE
|
||||||
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
|
let key = key.trim().to_string();
|
||||||
|
let value = value.trim().to_string();
|
||||||
|
|
||||||
|
// 验证 key 是否有效(不为空,只包含字母、数字和下划线)
|
||||||
|
if !key.is_empty() && key.chars().all(|c| c.is_alphanumeric() || c == '_') {
|
||||||
|
map.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 严格解析 .env 文件内容,返回详细的错误信息
|
||||||
|
///
|
||||||
|
/// 与 `parse_env_file` 不同,此函数在遇到无效行时会返回错误,
|
||||||
|
/// 包含行号和详细的错误信息。
|
||||||
|
///
|
||||||
|
/// # 错误
|
||||||
|
///
|
||||||
|
/// 返回 `AppError` 如果遇到以下情况:
|
||||||
|
/// - 行不包含 `=` 分隔符
|
||||||
|
/// - Key 为空或包含无效字符
|
||||||
|
/// - Key 不符合环境变量命名规范
|
||||||
|
///
|
||||||
|
/// # 使用场景
|
||||||
|
///
|
||||||
|
/// 此函数为未来的严格验证场景预留,当前运行时使用宽松的 `parse_env_file`。
|
||||||
|
/// 可用于:
|
||||||
|
/// - 配置导入验证
|
||||||
|
/// - CLI 工具的严格模式
|
||||||
|
/// - 配置文件错误诊断
|
||||||
|
///
|
||||||
|
/// 已有完整的测试覆盖,可直接使用。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn parse_env_file_strict(content: &str) -> Result<HashMap<String, String>, AppError> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
|
||||||
|
for (line_num, line) in content.lines().enumerate() {
|
||||||
|
let line = line.trim();
|
||||||
|
let line_number = line_num + 1; // 行号从 1 开始
|
||||||
|
|
||||||
|
// 跳过空行和注释
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否包含 =
|
||||||
|
if !line.contains('=') {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.env.parse_error.no_equals",
|
||||||
|
format!("Gemini .env 文件格式错误(第 {line_number} 行):缺少 '=' 分隔符\n行内容: {line}"),
|
||||||
|
format!("Invalid Gemini .env format (line {line_number}): missing '=' separator\nLine: {line}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 KEY=VALUE
|
||||||
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
|
let key = key.trim();
|
||||||
|
let value = value.trim();
|
||||||
|
|
||||||
|
// 验证 key 不为空
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.env.parse_error.empty_key",
|
||||||
|
format!("Gemini .env 文件格式错误(第 {line_number} 行):环境变量名不能为空\n行内容: {line}"),
|
||||||
|
format!("Invalid Gemini .env format (line {line_number}): variable name cannot be empty\nLine: {line}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 key 只包含字母、数字和下划线
|
||||||
|
if !key.chars().all(|c| c.is_alphanumeric() || c == '_') {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.env.parse_error.invalid_key",
|
||||||
|
format!("Gemini .env 文件格式错误(第 {line_number} 行):环境变量名只能包含字母、数字和下划线\n变量名: {key}"),
|
||||||
|
format!("Invalid Gemini .env format (line {line_number}): variable name can only contain letters, numbers, and underscores\nVariable: {key}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
map.insert(key.to_string(), value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将键值对序列化为 .env 格式
|
||||||
|
pub fn serialize_env_file(map: &HashMap<String, String>) -> String {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
// 按键排序以保证输出稳定
|
||||||
|
let mut keys: Vec<_> = map.keys().collect();
|
||||||
|
keys.sort();
|
||||||
|
|
||||||
|
for key in keys {
|
||||||
|
if let Some(value) = map.get(key) {
|
||||||
|
lines.push(format!("{key}={value}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取 Gemini .env 文件
|
||||||
|
pub fn read_gemini_env() -> Result<HashMap<String, String>, AppError> {
|
||||||
|
let path = get_gemini_env_path();
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
|
||||||
|
Ok(parse_env_file(&content))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入 Gemini .env 文件(原子操作)
|
||||||
|
pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppError> {
|
||||||
|
let path = get_gemini_env_path();
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
|
||||||
|
// 设置目录权限为 700(仅所有者可读写执行)
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = fs::metadata(parent)
|
||||||
|
.map_err(|e| AppError::io(parent, e))?
|
||||||
|
.permissions();
|
||||||
|
perms.set_mode(0o700);
|
||||||
|
fs::set_permissions(parent, perms).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = serialize_env_file(map);
|
||||||
|
write_text_file(&path, &content)?;
|
||||||
|
|
||||||
|
// 设置文件权限为 600(仅所有者可读写)
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = fs::metadata(&path)
|
||||||
|
.map_err(|e| AppError::io(&path, e))?
|
||||||
|
.permissions();
|
||||||
|
perms.set_mode(0o600);
|
||||||
|
fs::set_permissions(&path, perms).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 .env 格式转换为 Provider.settings_config (JSON Value)
|
||||||
|
pub fn env_to_json(env_map: &HashMap<String, String>) -> Value {
|
||||||
|
let mut json_map = serde_json::Map::new();
|
||||||
|
|
||||||
|
for (key, value) in env_map {
|
||||||
|
json_map.insert(key.clone(), Value::String(value.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::json!({ "env": json_map })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Provider.settings_config (JSON Value) 提取 .env 格式
|
||||||
|
pub fn json_to_env(settings: &Value) -> Result<HashMap<String, String>, AppError> {
|
||||||
|
let mut env_map = HashMap::new();
|
||||||
|
|
||||||
|
if let Some(env_obj) = settings.get("env").and_then(|v| v.as_object()) {
|
||||||
|
for (key, value) in env_obj {
|
||||||
|
if let Some(val_str) = value.as_str() {
|
||||||
|
env_map.insert(key.clone(), val_str.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(env_map)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证 Gemini 配置的基本结构
|
||||||
|
///
|
||||||
|
/// 此函数只验证配置的基本格式,不强制要求 GEMINI_API_KEY。
|
||||||
|
/// 这允许用户先创建供应商配置,稍后再填写 API Key。
|
||||||
|
///
|
||||||
|
/// API Key 的验证会在切换供应商时进行(通过 `validate_gemini_settings_strict`)。
|
||||||
|
pub fn validate_gemini_settings(settings: &Value) -> Result<(), AppError> {
|
||||||
|
// 只验证基本结构,不强制要求 GEMINI_API_KEY
|
||||||
|
// 如果有 env 字段,验证它是一个对象
|
||||||
|
if let Some(env) = settings.get("env") {
|
||||||
|
if !env.is_object() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.validation.invalid_env",
|
||||||
|
"Gemini 配置格式错误: env 必须是对象",
|
||||||
|
"Gemini config invalid: env must be an object",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 严格验证 Gemini 配置(要求必需字段)
|
||||||
|
///
|
||||||
|
/// 此函数在切换供应商时使用,确保配置包含所有必需的字段。
|
||||||
|
/// 对于需要 API Key 的供应商(如 PackyCode),会验证 GEMINI_API_KEY 字段。
|
||||||
|
pub fn validate_gemini_settings_strict(settings: &Value) -> Result<(), AppError> {
|
||||||
|
let env_map = json_to_env(settings)?;
|
||||||
|
|
||||||
|
// 如果 env 为空,表示使用 OAuth(如 Google 官方),跳过验证
|
||||||
|
if env_map.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 env 不为空,检查必需字段 GEMINI_API_KEY
|
||||||
|
if !env_map.contains_key("GEMINI_API_KEY") {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.validation.missing_api_key",
|
||||||
|
"Gemini 配置缺少必需字段: GEMINI_API_KEY",
|
||||||
|
"Gemini config missing required field: GEMINI_API_KEY",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Gemini settings.json 文件路径
|
||||||
|
///
|
||||||
|
/// 返回路径:`~/.gemini/settings.json`(与 `.env` 文件同级)
|
||||||
|
pub fn get_gemini_settings_path() -> PathBuf {
|
||||||
|
get_gemini_dir().join("settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新 Gemini 目录 settings.json 中的 security.auth.selectedType 字段
|
||||||
|
///
|
||||||
|
/// 此函数会:
|
||||||
|
/// 1. 读取现有的 settings.json(如果存在)
|
||||||
|
/// 2. 只更新 `security.auth.selectedType` 字段,保留其他所有字段
|
||||||
|
/// 3. 原子性写入文件
|
||||||
|
///
|
||||||
|
/// # 参数
|
||||||
|
/// - `selected_type`: 要设置的 selectedType 值(如 "gemini-api-key" 或 "oauth-personal")
|
||||||
|
fn update_selected_type(selected_type: &str) -> Result<(), AppError> {
|
||||||
|
let settings_path = get_gemini_settings_path();
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
if let Some(parent) = settings_path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取现有的 settings.json(如果存在)
|
||||||
|
let mut settings_content = if settings_path.exists() {
|
||||||
|
let content =
|
||||||
|
fs::read_to_string(&settings_path).map_err(|e| AppError::io(&settings_path, e))?;
|
||||||
|
serde_json::from_str::<Value>(&content).unwrap_or_else(|_| serde_json::json!({}))
|
||||||
|
} else {
|
||||||
|
serde_json::json!({})
|
||||||
|
};
|
||||||
|
|
||||||
|
// 只更新 security.auth.selectedType 字段
|
||||||
|
if let Some(obj) = settings_content.as_object_mut() {
|
||||||
|
let security = obj
|
||||||
|
.entry("security")
|
||||||
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
|
||||||
|
if let Some(security_obj) = security.as_object_mut() {
|
||||||
|
let auth = security_obj
|
||||||
|
.entry("auth")
|
||||||
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
|
||||||
|
if let Some(auth_obj) = auth.as_object_mut() {
|
||||||
|
auth_obj.insert(
|
||||||
|
"selectedType".to_string(),
|
||||||
|
Value::String(selected_type.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入文件
|
||||||
|
crate::config::write_json_file(&settings_path, &settings_content)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为 Packycode Gemini 供应商写入 settings.json
|
||||||
|
///
|
||||||
|
/// 设置 `~/.gemini/settings.json` 中的:
|
||||||
|
/// ```json
|
||||||
|
/// {
|
||||||
|
/// "security": {
|
||||||
|
/// "auth": {
|
||||||
|
/// "selectedType": "gemini-api-key"
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 保留文件中的其他所有字段。
|
||||||
|
pub fn write_packycode_settings() -> Result<(), AppError> {
|
||||||
|
update_selected_type("gemini-api-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为 Google 官方 Gemini 供应商写入 settings.json(OAuth 模式)
|
||||||
|
///
|
||||||
|
/// 设置 `~/.gemini/settings.json` 中的:
|
||||||
|
/// ```json
|
||||||
|
/// {
|
||||||
|
/// "security": {
|
||||||
|
/// "auth": {
|
||||||
|
/// "selectedType": "oauth-personal"
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 保留文件中的其他所有字段。
|
||||||
|
pub fn write_google_oauth_settings() -> Result<(), AppError> {
|
||||||
|
update_selected_type("oauth-personal")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file() {
|
||||||
|
let content = r#"
|
||||||
|
# Comment line
|
||||||
|
GOOGLE_GEMINI_BASE_URL=https://example.com
|
||||||
|
GEMINI_API_KEY=sk-test123
|
||||||
|
GEMINI_MODEL=gemini-2.5-pro
|
||||||
|
|
||||||
|
# Another comment
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let map = parse_env_file(content);
|
||||||
|
|
||||||
|
assert_eq!(map.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
map.get("GOOGLE_GEMINI_BASE_URL"),
|
||||||
|
Some(&"https://example.com".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
|
||||||
|
assert_eq!(map.get("GEMINI_MODEL"), Some(&"gemini-2.5-pro".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_env_file() {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
map.insert("GEMINI_API_KEY".to_string(), "sk-test".to_string());
|
||||||
|
map.insert("GEMINI_MODEL".to_string(), "gemini-2.5-pro".to_string());
|
||||||
|
|
||||||
|
let content = serialize_env_file(&map);
|
||||||
|
|
||||||
|
assert!(content.contains("GEMINI_API_KEY=sk-test"));
|
||||||
|
assert!(content.contains("GEMINI_MODEL=gemini-2.5-pro"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_env_json_conversion() {
|
||||||
|
let mut env_map = HashMap::new();
|
||||||
|
env_map.insert("GEMINI_API_KEY".to_string(), "test-key".to_string());
|
||||||
|
|
||||||
|
let json = env_to_json(&env_map);
|
||||||
|
let converted = json_to_env(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
converted.get("GEMINI_API_KEY"),
|
||||||
|
Some(&"test-key".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file_strict_success() {
|
||||||
|
// 测试严格模式下正常解析
|
||||||
|
let content = r#"
|
||||||
|
# Comment line
|
||||||
|
GOOGLE_GEMINI_BASE_URL=https://example.com
|
||||||
|
GEMINI_API_KEY=sk-test123
|
||||||
|
GEMINI_MODEL=gemini-2.5-pro
|
||||||
|
|
||||||
|
# Another comment
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let result = parse_env_file_strict(content);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let map = result.unwrap();
|
||||||
|
assert_eq!(map.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
map.get("GOOGLE_GEMINI_BASE_URL"),
|
||||||
|
Some(&"https://example.com".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
|
||||||
|
assert_eq!(map.get("GEMINI_MODEL"), Some(&"gemini-2.5-pro".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file_strict_missing_equals() {
|
||||||
|
// 测试严格模式下检测缺少 = 的行
|
||||||
|
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
|
||||||
|
INVALID_LINE_WITHOUT_EQUALS
|
||||||
|
GEMINI_API_KEY=sk-test123";
|
||||||
|
|
||||||
|
let result = parse_env_file_strict(content);
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
let err_msg = format!("{err:?}");
|
||||||
|
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
|
||||||
|
assert!(err_msg.contains("INVALID_LINE_WITHOUT_EQUALS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file_strict_empty_key() {
|
||||||
|
// 测试严格模式下检测空 key
|
||||||
|
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
|
||||||
|
=value_without_key
|
||||||
|
GEMINI_API_KEY=sk-test123";
|
||||||
|
|
||||||
|
let result = parse_env_file_strict(content);
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
let err_msg = format!("{err:?}");
|
||||||
|
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
|
||||||
|
assert!(err_msg.contains("empty") || err_msg.contains("空"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file_strict_invalid_key_characters() {
|
||||||
|
// 测试严格模式下检测无效字符(如空格、特殊符号)
|
||||||
|
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
|
||||||
|
INVALID KEY WITH SPACES=value
|
||||||
|
GEMINI_API_KEY=sk-test123";
|
||||||
|
|
||||||
|
let result = parse_env_file_strict(content);
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
let err_msg = format!("{err:?}");
|
||||||
|
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
|
||||||
|
assert!(err_msg.contains("INVALID KEY WITH SPACES"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file_lax_vs_strict() {
|
||||||
|
// 测试宽松模式和严格模式的差异
|
||||||
|
let content = "VALID_KEY=value
|
||||||
|
INVALID LINE
|
||||||
|
KEY_WITH-DASH=value";
|
||||||
|
|
||||||
|
// 宽松模式:跳过无效行,继续解析
|
||||||
|
let lax_result = parse_env_file(content);
|
||||||
|
assert_eq!(lax_result.len(), 1); // 只有 VALID_KEY
|
||||||
|
assert_eq!(lax_result.get("VALID_KEY"), Some(&"value".to_string()));
|
||||||
|
|
||||||
|
// 严格模式:遇到无效行立即返回错误
|
||||||
|
let strict_result = parse_env_file_strict(content);
|
||||||
|
assert!(strict_result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_packycode_settings_structure() {
|
||||||
|
// 验证 Packycode settings.json 的结构正确
|
||||||
|
let settings_content = serde_json::json!({
|
||||||
|
"security": {
|
||||||
|
"auth": {
|
||||||
|
"selectedType": "gemini-api-key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
settings_content["security"]["auth"]["selectedType"],
|
||||||
|
"gemini-api-key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_packycode_settings_merge() {
|
||||||
|
// 测试合并逻辑:应该保留其他字段
|
||||||
|
let mut existing_settings = serde_json::json!({
|
||||||
|
"otherField": "should-be-kept",
|
||||||
|
"security": {
|
||||||
|
"otherSetting": "also-kept",
|
||||||
|
"auth": {
|
||||||
|
"otherAuth": "preserved"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 模拟更新 selectedType
|
||||||
|
if let Some(obj) = existing_settings.as_object_mut() {
|
||||||
|
let security = obj
|
||||||
|
.entry("security")
|
||||||
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
|
||||||
|
if let Some(security_obj) = security.as_object_mut() {
|
||||||
|
let auth = security_obj
|
||||||
|
.entry("auth")
|
||||||
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
|
||||||
|
if let Some(auth_obj) = auth.as_object_mut() {
|
||||||
|
auth_obj.insert(
|
||||||
|
"selectedType".to_string(),
|
||||||
|
Value::String("gemini-api-key".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证所有字段都被保留
|
||||||
|
assert_eq!(existing_settings["otherField"], "should-be-kept");
|
||||||
|
assert_eq!(existing_settings["security"]["otherSetting"], "also-kept");
|
||||||
|
assert_eq!(
|
||||||
|
existing_settings["security"]["auth"]["otherAuth"],
|
||||||
|
"preserved"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
existing_settings["security"]["auth"]["selectedType"],
|
||||||
|
"gemini-api-key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_google_oauth_settings_structure() {
|
||||||
|
// 验证 Google OAuth settings.json 的结构正确
|
||||||
|
let settings_content = serde_json::json!({
|
||||||
|
"security": {
|
||||||
|
"auth": {
|
||||||
|
"selectedType": "oauth-personal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
settings_content["security"]["auth"]["selectedType"],
|
||||||
|
"oauth-personal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_empty_env_for_oauth() {
|
||||||
|
// 测试空 env(Google 官方 OAuth)可以通过基本验证
|
||||||
|
let settings = serde_json::json!({
|
||||||
|
"env": {}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(validate_gemini_settings(&settings).is_ok());
|
||||||
|
// 严格验证也应该通过(空 env 表示 OAuth)
|
||||||
|
assert!(validate_gemini_settings_strict(&settings).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_env_with_api_key() {
|
||||||
|
// 测试有 API Key 的配置可以通过验证
|
||||||
|
let settings = serde_json::json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "sk-test123",
|
||||||
|
"GEMINI_MODEL": "gemini-2.5-pro"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(validate_gemini_settings(&settings).is_ok());
|
||||||
|
assert!(validate_gemini_settings_strict(&settings).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_env_without_api_key_relaxed() {
|
||||||
|
// 测试缺少 API Key 的非空配置在基本验证中可以通过(用户稍后填写)
|
||||||
|
let settings = serde_json::json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_MODEL": "gemini-2.5-pro"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 基本验证应该通过(允许稍后填写 API Key)
|
||||||
|
assert!(validate_gemini_settings(&settings).is_ok());
|
||||||
|
// 严格验证应该失败(切换时要求完整配置)
|
||||||
|
assert!(validate_gemini_settings_strict(&settings).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_invalid_env_type() {
|
||||||
|
// 测试 env 不是对象时会失败
|
||||||
|
let settings = serde_json::json!({
|
||||||
|
"env": "invalid_string"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(validate_gemini_settings(&settings).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src-tauri/src/gemini_mcp.rs
Normal file
121
src-tauri/src/gemini_mcp.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::config::atomic_write;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::gemini_config::get_gemini_settings_path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct McpStatus {
|
||||||
|
pub user_config_path: String,
|
||||||
|
pub user_config_exists: bool,
|
||||||
|
pub server_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Gemini MCP 配置文件路径(~/.gemini/settings.json)
|
||||||
|
fn user_config_path() -> PathBuf {
|
||||||
|
get_gemini_settings_path()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_json_value(path: &Path) -> Result<Value, AppError> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(serde_json::json!({}));
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
|
||||||
|
let value: Value = serde_json::from_str(&content).map_err(|e| AppError::json(path, e))?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
let json =
|
||||||
|
serde_json::to_string_pretty(value).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
|
atomic_write(path, json.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取 Gemini MCP 配置文件的完整 JSON 文本
|
||||||
|
pub fn read_mcp_json() -> Result<Option<String>, AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
Ok(Some(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取 Gemini settings.json 中的 mcpServers 映射
|
||||||
|
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(std::collections::HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = read_json_value(&path)?;
|
||||||
|
let servers = root
|
||||||
|
.get("mcpServers")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将给定的启用 MCP 服务器映射写入到 Gemini settings.json 的 mcpServers 字段
|
||||||
|
/// 仅覆盖 mcpServers,其他字段保持不变
|
||||||
|
pub fn set_mcp_servers_map(
|
||||||
|
servers: &std::collections::HashMap<String, Value>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
let mut root = if path.exists() {
|
||||||
|
read_json_value(&path)?
|
||||||
|
} else {
|
||||||
|
serde_json::json!({})
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
|
||||||
|
let mut out: Map<String, Value> = Map::new();
|
||||||
|
for (id, spec) in servers.iter() {
|
||||||
|
let mut obj = if let Some(map) = spec.as_object() {
|
||||||
|
map.clone()
|
||||||
|
} else {
|
||||||
|
return Err(AppError::McpValidation(format!(
|
||||||
|
"MCP 服务器 '{id}' 不是对象"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提取 server 字段(如果存在)
|
||||||
|
if let Some(server_val) = obj.remove("server") {
|
||||||
|
let server_obj = server_val.as_object().cloned().ok_or_else(|| {
|
||||||
|
AppError::McpValidation(format!("MCP 服务器 '{id}' server 字段不是对象"))
|
||||||
|
})?;
|
||||||
|
obj = server_obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除 UI 辅助字段
|
||||||
|
obj.remove("enabled");
|
||||||
|
obj.remove("source");
|
||||||
|
obj.remove("id");
|
||||||
|
obj.remove("name");
|
||||||
|
obj.remove("description");
|
||||||
|
obj.remove("tags");
|
||||||
|
obj.remove("homepage");
|
||||||
|
obj.remove("docs");
|
||||||
|
|
||||||
|
out.insert(id.clone(), Value::Object(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let obj = root
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| AppError::Config("~/.gemini/settings.json 根必须是对象".into()))?;
|
||||||
|
obj.insert("mcpServers".into(), Value::Object(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
write_json_value(&path, &root)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -5,28 +5,42 @@ mod claude_plugin;
|
|||||||
mod codex_config;
|
mod codex_config;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod deeplink;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod gemini_config; // 新增
|
||||||
|
mod gemini_mcp;
|
||||||
mod init_status;
|
mod init_status;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
|
mod prompt;
|
||||||
|
mod prompt_files;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod services;
|
mod services;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod store;
|
mod store;
|
||||||
mod usage_script;
|
mod usage_script;
|
||||||
|
|
||||||
pub use app_config::{AppType, MultiAppConfig};
|
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
|
||||||
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
||||||
pub use commands::*;
|
pub use commands::*;
|
||||||
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
||||||
|
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
|
||||||
pub use error::AppError;
|
pub use error::AppError;
|
||||||
pub use mcp::{
|
pub use mcp::{
|
||||||
import_from_claude, import_from_codex, sync_enabled_to_claude, sync_enabled_to_codex,
|
import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude,
|
||||||
|
remove_server_from_codex, remove_server_from_gemini, sync_enabled_to_claude,
|
||||||
|
sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude,
|
||||||
|
sync_single_server_to_codex, sync_single_server_to_gemini,
|
||||||
|
};
|
||||||
|
pub use provider::{Provider, ProviderMeta};
|
||||||
|
pub use services::{
|
||||||
|
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SkillService,
|
||||||
|
SpeedtestService,
|
||||||
};
|
};
|
||||||
pub use provider::Provider;
|
|
||||||
pub use services::{ConfigService, EndpointLatency, McpService, ProviderService, SpeedtestService};
|
|
||||||
pub use settings::{update_settings, AppSettings};
|
pub use settings::{update_settings, AppSettings};
|
||||||
pub use store::AppState;
|
pub use store::AppState;
|
||||||
|
use tauri_plugin_deep_link::DeepLinkExt;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
|
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
|
||||||
tray::{TrayIconBuilder, TrayIconEvent},
|
tray::{TrayIconBuilder, TrayIconEvent},
|
||||||
@@ -59,6 +73,129 @@ impl TrayTexts {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct TrayAppSection {
|
||||||
|
app_type: AppType,
|
||||||
|
prefix: &'static str,
|
||||||
|
header_id: &'static str,
|
||||||
|
empty_id: &'static str,
|
||||||
|
header_label: &'static str,
|
||||||
|
log_name: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||||
|
TrayAppSection {
|
||||||
|
app_type: AppType::Claude,
|
||||||
|
prefix: "claude_",
|
||||||
|
header_id: "claude_header",
|
||||||
|
empty_id: "claude_empty",
|
||||||
|
header_label: "─── Claude ───",
|
||||||
|
log_name: "Claude",
|
||||||
|
},
|
||||||
|
TrayAppSection {
|
||||||
|
app_type: AppType::Codex,
|
||||||
|
prefix: "codex_",
|
||||||
|
header_id: "codex_header",
|
||||||
|
empty_id: "codex_empty",
|
||||||
|
header_label: "─── Codex ───",
|
||||||
|
log_name: "Codex",
|
||||||
|
},
|
||||||
|
TrayAppSection {
|
||||||
|
app_type: AppType::Gemini,
|
||||||
|
prefix: "gemini_",
|
||||||
|
header_id: "gemini_header",
|
||||||
|
empty_id: "gemini_empty",
|
||||||
|
header_label: "─── Gemini ───",
|
||||||
|
log_name: "Gemini",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
fn append_provider_section<'a>(
|
||||||
|
app: &'a tauri::AppHandle,
|
||||||
|
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
|
||||||
|
manager: Option<&crate::provider::ProviderManager>,
|
||||||
|
section: &TrayAppSection,
|
||||||
|
tray_texts: &TrayTexts,
|
||||||
|
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
||||||
|
let Some(manager) = manager else {
|
||||||
|
return Ok(menu_builder);
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = MenuItem::with_id(
|
||||||
|
app,
|
||||||
|
section.header_id,
|
||||||
|
section.header_label,
|
||||||
|
false,
|
||||||
|
None::<&str>,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?;
|
||||||
|
menu_builder = menu_builder.item(&header);
|
||||||
|
|
||||||
|
if manager.providers.is_empty() {
|
||||||
|
let empty_hint = MenuItem::with_id(
|
||||||
|
app,
|
||||||
|
section.empty_id,
|
||||||
|
tray_texts.no_provider_hint,
|
||||||
|
false,
|
||||||
|
None::<&str>,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?;
|
||||||
|
return Ok(menu_builder.item(&empty_hint));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
||||||
|
sorted_providers.sort_by(|(_, a), (_, b)| {
|
||||||
|
match (a.sort_index, b.sort_index) {
|
||||||
|
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
|
||||||
|
(Some(_), None) => return std::cmp::Ordering::Less,
|
||||||
|
(None, Some(_)) => return std::cmp::Ordering::Greater,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
match (a.created_at, b.created_at) {
|
||||||
|
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
|
||||||
|
(Some(_), None) => return std::cmp::Ordering::Greater,
|
||||||
|
(None, Some(_)) => return std::cmp::Ordering::Less,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.name.cmp(&b.name)
|
||||||
|
});
|
||||||
|
|
||||||
|
for (id, provider) in sorted_providers {
|
||||||
|
let is_current = manager.current == *id;
|
||||||
|
let item = CheckMenuItem::with_id(
|
||||||
|
app,
|
||||||
|
format!("{}{}", section.prefix, id),
|
||||||
|
&provider.name,
|
||||||
|
true,
|
||||||
|
is_current,
|
||||||
|
None::<&str>,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?;
|
||||||
|
menu_builder = menu_builder.item(&item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(menu_builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
|
||||||
|
for section in TRAY_SECTIONS.iter() {
|
||||||
|
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
|
||||||
|
log::info!("切换到{}供应商: {provider_id}", section.log_name);
|
||||||
|
let app_handle = app.clone();
|
||||||
|
let provider_id = provider_id.to_string();
|
||||||
|
let app_type = section.app_type.clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
|
||||||
|
log::error!("切换{}供应商失败: {e}", section.log_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// 创建动态托盘菜单
|
/// 创建动态托盘菜单
|
||||||
fn create_tray_menu(
|
fn create_tray_menu(
|
||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
@@ -74,131 +211,29 @@ fn create_tray_menu(
|
|||||||
// 顶部:打开主界面
|
// 顶部:打开主界面
|
||||||
let show_main_item =
|
let show_main_item =
|
||||||
MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>)
|
MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>)
|
||||||
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {}", e)))?;
|
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
|
||||||
menu_builder = menu_builder.item(&show_main_item).separator();
|
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||||
|
|
||||||
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
||||||
if let Some(claude_manager) = config.get_manager(&crate::app_config::AppType::Claude) {
|
for section in TRAY_SECTIONS.iter() {
|
||||||
// 添加Claude标题(禁用状态,仅作为分组标识)
|
menu_builder = append_provider_section(
|
||||||
let claude_header =
|
|
||||||
MenuItem::with_id(app, "claude_header", "─── Claude ───", false, None::<&str>)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建Claude标题失败: {}", e)))?;
|
|
||||||
menu_builder = menu_builder.item(&claude_header);
|
|
||||||
|
|
||||||
if !claude_manager.providers.is_empty() {
|
|
||||||
// Sort providers by sortIndex, then by createdAt, then by name
|
|
||||||
let mut sorted_providers: Vec<_> = claude_manager.providers.iter().collect();
|
|
||||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
|
||||||
// Priority 1: sortIndex
|
|
||||||
match (a.sort_index, b.sort_index) {
|
|
||||||
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
|
|
||||||
(Some(_), None) => return std::cmp::Ordering::Less,
|
|
||||||
(None, Some(_)) => return std::cmp::Ordering::Greater,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
// Priority 2: createdAt
|
|
||||||
match (a.created_at, b.created_at) {
|
|
||||||
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
|
|
||||||
(Some(_), None) => return std::cmp::Ordering::Greater,
|
|
||||||
(None, Some(_)) => return std::cmp::Ordering::Less,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
// Priority 3: name
|
|
||||||
a.name.cmp(&b.name)
|
|
||||||
});
|
|
||||||
|
|
||||||
for (id, provider) in sorted_providers {
|
|
||||||
let is_current = claude_manager.current == *id;
|
|
||||||
let item = CheckMenuItem::with_id(
|
|
||||||
app,
|
app,
|
||||||
format!("claude_{}", id),
|
menu_builder,
|
||||||
&provider.name,
|
config.get_manager(§ion.app_type),
|
||||||
true,
|
section,
|
||||||
is_current,
|
&tray_texts,
|
||||||
None::<&str>,
|
)?;
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建菜单项失败: {}", e)))?;
|
|
||||||
menu_builder = menu_builder.item(&item);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 没有供应商时显示提示
|
|
||||||
let empty_hint = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"claude_empty",
|
|
||||||
tray_texts.no_provider_hint,
|
|
||||||
false,
|
|
||||||
None::<&str>,
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建Claude空提示失败: {}", e)))?;
|
|
||||||
menu_builder = menu_builder.item(&empty_hint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(codex_manager) = config.get_manager(&crate::app_config::AppType::Codex) {
|
|
||||||
// 添加Codex标题(禁用状态,仅作为分组标识)
|
|
||||||
let codex_header =
|
|
||||||
MenuItem::with_id(app, "codex_header", "─── Codex ───", false, None::<&str>)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建Codex标题失败: {}", e)))?;
|
|
||||||
menu_builder = menu_builder.item(&codex_header);
|
|
||||||
|
|
||||||
if !codex_manager.providers.is_empty() {
|
|
||||||
// Sort providers by sortIndex, then by createdAt, then by name
|
|
||||||
let mut sorted_providers: Vec<_> = codex_manager.providers.iter().collect();
|
|
||||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
|
||||||
// Priority 1: sortIndex
|
|
||||||
match (a.sort_index, b.sort_index) {
|
|
||||||
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
|
|
||||||
(Some(_), None) => return std::cmp::Ordering::Less,
|
|
||||||
(None, Some(_)) => return std::cmp::Ordering::Greater,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
// Priority 2: createdAt
|
|
||||||
match (a.created_at, b.created_at) {
|
|
||||||
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
|
|
||||||
(Some(_), None) => return std::cmp::Ordering::Greater,
|
|
||||||
(None, Some(_)) => return std::cmp::Ordering::Less,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
// Priority 3: name
|
|
||||||
a.name.cmp(&b.name)
|
|
||||||
});
|
|
||||||
|
|
||||||
for (id, provider) in sorted_providers {
|
|
||||||
let is_current = codex_manager.current == *id;
|
|
||||||
let item = CheckMenuItem::with_id(
|
|
||||||
app,
|
|
||||||
format!("codex_{}", id),
|
|
||||||
&provider.name,
|
|
||||||
true,
|
|
||||||
is_current,
|
|
||||||
None::<&str>,
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建菜单项失败: {}", e)))?;
|
|
||||||
menu_builder = menu_builder.item(&item);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 没有供应商时显示提示
|
|
||||||
let empty_hint = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"codex_empty",
|
|
||||||
tray_texts.no_provider_hint,
|
|
||||||
false,
|
|
||||||
None::<&str>,
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建Codex空提示失败: {}", e)))?;
|
|
||||||
menu_builder = menu_builder.item(&empty_hint);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分隔符和退出菜单
|
// 分隔符和退出菜单
|
||||||
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
|
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
|
||||||
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {}", e)))?;
|
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
|
||||||
|
|
||||||
menu_builder = menu_builder.separator().item(&quit_item);
|
menu_builder = menu_builder.separator().item(&quit_item);
|
||||||
|
|
||||||
menu_builder
|
menu_builder
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| AppError::Message(format!("构建菜单失败: {}", e)))
|
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -210,17 +245,17 @@ fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Err(err) = app.set_dock_visibility(dock_visible) {
|
if let Err(err) = app.set_dock_visibility(dock_visible) {
|
||||||
log::warn!("设置 Dock 显示状态失败: {}", err);
|
log::warn!("设置 Dock 显示状态失败: {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(err) = app.set_activation_policy(desired_policy) {
|
if let Err(err) = app.set_activation_policy(desired_policy) {
|
||||||
log::warn!("设置激活策略失败: {}", err);
|
log::warn!("设置激活策略失败: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 处理托盘菜单事件
|
/// 处理托盘菜单事件
|
||||||
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||||
log::info!("处理托盘菜单事件: {}", event_id);
|
log::info!("处理托盘菜单事件: {event_id}");
|
||||||
|
|
||||||
match event_id {
|
match event_id {
|
||||||
"show_main" => {
|
"show_main" => {
|
||||||
@@ -242,52 +277,74 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
|||||||
log::info!("退出应用");
|
log::info!("退出应用");
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
}
|
}
|
||||||
id if id.starts_with("claude_") => {
|
|
||||||
let Some(provider_id) = id.strip_prefix("claude_") else {
|
|
||||||
log::error!("无效的 Claude 菜单项 ID: {}", id);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
log::info!("切换到Claude供应商: {}", provider_id);
|
|
||||||
|
|
||||||
// 执行切换
|
|
||||||
let app_handle = app.clone();
|
|
||||||
let provider_id = provider_id.to_string();
|
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
|
||||||
if let Err(e) = switch_provider_internal(
|
|
||||||
&app_handle,
|
|
||||||
crate::app_config::AppType::Claude,
|
|
||||||
provider_id,
|
|
||||||
) {
|
|
||||||
log::error!("切换Claude供应商失败: {}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
id if id.starts_with("codex_") => {
|
|
||||||
let Some(provider_id) = id.strip_prefix("codex_") else {
|
|
||||||
log::error!("无效的 Codex 菜单项 ID: {}", id);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
log::info!("切换到Codex供应商: {}", provider_id);
|
|
||||||
|
|
||||||
// 执行切换
|
|
||||||
let app_handle = app.clone();
|
|
||||||
let provider_id = provider_id.to_string();
|
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
|
||||||
if let Err(e) = switch_provider_internal(
|
|
||||||
&app_handle,
|
|
||||||
crate::app_config::AppType::Codex,
|
|
||||||
provider_id,
|
|
||||||
) {
|
|
||||||
log::error!("切换Codex供应商失败: {}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => {
|
_ => {
|
||||||
log::warn!("未处理的菜单事件: {}", event_id);
|
if handle_provider_tray_event(app, event_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log::warn!("未处理的菜单事件: {event_id}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 统一处理 ccswitch:// 深链接 URL
|
||||||
|
///
|
||||||
|
/// - 解析 URL
|
||||||
|
/// - 向前端发射 `deeplink-import` / `deeplink-error` 事件
|
||||||
|
/// - 可选:在成功时聚焦主窗口
|
||||||
|
fn handle_deeplink_url(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
url_str: &str,
|
||||||
|
focus_main_window: bool,
|
||||||
|
source: &str,
|
||||||
|
) -> bool {
|
||||||
|
if !url_str.starts_with("ccswitch://") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("✓ Deep link URL detected from {source}: {url_str}");
|
||||||
|
|
||||||
|
match crate::deeplink::parse_deeplink_url(url_str) {
|
||||||
|
Ok(request) => {
|
||||||
|
log::info!(
|
||||||
|
"✓ Successfully parsed deep link: resource={}, app={}, name={}",
|
||||||
|
request.resource,
|
||||||
|
request.app,
|
||||||
|
request.name
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = app.emit("deeplink-import", &request) {
|
||||||
|
log::error!("✗ Failed to emit deeplink-import event: {e}");
|
||||||
|
} else {
|
||||||
|
log::info!("✓ Emitted deeplink-import event to frontend");
|
||||||
|
}
|
||||||
|
|
||||||
|
if focus_main_window {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
log::info!("✓ Window shown and focused");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("✗ Failed to parse deep link URL: {e}");
|
||||||
|
|
||||||
|
if let Err(emit_err) = app.emit(
|
||||||
|
"deeplink-error",
|
||||||
|
serde_json::json!({
|
||||||
|
"url": url_str,
|
||||||
|
"error": e.to_string()
|
||||||
|
}),
|
||||||
|
) {
|
||||||
|
log::error!("✗ Failed to emit deeplink-error event: {emit_err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|
||||||
/// 内部切换供应商函数
|
/// 内部切换供应商函数
|
||||||
@@ -308,7 +365,7 @@ fn switch_provider_internal(
|
|||||||
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
|
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
|
||||||
if let Some(tray) = app.tray_by_id("main") {
|
if let Some(tray) = app.tray_by_id("main") {
|
||||||
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
||||||
log::error!("更新托盘菜单失败: {}", e);
|
log::error!("更新托盘菜单失败: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,7 +376,7 @@ fn switch_provider_internal(
|
|||||||
"providerId": provider_id_clone
|
"providerId": provider_id_clone
|
||||||
});
|
});
|
||||||
if let Err(e) = app.emit("provider-switched", event_data) {
|
if let Err(e) = app.emit("provider-switched", event_data) {
|
||||||
log::error!("发射供应商切换事件失败: {}", e);
|
log::error!("发射供应商切换事件失败: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -335,13 +392,13 @@ async fn update_tray_menu(
|
|||||||
Ok(new_menu) => {
|
Ok(new_menu) => {
|
||||||
if let Some(tray) = app.tray_by_id("main") {
|
if let Some(tray) = app.tray_by_id("main") {
|
||||||
tray.set_menu(Some(new_menu))
|
tray.set_menu(Some(new_menu))
|
||||||
.map_err(|e| format!("更新托盘菜单失败: {}", e))?;
|
.map_err(|e| format!("更新托盘菜单失败: {e}"))?;
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::error!("创建托盘菜单失败: {}", err);
|
log::error!("创建托盘菜单失败: {err}");
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,7 +410,27 @@ pub fn run() {
|
|||||||
|
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||||
{
|
{
|
||||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||||
|
log::info!("=== Single Instance Callback Triggered ===");
|
||||||
|
log::info!("Args count: {}", args.len());
|
||||||
|
for (i, arg) in args.iter().enumerate() {
|
||||||
|
log::info!(" arg[{i}]: {arg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for deep link URL in args (mainly for Windows/Linux command line)
|
||||||
|
let mut found_deeplink = false;
|
||||||
|
for arg in &args {
|
||||||
|
if handle_deeplink_url(app, arg, false, "single_instance args") {
|
||||||
|
found_deeplink = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found_deeplink {
|
||||||
|
log::info!("ℹ No deep link URL found in args (this is expected on macOS when launched via system)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show and focus window regardless
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
let _ = window.unminimize();
|
let _ = window.unminimize();
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
@@ -363,6 +440,8 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let builder = builder
|
let builder = builder
|
||||||
|
// 注册 deep-link 插件(处理 macOS AppleEvent 和其他平台的深链接)
|
||||||
|
.plugin(tauri_plugin_deep_link::init())
|
||||||
// 拦截窗口关闭:根据设置决定是否最小化到托盘
|
// 拦截窗口关闭:根据设置决定是否最小化到托盘
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
@@ -397,7 +476,7 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
{
|
{
|
||||||
// 若配置不完整(如缺少 pubkey),跳过 Updater 而不中断应用
|
// 若配置不完整(如缺少 pubkey),跳过 Updater 而不中断应用
|
||||||
log::warn!("初始化 Updater 插件失败,已跳过:{}", e);
|
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -454,7 +533,7 @@ pub fn run() {
|
|||||||
});
|
});
|
||||||
// 事件通知(可能早于前端订阅,不保证送达)
|
// 事件通知(可能早于前端订阅,不保证送达)
|
||||||
if let Err(e) = app.emit("configLoadError", payload_json) {
|
if let Err(e) = app.emit("configLoadError", payload_json) {
|
||||||
log::error!("发射配置加载错误事件失败: {}", e);
|
log::error!("发射配置加载错误事件失败: {e}");
|
||||||
}
|
}
|
||||||
// 同时缓存错误,供前端启动阶段主动拉取
|
// 同时缓存错误,供前端启动阶段主动拉取
|
||||||
crate::init_status::set_init_error(crate::init_status::InitErrorPayload {
|
crate::init_status::set_init_error(crate::init_status::InitErrorPayload {
|
||||||
@@ -468,7 +547,7 @@ pub fn run() {
|
|||||||
|
|
||||||
// 迁移旧的 app_config_dir 配置到 Store
|
// 迁移旧的 app_config_dir 配置到 Store
|
||||||
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
|
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
|
||||||
log::warn!("迁移 app_config_dir 失败: {}", e);
|
log::warn!("迁移 app_config_dir 失败: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保配置结构就绪(已移除旧版本的副本迁移逻辑)
|
// 确保配置结构就绪(已移除旧版本的副本迁移逻辑)
|
||||||
@@ -478,7 +557,40 @@ pub fn run() {
|
|||||||
config_guard.ensure_app(&app_config::AppType::Codex);
|
config_guard.ensure_app(&app_config::AppType::Codex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动阶段不再无条件保存,避免意外覆盖用户配置。
|
// 启动阶段不再无条件保存,避免意外覆盖用户配置。
|
||||||
|
|
||||||
|
// 注册 deep-link URL 处理器(使用正确的 DeepLinkExt API)
|
||||||
|
log::info!("=== Registering deep-link URL handler ===");
|
||||||
|
|
||||||
|
// Linux 和 Windows 调试模式需要显式注册
|
||||||
|
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
|
||||||
|
{
|
||||||
|
if let Err(e) = app.deep_link().register_all() {
|
||||||
|
log::error!("✗ Failed to register deep link schemes: {}", e);
|
||||||
|
} else {
|
||||||
|
log::info!("✓ Deep link schemes registered (Linux/Windows)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册 URL 处理回调(所有平台通用)
|
||||||
|
app.deep_link().on_open_url({
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
|
move |event| {
|
||||||
|
log::info!("=== Deep Link Event Received (on_open_url) ===");
|
||||||
|
let urls = event.urls();
|
||||||
|
log::info!("Received {} URL(s)", urls.len());
|
||||||
|
|
||||||
|
for (i, url) in urls.iter().enumerate() {
|
||||||
|
let url_str = url.as_str();
|
||||||
|
log::info!(" URL[{i}]: {url_str}");
|
||||||
|
|
||||||
|
if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") {
|
||||||
|
break; // Process only first ccswitch:// URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
log::info!("✓ Deep-link URL handler registered");
|
||||||
|
|
||||||
// 创建动态托盘菜单
|
// 创建动态托盘菜单
|
||||||
let menu = create_tray_menu(app.handle(), &app_state)?;
|
let menu = create_tray_menu(app.handle(), &app_state)?;
|
||||||
@@ -502,6 +614,17 @@ pub fn run() {
|
|||||||
let _tray = tray_builder.build(app)?;
|
let _tray = tray_builder.build(app)?;
|
||||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||||
app.manage(app_state);
|
app.manage(app_state);
|
||||||
|
|
||||||
|
// 初始化 SkillService
|
||||||
|
match SkillService::new() {
|
||||||
|
Ok(skill_service) => {
|
||||||
|
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("初始化 SkillService 失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -522,6 +645,10 @@ pub fn run() {
|
|||||||
commands::get_init_error,
|
commands::get_init_error,
|
||||||
commands::get_app_config_path,
|
commands::get_app_config_path,
|
||||||
commands::open_app_config_folder,
|
commands::open_app_config_folder,
|
||||||
|
commands::get_claude_common_config_snippet,
|
||||||
|
commands::set_claude_common_config_snippet,
|
||||||
|
commands::get_common_config_snippet,
|
||||||
|
commands::set_common_config_snippet,
|
||||||
commands::read_live_provider_settings,
|
commands::read_live_provider_settings,
|
||||||
commands::get_settings,
|
commands::get_settings,
|
||||||
commands::save_settings,
|
commands::save_settings,
|
||||||
@@ -546,10 +673,18 @@ pub fn run() {
|
|||||||
commands::upsert_mcp_server_in_config,
|
commands::upsert_mcp_server_in_config,
|
||||||
commands::delete_mcp_server_in_config,
|
commands::delete_mcp_server_in_config,
|
||||||
commands::set_mcp_enabled,
|
commands::set_mcp_enabled,
|
||||||
commands::sync_enabled_mcp_to_claude,
|
// v3.7.0: Unified MCP management
|
||||||
commands::sync_enabled_mcp_to_codex,
|
commands::get_mcp_servers,
|
||||||
commands::import_mcp_from_claude,
|
commands::upsert_mcp_server,
|
||||||
commands::import_mcp_from_codex,
|
commands::delete_mcp_server,
|
||||||
|
commands::toggle_mcp_app,
|
||||||
|
// Prompt management
|
||||||
|
commands::get_prompts,
|
||||||
|
commands::upsert_prompt,
|
||||||
|
commands::delete_prompt,
|
||||||
|
commands::enable_prompt,
|
||||||
|
commands::import_prompt_from_file,
|
||||||
|
commands::get_current_prompt_file_content,
|
||||||
// ours: endpoint speed test + custom endpoint management
|
// ours: endpoint speed test + custom endpoint management
|
||||||
commands::test_api_endpoints,
|
commands::test_api_endpoints,
|
||||||
commands::get_custom_endpoints,
|
commands::get_custom_endpoints,
|
||||||
@@ -567,7 +702,21 @@ pub fn run() {
|
|||||||
commands::save_file_dialog,
|
commands::save_file_dialog,
|
||||||
commands::open_file_dialog,
|
commands::open_file_dialog,
|
||||||
commands::sync_current_providers_live,
|
commands::sync_current_providers_live,
|
||||||
|
// Deep link import
|
||||||
|
commands::parse_deeplink,
|
||||||
|
commands::import_from_deeplink,
|
||||||
update_tray_menu,
|
update_tray_menu,
|
||||||
|
// Environment variable management
|
||||||
|
commands::check_env_conflicts,
|
||||||
|
commands::delete_env_vars,
|
||||||
|
commands::restore_env_backup,
|
||||||
|
// Skill management
|
||||||
|
commands::get_skills,
|
||||||
|
commands::install_skill,
|
||||||
|
commands::uninstall_skill,
|
||||||
|
commands::get_skill_repos,
|
||||||
|
commands::add_skill_repo,
|
||||||
|
commands::remove_skill_repo,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let app = builder
|
let app = builder
|
||||||
@@ -576,8 +725,10 @@ pub fn run() {
|
|||||||
|
|
||||||
app.run(|app_handle, event| {
|
app.run(|app_handle, event| {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
match event {
|
||||||
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
|
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
|
||||||
if let RunEvent::Reopen { .. } = event {
|
RunEvent::Reopen { .. } => {
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
@@ -589,6 +740,61 @@ pub fn run() {
|
|||||||
apply_tray_policy(app_handle, true);
|
apply_tray_policy(app_handle, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...)
|
||||||
|
RunEvent::Opened { urls } => {
|
||||||
|
if let Some(url) = urls.first() {
|
||||||
|
let url_str = url.to_string();
|
||||||
|
log::info!("RunEvent::Opened with URL: {url_str}");
|
||||||
|
|
||||||
|
if url_str.starts_with("ccswitch://") {
|
||||||
|
// 解析并广播深链接事件,复用与 single_instance 相同的逻辑
|
||||||
|
match crate::deeplink::parse_deeplink_url(&url_str) {
|
||||||
|
Ok(request) => {
|
||||||
|
log::info!(
|
||||||
|
"Successfully parsed deep link from RunEvent::Opened: resource={}, app={}",
|
||||||
|
request.resource,
|
||||||
|
request.app
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) =
|
||||||
|
app_handle.emit("deeplink-import", &request)
|
||||||
|
{
|
||||||
|
log::error!(
|
||||||
|
"Failed to emit deep link event from RunEvent::Opened: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"Failed to parse deep link URL from RunEvent::Opened: {e}"
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(emit_err) = app_handle.emit(
|
||||||
|
"deeplink-error",
|
||||||
|
serde_json::json!({
|
||||||
|
"url": url_str,
|
||||||
|
"error": e.to_string()
|
||||||
|
}),
|
||||||
|
) {
|
||||||
|
log::error!(
|
||||||
|
"Failed to emit deep link error event from RunEvent::Opened: {emit_err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保主窗口可见
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
16
src-tauri/src/prompt.rs
Normal file
16
src-tauri/src/prompt.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Prompt {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub content: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
}
|
||||||
41
src-tauri/src/prompt_files.rs
Normal file
41
src-tauri/src/prompt_files.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::codex_config::get_codex_auth_path;
|
||||||
|
use crate::config::get_claude_settings_path;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::gemini_config::get_gemini_dir;
|
||||||
|
|
||||||
|
/// 返回指定应用所使用的提示词文件路径。
|
||||||
|
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||||
|
let base_dir: PathBuf = match app {
|
||||||
|
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
|
||||||
|
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
|
||||||
|
AppType::Gemini => get_gemini_dir(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let filename = match app {
|
||||||
|
AppType::Claude => "CLAUDE.md",
|
||||||
|
AppType::Codex => "AGENTS.md",
|
||||||
|
AppType::Gemini => "GEMINI.md",
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(base_dir.join(filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_base_dir_with_fallback(
|
||||||
|
primary_path: PathBuf,
|
||||||
|
fallback_dir: &str,
|
||||||
|
) -> Result<PathBuf, AppError> {
|
||||||
|
primary_path
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.or_else(|| dirs::home_dir().map(|h| h.join(fallback_dir)))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::localized(
|
||||||
|
"home_dir_not_found",
|
||||||
|
format!("无法确定 {fallback_dir} 配置目录:用户主目录不存在"),
|
||||||
|
format!("Cannot determine {fallback_dir} config directory: user home not found"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ pub struct Provider {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "sortIndex")]
|
#[serde(rename = "sortIndex")]
|
||||||
pub sort_index: Option<usize>,
|
pub sort_index: Option<usize>,
|
||||||
|
/// 备注信息
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub notes: Option<String>,
|
||||||
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json)
|
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub meta: Option<ProviderMeta>,
|
pub meta: Option<ProviderMeta>,
|
||||||
@@ -43,6 +46,7 @@ impl Provider {
|
|||||||
category: None,
|
category: None,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
sort_index: None,
|
sort_index: None,
|
||||||
|
notes: None,
|
||||||
meta: None,
|
meta: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,6 +132,15 @@ pub struct ProviderMeta {
|
|||||||
/// 用量查询脚本配置
|
/// 用量查询脚本配置
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub usage_script: Option<UsageScript>,
|
pub usage_script: Option<UsageScript>,
|
||||||
|
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
|
||||||
|
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub is_partner: Option<bool>,
|
||||||
|
/// 合作伙伴促销 key,用于识别 PackyCode 等特殊供应商
|
||||||
|
#[serde(
|
||||||
|
rename = "partnerPromotionKey",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub partner_promotion_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderManager {
|
impl ProviderManager {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::provider::ProviderService;
|
||||||
use crate::app_config::{AppType, MultiAppConfig};
|
use crate::app_config::{AppType, MultiAppConfig};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
@@ -20,7 +21,7 @@ impl ConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
|
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
|
||||||
let backup_id = format!("backup_{}", timestamp);
|
let backup_id = format!("backup_{timestamp}");
|
||||||
|
|
||||||
let backup_dir = config_path
|
let backup_dir = config_path
|
||||||
.parent()
|
.parent()
|
||||||
@@ -29,7 +30,7 @@ impl ConfigService {
|
|||||||
|
|
||||||
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
|
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
|
||||||
|
|
||||||
let backup_path = backup_dir.join(format!("{}.json", backup_id));
|
let backup_path = backup_dir.join(format!("{backup_id}.json"));
|
||||||
let contents = fs::read(config_path).map_err(|e| AppError::io(config_path, e))?;
|
let contents = fs::read(config_path).map_err(|e| AppError::io(config_path, e))?;
|
||||||
fs::write(&backup_path, contents).map_err(|e| AppError::io(&backup_path, e))?;
|
fs::write(&backup_path, contents).map_err(|e| AppError::io(&backup_path, e))?;
|
||||||
|
|
||||||
@@ -123,6 +124,7 @@ impl ConfigService {
|
|||||||
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
|
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
|
||||||
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
|
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
|
||||||
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
|
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
|
||||||
|
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,9 +147,7 @@ impl ConfigService {
|
|||||||
Some(provider) => provider.clone(),
|
Some(provider) => provider.clone(),
|
||||||
None => {
|
None => {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"当前应用 {:?} 的供应商 {} 不存在,跳过 live 同步",
|
"当前应用 {app_type:?} 的供应商 {current_id} 不存在,跳过 live 同步"
|
||||||
app_type,
|
|
||||||
current_id
|
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -158,6 +158,7 @@ impl ConfigService {
|
|||||||
match app_type {
|
match app_type {
|
||||||
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
||||||
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
||||||
|
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -169,18 +170,14 @@ impl ConfigService {
|
|||||||
provider: &Provider,
|
provider: &Provider,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
||||||
AppError::Config(format!("供应商 {} 的 Codex 配置必须是对象", provider_id))
|
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象"))
|
||||||
})?;
|
})?;
|
||||||
let auth = settings.get("auth").ok_or_else(|| {
|
let auth = settings.get("auth").ok_or_else(|| {
|
||||||
AppError::Config(format!(
|
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置缺少 auth 字段"))
|
||||||
"供应商 {} 的 Codex 配置缺少 auth 字段",
|
|
||||||
provider_id
|
|
||||||
))
|
|
||||||
})?;
|
})?;
|
||||||
if !auth.is_object() {
|
if !auth.is_object() {
|
||||||
return Err(AppError::Config(format!(
|
return Err(AppError::Config(format!(
|
||||||
"供应商 {} 的 Codex auth 配置必须是 JSON 对象",
|
"供应商 {provider_id} 的 Codex auth 配置必须是 JSON 对象"
|
||||||
provider_id
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let cfg_text = settings.get("config").and_then(Value::as_str);
|
let cfg_text = settings.get("config").and_then(Value::as_str);
|
||||||
@@ -226,4 +223,55 @@ impl ConfigService {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sync_gemini_live(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
provider_id: &str,
|
||||||
|
provider: &Provider,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
use crate::gemini_config::{
|
||||||
|
env_to_json, json_to_env, read_gemini_env, write_gemini_env_atomic,
|
||||||
|
};
|
||||||
|
|
||||||
|
let env_path = crate::gemini_config::get_gemini_env_path();
|
||||||
|
if let Some(parent) = env_path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换 JSON 配置为 .env 格式
|
||||||
|
let env_map = json_to_env(&provider.settings_config)?;
|
||||||
|
|
||||||
|
// Google 官方(OAuth): env 为空,写入空文件并设置安全标志后返回
|
||||||
|
if env_map.is_empty() {
|
||||||
|
write_gemini_env_atomic(&env_map)?;
|
||||||
|
ProviderService::ensure_google_oauth_security_flag(provider)?;
|
||||||
|
|
||||||
|
let live_after_env = read_gemini_env()?;
|
||||||
|
let live_after = env_to_json(&live_after_env);
|
||||||
|
|
||||||
|
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
|
||||||
|
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||||
|
target.settings_config = live_after;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非 OAuth:按常规写入,并在必要时设置 Packycode 安全标志
|
||||||
|
write_gemini_env_atomic(&env_map)?;
|
||||||
|
ProviderService::ensure_packycode_security_flag(provider)?;
|
||||||
|
|
||||||
|
// 读回实际写入的内容并更新到配置中
|
||||||
|
let live_after_env = read_gemini_env()?;
|
||||||
|
let live_after = env_to_json(&live_after_env);
|
||||||
|
|
||||||
|
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
|
||||||
|
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||||
|
target.settings_config = live_after;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
166
src-tauri/src/services/env_checker.rs
Normal file
166
src-tauri/src/services/env_checker.rs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EnvConflict {
|
||||||
|
pub var_name: String,
|
||||||
|
pub var_value: String,
|
||||||
|
pub source_type: String, // "system" | "file"
|
||||||
|
pub source_path: String, // Registry path or file path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use winreg::enums::*;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use winreg::RegKey;
|
||||||
|
|
||||||
|
/// Check environment variables for conflicts
|
||||||
|
pub fn check_env_conflicts(app: &str) -> Result<Vec<EnvConflict>, String> {
|
||||||
|
let keywords = get_keywords_for_app(app);
|
||||||
|
let mut conflicts = Vec::new();
|
||||||
|
|
||||||
|
// Check system environment variables
|
||||||
|
conflicts.extend(check_system_env(&keywords)?);
|
||||||
|
|
||||||
|
// Check shell configuration files (Unix only)
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
conflicts.extend(check_shell_configs(&keywords)?);
|
||||||
|
|
||||||
|
Ok(conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get relevant keywords for each app
|
||||||
|
fn get_keywords_for_app(app: &str) -> Vec<&str> {
|
||||||
|
match app.to_lowercase().as_str() {
|
||||||
|
"claude" => vec!["ANTHROPIC"],
|
||||||
|
"codex" => vec!["OPENAI"],
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check system environment variables (Windows Registry or Unix env)
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||||
|
let mut conflicts = Vec::new();
|
||||||
|
|
||||||
|
// Check HKEY_CURRENT_USER\Environment
|
||||||
|
if let Ok(hkcu) = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment") {
|
||||||
|
for (name, value) in hkcu.enum_values().filter_map(Result::ok) {
|
||||||
|
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
|
||||||
|
if let Ok(val) = value.to_string() {
|
||||||
|
conflicts.push(EnvConflict {
|
||||||
|
var_name: name.clone(),
|
||||||
|
var_value: val,
|
||||||
|
source_type: "system".to_string(),
|
||||||
|
source_path: "HKEY_CURRENT_USER\\Environment".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment
|
||||||
|
if let Ok(hklm) = RegKey::predef(HKEY_LOCAL_MACHINE)
|
||||||
|
.open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment")
|
||||||
|
{
|
||||||
|
for (name, value) in hklm.enum_values().filter_map(Result::ok) {
|
||||||
|
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
|
||||||
|
if let Ok(val) = value.to_string() {
|
||||||
|
conflicts.push(EnvConflict {
|
||||||
|
var_name: name.clone(),
|
||||||
|
var_value: val,
|
||||||
|
source_type: "system".to_string(),
|
||||||
|
source_path: "HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||||
|
let mut conflicts = Vec::new();
|
||||||
|
|
||||||
|
// Check current process environment
|
||||||
|
for (key, value) in std::env::vars() {
|
||||||
|
if keywords.iter().any(|k| key.to_uppercase().contains(k)) {
|
||||||
|
conflicts.push(EnvConflict {
|
||||||
|
var_name: key,
|
||||||
|
var_value: value,
|
||||||
|
source_type: "system".to_string(),
|
||||||
|
source_path: "Process Environment".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check shell configuration files for environment variable exports (Unix only)
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||||
|
let mut conflicts = Vec::new();
|
||||||
|
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||||
|
let config_files = vec![
|
||||||
|
format!("{}/.bashrc", home),
|
||||||
|
format!("{}/.bash_profile", home),
|
||||||
|
format!("{}/.zshrc", home),
|
||||||
|
format!("{}/.zprofile", home),
|
||||||
|
format!("{}/.profile", home),
|
||||||
|
"/etc/profile".to_string(),
|
||||||
|
"/etc/bashrc".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
for file_path in config_files {
|
||||||
|
if let Ok(content) = fs::read_to_string(&file_path) {
|
||||||
|
// Parse lines for export statements
|
||||||
|
for (line_num, line) in content.lines().enumerate() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
|
||||||
|
// Match patterns like: export VAR=value or VAR=value
|
||||||
|
if trimmed.starts_with("export ")
|
||||||
|
|| (!trimmed.starts_with('#') && trimmed.contains('='))
|
||||||
|
{
|
||||||
|
let export_line = trimmed.strip_prefix("export ").unwrap_or(trimmed);
|
||||||
|
|
||||||
|
if let Some(eq_pos) = export_line.find('=') {
|
||||||
|
let var_name = export_line[..eq_pos].trim();
|
||||||
|
let var_value = export_line[eq_pos + 1..].trim();
|
||||||
|
|
||||||
|
// Check if variable name contains any keyword
|
||||||
|
if keywords.iter().any(|k| var_name.to_uppercase().contains(k)) {
|
||||||
|
conflicts.push(EnvConflict {
|
||||||
|
var_name: var_name.to_string(),
|
||||||
|
var_value: var_value
|
||||||
|
.trim_matches('"')
|
||||||
|
.trim_matches('\'')
|
||||||
|
.to_string(),
|
||||||
|
source_type: "file".to_string(),
|
||||||
|
source_path: format!("{}:{}", file_path, line_num + 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_keywords() {
|
||||||
|
assert_eq!(get_keywords_for_app("claude"), vec!["ANTHROPIC"]);
|
||||||
|
assert_eq!(get_keywords_for_app("codex"), vec!["OPENAI"]);
|
||||||
|
assert_eq!(get_keywords_for_app("unknown"), Vec::<&str>::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
240
src-tauri/src/services/env_manager.rs
Normal file
240
src-tauri/src/services/env_manager.rs
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
use super::env_checker::EnvConflict;
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use winreg::enums::*;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use winreg::RegKey;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BackupInfo {
|
||||||
|
pub backup_path: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
pub conflicts: Vec<EnvConflict>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete environment variables with automatic backup
|
||||||
|
pub fn delete_env_vars(conflicts: Vec<EnvConflict>) -> Result<BackupInfo, String> {
|
||||||
|
// Step 1: Create backup
|
||||||
|
let backup_info = create_backup(&conflicts)?;
|
||||||
|
|
||||||
|
// Step 2: Delete variables
|
||||||
|
for conflict in &conflicts {
|
||||||
|
match delete_single_env(conflict) {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
// If deletion fails, we keep the backup but return error
|
||||||
|
return Err(format!(
|
||||||
|
"删除环境变量失败: {}. 备份已保存到: {}",
|
||||||
|
e, backup_info.backup_path
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(backup_info)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create backup file before deletion
|
||||||
|
fn create_backup(conflicts: &[EnvConflict]) -> Result<BackupInfo, String> {
|
||||||
|
// Get backup directory
|
||||||
|
let backup_dir = get_backup_dir()?;
|
||||||
|
fs::create_dir_all(&backup_dir).map_err(|e| format!("创建备份目录失败: {e}"))?;
|
||||||
|
|
||||||
|
// Generate backup file name with timestamp
|
||||||
|
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
||||||
|
let backup_file = backup_dir.join(format!("env-backup-{timestamp}.json"));
|
||||||
|
|
||||||
|
// Create backup data
|
||||||
|
let backup_info = BackupInfo {
|
||||||
|
backup_path: backup_file.to_string_lossy().to_string(),
|
||||||
|
timestamp: timestamp.clone(),
|
||||||
|
conflicts: conflicts.to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write backup file
|
||||||
|
let json = serde_json::to_string_pretty(&backup_info)
|
||||||
|
.map_err(|e| format!("序列化备份数据失败: {e}"))?;
|
||||||
|
|
||||||
|
fs::write(&backup_file, json).map_err(|e| format!("写入备份文件失败: {e}"))?;
|
||||||
|
|
||||||
|
Ok(backup_info)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get backup directory path
|
||||||
|
fn get_backup_dir() -> Result<PathBuf, String> {
|
||||||
|
let home = dirs::home_dir().ok_or("无法获取用户主目录")?;
|
||||||
|
Ok(home.join(".cc-switch").join("backups"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a single environment variable
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> {
|
||||||
|
match conflict.source_type.as_str() {
|
||||||
|
"system" => {
|
||||||
|
if conflict.source_path.contains("HKEY_CURRENT_USER") {
|
||||||
|
let hkcu = RegKey::predef(HKEY_CURRENT_USER)
|
||||||
|
.open_subkey_with_flags("Environment", KEY_ALL_ACCESS)
|
||||||
|
.map_err(|e| format!("打开注册表失败: {}", e))?;
|
||||||
|
|
||||||
|
hkcu.delete_value(&conflict.var_name)
|
||||||
|
.map_err(|e| format!("删除注册表项失败: {}", e))?;
|
||||||
|
} else if conflict.source_path.contains("HKEY_LOCAL_MACHINE") {
|
||||||
|
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE)
|
||||||
|
.open_subkey_with_flags(
|
||||||
|
"SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
|
||||||
|
KEY_ALL_ACCESS,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("打开系统注册表失败 (需要管理员权限): {}", e))?;
|
||||||
|
|
||||||
|
hklm.delete_value(&conflict.var_name)
|
||||||
|
.map_err(|e| format!("删除系统注册表项失败: {}", e))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"file" => Err("Windows 系统不应该有文件类型的环境变量".to_string()),
|
||||||
|
_ => Err(format!("未知的环境变量来源类型: {}", conflict.source_type)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> {
|
||||||
|
match conflict.source_type.as_str() {
|
||||||
|
"file" => {
|
||||||
|
// Parse file path and line number from source_path (format: "path:line")
|
||||||
|
let parts: Vec<&str> = conflict.source_path.split(':').collect();
|
||||||
|
if parts.len() < 2 {
|
||||||
|
return Err("无效的文件路径格式".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_path = parts[0];
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
let content = fs::read_to_string(file_path)
|
||||||
|
.map_err(|e| format!("读取文件失败 {file_path}: {e}"))?;
|
||||||
|
|
||||||
|
// Filter out the line containing the environment variable
|
||||||
|
let new_content: Vec<String> = content
|
||||||
|
.lines()
|
||||||
|
.filter(|line| {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
let export_line = trimmed.strip_prefix("export ").unwrap_or(trimmed);
|
||||||
|
|
||||||
|
// Check if this line sets the target variable
|
||||||
|
if let Some(eq_pos) = export_line.find('=') {
|
||||||
|
let var_name = export_line[..eq_pos].trim();
|
||||||
|
var_name != conflict.var_name
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Write back to file
|
||||||
|
fs::write(file_path, new_content.join("\n"))
|
||||||
|
.map_err(|e| format!("写入文件失败 {file_path}: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"system" => {
|
||||||
|
// On Unix, we can't directly delete process environment variables
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(format!("未知的环境变量来源类型: {}", conflict.source_type)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore environment variables from backup
|
||||||
|
pub fn restore_from_backup(backup_path: String) -> Result<(), String> {
|
||||||
|
// Read backup file
|
||||||
|
let content = fs::read_to_string(&backup_path).map_err(|e| format!("读取备份文件失败: {e}"))?;
|
||||||
|
|
||||||
|
let backup_info: BackupInfo =
|
||||||
|
serde_json::from_str(&content).map_err(|e| format!("解析备份文件失败: {e}"))?;
|
||||||
|
|
||||||
|
// Restore each variable
|
||||||
|
for conflict in &backup_info.conflicts {
|
||||||
|
restore_single_env(conflict)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore a single environment variable
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn restore_single_env(conflict: &EnvConflict) -> Result<(), String> {
|
||||||
|
match conflict.source_type.as_str() {
|
||||||
|
"system" => {
|
||||||
|
if conflict.source_path.contains("HKEY_CURRENT_USER") {
|
||||||
|
let (hkcu, _) = RegKey::predef(HKEY_CURRENT_USER)
|
||||||
|
.create_subkey("Environment")
|
||||||
|
.map_err(|e| format!("打开注册表失败: {}", e))?;
|
||||||
|
|
||||||
|
hkcu.set_value(&conflict.var_name, &conflict.var_value)
|
||||||
|
.map_err(|e| format!("恢复注册表项失败: {}", e))?;
|
||||||
|
} else if conflict.source_path.contains("HKEY_LOCAL_MACHINE") {
|
||||||
|
let (hklm, _) = RegKey::predef(HKEY_LOCAL_MACHINE)
|
||||||
|
.create_subkey(
|
||||||
|
"SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("打开系统注册表失败 (需要管理员权限): {}", e))?;
|
||||||
|
|
||||||
|
hklm.set_value(&conflict.var_name, &conflict.var_value)
|
||||||
|
.map_err(|e| format!("恢复系统注册表项失败: {}", e))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(format!(
|
||||||
|
"无法恢复类型为 {} 的环境变量",
|
||||||
|
conflict.source_type
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn restore_single_env(conflict: &EnvConflict) -> Result<(), String> {
|
||||||
|
match conflict.source_type.as_str() {
|
||||||
|
"file" => {
|
||||||
|
// Parse file path from source_path
|
||||||
|
let parts: Vec<&str> = conflict.source_path.split(':').collect();
|
||||||
|
if parts.is_empty() {
|
||||||
|
return Err("无效的文件路径格式".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_path = parts[0];
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
let mut content = fs::read_to_string(file_path)
|
||||||
|
.map_err(|e| format!("读取文件失败 {file_path}: {e}"))?;
|
||||||
|
|
||||||
|
// Append the environment variable line
|
||||||
|
let export_line = format!("\nexport {}={}", conflict.var_name, conflict.var_value);
|
||||||
|
content.push_str(&export_line);
|
||||||
|
|
||||||
|
// Write back to file
|
||||||
|
fs::write(file_path, content).map_err(|e| format!("写入文件失败 {file_path}: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(format!(
|
||||||
|
"无法恢复类型为 {} 的环境变量",
|
||||||
|
conflict.source_type
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_backup_dir_creation() {
|
||||||
|
let backup_dir = get_backup_dir();
|
||||||
|
assert!(backup_dir.is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,191 +1,260 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use serde_json::Value;
|
use crate::app_config::{AppType, McpServer, MultiAppConfig};
|
||||||
|
|
||||||
use crate::app_config::{AppType, MultiAppConfig};
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::mcp;
|
use crate::mcp;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
|
|
||||||
/// MCP 相关业务逻辑
|
/// MCP 相关业务逻辑(v3.7.0 统一结构)
|
||||||
pub struct McpService;
|
pub struct McpService;
|
||||||
|
|
||||||
impl McpService {
|
impl McpService {
|
||||||
/// 获取指定应用的 MCP 服务器快照,并在必要时回写归一化后的配置。
|
/// 获取所有 MCP 服务器(统一结构)
|
||||||
pub fn get_servers(state: &AppState, app: AppType) -> Result<HashMap<String, Value>, AppError> {
|
pub fn get_all_servers(state: &AppState) -> Result<HashMap<String, McpServer>, AppError> {
|
||||||
|
let cfg = state.config.read()?;
|
||||||
|
|
||||||
|
// 如果是新结构,直接返回
|
||||||
|
if let Some(servers) = &cfg.mcp.servers {
|
||||||
|
return Ok(servers.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 理论上不应该走到这里,因为 load 时会自动迁移
|
||||||
|
Err(AppError::localized(
|
||||||
|
"mcp.old_structure",
|
||||||
|
"检测到旧版 MCP 结构,请重启应用完成迁移",
|
||||||
|
"Old MCP structure detected, please restart app to complete migration",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加或更新 MCP 服务器
|
||||||
|
pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> {
|
||||||
|
{
|
||||||
let mut cfg = state.config.write()?;
|
let mut cfg = state.config.write()?;
|
||||||
let (snapshot, normalized) = mcp::get_servers_snapshot_for(&mut cfg, &app);
|
|
||||||
drop(cfg);
|
// 确保 servers 字段存在
|
||||||
if normalized > 0 {
|
if cfg.mcp.servers.is_none() {
|
||||||
|
cfg.mcp.servers = Some(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let servers = cfg.mcp.servers.as_mut().unwrap();
|
||||||
|
let id = server.id.clone();
|
||||||
|
|
||||||
|
// 插入或更新
|
||||||
|
servers.insert(id, server.clone());
|
||||||
|
}
|
||||||
|
|
||||||
state.save()?;
|
state.save()?;
|
||||||
}
|
|
||||||
Ok(snapshot)
|
// 同步到各个启用的应用
|
||||||
|
Self::sync_server_to_apps(state, &server)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 在 config.json 中新增或更新指定 MCP 服务器,并按需同步到对应客户端。
|
/// 删除 MCP 服务器
|
||||||
pub fn upsert_server(
|
pub fn delete_server(state: &AppState, id: &str) -> Result<bool, AppError> {
|
||||||
state: &AppState,
|
let server = {
|
||||||
app: AppType,
|
|
||||||
id: &str,
|
|
||||||
spec: Value,
|
|
||||||
sync_other_side: bool,
|
|
||||||
) -> Result<bool, AppError> {
|
|
||||||
let (changed, snapshot, sync_claude, sync_codex): (
|
|
||||||
bool,
|
|
||||||
Option<MultiAppConfig>,
|
|
||||||
bool,
|
|
||||||
bool,
|
|
||||||
) = {
|
|
||||||
let mut cfg = state.config.write()?;
|
let mut cfg = state.config.write()?;
|
||||||
let changed = mcp::upsert_in_config_for(&mut cfg, &app, id, spec)?;
|
|
||||||
|
|
||||||
// 修复:默认启用(unwrap_or(true))
|
if let Some(servers) = &mut cfg.mcp.servers {
|
||||||
// 新增的 MCP 如果缺少 enabled 字段,应该默认为启用状态
|
servers.remove(id)
|
||||||
let enabled = cfg
|
|
||||||
.mcp_for(&app)
|
|
||||||
.servers
|
|
||||||
.get(id)
|
|
||||||
.and_then(|entry| entry.get("enabled"))
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(true);
|
|
||||||
|
|
||||||
let mut sync_claude = matches!(app, AppType::Claude) && enabled;
|
|
||||||
let mut sync_codex = matches!(app, AppType::Codex) && enabled;
|
|
||||||
|
|
||||||
// 修复:sync_other_side=true 时,先将 MCP 复制到另一侧,然后强制同步
|
|
||||||
// 这才是"同步到另一侧"的正确语义:将 MCP 跨应用复制
|
|
||||||
if sync_other_side {
|
|
||||||
// 获取当前 MCP 条目的克隆(刚刚插入的,不可能失败)
|
|
||||||
let current_entry = cfg
|
|
||||||
.mcp_for(&app)
|
|
||||||
.servers
|
|
||||||
.get(id)
|
|
||||||
.cloned()
|
|
||||||
.expect("刚刚插入的 MCP 条目必定存在");
|
|
||||||
|
|
||||||
// 将该 MCP 复制到另一侧的 servers
|
|
||||||
let other_app = match app {
|
|
||||||
AppType::Claude => AppType::Codex,
|
|
||||||
AppType::Codex => AppType::Claude,
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg.mcp_for_mut(&other_app)
|
|
||||||
.servers
|
|
||||||
.insert(id.to_string(), current_entry);
|
|
||||||
|
|
||||||
// 强制同步另一侧
|
|
||||||
match app {
|
|
||||||
AppType::Claude => sync_codex = true,
|
|
||||||
AppType::Codex => sync_claude = true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let snapshot = if sync_claude || sync_codex {
|
|
||||||
Some(cfg.clone())
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
(changed, snapshot, sync_claude, sync_codex)
|
if let Some(server) = server {
|
||||||
};
|
|
||||||
|
|
||||||
// 保持原有行为:始终尝试持久化,避免遗漏 normalize 带来的隐式变更
|
|
||||||
state.save()?;
|
state.save()?;
|
||||||
|
|
||||||
if let Some(snapshot) = snapshot {
|
// 从所有应用的 live 配置中移除
|
||||||
if sync_claude {
|
Self::remove_server_from_all_apps(state, id, &server)?;
|
||||||
mcp::sync_enabled_to_claude(&snapshot)?;
|
Ok(true)
|
||||||
}
|
} else {
|
||||||
if sync_codex {
|
Ok(false)
|
||||||
mcp::sync_enabled_to_codex(&snapshot)?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(changed)
|
/// 切换指定应用的启用状态
|
||||||
}
|
pub fn toggle_app(
|
||||||
|
state: &AppState,
|
||||||
/// 删除 config.json 中的 MCP 服务器条目,并同步客户端配置。
|
server_id: &str,
|
||||||
pub fn delete_server(state: &AppState, app: AppType, id: &str) -> Result<bool, AppError> {
|
app: AppType,
|
||||||
let (existed, snapshot): (bool, Option<MultiAppConfig>) = {
|
enabled: bool,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let server = {
|
||||||
let mut cfg = state.config.write()?;
|
let mut cfg = state.config.write()?;
|
||||||
let existed = mcp::delete_in_config_for(&mut cfg, &app, id)?;
|
|
||||||
let snapshot = if existed { Some(cfg.clone()) } else { None };
|
if let Some(servers) = &mut cfg.mcp.servers {
|
||||||
(existed, snapshot)
|
if let Some(server) = servers.get_mut(server_id) {
|
||||||
|
server.apps.set_enabled_for(&app, enabled);
|
||||||
|
Some(server.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if existed {
|
|
||||||
|
if let Some(server) = server {
|
||||||
state.save()?;
|
state.save()?;
|
||||||
if let Some(snapshot) = snapshot {
|
|
||||||
match app {
|
// 同步到对应应用
|
||||||
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
|
if enabled {
|
||||||
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
|
Self::sync_server_to_app(state, &server, &app)?;
|
||||||
|
} else {
|
||||||
|
Self::remove_server_from_app(state, server_id, &app)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Ok(existed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置 MCP 启用状态,并同步到客户端配置。
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 MCP 服务器同步到所有启用的应用
|
||||||
|
fn sync_server_to_apps(state: &AppState, server: &McpServer) -> Result<(), AppError> {
|
||||||
|
let cfg = state.config.read()?;
|
||||||
|
|
||||||
|
for app in server.apps.enabled_apps() {
|
||||||
|
Self::sync_server_to_app_internal(&cfg, server, &app)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 MCP 服务器同步到指定应用
|
||||||
|
fn sync_server_to_app(
|
||||||
|
state: &AppState,
|
||||||
|
server: &McpServer,
|
||||||
|
app: &AppType,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let cfg = state.config.read()?;
|
||||||
|
Self::sync_server_to_app_internal(&cfg, server, app)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_server_to_app_internal(
|
||||||
|
cfg: &MultiAppConfig,
|
||||||
|
server: &McpServer,
|
||||||
|
app: &AppType,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => {
|
||||||
|
mcp::sync_single_server_to_claude(cfg, &server.id, &server.server)?;
|
||||||
|
}
|
||||||
|
AppType::Codex => {
|
||||||
|
mcp::sync_single_server_to_codex(cfg, &server.id, &server.server)?;
|
||||||
|
}
|
||||||
|
AppType::Gemini => {
|
||||||
|
mcp::sync_single_server_to_gemini(cfg, &server.id, &server.server)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从所有曾启用过该服务器的应用中移除
|
||||||
|
fn remove_server_from_all_apps(
|
||||||
|
state: &AppState,
|
||||||
|
id: &str,
|
||||||
|
server: &McpServer,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
// 从所有曾启用的应用中移除
|
||||||
|
for app in server.apps.enabled_apps() {
|
||||||
|
Self::remove_server_from_app(state, id, &app)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_server_from_app(_state: &AppState, id: &str, app: &AppType) -> Result<(), AppError> {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => mcp::remove_server_from_claude(id)?,
|
||||||
|
AppType::Codex => mcp::remove_server_from_codex(id)?,
|
||||||
|
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手动同步所有启用的 MCP 服务器到对应的应用
|
||||||
|
pub fn sync_all_enabled(state: &AppState) -> Result<(), AppError> {
|
||||||
|
let servers = Self::get_all_servers(state)?;
|
||||||
|
|
||||||
|
for server in servers.values() {
|
||||||
|
Self::sync_server_to_apps(state, server)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 兼容层:支持旧的 v3.6.x 命令(已废弃,将在 v4.0 移除)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/// [已废弃] 获取指定应用的 MCP 服务器(兼容旧 API)
|
||||||
|
#[deprecated(since = "3.7.0", note = "Use get_all_servers instead")]
|
||||||
|
pub fn get_servers(
|
||||||
|
state: &AppState,
|
||||||
|
app: AppType,
|
||||||
|
) -> Result<HashMap<String, serde_json::Value>, AppError> {
|
||||||
|
let all_servers = Self::get_all_servers(state)?;
|
||||||
|
let mut result = HashMap::new();
|
||||||
|
|
||||||
|
for (id, server) in all_servers {
|
||||||
|
if server.apps.is_enabled_for(&app) {
|
||||||
|
result.insert(id, server.server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [已废弃] 设置 MCP 服务器在指定应用的启用状态(兼容旧 API)
|
||||||
|
#[deprecated(since = "3.7.0", note = "Use toggle_app instead")]
|
||||||
pub fn set_enabled(
|
pub fn set_enabled(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
app: AppType,
|
app: AppType,
|
||||||
id: &str,
|
id: &str,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
) -> Result<bool, AppError> {
|
) -> Result<bool, AppError> {
|
||||||
let (existed, snapshot): (bool, Option<MultiAppConfig>) = {
|
Self::toggle_app(state, id, app, enabled)?;
|
||||||
let mut cfg = state.config.write()?;
|
Ok(true)
|
||||||
let existed = mcp::set_enabled_flag_for(&mut cfg, &app, id, enabled)?;
|
|
||||||
let snapshot = if existed { Some(cfg.clone()) } else { None };
|
|
||||||
(existed, snapshot)
|
|
||||||
};
|
|
||||||
|
|
||||||
if existed {
|
|
||||||
state.save()?;
|
|
||||||
if let Some(snapshot) = snapshot {
|
|
||||||
match app {
|
|
||||||
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
|
|
||||||
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(existed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 手动同步已启用的 MCP 服务器到客户端配置。
|
/// [已废弃] 同步启用的 MCP 到指定应用(兼容旧 API)
|
||||||
|
#[deprecated(since = "3.7.0", note = "Use sync_all_enabled instead")]
|
||||||
pub fn sync_enabled(state: &AppState, app: AppType) -> Result<(), AppError> {
|
pub fn sync_enabled(state: &AppState, app: AppType) -> Result<(), AppError> {
|
||||||
let (snapshot, normalized): (MultiAppConfig, usize) = {
|
let servers = Self::get_all_servers(state)?;
|
||||||
let mut cfg = state.config.write()?;
|
|
||||||
let normalized = mcp::normalize_servers_for(&mut cfg, &app);
|
for server in servers.values() {
|
||||||
(cfg.clone(), normalized)
|
if server.apps.is_enabled_for(&app) {
|
||||||
};
|
Self::sync_server_to_app(state, server, &app)?;
|
||||||
if normalized > 0 {
|
|
||||||
state.save()?;
|
|
||||||
}
|
}
|
||||||
match app {
|
|
||||||
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
|
|
||||||
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 Claude 客户端配置导入 MCP 定义。
|
/// 从 Claude 导入 MCP(v3.7.0 已更新为统一结构)
|
||||||
pub fn import_from_claude(state: &AppState) -> Result<usize, AppError> {
|
pub fn import_from_claude(state: &AppState) -> Result<usize, AppError> {
|
||||||
let mut cfg = state.config.write()?;
|
let mut cfg = state.config.write()?;
|
||||||
let changed = mcp::import_from_claude(&mut cfg)?;
|
let count = mcp::import_from_claude(&mut cfg)?;
|
||||||
drop(cfg);
|
drop(cfg);
|
||||||
if changed > 0 {
|
|
||||||
state.save()?;
|
state.save()?;
|
||||||
}
|
Ok(count)
|
||||||
Ok(changed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 Codex 客户端配置导入 MCP 定义。
|
/// 从 Codex 导入 MCP(v3.7.0 已更新为统一结构)
|
||||||
pub fn import_from_codex(state: &AppState) -> Result<usize, AppError> {
|
pub fn import_from_codex(state: &AppState) -> Result<usize, AppError> {
|
||||||
let mut cfg = state.config.write()?;
|
let mut cfg = state.config.write()?;
|
||||||
let changed = mcp::import_from_codex(&mut cfg)?;
|
let count = mcp::import_from_codex(&mut cfg)?;
|
||||||
drop(cfg);
|
drop(cfg);
|
||||||
if changed > 0 {
|
|
||||||
state.save()?;
|
state.save()?;
|
||||||
|
Ok(count)
|
||||||
}
|
}
|
||||||
Ok(changed)
|
|
||||||
|
/// 从 Gemini 导入 MCP(v3.7.0 已更新为统一结构)
|
||||||
|
pub fn import_from_gemini(state: &AppState) -> Result<usize, AppError> {
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let count = mcp::import_from_gemini(&mut cfg)?;
|
||||||
|
drop(cfg);
|
||||||
|
state.save()?;
|
||||||
|
Ok(count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod env_checker;
|
||||||
|
pub mod env_manager;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
|
pub mod prompt;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
|
pub mod skill;
|
||||||
pub mod speedtest;
|
pub mod speedtest;
|
||||||
|
|
||||||
pub use config::ConfigService;
|
pub use config::ConfigService;
|
||||||
pub use mcp::McpService;
|
pub use mcp::McpService;
|
||||||
|
pub use prompt::PromptService;
|
||||||
pub use provider::{ProviderService, ProviderSortUpdate};
|
pub use provider::{ProviderService, ProviderSortUpdate};
|
||||||
|
pub use skill::{Skill, SkillRepo, SkillService};
|
||||||
pub use speedtest::{EndpointLatency, SpeedtestService};
|
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||||
|
|||||||
203
src-tauri/src/services/prompt.rs
Normal file
203
src-tauri/src/services/prompt.rs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::config::write_text_file;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::prompt::Prompt;
|
||||||
|
use crate::prompt_files::prompt_file_path;
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
pub struct PromptService;
|
||||||
|
|
||||||
|
impl PromptService {
|
||||||
|
pub fn get_prompts(
|
||||||
|
state: &AppState,
|
||||||
|
app: AppType,
|
||||||
|
) -> Result<HashMap<String, Prompt>, AppError> {
|
||||||
|
let cfg = state.config.read()?;
|
||||||
|
let prompts = match app {
|
||||||
|
AppType::Claude => &cfg.prompts.claude.prompts,
|
||||||
|
AppType::Codex => &cfg.prompts.codex.prompts,
|
||||||
|
AppType::Gemini => &cfg.prompts.gemini.prompts,
|
||||||
|
};
|
||||||
|
Ok(prompts.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_prompt(
|
||||||
|
state: &AppState,
|
||||||
|
app: AppType,
|
||||||
|
id: &str,
|
||||||
|
prompt: Prompt,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
// 检查是否为已启用的提示词
|
||||||
|
let is_enabled = prompt.enabled;
|
||||||
|
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let prompts = match app {
|
||||||
|
AppType::Claude => &mut cfg.prompts.claude.prompts,
|
||||||
|
AppType::Codex => &mut cfg.prompts.codex.prompts,
|
||||||
|
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
|
||||||
|
};
|
||||||
|
prompts.insert(id.to_string(), prompt.clone());
|
||||||
|
drop(cfg);
|
||||||
|
state.save()?;
|
||||||
|
|
||||||
|
// 如果是已启用的提示词,同步更新到对应的文件
|
||||||
|
if is_enabled {
|
||||||
|
let target_path = prompt_file_path(&app)?;
|
||||||
|
write_text_file(&target_path, &prompt.content)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let prompts = match app {
|
||||||
|
AppType::Claude => &mut cfg.prompts.claude.prompts,
|
||||||
|
AppType::Codex => &mut cfg.prompts.codex.prompts,
|
||||||
|
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(prompt) = prompts.get(id) {
|
||||||
|
if prompt.enabled {
|
||||||
|
return Err(AppError::InvalidInput("无法删除已启用的提示词".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompts.remove(id);
|
||||||
|
drop(cfg);
|
||||||
|
state.save()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enable_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
|
||||||
|
// 回填当前 live 文件内容到已启用的提示词,或创建备份
|
||||||
|
let target_path = prompt_file_path(&app)?;
|
||||||
|
if target_path.exists() {
|
||||||
|
if let Ok(live_content) = std::fs::read_to_string(&target_path) {
|
||||||
|
if !live_content.trim().is_empty() {
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let prompts = match app {
|
||||||
|
AppType::Claude => &mut cfg.prompts.claude.prompts,
|
||||||
|
AppType::Codex => &mut cfg.prompts.codex.prompts,
|
||||||
|
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 尝试回填到当前已启用的提示词
|
||||||
|
if let Some((enabled_id, enabled_prompt)) = prompts
|
||||||
|
.iter_mut()
|
||||||
|
.find(|(_, p)| p.enabled)
|
||||||
|
.map(|(id, p)| (id.clone(), p))
|
||||||
|
{
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
enabled_prompt.content = live_content.clone();
|
||||||
|
enabled_prompt.updated_at = Some(timestamp);
|
||||||
|
log::info!("回填 live 提示词内容到已启用项: {enabled_id}");
|
||||||
|
drop(cfg); // 释放锁后保存,避免死锁
|
||||||
|
state.save()?; // 第一次保存:回填后立即持久化
|
||||||
|
} else {
|
||||||
|
// 没有已启用的提示词,则创建一次备份(避免重复备份)
|
||||||
|
let content_exists = prompts
|
||||||
|
.values()
|
||||||
|
.any(|p| p.content.trim() == live_content.trim());
|
||||||
|
if !content_exists {
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
let backup_id = format!("backup-{timestamp}");
|
||||||
|
let backup_prompt = Prompt {
|
||||||
|
id: backup_id.clone(),
|
||||||
|
name: format!(
|
||||||
|
"原始提示词 {}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M")
|
||||||
|
),
|
||||||
|
content: live_content,
|
||||||
|
description: Some("自动备份的原始提示词".to_string()),
|
||||||
|
enabled: false,
|
||||||
|
created_at: Some(timestamp),
|
||||||
|
updated_at: Some(timestamp),
|
||||||
|
};
|
||||||
|
prompts.insert(backup_id.clone(), backup_prompt);
|
||||||
|
log::info!("回填 live 提示词内容,创建备份: {backup_id}");
|
||||||
|
drop(cfg); // 释放锁后保存
|
||||||
|
state.save()?; // 第一次保存:回填后立即持久化
|
||||||
|
} else {
|
||||||
|
// 即使内容已存在,也无需重复备份;但不需要保存任何更改
|
||||||
|
drop(cfg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用目标提示词并写入文件
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let prompts = match app {
|
||||||
|
AppType::Claude => &mut cfg.prompts.claude.prompts,
|
||||||
|
AppType::Codex => &mut cfg.prompts.codex.prompts,
|
||||||
|
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
|
||||||
|
};
|
||||||
|
|
||||||
|
for prompt in prompts.values_mut() {
|
||||||
|
prompt.enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(prompt) = prompts.get_mut(id) {
|
||||||
|
prompt.enabled = true;
|
||||||
|
write_text_file(&target_path, &prompt.content)?; // 原子写入
|
||||||
|
} else {
|
||||||
|
return Err(AppError::InvalidInput(format!("提示词 {id} 不存在")));
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(cfg);
|
||||||
|
state.save()?; // 第二次保存:启用目标提示词并写入文件后
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn import_from_file(state: &AppState, app: AppType) -> Result<String, AppError> {
|
||||||
|
let file_path = prompt_file_path(&app)?;
|
||||||
|
|
||||||
|
if !file_path.exists() {
|
||||||
|
return Err(AppError::Message("提示词文件不存在".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let content =
|
||||||
|
std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
let id = format!("imported-{timestamp}");
|
||||||
|
let prompt = Prompt {
|
||||||
|
id: id.clone(),
|
||||||
|
name: format!(
|
||||||
|
"导入的提示词 {}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M")
|
||||||
|
),
|
||||||
|
content,
|
||||||
|
description: Some("从现有配置文件导入".to_string()),
|
||||||
|
enabled: false,
|
||||||
|
created_at: Some(timestamp),
|
||||||
|
updated_at: Some(timestamp),
|
||||||
|
};
|
||||||
|
|
||||||
|
Self::upsert_prompt(state, app, &id, prompt)?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_current_file_content(app: AppType) -> Result<Option<String>, AppError> {
|
||||||
|
let file_path = prompt_file_path(&app)?;
|
||||||
|
if !file_path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let content =
|
||||||
|
std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
|
||||||
|
Ok(Some(content))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ use crate::config::{
|
|||||||
write_json_file, write_text_file,
|
write_json_file, write_text_file,
|
||||||
};
|
};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::mcp;
|
|
||||||
use crate::provider::{Provider, ProviderMeta, UsageData, UsageResult};
|
use crate::provider::{Provider, ProviderMeta, UsageData, UsageResult};
|
||||||
use crate::settings::{self, CustomEndpoint};
|
use crate::settings::{self, CustomEndpoint};
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
@@ -29,6 +28,9 @@ enum LiveSnapshot {
|
|||||||
auth: Option<Value>,
|
auth: Option<Value>,
|
||||||
config: Option<String>,
|
config: Option<String>,
|
||||||
},
|
},
|
||||||
|
Gemini {
|
||||||
|
env: Option<HashMap<String, String>>, // 新增
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -66,6 +68,16 @@ impl LiveSnapshot {
|
|||||||
delete_file(&config_path)?;
|
delete_file(&config_path)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LiveSnapshot::Gemini { env } => {
|
||||||
|
// 新增
|
||||||
|
use crate::gemini_config::{get_gemini_env_path, write_gemini_env_atomic};
|
||||||
|
let path = get_gemini_env_path();
|
||||||
|
if let Some(env_map) = env {
|
||||||
|
write_gemini_env_atomic(env_map)?;
|
||||||
|
} else if path.exists() {
|
||||||
|
delete_file(&path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -111,7 +123,285 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gemini 认证类型枚举
|
||||||
|
///
|
||||||
|
/// 用于优化性能,避免重复检测供应商类型
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum GeminiAuthType {
|
||||||
|
/// PackyCode 供应商(使用 API Key)
|
||||||
|
Packycode,
|
||||||
|
/// Google 官方(使用 OAuth)
|
||||||
|
GoogleOfficial,
|
||||||
|
/// 通用 Gemini 供应商(使用 API Key)
|
||||||
|
Generic,
|
||||||
|
}
|
||||||
|
|
||||||
impl ProviderService {
|
impl ProviderService {
|
||||||
|
// 认证类型常量
|
||||||
|
const PACKYCODE_SECURITY_SELECTED_TYPE: &'static str = "gemini-api-key";
|
||||||
|
const GOOGLE_OAUTH_SECURITY_SELECTED_TYPE: &'static str = "oauth-personal";
|
||||||
|
|
||||||
|
// Partner Promotion Key 常量
|
||||||
|
const PACKYCODE_PARTNER_KEY: &'static str = "packycode";
|
||||||
|
const GOOGLE_OFFICIAL_PARTNER_KEY: &'static str = "google-official";
|
||||||
|
|
||||||
|
// PackyCode 关键词常量
|
||||||
|
const PACKYCODE_KEYWORDS: [&'static str; 3] = ["packycode", "packyapi", "packy"];
|
||||||
|
|
||||||
|
/// 检测 Gemini 供应商的认证类型
|
||||||
|
///
|
||||||
|
/// 一次性检测,避免在多个地方重复调用 `is_packycode_gemini` 和 `is_google_official_gemini`
|
||||||
|
///
|
||||||
|
/// # 返回值
|
||||||
|
///
|
||||||
|
/// - `GeminiAuthType::GoogleOfficial`: Google 官方,使用 OAuth
|
||||||
|
/// - `GeminiAuthType::Packycode`: PackyCode 供应商,使用 API Key
|
||||||
|
/// - `GeminiAuthType::Generic`: 其他通用供应商,使用 API Key
|
||||||
|
fn detect_gemini_auth_type(provider: &Provider) -> GeminiAuthType {
|
||||||
|
// 优先检查 partner_promotion_key(最可靠)
|
||||||
|
if let Some(key) = provider
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.partner_promotion_key.as_deref())
|
||||||
|
{
|
||||||
|
if key.eq_ignore_ascii_case(Self::GOOGLE_OFFICIAL_PARTNER_KEY) {
|
||||||
|
return GeminiAuthType::GoogleOfficial;
|
||||||
|
}
|
||||||
|
if key.eq_ignore_ascii_case(Self::PACKYCODE_PARTNER_KEY) {
|
||||||
|
return GeminiAuthType::Packycode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 Google 官方(名称匹配)
|
||||||
|
let name_lower = provider.name.to_ascii_lowercase();
|
||||||
|
if name_lower == "google" || name_lower.starts_with("google ") {
|
||||||
|
return GeminiAuthType::GoogleOfficial;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 PackyCode 关键词
|
||||||
|
if Self::contains_packycode_keyword(&provider.name) {
|
||||||
|
return GeminiAuthType::Packycode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(site) = provider.website_url.as_deref() {
|
||||||
|
if Self::contains_packycode_keyword(site) {
|
||||||
|
return GeminiAuthType::Packycode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(base_url) = provider
|
||||||
|
.settings_config
|
||||||
|
.pointer("/env/GOOGLE_GEMINI_BASE_URL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
if Self::contains_packycode_keyword(base_url) {
|
||||||
|
return GeminiAuthType::Packycode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GeminiAuthType::Generic
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查字符串是否包含 PackyCode 相关关键词(不区分大小写)
|
||||||
|
///
|
||||||
|
/// 关键词列表:["packycode", "packyapi", "packy"]
|
||||||
|
fn contains_packycode_keyword(value: &str) -> bool {
|
||||||
|
let lower = value.to_ascii_lowercase();
|
||||||
|
Self::PACKYCODE_KEYWORDS
|
||||||
|
.iter()
|
||||||
|
.any(|keyword| lower.contains(keyword))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检测供应商是否为 PackyCode Gemini(使用 API Key 认证)
|
||||||
|
///
|
||||||
|
/// PackyCode 是官方合作伙伴,需要特殊的安全配置。
|
||||||
|
///
|
||||||
|
/// # 检测规则(优先级从高到低)
|
||||||
|
///
|
||||||
|
/// 1. **Partner Promotion Key**(最可靠):
|
||||||
|
/// - `provider.meta.partner_promotion_key == "packycode"`
|
||||||
|
///
|
||||||
|
/// 2. **供应商名称**:
|
||||||
|
/// - 名称包含 "packycode"、"packyapi" 或 "packy"(不区分大小写)
|
||||||
|
///
|
||||||
|
/// 3. **网站 URL**:
|
||||||
|
/// - `provider.website_url` 包含关键词
|
||||||
|
///
|
||||||
|
/// 4. **Base URL**:
|
||||||
|
/// - `settings_config.env.GOOGLE_GEMINI_BASE_URL` 包含关键词
|
||||||
|
///
|
||||||
|
/// # 为什么需要多重检测
|
||||||
|
///
|
||||||
|
/// - 用户可能手动创建供应商,没有 `partner_promotion_key`
|
||||||
|
/// - 从预设复制后可能修改了 meta 字段
|
||||||
|
/// - 确保所有 PackyCode 供应商都能正确设置安全标志
|
||||||
|
fn is_packycode_gemini(provider: &Provider) -> bool {
|
||||||
|
// 策略 1: 检查 partner_promotion_key(最可靠)
|
||||||
|
if provider
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.partner_promotion_key.as_deref())
|
||||||
|
.is_some_and(|key| key.eq_ignore_ascii_case(Self::PACKYCODE_PARTNER_KEY))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 策略 2: 检查供应商名称
|
||||||
|
if Self::contains_packycode_keyword(&provider.name) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 策略 3: 检查网站 URL
|
||||||
|
if let Some(site) = provider.website_url.as_deref() {
|
||||||
|
if Self::contains_packycode_keyword(site) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 策略 4: 检查 Base URL
|
||||||
|
if let Some(base_url) = provider
|
||||||
|
.settings_config
|
||||||
|
.pointer("/env/GOOGLE_GEMINI_BASE_URL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
if Self::contains_packycode_keyword(base_url) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检测供应商是否为 Google 官方 Gemini(使用 OAuth 认证)
|
||||||
|
///
|
||||||
|
/// Google 官方 Gemini 使用 OAuth 个人认证,不需要 API Key。
|
||||||
|
///
|
||||||
|
/// # 检测规则(优先级从高到低)
|
||||||
|
///
|
||||||
|
/// 1. **Partner Promotion Key**(最可靠):
|
||||||
|
/// - `provider.meta.partner_promotion_key == "google-official"`
|
||||||
|
///
|
||||||
|
/// 2. **供应商名称**:
|
||||||
|
/// - 名称完全等于 "google"(不区分大小写)
|
||||||
|
/// - 或名称以 "google " 开头(例如 "Google Official")
|
||||||
|
///
|
||||||
|
/// # OAuth vs API Key
|
||||||
|
///
|
||||||
|
/// - **OAuth 模式**: `security.auth.selectedType = "oauth-personal"`
|
||||||
|
/// - 用户需要通过浏览器登录 Google 账号
|
||||||
|
/// - 不需要在 `.env` 文件中配置 API Key
|
||||||
|
///
|
||||||
|
/// - **API Key 模式**: `security.auth.selectedType = "gemini-api-key"`
|
||||||
|
/// - 用于第三方中转服务(如 PackyCode)
|
||||||
|
/// - 需要在 `.env` 文件中配置 `GEMINI_API_KEY`
|
||||||
|
fn is_google_official_gemini(provider: &Provider) -> bool {
|
||||||
|
// 策略 1: 检查 partner_promotion_key(最可靠)
|
||||||
|
if provider
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.partner_promotion_key.as_deref())
|
||||||
|
.is_some_and(|key| key.eq_ignore_ascii_case(Self::GOOGLE_OFFICIAL_PARTNER_KEY))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 策略 2: 检查名称匹配(备用方案)
|
||||||
|
let name_lower = provider.name.to_ascii_lowercase();
|
||||||
|
name_lower == "google" || name_lower.starts_with("google ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 确保 PackyCode Gemini 供应商的安全标志正确设置
|
||||||
|
///
|
||||||
|
/// PackyCode 是官方合作伙伴,使用 API Key 认证模式。
|
||||||
|
///
|
||||||
|
/// # 写入两处 settings.json 的原因
|
||||||
|
///
|
||||||
|
/// 1. **`~/.cc-switch/settings.json`** (应用级配置):
|
||||||
|
/// - CC-Switch 应用的全局设置
|
||||||
|
/// - 确保应用知道当前使用的认证类型
|
||||||
|
/// - 用于 UI 显示和其他应用逻辑
|
||||||
|
///
|
||||||
|
/// 2. **`~/.gemini/settings.json`** (Gemini 客户端配置):
|
||||||
|
/// - Gemini CLI 客户端读取的配置文件
|
||||||
|
/// - 直接影响 Gemini 客户端的认证行为
|
||||||
|
/// - 确保 Gemini 使用正确的认证方式连接 API
|
||||||
|
///
|
||||||
|
/// # 设置的值
|
||||||
|
///
|
||||||
|
/// ```json
|
||||||
|
/// {
|
||||||
|
/// "security": {
|
||||||
|
/// "auth": {
|
||||||
|
/// "selectedType": "gemini-api-key"
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # 错误处理
|
||||||
|
///
|
||||||
|
/// 如果供应商不是 PackyCode,函数立即返回 `Ok(())`,不做任何操作。
|
||||||
|
pub(crate) fn ensure_packycode_security_flag(provider: &Provider) -> Result<(), AppError> {
|
||||||
|
if !Self::is_packycode_gemini(provider) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入应用级别的 settings.json (~/.cc-switch/settings.json)
|
||||||
|
settings::ensure_security_auth_selected_type(Self::PACKYCODE_SECURITY_SELECTED_TYPE)?;
|
||||||
|
|
||||||
|
// 写入 Gemini 目录的 settings.json (~/.gemini/settings.json)
|
||||||
|
use crate::gemini_config::write_packycode_settings;
|
||||||
|
write_packycode_settings()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 确保 Google 官方 Gemini 供应商的安全标志正确设置(OAuth 模式)
|
||||||
|
///
|
||||||
|
/// Google 官方 Gemini 使用 OAuth 个人认证,不需要 API Key。
|
||||||
|
///
|
||||||
|
/// # 写入两处 settings.json 的原因
|
||||||
|
///
|
||||||
|
/// 同 `ensure_packycode_security_flag`,需要同时配置应用级和客户端级设置。
|
||||||
|
///
|
||||||
|
/// # 设置的值
|
||||||
|
///
|
||||||
|
/// ```json
|
||||||
|
/// {
|
||||||
|
/// "security": {
|
||||||
|
/// "auth": {
|
||||||
|
/// "selectedType": "oauth-personal"
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # OAuth 认证流程
|
||||||
|
///
|
||||||
|
/// 1. 用户切换到 Google 官方供应商
|
||||||
|
/// 2. CC-Switch 设置 `selectedType = "oauth-personal"`
|
||||||
|
/// 3. 用户首次使用 Gemini CLI 时,会自动打开浏览器进行 OAuth 登录
|
||||||
|
/// 4. 登录成功后,凭证保存在 Gemini 的 credential store 中
|
||||||
|
/// 5. 后续请求自动使用保存的凭证
|
||||||
|
///
|
||||||
|
/// # 错误处理
|
||||||
|
///
|
||||||
|
/// 如果供应商不是 Google 官方,函数立即返回 `Ok(())`,不做任何操作。
|
||||||
|
pub(crate) fn ensure_google_oauth_security_flag(provider: &Provider) -> Result<(), AppError> {
|
||||||
|
if !Self::is_google_official_gemini(provider) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入应用级别的 settings.json (~/.cc-switch/settings.json)
|
||||||
|
settings::ensure_security_auth_selected_type(Self::GOOGLE_OAUTH_SECURITY_SELECTED_TYPE)?;
|
||||||
|
|
||||||
|
// 写入 Gemini 目录的 settings.json (~/.gemini/settings.json)
|
||||||
|
use crate::gemini_config::write_google_oauth_settings;
|
||||||
|
write_google_oauth_settings()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 归一化 Claude 模型键:读旧键(ANTHROPIC_SMALL_FAST_MODEL),写新键(DEFAULT_*), 并删除旧键
|
/// 归一化 Claude 模型键:读旧键(ANTHROPIC_SMALL_FAST_MODEL),写新键(DEFAULT_*), 并删除旧键
|
||||||
fn normalize_claude_models_in_value(settings: &mut Value) -> bool {
|
fn normalize_claude_models_in_value(settings: &mut Value) -> bool {
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
@@ -211,11 +501,8 @@ impl ProviderService {
|
|||||||
if let Err(rollback_err) = Self::restore_config_only(state, original.clone()) {
|
if let Err(rollback_err) = Self::restore_config_only(state, original.clone()) {
|
||||||
return Err(AppError::localized(
|
return Err(AppError::localized(
|
||||||
"config.save.rollback_failed",
|
"config.save.rollback_failed",
|
||||||
format!("保存配置失败: {};回滚失败: {}", save_err, rollback_err),
|
format!("保存配置失败: {save_err};回滚失败: {rollback_err}"),
|
||||||
format!(
|
format!("Failed to save config: {save_err}; rollback failed: {rollback_err}"),
|
||||||
"Failed to save config: {}; rollback failed: {}",
|
|
||||||
save_err, rollback_err
|
|
||||||
),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return Err(save_err);
|
return Err(save_err);
|
||||||
@@ -228,11 +515,8 @@ impl ProviderService {
|
|||||||
{
|
{
|
||||||
return Err(AppError::localized(
|
return Err(AppError::localized(
|
||||||
"post_commit.rollback_failed",
|
"post_commit.rollback_failed",
|
||||||
format!("后置操作失败: {};回滚失败: {}", err, rollback_err),
|
format!("后置操作失败: {err};回滚失败: {rollback_err}"),
|
||||||
format!(
|
format!("Post-commit step failed: {err}; rollback failed: {rollback_err}"),
|
||||||
"Post-commit step failed: {}; rollback failed: {}",
|
|
||||||
err, rollback_err
|
|
||||||
),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return Err(err);
|
return Err(err);
|
||||||
@@ -262,11 +546,9 @@ impl ProviderService {
|
|||||||
fn apply_post_commit(state: &AppState, action: &PostCommitAction) -> Result<(), AppError> {
|
fn apply_post_commit(state: &AppState, action: &PostCommitAction) -> Result<(), AppError> {
|
||||||
Self::write_live_snapshot(&action.app_type, &action.provider)?;
|
Self::write_live_snapshot(&action.app_type, &action.provider)?;
|
||||||
if action.sync_mcp {
|
if action.sync_mcp {
|
||||||
let config_clone = {
|
// 使用 v3.7.0 统一的 MCP 同步机制,支持所有应用
|
||||||
let guard = state.config.read().map_err(AppError::from)?;
|
use crate::services::mcp::McpService;
|
||||||
guard.clone()
|
McpService::sync_all_enabled(state)?;
|
||||||
};
|
|
||||||
mcp::sync_enabled_to_codex(&config_clone)?;
|
|
||||||
}
|
}
|
||||||
if action.refresh_snapshot {
|
if action.refresh_snapshot {
|
||||||
Self::refresh_provider_snapshot(state, &action.app_type, &action.provider.id)?;
|
Self::refresh_provider_snapshot(state, &action.app_type, &action.provider.id)?;
|
||||||
@@ -319,8 +601,7 @@ impl ProviderService {
|
|||||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||||
let obj = target.settings_config.as_object_mut().ok_or_else(|| {
|
let obj = target.settings_config.as_object_mut().ok_or_else(|| {
|
||||||
AppError::Config(format!(
|
AppError::Config(format!(
|
||||||
"供应商 {} 的 Codex 配置必须是 JSON 对象",
|
"供应商 {provider_id} 的 Codex 配置必须是 JSON 对象"
|
||||||
provider_id
|
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
obj.insert("auth".to_string(), auth.clone());
|
obj.insert("auth".to_string(), auth.clone());
|
||||||
@@ -330,6 +611,30 @@ impl ProviderService {
|
|||||||
}
|
}
|
||||||
state.save()?;
|
state.save()?;
|
||||||
}
|
}
|
||||||
|
AppType::Gemini => {
|
||||||
|
use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
|
||||||
|
|
||||||
|
let env_path = get_gemini_env_path();
|
||||||
|
if !env_path.exists() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.live.missing",
|
||||||
|
"Gemini .env 文件不存在,无法刷新快照",
|
||||||
|
"Gemini .env file missing; cannot refresh snapshot",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let env_map = read_gemini_env()?;
|
||||||
|
let live_after = env_to_json(&env_map);
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut guard = state.config.write().map_err(AppError::from)?;
|
||||||
|
if let Some(manager) = guard.get_manager_mut(app_type) {
|
||||||
|
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||||
|
target.settings_config = live_after;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.save()?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -363,6 +668,17 @@ impl ProviderService {
|
|||||||
};
|
};
|
||||||
Ok(LiveSnapshot::Codex { auth, config })
|
Ok(LiveSnapshot::Codex { auth, config })
|
||||||
}
|
}
|
||||||
|
AppType::Gemini => {
|
||||||
|
// 新增
|
||||||
|
use crate::gemini_config::{get_gemini_env_path, read_gemini_env};
|
||||||
|
let path = get_gemini_env_path();
|
||||||
|
let env = if path.exists() {
|
||||||
|
Some(read_gemini_env()?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(LiveSnapshot::Gemini { env })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,8 +763,8 @@ impl ProviderService {
|
|||||||
if !manager.providers.contains_key(&provider_id) {
|
if !manager.providers.contains_key(&provider_id) {
|
||||||
return Err(AppError::localized(
|
return Err(AppError::localized(
|
||||||
"provider.not_found",
|
"provider.not_found",
|
||||||
format!("供应商不存在: {}", provider_id),
|
format!("供应商不存在: {provider_id}"),
|
||||||
format!("Provider not found: {}", provider_id),
|
format!("Provider not found: {provider_id}"),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,6 +846,21 @@ impl ProviderService {
|
|||||||
let _ = Self::normalize_claude_models_in_value(&mut v);
|
let _ = Self::normalize_claude_models_in_value(&mut v);
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
AppType::Gemini => {
|
||||||
|
// 新增
|
||||||
|
use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
|
||||||
|
|
||||||
|
let path = get_gemini_env_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.live.missing",
|
||||||
|
"Gemini 配置文件不存在",
|
||||||
|
"Gemini configuration file is missing",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let env_map = read_gemini_env()?;
|
||||||
|
env_to_json(&env_map)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut provider = Provider::with_id(
|
let mut provider = Provider::with_id(
|
||||||
@@ -582,6 +913,22 @@ impl ProviderService {
|
|||||||
}
|
}
|
||||||
read_json_file(&path)
|
read_json_file(&path)
|
||||||
}
|
}
|
||||||
|
AppType::Gemini => {
|
||||||
|
// 新增
|
||||||
|
use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
|
||||||
|
|
||||||
|
let path = get_gemini_env_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.env.missing",
|
||||||
|
"Gemini .env 文件不存在",
|
||||||
|
"Gemini .env file not found",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let env_map = read_gemini_env()?;
|
||||||
|
Ok(env_to_json(&env_map))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,8 +982,8 @@ impl ProviderService {
|
|||||||
let provider = manager.providers.get_mut(provider_id).ok_or_else(|| {
|
let provider = manager.providers.get_mut(provider_id).ok_or_else(|| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"provider.not_found",
|
"provider.not_found",
|
||||||
format!("供应商不存在: {}", provider_id),
|
format!("供应商不存在: {provider_id}"),
|
||||||
format!("Provider not found: {}", provider_id),
|
format!("Provider not found: {provider_id}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
|
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
|
||||||
@@ -750,16 +1097,16 @@ impl ProviderService {
|
|||||||
serde_json::from_value(data).map_err(|e| {
|
serde_json::from_value(data).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.data_format_error",
|
"usage_script.data_format_error",
|
||||||
format!("数据格式错误: {}", e),
|
format!("数据格式错误: {e}"),
|
||||||
format!("Data format error: {}", e),
|
format!("Data format error: {e}"),
|
||||||
)
|
)
|
||||||
})?
|
})?
|
||||||
} else {
|
} else {
|
||||||
let single: UsageData = serde_json::from_value(data).map_err(|e| {
|
let single: UsageData = serde_json::from_value(data).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.data_format_error",
|
"usage_script.data_format_error",
|
||||||
format!("数据格式错误: {}", e),
|
format!("数据格式错误: {e}"),
|
||||||
format!("Data format error: {}", e),
|
format!("Data format error: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
vec![single]
|
vec![single]
|
||||||
@@ -810,8 +1157,8 @@ impl ProviderService {
|
|||||||
let provider = manager.providers.get(provider_id).cloned().ok_or_else(|| {
|
let provider = manager.providers.get(provider_id).cloned().ok_or_else(|| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"provider.not_found",
|
"provider.not_found",
|
||||||
format!("供应商不存在: {}", provider_id),
|
format!("供应商不存在: {provider_id}"),
|
||||||
format!("Provider not found: {}", provider_id),
|
format!("Provider not found: {provider_id}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -891,13 +1238,14 @@ impl ProviderService {
|
|||||||
let provider = match app_type_clone {
|
let provider = match app_type_clone {
|
||||||
AppType::Codex => Self::prepare_switch_codex(config, &provider_id_owned)?,
|
AppType::Codex => Self::prepare_switch_codex(config, &provider_id_owned)?,
|
||||||
AppType::Claude => Self::prepare_switch_claude(config, &provider_id_owned)?,
|
AppType::Claude => Self::prepare_switch_claude(config, &provider_id_owned)?,
|
||||||
|
AppType::Gemini => Self::prepare_switch_gemini(config, &provider_id_owned)?,
|
||||||
};
|
};
|
||||||
|
|
||||||
let action = PostCommitAction {
|
let action = PostCommitAction {
|
||||||
app_type: app_type_clone.clone(),
|
app_type: app_type_clone.clone(),
|
||||||
provider,
|
provider,
|
||||||
backup,
|
backup,
|
||||||
sync_mcp: matches!(app_type_clone, AppType::Codex),
|
sync_mcp: true, // v3.7.0: 所有应用切换时都同步 MCP,防止配置丢失
|
||||||
refresh_snapshot: true,
|
refresh_snapshot: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -918,8 +1266,8 @@ impl ProviderService {
|
|||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"provider.not_found",
|
"provider.not_found",
|
||||||
format!("供应商不存在: {}", provider_id),
|
format!("供应商不存在: {provider_id}"),
|
||||||
format!("Provider not found: {}", provider_id),
|
format!("Provider not found: {provider_id}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -1005,8 +1353,8 @@ impl ProviderService {
|
|||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"provider.not_found",
|
"provider.not_found",
|
||||||
format!("供应商不存在: {}", provider_id),
|
format!("供应商不存在: {provider_id}"),
|
||||||
format!("Provider not found: {}", provider_id),
|
format!("Provider not found: {provider_id}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -1019,6 +1367,33 @@ impl ProviderService {
|
|||||||
Ok(provider)
|
Ok(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn prepare_switch_gemini(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
provider_id: &str,
|
||||||
|
) -> Result<Provider, AppError> {
|
||||||
|
let provider = config
|
||||||
|
.get_manager(&AppType::Gemini)
|
||||||
|
.ok_or_else(|| Self::app_not_found(&AppType::Gemini))?
|
||||||
|
.providers
|
||||||
|
.get(provider_id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::localized(
|
||||||
|
"provider.not_found",
|
||||||
|
format!("供应商不存在: {provider_id}"),
|
||||||
|
format!("Provider not found: {provider_id}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Self::backfill_gemini_current(config, provider_id)?;
|
||||||
|
|
||||||
|
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
|
||||||
|
manager.current = provider_id.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(provider)
|
||||||
|
}
|
||||||
|
|
||||||
fn backfill_claude_current(
|
fn backfill_claude_current(
|
||||||
config: &mut MultiAppConfig,
|
config: &mut MultiAppConfig,
|
||||||
next_provider: &str,
|
next_provider: &str,
|
||||||
@@ -1047,23 +1422,82 @@ impl ProviderService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_claude_live(provider: &Provider) -> Result<(), AppError> {
|
fn backfill_gemini_current(
|
||||||
let settings_path = get_claude_settings_path();
|
config: &mut MultiAppConfig,
|
||||||
if let Some(parent) = settings_path.parent() {
|
next_provider: &str,
|
||||||
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
) -> Result<(), AppError> {
|
||||||
|
use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
|
||||||
|
|
||||||
|
let env_path = get_gemini_env_path();
|
||||||
|
if !env_path.exists() {
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 归一化后再写入
|
let current_id = config
|
||||||
|
.get_manager(&AppType::Gemini)
|
||||||
|
.map(|m| m.current.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if current_id.is_empty() || current_id == next_provider {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let env_map = read_gemini_env()?;
|
||||||
|
let live = env_to_json(&env_map);
|
||||||
|
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
|
||||||
|
if let Some(current) = manager.providers.get_mut(¤t_id) {
|
||||||
|
current.settings_config = live;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_claude_live(provider: &Provider) -> Result<(), AppError> {
|
||||||
|
let settings_path = get_claude_settings_path();
|
||||||
let mut content = provider.settings_config.clone();
|
let mut content = provider.settings_config.clone();
|
||||||
let _ = Self::normalize_claude_models_in_value(&mut content);
|
let _ = Self::normalize_claude_models_in_value(&mut content);
|
||||||
write_json_file(&settings_path, &content)?;
|
write_json_file(&settings_path, &content)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||||||
|
use crate::gemini_config::{
|
||||||
|
json_to_env, validate_gemini_settings_strict, write_gemini_env_atomic,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 一次性检测认证类型,避免重复检测
|
||||||
|
let auth_type = Self::detect_gemini_auth_type(provider);
|
||||||
|
|
||||||
|
match auth_type {
|
||||||
|
GeminiAuthType::GoogleOfficial => {
|
||||||
|
// Google 官方使用 OAuth,清空 env
|
||||||
|
let empty_env = std::collections::HashMap::new();
|
||||||
|
write_gemini_env_atomic(&empty_env)?;
|
||||||
|
Self::ensure_google_oauth_security_flag(provider)?;
|
||||||
|
}
|
||||||
|
GeminiAuthType::Packycode => {
|
||||||
|
// PackyCode 供应商,使用 API Key(切换时严格验证)
|
||||||
|
validate_gemini_settings_strict(&provider.settings_config)?;
|
||||||
|
let env_map = json_to_env(&provider.settings_config)?;
|
||||||
|
write_gemini_env_atomic(&env_map)?;
|
||||||
|
Self::ensure_packycode_security_flag(provider)?;
|
||||||
|
}
|
||||||
|
GeminiAuthType::Generic => {
|
||||||
|
// 通用供应商,使用 API Key(切换时严格验证)
|
||||||
|
validate_gemini_settings_strict(&provider.settings_config)?;
|
||||||
|
let env_map = json_to_env(&provider.settings_config)?;
|
||||||
|
write_gemini_env_atomic(&env_map)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
||||||
match app_type {
|
match app_type {
|
||||||
AppType::Codex => Self::write_codex_live(provider),
|
AppType::Codex => Self::write_codex_live(provider),
|
||||||
AppType::Claude => Self::write_claude_live(provider),
|
AppType::Claude => Self::write_claude_live(provider),
|
||||||
|
AppType::Gemini => Self::write_gemini_live(provider), // 新增
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1118,6 +1552,38 @@ impl ProviderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppType::Gemini => {
|
||||||
|
// 新增
|
||||||
|
use crate::gemini_config::validate_gemini_settings;
|
||||||
|
validate_gemini_settings(&provider.settings_config)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 验证并清理 UsageScript 配置(所有应用类型通用)
|
||||||
|
if let Some(meta) = &provider.meta {
|
||||||
|
if let Some(usage_script) = &meta.usage_script {
|
||||||
|
Self::validate_usage_script(usage_script)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证 UsageScript 配置(边界检查)
|
||||||
|
fn validate_usage_script(script: &crate::provider::UsageScript) -> Result<(), AppError> {
|
||||||
|
// 验证自动查询间隔 (0-1440 分钟,即最大24小时)
|
||||||
|
if let Some(interval) = script.auto_query_interval {
|
||||||
|
if interval > 1440 {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.interval_too_large",
|
||||||
|
format!(
|
||||||
|
"自动查询间隔不能超过 1440 分钟(24小时),当前值: {interval}"
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"Auto query interval cannot exceed 1440 minutes (24 hours), current: {interval}"
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1204,8 +1670,8 @@ impl ProviderService {
|
|||||||
let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).map_err(|e| {
|
let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"provider.regex_init_failed",
|
"provider.regex_init_failed",
|
||||||
format!("正则初始化失败: {}", e),
|
format!("正则初始化失败: {e}"),
|
||||||
format!("Failed to initialize regex: {}", e),
|
format!("Failed to initialize regex: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
re.captures(config_toml)
|
re.captures(config_toml)
|
||||||
@@ -1226,6 +1692,27 @@ impl ProviderService {
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Ok((api_key, base_url))
|
||||||
|
}
|
||||||
|
AppType::Gemini => {
|
||||||
|
// 新增
|
||||||
|
use crate::gemini_config::json_to_env;
|
||||||
|
|
||||||
|
let env_map = json_to_env(&provider.settings_config)?;
|
||||||
|
|
||||||
|
let api_key = env_map.get("GEMINI_API_KEY").cloned().ok_or_else(|| {
|
||||||
|
AppError::localized(
|
||||||
|
"gemini.missing_api_key",
|
||||||
|
"缺少 GEMINI_API_KEY",
|
||||||
|
"Missing GEMINI_API_KEY",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let base_url = env_map
|
||||||
|
.get("GOOGLE_GEMINI_BASE_URL")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_string());
|
||||||
|
|
||||||
Ok((api_key, base_url))
|
Ok((api_key, base_url))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1234,8 +1721,8 @@ impl ProviderService {
|
|||||||
fn app_not_found(app_type: &AppType) -> AppError {
|
fn app_not_found(app_type: &AppType) -> AppError {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"provider.app_not_found",
|
"provider.app_not_found",
|
||||||
format!("应用类型不存在: {:?}", app_type),
|
format!("应用类型不存在: {app_type:?}"),
|
||||||
format!("App type not found: {:?}", app_type),
|
format!("App type not found: {app_type:?}"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1264,8 +1751,8 @@ impl ProviderService {
|
|||||||
manager.providers.get(provider_id).cloned().ok_or_else(|| {
|
manager.providers.get(provider_id).cloned().ok_or_else(|| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"provider.not_found",
|
"provider.not_found",
|
||||||
format!("供应商不存在: {}", provider_id),
|
format!("供应商不存在: {provider_id}"),
|
||||||
format!("Provider not found: {}", provider_id),
|
format!("Provider not found: {provider_id}"),
|
||||||
)
|
)
|
||||||
})?
|
})?
|
||||||
};
|
};
|
||||||
@@ -1285,6 +1772,9 @@ impl ProviderService {
|
|||||||
delete_file(&by_name)?;
|
delete_file(&by_name)?;
|
||||||
delete_file(&by_id)?;
|
delete_file(&by_id)?;
|
||||||
}
|
}
|
||||||
|
AppType::Gemini => {
|
||||||
|
// Gemini 使用单一的 .env 文件,不需要删除单独的供应商配置文件
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
526
src-tauri/src/services/skill.rs
Normal file
526
src-tauri/src/services/skill.rs
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
/// 技能对象
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Skill {
|
||||||
|
/// 唯一标识: "owner/name:directory" 或 "local:directory"
|
||||||
|
pub key: String,
|
||||||
|
/// 显示名称 (从 SKILL.md 解析)
|
||||||
|
pub name: String,
|
||||||
|
/// 技能描述
|
||||||
|
pub description: String,
|
||||||
|
/// 目录名称 (安装路径的最后一段)
|
||||||
|
pub directory: String,
|
||||||
|
/// GitHub README URL
|
||||||
|
#[serde(rename = "readmeUrl")]
|
||||||
|
pub readme_url: Option<String>,
|
||||||
|
/// 是否已安装
|
||||||
|
pub installed: bool,
|
||||||
|
/// 仓库所有者
|
||||||
|
#[serde(rename = "repoOwner")]
|
||||||
|
pub repo_owner: Option<String>,
|
||||||
|
/// 仓库名称
|
||||||
|
#[serde(rename = "repoName")]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
/// 分支名称
|
||||||
|
#[serde(rename = "repoBranch")]
|
||||||
|
pub repo_branch: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仓库配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SkillRepo {
|
||||||
|
/// GitHub 用户/组织名
|
||||||
|
pub owner: String,
|
||||||
|
/// 仓库名称
|
||||||
|
pub name: String,
|
||||||
|
/// 分支 (默认 "main")
|
||||||
|
pub branch: String,
|
||||||
|
/// 是否启用
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 技能所在的子目录路径 (可选, 如 "skills", "my-skills/subdir")
|
||||||
|
#[serde(rename = "skillsPath")]
|
||||||
|
pub skills_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 技能安装状态
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SkillState {
|
||||||
|
/// 是否已安装
|
||||||
|
pub installed: bool,
|
||||||
|
/// 安装时间
|
||||||
|
#[serde(rename = "installedAt")]
|
||||||
|
pub installed_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 持久化存储结构
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SkillStore {
|
||||||
|
/// directory -> 安装状态
|
||||||
|
pub skills: HashMap<String, SkillState>,
|
||||||
|
/// 仓库列表
|
||||||
|
pub repos: Vec<SkillRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SkillStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
SkillStore {
|
||||||
|
skills: HashMap::new(),
|
||||||
|
repos: vec![
|
||||||
|
SkillRepo {
|
||||||
|
owner: "ComposioHQ".to_string(),
|
||||||
|
name: "awesome-claude-skills".to_string(),
|
||||||
|
branch: "main".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
skills_path: None, // 扫描根目录
|
||||||
|
},
|
||||||
|
SkillRepo {
|
||||||
|
owner: "anthropics".to_string(),
|
||||||
|
name: "skills".to_string(),
|
||||||
|
branch: "main".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
skills_path: None, // 扫描根目录
|
||||||
|
},
|
||||||
|
SkillRepo {
|
||||||
|
owner: "cexll".to_string(),
|
||||||
|
name: "myclaude".to_string(),
|
||||||
|
branch: "master".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
skills_path: Some("skills".to_string()), // 扫描 skills 子目录
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 技能元数据 (从 SKILL.md 解析)
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct SkillMetadata {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SkillService {
|
||||||
|
http_client: Client,
|
||||||
|
install_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkillService {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let install_dir = Self::get_install_dir()?;
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
fs::create_dir_all(&install_dir)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
http_client: Client::builder()
|
||||||
|
.user_agent("cc-switch")
|
||||||
|
// 将单次请求超时时间控制在 10 秒以内,避免无效链接导致长时间卡住
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()?,
|
||||||
|
install_dir,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_install_dir() -> Result<PathBuf> {
|
||||||
|
let home = dirs::home_dir().context("无法获取用户主目录")?;
|
||||||
|
Ok(home.join(".claude").join("skills"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 核心方法实现
|
||||||
|
impl SkillService {
|
||||||
|
/// 列出所有技能
|
||||||
|
pub async fn list_skills(&self, repos: Vec<SkillRepo>) -> Result<Vec<Skill>> {
|
||||||
|
let mut skills = Vec::new();
|
||||||
|
|
||||||
|
// 仅使用启用的仓库,并行获取技能列表,避免单个无效仓库拖慢整体刷新
|
||||||
|
let enabled_repos: Vec<SkillRepo> = repos.into_iter().filter(|repo| repo.enabled).collect();
|
||||||
|
|
||||||
|
let fetch_tasks = enabled_repos
|
||||||
|
.iter()
|
||||||
|
.map(|repo| self.fetch_repo_skills(repo));
|
||||||
|
|
||||||
|
let results: Vec<Result<Vec<Skill>>> = futures::future::join_all(fetch_tasks).await;
|
||||||
|
|
||||||
|
for (repo, result) in enabled_repos.into_iter().zip(results.into_iter()) {
|
||||||
|
match result {
|
||||||
|
Ok(repo_skills) => skills.extend(repo_skills),
|
||||||
|
Err(e) => log::warn!("获取仓库 {}/{} 技能失败: {}", repo.owner, repo.name, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并本地技能
|
||||||
|
self.merge_local_skills(&mut skills)?;
|
||||||
|
|
||||||
|
// 去重并排序
|
||||||
|
Self::deduplicate_skills(&mut skills);
|
||||||
|
skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||||
|
|
||||||
|
Ok(skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从仓库获取技能列表
|
||||||
|
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<Skill>> {
|
||||||
|
// 为单个仓库加载增加整体超时,避免无效链接长时间阻塞
|
||||||
|
let temp_dir = timeout(std::time::Duration::from_secs(15), self.download_repo(repo))
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??;
|
||||||
|
let mut skills = Vec::new();
|
||||||
|
|
||||||
|
// 确定要扫描的目录路径
|
||||||
|
let scan_dir = if let Some(ref skills_path) = repo.skills_path {
|
||||||
|
// 如果指定了 skillsPath,则扫描该子目录
|
||||||
|
let subdir = temp_dir.join(skills_path.trim_matches('/'));
|
||||||
|
if !subdir.exists() {
|
||||||
|
log::warn!(
|
||||||
|
"仓库 {}/{} 中指定的技能路径 '{}' 不存在",
|
||||||
|
repo.owner,
|
||||||
|
repo.name,
|
||||||
|
skills_path
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
return Ok(skills);
|
||||||
|
}
|
||||||
|
subdir
|
||||||
|
} else {
|
||||||
|
// 否则扫描仓库根目录
|
||||||
|
temp_dir.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 遍历目标目录
|
||||||
|
for entry in fs::read_dir(&scan_dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let skill_md = path.join("SKILL.md");
|
||||||
|
if !skill_md.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析技能元数据
|
||||||
|
match self.parse_skill_metadata(&skill_md) {
|
||||||
|
Ok(meta) => {
|
||||||
|
let directory = path.file_name().unwrap().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// 构建 README URL(考虑 skillsPath)
|
||||||
|
let readme_path = if let Some(ref skills_path) = repo.skills_path {
|
||||||
|
format!("{}/{}", skills_path.trim_matches('/'), directory)
|
||||||
|
} else {
|
||||||
|
directory.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
skills.push(Skill {
|
||||||
|
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
|
||||||
|
name: meta.name.unwrap_or_else(|| directory.clone()),
|
||||||
|
description: meta.description.unwrap_or_default(),
|
||||||
|
directory,
|
||||||
|
readme_url: Some(format!(
|
||||||
|
"https://github.com/{}/{}/tree/{}/{}",
|
||||||
|
repo.owner, repo.name, repo.branch, readme_path
|
||||||
|
)),
|
||||||
|
installed: false,
|
||||||
|
repo_owner: Some(repo.owner.clone()),
|
||||||
|
repo_name: Some(repo.name.clone()),
|
||||||
|
repo_branch: Some(repo.branch.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => log::warn!("解析 {} 元数据失败: {}", skill_md.display(), e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理临时目录
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
|
||||||
|
Ok(skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析技能元数据
|
||||||
|
fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> {
|
||||||
|
let content = fs::read_to_string(path)?;
|
||||||
|
|
||||||
|
// 移除 BOM
|
||||||
|
let content = content.trim_start_matches('\u{feff}');
|
||||||
|
|
||||||
|
// 提取 YAML front matter
|
||||||
|
let parts: Vec<&str> = content.splitn(3, "---").collect();
|
||||||
|
if parts.len() < 3 {
|
||||||
|
return Ok(SkillMetadata {
|
||||||
|
name: None,
|
||||||
|
description: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let front_matter = parts[1].trim();
|
||||||
|
let meta: SkillMetadata = serde_yaml::from_str(front_matter).unwrap_or(SkillMetadata {
|
||||||
|
name: None,
|
||||||
|
description: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合并本地技能
|
||||||
|
fn merge_local_skills(&self, skills: &mut Vec<Skill>) -> Result<()> {
|
||||||
|
if !self.install_dir.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in fs::read_dir(&self.install_dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let directory = path.file_name().unwrap().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// 更新已安装状态
|
||||||
|
let mut found = false;
|
||||||
|
for skill in skills.iter_mut() {
|
||||||
|
if skill.directory.eq_ignore_ascii_case(&directory) {
|
||||||
|
skill.installed = true;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加本地独有的技能(仅当在仓库中未找到时)
|
||||||
|
if !found {
|
||||||
|
let skill_md = path.join("SKILL.md");
|
||||||
|
if skill_md.exists() {
|
||||||
|
if let Ok(meta) = self.parse_skill_metadata(&skill_md) {
|
||||||
|
skills.push(Skill {
|
||||||
|
key: format!("local:{directory}"),
|
||||||
|
name: meta.name.unwrap_or_else(|| directory.clone()),
|
||||||
|
description: meta.description.unwrap_or_default(),
|
||||||
|
directory: directory.clone(),
|
||||||
|
readme_url: None,
|
||||||
|
installed: true,
|
||||||
|
repo_owner: None,
|
||||||
|
repo_name: None,
|
||||||
|
repo_branch: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 去重技能列表
|
||||||
|
fn deduplicate_skills(skills: &mut Vec<Skill>) {
|
||||||
|
let mut seen = HashMap::new();
|
||||||
|
skills.retain(|skill| {
|
||||||
|
let key = skill.directory.to_lowercase();
|
||||||
|
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(key) {
|
||||||
|
e.insert(true);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载仓库
|
||||||
|
async fn download_repo(&self, repo: &SkillRepo) -> Result<PathBuf> {
|
||||||
|
let temp_dir = tempfile::tempdir()?;
|
||||||
|
let temp_path = temp_dir.path().to_path_buf();
|
||||||
|
let _ = temp_dir.keep(); // 保持临时目录,稍后手动清理
|
||||||
|
|
||||||
|
// 尝试多个分支
|
||||||
|
let branches = if repo.branch.is_empty() {
|
||||||
|
vec!["main", "master"]
|
||||||
|
} else {
|
||||||
|
vec![repo.branch.as_str(), "main", "master"]
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut last_error = None;
|
||||||
|
for branch in branches {
|
||||||
|
let url = format!(
|
||||||
|
"https://github.com/{}/{}/archive/refs/heads/{}.zip",
|
||||||
|
repo.owner, repo.name, branch
|
||||||
|
);
|
||||||
|
|
||||||
|
match self.download_and_extract(&url, &temp_path).await {
|
||||||
|
Ok(_) => {
|
||||||
|
return Ok(temp_path);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
last_error = Some(e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("所有分支下载失败")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载并解压 ZIP
|
||||||
|
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
|
||||||
|
// 下载 ZIP
|
||||||
|
let response = self.http_client.get(url).send().await?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow::anyhow!("下载失败: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
|
||||||
|
// 解压
|
||||||
|
let cursor = std::io::Cursor::new(bytes);
|
||||||
|
let mut archive = zip::ZipArchive::new(cursor)?;
|
||||||
|
|
||||||
|
// 获取根目录名称 (GitHub 的 zip 会有一个根目录)
|
||||||
|
let root_name = if !archive.is_empty() {
|
||||||
|
let first_file = archive.by_index(0)?;
|
||||||
|
let name = first_file.name();
|
||||||
|
name.split('/').next().unwrap_or("").to_string()
|
||||||
|
} else {
|
||||||
|
return Err(anyhow::anyhow!("空的压缩包"));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 解压所有文件
|
||||||
|
for i in 0..archive.len() {
|
||||||
|
let mut file = archive.by_index(i)?;
|
||||||
|
let file_path = file.name();
|
||||||
|
|
||||||
|
// 跳过根目录,直接提取内容
|
||||||
|
let relative_path =
|
||||||
|
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
|
||||||
|
stripped
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if relative_path.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let outpath = dest.join(relative_path);
|
||||||
|
|
||||||
|
if file.is_dir() {
|
||||||
|
fs::create_dir_all(&outpath)?;
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = outpath.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut outfile = fs::File::create(&outpath)?;
|
||||||
|
std::io::copy(&mut file, &mut outfile)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 安装技能(仅负责下载和文件操作,状态更新由上层负责)
|
||||||
|
pub async fn install_skill(&self, directory: String, repo: SkillRepo) -> Result<()> {
|
||||||
|
let dest = self.install_dir.join(&directory);
|
||||||
|
|
||||||
|
// 若目标目录已存在,则视为已安装,避免重复下载
|
||||||
|
if dest.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载仓库时增加总超时,防止无效链接导致长时间卡住安装过程
|
||||||
|
let temp_dir = timeout(
|
||||||
|
std::time::Duration::from_secs(15),
|
||||||
|
self.download_repo(&repo),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??;
|
||||||
|
|
||||||
|
// 复制到安装目录
|
||||||
|
let source = temp_dir.join(&directory);
|
||||||
|
|
||||||
|
if !source.exists() {
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
return Err(anyhow::anyhow!("技能目录不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除旧版本
|
||||||
|
if dest.exists() {
|
||||||
|
fs::remove_dir_all(&dest)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归复制
|
||||||
|
Self::copy_dir_recursive(&source, &dest)?;
|
||||||
|
|
||||||
|
// 清理临时目录
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归复制目录
|
||||||
|
fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
|
||||||
|
fs::create_dir_all(dest)?;
|
||||||
|
|
||||||
|
for entry in fs::read_dir(src)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
let dest_path = dest.join(entry.file_name());
|
||||||
|
|
||||||
|
if path.is_dir() {
|
||||||
|
Self::copy_dir_recursive(&path, &dest_path)?;
|
||||||
|
} else {
|
||||||
|
fs::copy(&path, &dest_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 卸载技能(仅负责文件操作,状态更新由上层负责)
|
||||||
|
pub fn uninstall_skill(&self, directory: String) -> Result<()> {
|
||||||
|
let dest = self.install_dir.join(&directory);
|
||||||
|
|
||||||
|
if dest.exists() {
|
||||||
|
fs::remove_dir_all(&dest)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出仓库
|
||||||
|
pub fn list_repos(&self, store: &SkillStore) -> Vec<SkillRepo> {
|
||||||
|
store.repos.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加仓库
|
||||||
|
pub fn add_repo(&self, store: &mut SkillStore, repo: SkillRepo) -> Result<()> {
|
||||||
|
// 检查重复
|
||||||
|
if let Some(pos) = store
|
||||||
|
.repos
|
||||||
|
.iter()
|
||||||
|
.position(|r| r.owner == repo.owner && r.name == repo.name)
|
||||||
|
{
|
||||||
|
store.repos[pos] = repo;
|
||||||
|
} else {
|
||||||
|
store.repos.push(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除仓库
|
||||||
|
pub fn remove_repo(&self, store: &mut SkillStore, owner: String, name: String) -> Result<()> {
|
||||||
|
store
|
||||||
|
.repos
|
||||||
|
.retain(|r| !(r.owner == owner && r.name == name));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,20 @@ pub struct CustomEndpoint {
|
|||||||
pub last_used: Option<i64>,
|
pub last_used: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SecurityAuthSettings {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub selected_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SecuritySettings {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub auth: Option<SecurityAuthSettings>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 应用设置结构,允许覆盖默认配置目录
|
/// 应用设置结构,允许覆盖默认配置目录
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -32,7 +46,11 @@ pub struct AppSettings {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub codex_config_dir: Option<String>,
|
pub codex_config_dir: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gemini_config_dir: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub security: Option<SecuritySettings>,
|
||||||
/// Claude 自定义端点列表
|
/// Claude 自定义端点列表
|
||||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
|
pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
|
||||||
@@ -57,7 +75,9 @@ impl Default for AppSettings {
|
|||||||
enable_claude_plugin_integration: false,
|
enable_claude_plugin_integration: false,
|
||||||
claude_config_dir: None,
|
claude_config_dir: None,
|
||||||
codex_config_dir: None,
|
codex_config_dir: None,
|
||||||
|
gemini_config_dir: None,
|
||||||
language: None,
|
language: None,
|
||||||
|
security: None,
|
||||||
custom_endpoints_claude: HashMap::new(),
|
custom_endpoints_claude: HashMap::new(),
|
||||||
custom_endpoints_codex: HashMap::new(),
|
custom_endpoints_codex: HashMap::new(),
|
||||||
}
|
}
|
||||||
@@ -89,6 +109,13 @@ impl AppSettings {
|
|||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
self.gemini_config_dir = self
|
||||||
|
.gemini_config_dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
self.language = self
|
self.language = self
|
||||||
.language
|
.language
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -171,6 +198,27 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn ensure_security_auth_selected_type(selected_type: &str) -> Result<(), AppError> {
|
||||||
|
let mut settings = get_settings();
|
||||||
|
let current = settings
|
||||||
|
.security
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|sec| sec.auth.as_ref())
|
||||||
|
.and_then(|auth| auth.selected_type.as_deref());
|
||||||
|
|
||||||
|
if current == Some(selected_type) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut security = settings.security.unwrap_or_default();
|
||||||
|
let mut auth = security.auth.unwrap_or_default();
|
||||||
|
auth.selected_type = Some(selected_type.to_string());
|
||||||
|
security.auth = Some(auth);
|
||||||
|
settings.security = Some(security);
|
||||||
|
|
||||||
|
update_settings(settings)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_claude_override_dir() -> Option<PathBuf> {
|
pub fn get_claude_override_dir() -> Option<PathBuf> {
|
||||||
let settings = settings_store().read().ok()?;
|
let settings = settings_store().read().ok()?;
|
||||||
settings
|
settings
|
||||||
@@ -186,3 +234,11 @@ pub fn get_codex_override_dir() -> Option<PathBuf> {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|p| resolve_override_path(p))
|
.map(|p| resolve_override_path(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_gemini_override_dir() -> Option<PathBuf> {
|
||||||
|
let settings = settings_store().read().ok()?;
|
||||||
|
settings
|
||||||
|
.gemini_config_dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| resolve_override_path(p))
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,15 +33,15 @@ pub async fn execute_usage_script(
|
|||||||
let runtime = Runtime::new().map_err(|e| {
|
let runtime = Runtime::new().map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.runtime_create_failed",
|
"usage_script.runtime_create_failed",
|
||||||
format!("创建 JS 运行时失败: {}", e),
|
format!("创建 JS 运行时失败: {e}"),
|
||||||
format!("Failed to create JS runtime: {}", e),
|
format!("Failed to create JS runtime: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let context = Context::full(&runtime).map_err(|e| {
|
let context = Context::full(&runtime).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.context_create_failed",
|
"usage_script.context_create_failed",
|
||||||
format!("创建 JS 上下文失败: {}", e),
|
format!("创建 JS 上下文失败: {e}"),
|
||||||
format!("Failed to create JS context: {}", e),
|
format!("Failed to create JS context: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -50,8 +50,8 @@ pub async fn execute_usage_script(
|
|||||||
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
|
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.config_parse_failed",
|
"usage_script.config_parse_failed",
|
||||||
format!("解析配置失败: {}", e),
|
format!("解析配置失败: {e}"),
|
||||||
format!("Failed to parse config: {}", e),
|
format!("Failed to parse config: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -59,8 +59,8 @@ pub async fn execute_usage_script(
|
|||||||
let request: rquickjs::Object = config.get("request").map_err(|e| {
|
let request: rquickjs::Object = config.get("request").map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.request_missing",
|
"usage_script.request_missing",
|
||||||
format!("缺少 request 配置: {}", e),
|
format!("缺少 request 配置: {e}"),
|
||||||
format!("Missing request config: {}", e),
|
format!("Missing request config: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -70,8 +70,8 @@ pub async fn execute_usage_script(
|
|||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.request_serialize_failed",
|
"usage_script.request_serialize_failed",
|
||||||
format!("序列化 request 失败: {}", e),
|
format!("序列化 request 失败: {e}"),
|
||||||
format!("Failed to serialize request: {}", e),
|
format!("Failed to serialize request: {e}"),
|
||||||
)
|
)
|
||||||
})?
|
})?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -85,8 +85,8 @@ pub async fn execute_usage_script(
|
|||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.get_string_failed",
|
"usage_script.get_string_failed",
|
||||||
format!("获取字符串失败: {}", e),
|
format!("获取字符串失败: {e}"),
|
||||||
format!("Failed to get string: {}", e),
|
format!("Failed to get string: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -98,8 +98,8 @@ pub async fn execute_usage_script(
|
|||||||
let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| {
|
let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.request_format_invalid",
|
"usage_script.request_format_invalid",
|
||||||
format!("request 配置格式错误: {}", e),
|
format!("request 配置格式错误: {e}"),
|
||||||
format!("Invalid request config format: {}", e),
|
format!("Invalid request config format: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -111,15 +111,15 @@ pub async fn execute_usage_script(
|
|||||||
let runtime = Runtime::new().map_err(|e| {
|
let runtime = Runtime::new().map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.runtime_create_failed",
|
"usage_script.runtime_create_failed",
|
||||||
format!("创建 JS 运行时失败: {}", e),
|
format!("创建 JS 运行时失败: {e}"),
|
||||||
format!("Failed to create JS runtime: {}", e),
|
format!("Failed to create JS runtime: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let context = Context::full(&runtime).map_err(|e| {
|
let context = Context::full(&runtime).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.context_create_failed",
|
"usage_script.context_create_failed",
|
||||||
format!("创建 JS 上下文失败: {}", e),
|
format!("创建 JS 上下文失败: {e}"),
|
||||||
format!("Failed to create JS context: {}", e),
|
format!("Failed to create JS context: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -128,8 +128,8 @@ pub async fn execute_usage_script(
|
|||||||
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
|
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.config_reparse_failed",
|
"usage_script.config_reparse_failed",
|
||||||
format!("重新解析配置失败: {}", e),
|
format!("重新解析配置失败: {e}"),
|
||||||
format!("Failed to re-parse config: {}", e),
|
format!("Failed to re-parse config: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -137,8 +137,8 @@ pub async fn execute_usage_script(
|
|||||||
let extractor: Function = config.get("extractor").map_err(|e| {
|
let extractor: Function = config.get("extractor").map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.extractor_missing",
|
"usage_script.extractor_missing",
|
||||||
format!("缺少 extractor 函数: {}", e),
|
format!("缺少 extractor 函数: {e}"),
|
||||||
format!("Missing extractor function: {}", e),
|
format!("Missing extractor function: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -147,8 +147,8 @@ pub async fn execute_usage_script(
|
|||||||
ctx.json_parse(response_data.as_str()).map_err(|e| {
|
ctx.json_parse(response_data.as_str()).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.response_parse_failed",
|
"usage_script.response_parse_failed",
|
||||||
format!("解析响应 JSON 失败: {}", e),
|
format!("解析响应 JSON 失败: {e}"),
|
||||||
format!("Failed to parse response JSON: {}", e),
|
format!("Failed to parse response JSON: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -156,8 +156,8 @@ pub async fn execute_usage_script(
|
|||||||
let result_js: rquickjs::Value = extractor.call((response_js,)).map_err(|e| {
|
let result_js: rquickjs::Value = extractor.call((response_js,)).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.extractor_exec_failed",
|
"usage_script.extractor_exec_failed",
|
||||||
format!("执行 extractor 失败: {}", e),
|
format!("执行 extractor 失败: {e}"),
|
||||||
format!("Failed to execute extractor: {}", e),
|
format!("Failed to execute extractor: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -167,8 +167,8 @@ pub async fn execute_usage_script(
|
|||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.result_serialize_failed",
|
"usage_script.result_serialize_failed",
|
||||||
format!("序列化结果失败: {}", e),
|
format!("序列化结果失败: {e}"),
|
||||||
format!("Failed to serialize result: {}", e),
|
format!("Failed to serialize result: {e}"),
|
||||||
)
|
)
|
||||||
})?
|
})?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -182,8 +182,8 @@ pub async fn execute_usage_script(
|
|||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.get_string_failed",
|
"usage_script.get_string_failed",
|
||||||
format!("获取字符串失败: {}", e),
|
format!("获取字符串失败: {e}"),
|
||||||
format!("Failed to get string: {}", e),
|
format!("Failed to get string: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -191,8 +191,8 @@ pub async fn execute_usage_script(
|
|||||||
serde_json::from_str(&result_json).map_err(|e| {
|
serde_json::from_str(&result_json).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.json_parse_failed",
|
"usage_script.json_parse_failed",
|
||||||
format!("JSON 解析失败: {}", e),
|
format!("JSON 解析失败: {e}"),
|
||||||
format!("JSON parse failed: {}", e),
|
format!("JSON parse failed: {e}"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
@@ -225,8 +225,8 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
|
|||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.client_create_failed",
|
"usage_script.client_create_failed",
|
||||||
format!("创建客户端失败: {}", e),
|
format!("创建客户端失败: {e}"),
|
||||||
format!("Failed to create client: {}", e),
|
format!("Failed to create client: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -255,8 +255,8 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
|
|||||||
let resp = req.send().await.map_err(|e| {
|
let resp = req.send().await.map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.request_failed",
|
"usage_script.request_failed",
|
||||||
format!("请求失败: {}", e),
|
format!("请求失败: {e}"),
|
||||||
format!("Request failed: {}", e),
|
format!("Request failed: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -264,8 +264,8 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
|
|||||||
let text = resp.text().await.map_err(|e| {
|
let text = resp.text().await.map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.read_response_failed",
|
"usage_script.read_response_failed",
|
||||||
format!("读取响应失败: {}", e),
|
format!("读取响应失败: {e}"),
|
||||||
format!("Failed to read response: {}", e),
|
format!("Failed to read response: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -277,8 +277,8 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
|
|||||||
};
|
};
|
||||||
return Err(AppError::localized(
|
return Err(AppError::localized(
|
||||||
"usage_script.http_error",
|
"usage_script.http_error",
|
||||||
format!("HTTP {} : {}", status, preview),
|
format!("HTTP {status} : {preview}"),
|
||||||
format!("HTTP {} : {}", status, preview),
|
format!("HTTP {status} : {preview}"),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,8 +300,8 @@ fn validate_result(result: &Value) -> Result<(), AppError> {
|
|||||||
validate_single_usage(item).map_err(|e| {
|
validate_single_usage(item).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.array_validation_failed",
|
"usage_script.array_validation_failed",
|
||||||
format!("数组索引[{}]验证失败: {}", idx, e),
|
format!("数组索引[{idx}]验证失败: {e}"),
|
||||||
format!("Validation failed at index [{}]: {}", idx, e),
|
format!("Validation failed at index [{idx}]: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CC Switch",
|
"productName": "CC Switch",
|
||||||
"version": "3.6.1",
|
"version": "3.6.2",
|
||||||
"identifier": "com.ccswitch.desktop",
|
"identifier": "com.ccswitch.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
@@ -14,17 +14,21 @@
|
|||||||
{
|
{
|
||||||
"label": "main",
|
"label": "main",
|
||||||
"title": "",
|
"title": "",
|
||||||
"width": 900,
|
"width": 1000,
|
||||||
"height": 650,
|
"height": 650,
|
||||||
"minWidth": 800,
|
"minWidth": 900,
|
||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"titleBarStyle": "Transparent"
|
"center": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:"
|
"csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:",
|
||||||
|
"assetProtocol": {
|
||||||
|
"enable": true,
|
||||||
|
"scope": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
@@ -42,9 +46,17 @@
|
|||||||
"wix": {
|
"wix": {
|
||||||
"template": "wix/per-user-main.wxs"
|
"template": "wix/per-user-main.wxs"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"macOS": {
|
||||||
|
"minimumSystemVersion": "10.15"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
|
"deep-link": {
|
||||||
|
"desktop": {
|
||||||
|
"schemes": ["ccswitch"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"updater": {
|
"updater": {
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
|
|||||||
121
src-tauri/tests/deeplink_import.rs
Normal file
121
src-tauri/tests/deeplink_import.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use cc_switch_lib::{
|
||||||
|
import_provider_from_deeplink, parse_deeplink_url, AppState, AppType, MultiAppConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[path = "support.rs"]
|
||||||
|
mod support;
|
||||||
|
use support::{ensure_test_home, reset_test_fs, test_mutex};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deeplink_import_claude_provider_persists_to_config() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let url = "ccswitch://v1/import?resource=provider&app=claude&name=DeepLink%20Claude&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=sk-test-claude-key&model=claude-sonnet-4";
|
||||||
|
let request = parse_deeplink_url(url).expect("parse deeplink url");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config.ensure_app(&AppType::Claude);
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider_id = import_provider_from_deeplink(&state, request.clone())
|
||||||
|
.expect("import provider from deeplink");
|
||||||
|
|
||||||
|
// 验证内存状态
|
||||||
|
let guard = state.config.read().expect("read config");
|
||||||
|
let manager = guard
|
||||||
|
.get_manager(&AppType::Claude)
|
||||||
|
.expect("claude manager should exist");
|
||||||
|
let provider = manager
|
||||||
|
.providers
|
||||||
|
.get(&provider_id)
|
||||||
|
.expect("provider created via deeplink");
|
||||||
|
assert_eq!(provider.name, request.name);
|
||||||
|
assert_eq!(
|
||||||
|
provider.website_url.as_deref(),
|
||||||
|
Some(request.homepage.as_str())
|
||||||
|
);
|
||||||
|
let auth_token = provider
|
||||||
|
.settings_config
|
||||||
|
.pointer("/env/ANTHROPIC_AUTH_TOKEN")
|
||||||
|
.and_then(|v| v.as_str());
|
||||||
|
let base_url = provider
|
||||||
|
.settings_config
|
||||||
|
.pointer("/env/ANTHROPIC_BASE_URL")
|
||||||
|
.and_then(|v| v.as_str());
|
||||||
|
assert_eq!(auth_token, Some(request.api_key.as_str()));
|
||||||
|
assert_eq!(base_url, Some(request.endpoint.as_str()));
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
// 验证配置已持久化
|
||||||
|
let config_path = home.join(".cc-switch").join("config.json");
|
||||||
|
assert!(
|
||||||
|
config_path.exists(),
|
||||||
|
"importing provider from deeplink should persist config.json"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deeplink_import_codex_provider_builds_auth_and_config() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let url = "ccswitch://v1/import?resource=provider&app=codex&name=DeepLink%20Codex&homepage=https%3A%2F%2Fopenai.example&endpoint=https%3A%2F%2Fapi.openai.example%2Fv1&apiKey=sk-test-codex-key&model=gpt-4o";
|
||||||
|
let request = parse_deeplink_url(url).expect("parse deeplink url");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config.ensure_app(&AppType::Codex);
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider_id = import_provider_from_deeplink(&state, request.clone())
|
||||||
|
.expect("import provider from deeplink");
|
||||||
|
|
||||||
|
let guard = state.config.read().expect("read config");
|
||||||
|
let manager = guard
|
||||||
|
.get_manager(&AppType::Codex)
|
||||||
|
.expect("codex manager should exist");
|
||||||
|
let provider = manager
|
||||||
|
.providers
|
||||||
|
.get(&provider_id)
|
||||||
|
.expect("provider created via deeplink");
|
||||||
|
assert_eq!(provider.name, request.name);
|
||||||
|
assert_eq!(
|
||||||
|
provider.website_url.as_deref(),
|
||||||
|
Some(request.homepage.as_str())
|
||||||
|
);
|
||||||
|
let auth_value = provider
|
||||||
|
.settings_config
|
||||||
|
.pointer("/auth/OPENAI_API_KEY")
|
||||||
|
.and_then(|v| v.as_str());
|
||||||
|
let config_text = provider
|
||||||
|
.settings_config
|
||||||
|
.get("config")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert_eq!(auth_value, Some(request.api_key.as_str()));
|
||||||
|
assert!(
|
||||||
|
config_text.contains(request.endpoint.as_str()),
|
||||||
|
"config.toml content should contain endpoint"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
config_text.contains("model = \"gpt-4o\""),
|
||||||
|
"config.toml content should contain model setting"
|
||||||
|
);
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
let config_path = home.join(".cc-switch").join("config.json");
|
||||||
|
assert!(
|
||||||
|
config_path.exists(),
|
||||||
|
"importing provider from deeplink should persist config.json"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ use tauri::async_runtime;
|
|||||||
|
|
||||||
use cc_switch_lib::{
|
use cc_switch_lib::{
|
||||||
get_claude_settings_path, read_json_file, AppError, AppState, AppType, ConfigService,
|
get_claude_settings_path, read_json_file, AppError, AppState, AppType, ConfigService,
|
||||||
MultiAppConfig, Provider,
|
MultiAppConfig, Provider, ProviderMeta,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[path = "support.rs"]
|
#[path = "support.rs"]
|
||||||
@@ -63,9 +63,7 @@ fn sync_claude_provider_writes_live_settings() {
|
|||||||
// 额外确认写入位置位于测试 HOME 下
|
// 额外确认写入位置位于测试 HOME 下
|
||||||
assert!(
|
assert!(
|
||||||
settings_path.starts_with(home),
|
settings_path.starts_with(home),
|
||||||
"settings path {:?} should reside under test HOME {:?}",
|
"settings path {settings_path:?} should reside under test HOME {home:?}"
|
||||||
settings_path,
|
|
||||||
home
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,10 +222,13 @@ mode = "dev"
|
|||||||
text.contains("[profile]"),
|
text.contains("[profile]"),
|
||||||
"non-MCP table should be preserved"
|
"non-MCP table should be preserved"
|
||||||
);
|
);
|
||||||
// 新增的 mcp_servers/或 mcp.servers 应存在并包含 echo
|
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("mcp_servers") || text.contains("[mcp.servers]"),
|
text.contains("mcp_servers"),
|
||||||
"one server table style should be present"
|
"mcp_servers table should be present"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!text.contains("[mcp.servers]"),
|
||||||
|
"invalid [mcp.servers] table should not appear"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("echo") && text.contains("command = \"echo\""),
|
text.contains("echo") && text.contains("command = \"echo\""),
|
||||||
@@ -236,14 +237,14 @@ mode = "dev"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sync_enabled_to_codex_keeps_existing_style_mcp_dot_servers() {
|
fn sync_enabled_to_codex_migrates_erroneous_mcp_dot_servers_to_mcp_servers() {
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
reset_test_fs();
|
reset_test_fs();
|
||||||
let path = cc_switch_lib::get_codex_config_path();
|
let path = cc_switch_lib::get_codex_config_path();
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).expect("create codex dir");
|
fs::create_dir_all(parent).expect("create codex dir");
|
||||||
}
|
}
|
||||||
// 预置 mcp.servers 风格
|
// 预置错误的 mcp.servers 风格(应迁移为顶层 mcp_servers)
|
||||||
let seed = r#"[mcp]
|
let seed = r#"[mcp]
|
||||||
other = "keep"
|
other = "keep"
|
||||||
[mcp.servers]
|
[mcp.servers]
|
||||||
@@ -262,14 +263,14 @@ fn sync_enabled_to_codex_keeps_existing_style_mcp_dot_servers() {
|
|||||||
|
|
||||||
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
|
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
|
||||||
let text = fs::read_to_string(&path).expect("read config.toml");
|
let text = fs::read_to_string(&path).expect("read config.toml");
|
||||||
// 仍应采用 mcp.servers 风格
|
// 应迁移到顶层 mcp_servers,并移除错误的 mcp.servers 表
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("[mcp.servers]"),
|
text.contains("mcp_servers"),
|
||||||
"should keep mcp.servers style"
|
"should migrate to mcp_servers table"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!text.contains("mcp_servers"),
|
!text.contains("[mcp.servers]"),
|
||||||
"should not switch to mcp_servers"
|
"invalid [mcp.servers] table should be removed"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,16 +490,19 @@ url = "https://example.com"
|
|||||||
let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex");
|
let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex");
|
||||||
assert!(changed >= 2, "should import both servers");
|
assert!(changed >= 2, "should import both servers");
|
||||||
|
|
||||||
let servers = &config.mcp.codex.servers;
|
// v3.7.0: 检查统一结构
|
||||||
let echo = servers
|
let servers = config
|
||||||
.get("echo_server")
|
.mcp
|
||||||
.and_then(|v| v.as_object())
|
.servers
|
||||||
.expect("echo server");
|
.as_ref()
|
||||||
assert_eq!(echo.get("enabled").and_then(|v| v.as_bool()), Some(true));
|
.expect("unified servers should exist");
|
||||||
let server_spec = echo
|
|
||||||
.get("server")
|
let echo = servers.get("echo_server").expect("echo server");
|
||||||
.and_then(|v| v.as_object())
|
assert_eq!(
|
||||||
.expect("server spec");
|
echo.apps.codex, true,
|
||||||
|
"Codex app should be enabled for echo_server"
|
||||||
|
);
|
||||||
|
let server_spec = echo.server.as_object().expect("server spec");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
server_spec
|
server_spec
|
||||||
.get("command")
|
.get("command")
|
||||||
@@ -507,14 +511,12 @@ url = "https://example.com"
|
|||||||
"echo"
|
"echo"
|
||||||
);
|
);
|
||||||
|
|
||||||
let http = servers
|
let http = servers.get("http_server").expect("http server");
|
||||||
.get("http_server")
|
assert_eq!(
|
||||||
.and_then(|v| v.as_object())
|
http.apps.codex, true,
|
||||||
.expect("http server");
|
"Codex app should be enabled for http_server"
|
||||||
let http_spec = http
|
);
|
||||||
.get("server")
|
let http_spec = http.server.as_object().expect("http spec");
|
||||||
.and_then(|v| v.as_object())
|
|
||||||
.expect("http spec");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
http_spec.get("url").and_then(|v| v.as_str()).unwrap_or(""),
|
http_spec.get("url").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
"https://example.com"
|
"https://example.com"
|
||||||
@@ -539,36 +541,54 @@ command = "echo"
|
|||||||
.expect("write codex config");
|
.expect("write codex config");
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
let mut config = MultiAppConfig::default();
|
||||||
config.mcp.codex.servers.insert(
|
// v3.7.0: 在统一结构中创建已存在的服务器
|
||||||
"existing".into(),
|
config.mcp.servers = Some(std::collections::HashMap::new());
|
||||||
json!({
|
config.mcp.servers.as_mut().unwrap().insert(
|
||||||
"id": "existing",
|
"existing".to_string(),
|
||||||
"name": "existing",
|
cc_switch_lib::McpServer {
|
||||||
"enabled": false,
|
id: "existing".to_string(),
|
||||||
"server": {
|
name: "existing".to_string(),
|
||||||
|
server: json!({
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "prev"
|
"command": "prev"
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
|
apps: cc_switch_lib::McpApps {
|
||||||
|
claude: false,
|
||||||
|
codex: false, // 初始未启用
|
||||||
|
gemini: false,
|
||||||
|
},
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
docs: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex");
|
let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex");
|
||||||
assert!(changed >= 1, "should mark change for enabled flag");
|
assert!(changed >= 1, "should mark change for enabled flag");
|
||||||
|
|
||||||
|
// v3.7.0: 检查统一结构
|
||||||
let entry = config
|
let entry = config
|
||||||
.mcp
|
.mcp
|
||||||
.codex
|
|
||||||
.servers
|
.servers
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
.get("existing")
|
.get("existing")
|
||||||
.and_then(|v| v.as_object())
|
|
||||||
.expect("existing entry");
|
.expect("existing entry");
|
||||||
assert_eq!(entry.get("enabled").and_then(|v| v.as_bool()), Some(true));
|
|
||||||
let spec = entry
|
// 验证 Codex 应用已启用
|
||||||
.get("server")
|
assert_eq!(
|
||||||
.and_then(|v| v.as_object())
|
entry.apps.codex, true,
|
||||||
.expect("server spec");
|
"Codex app should be enabled after import"
|
||||||
// 保留原 command,确保导入不会覆盖现有 server 细节
|
);
|
||||||
assert_eq!(spec.get("command").and_then(|v| v.as_str()), Some("prev"));
|
|
||||||
|
// 验证现有配置被保留(server 不应被覆盖)
|
||||||
|
let spec = entry.server.as_object().expect("server spec");
|
||||||
|
assert_eq!(
|
||||||
|
spec.get("command").and_then(|v| v.as_str()),
|
||||||
|
Some("prev"),
|
||||||
|
"existing server config should be preserved, not overwritten by import"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -646,34 +666,49 @@ fn import_from_claude_merges_into_config() {
|
|||||||
.expect("write claude json");
|
.expect("write claude json");
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
let mut config = MultiAppConfig::default();
|
||||||
config.mcp.claude.servers.insert(
|
// v3.7.0: 在统一结构中创建已存在的服务器
|
||||||
"stdio-enabled".into(),
|
config.mcp.servers = Some(std::collections::HashMap::new());
|
||||||
json!({
|
config.mcp.servers.as_mut().unwrap().insert(
|
||||||
"id": "stdio-enabled",
|
"stdio-enabled".to_string(),
|
||||||
"name": "stdio-enabled",
|
cc_switch_lib::McpServer {
|
||||||
"enabled": false,
|
id: "stdio-enabled".to_string(),
|
||||||
"server": {
|
name: "stdio-enabled".to_string(),
|
||||||
|
server: json!({
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "prev"
|
"command": "prev"
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
|
apps: cc_switch_lib::McpApps {
|
||||||
|
claude: false, // 初始未启用
|
||||||
|
codex: false,
|
||||||
|
gemini: false,
|
||||||
|
},
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
docs: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let changed = cc_switch_lib::import_from_claude(&mut config).expect("import from claude");
|
let changed = cc_switch_lib::import_from_claude(&mut config).expect("import from claude");
|
||||||
assert!(changed >= 1, "should mark at least one change");
|
assert!(changed >= 1, "should mark at least one change");
|
||||||
|
|
||||||
|
// v3.7.0: 检查统一结构
|
||||||
let entry = config
|
let entry = config
|
||||||
.mcp
|
.mcp
|
||||||
.claude
|
|
||||||
.servers
|
.servers
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
.get("stdio-enabled")
|
.get("stdio-enabled")
|
||||||
.and_then(|v| v.as_object())
|
|
||||||
.expect("entry exists");
|
.expect("entry exists");
|
||||||
assert_eq!(entry.get("enabled").and_then(|v| v.as_bool()), Some(true));
|
|
||||||
let server = entry
|
// 验证 Claude 应用已启用
|
||||||
.get("server")
|
assert_eq!(
|
||||||
.and_then(|v| v.as_object())
|
entry.apps.claude, true,
|
||||||
.expect("server obj");
|
"Claude app should be enabled after import"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 验证现有配置被保留(server 不应被覆盖)
|
||||||
|
let server = entry.server.as_object().expect("server obj");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
server.get("command").and_then(|v| v.as_str()).unwrap_or(""),
|
server.get("command").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
"prev",
|
"prev",
|
||||||
@@ -909,6 +944,121 @@ fn import_config_from_path_missing_file_produces_io_error() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_gemini_packycode_sets_security_selected_type() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Gemini)
|
||||||
|
.expect("gemini manager");
|
||||||
|
manager.current = "packy-1".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"packy-1".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"packy-1".to_string(),
|
||||||
|
"PackyCode".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "pk-key",
|
||||||
|
"GOOGLE_GEMINI_BASE_URL": "https://api-slb.packyapi.com"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Some("https://www.packyapi.com".to_string()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigService::sync_current_providers_to_live(&mut config)
|
||||||
|
.expect("syncing gemini live should succeed");
|
||||||
|
|
||||||
|
let settings_path = home.join(".cc-switch").join("settings.json");
|
||||||
|
assert!(
|
||||||
|
settings_path.exists(),
|
||||||
|
"settings.json should exist at {}",
|
||||||
|
settings_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json");
|
||||||
|
assert_eq!(
|
||||||
|
value
|
||||||
|
.pointer("/security/auth/selectedType")
|
||||||
|
.and_then(|v| v.as_str()),
|
||||||
|
Some("gemini-api-key"),
|
||||||
|
"syncing PackyCode Gemini should enforce security.auth.selectedType"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_gemini_google_official_sets_oauth_security() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Gemini)
|
||||||
|
.expect("gemini manager");
|
||||||
|
manager.current = "google-official".to_string();
|
||||||
|
let mut provider = Provider::with_id(
|
||||||
|
"google-official".to_string(),
|
||||||
|
"Google".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": {}
|
||||||
|
}),
|
||||||
|
Some("https://ai.google.dev".to_string()),
|
||||||
|
);
|
||||||
|
provider.meta = Some(ProviderMeta {
|
||||||
|
partner_promotion_key: Some("google-official".to_string()),
|
||||||
|
..ProviderMeta::default()
|
||||||
|
});
|
||||||
|
manager
|
||||||
|
.providers
|
||||||
|
.insert("google-official".to_string(), provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigService::sync_current_providers_to_live(&mut config)
|
||||||
|
.expect("syncing google official gemini should succeed");
|
||||||
|
|
||||||
|
let cc_settings = home.join(".cc-switch").join("settings.json");
|
||||||
|
assert!(
|
||||||
|
cc_settings.exists(),
|
||||||
|
"app settings should exist at {}",
|
||||||
|
cc_settings.display()
|
||||||
|
);
|
||||||
|
let cc_raw = std::fs::read_to_string(&cc_settings).expect("read .cc-switch settings");
|
||||||
|
let cc_value: serde_json::Value = serde_json::from_str(&cc_raw).expect("parse app settings");
|
||||||
|
assert_eq!(
|
||||||
|
cc_value
|
||||||
|
.pointer("/security/auth/selectedType")
|
||||||
|
.and_then(|v| v.as_str()),
|
||||||
|
Some("oauth-personal"),
|
||||||
|
"syncing Google official should set oauth-personal in app settings"
|
||||||
|
);
|
||||||
|
|
||||||
|
let gemini_settings = home.join(".gemini").join("settings.json");
|
||||||
|
assert!(
|
||||||
|
gemini_settings.exists(),
|
||||||
|
"Gemini settings should exist at {}",
|
||||||
|
gemini_settings.display()
|
||||||
|
);
|
||||||
|
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
|
||||||
|
let gemini_value: serde_json::Value =
|
||||||
|
serde_json::from_str(&gemini_raw).expect("parse gemini settings json");
|
||||||
|
assert_eq!(
|
||||||
|
gemini_value
|
||||||
|
.pointer("/security/auth/selectedType")
|
||||||
|
.and_then(|v| v.as_str()),
|
||||||
|
Some("oauth-personal"),
|
||||||
|
"Gemini settings should also record oauth-personal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn export_config_to_file_writes_target_path() {
|
fn export_config_to_file_writes_target_path() {
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use std::{fs, sync::RwLock};
|
use std::{collections::HashMap, fs, sync::RwLock};
|
||||||
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use cc_switch_lib::{
|
use cc_switch_lib::{
|
||||||
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,
|
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,
|
||||||
AppState, AppType, McpService, MultiAppConfig,
|
AppState, AppType, McpApps, McpServer, McpService, MultiAppConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[path = "support.rs"]
|
#[path = "support.rs"]
|
||||||
@@ -126,16 +126,18 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let guard = state.config.read().expect("lock config");
|
let guard = state.config.read().expect("lock config");
|
||||||
let claude_servers = &guard.mcp.claude.servers;
|
// v3.7.0: 检查统一结构
|
||||||
let entry = claude_servers
|
let servers = guard
|
||||||
|
.mcp
|
||||||
|
.servers
|
||||||
|
.as_ref()
|
||||||
|
.expect("unified servers should exist");
|
||||||
|
let entry = servers
|
||||||
.get("echo")
|
.get("echo")
|
||||||
.expect("server imported into config.json");
|
.expect("server imported into unified structure");
|
||||||
assert!(
|
assert!(
|
||||||
entry
|
entry.apps.claude,
|
||||||
.get("enabled")
|
"imported server should have Claude app enabled"
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false),
|
|
||||||
"imported server should be marked enabled"
|
|
||||||
);
|
);
|
||||||
drop(guard);
|
drop(guard);
|
||||||
|
|
||||||
@@ -181,43 +183,63 @@ fn import_mcp_from_claude_invalid_json_preserves_state() {
|
|||||||
fn set_mcp_enabled_for_codex_writes_live_config() {
|
fn set_mcp_enabled_for_codex_writes_live_config() {
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
reset_test_fs();
|
reset_test_fs();
|
||||||
ensure_test_home();
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
// 创建 Codex 配置目录和文件
|
||||||
|
let codex_dir = home.join(".codex");
|
||||||
|
fs::create_dir_all(&codex_dir).expect("create codex dir");
|
||||||
|
fs::write(
|
||||||
|
codex_dir.join("auth.json"),
|
||||||
|
r#"{"OPENAI_API_KEY":"test-key"}"#,
|
||||||
|
)
|
||||||
|
.expect("create auth.json");
|
||||||
|
fs::write(codex_dir.join("config.toml"), "").expect("create empty config.toml");
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
let mut config = MultiAppConfig::default();
|
||||||
config.ensure_app(&AppType::Codex);
|
config.ensure_app(&AppType::Codex);
|
||||||
config.mcp.codex.servers.insert(
|
|
||||||
|
// v3.7.0: 使用统一结构
|
||||||
|
config.mcp.servers = Some(HashMap::new());
|
||||||
|
config.mcp.servers.as_mut().unwrap().insert(
|
||||||
"codex-server".into(),
|
"codex-server".into(),
|
||||||
json!({
|
McpServer {
|
||||||
"id": "codex-server",
|
id: "codex-server".to_string(),
|
||||||
"name": "Codex Server",
|
name: "Codex Server".to_string(),
|
||||||
"server": {
|
server: json!({
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "echo"
|
"command": "echo"
|
||||||
},
|
|
||||||
"enabled": false
|
|
||||||
}),
|
}),
|
||||||
|
apps: McpApps {
|
||||||
|
claude: false,
|
||||||
|
codex: false, // 初始未启用
|
||||||
|
gemini: false,
|
||||||
|
},
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
docs: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
config: RwLock::new(config),
|
config: RwLock::new(config),
|
||||||
};
|
};
|
||||||
|
|
||||||
McpService::set_enabled(&state, AppType::Codex, "codex-server", true)
|
// v3.7.0: 使用 toggle_app 替代 set_enabled
|
||||||
.expect("set enabled should succeed");
|
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
|
||||||
|
.expect("toggle_app should succeed");
|
||||||
|
|
||||||
let guard = state.config.read().expect("lock config");
|
let guard = state.config.read().expect("lock config");
|
||||||
let entry = guard
|
let entry = guard
|
||||||
.mcp
|
.mcp
|
||||||
.codex
|
|
||||||
.servers
|
.servers
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
.get("codex-server")
|
.get("codex-server")
|
||||||
.expect("codex server exists");
|
.expect("codex server exists");
|
||||||
assert!(
|
assert!(
|
||||||
entry
|
entry.apps.codex,
|
||||||
.get("enabled")
|
"server should have Codex app enabled after toggle"
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false),
|
|
||||||
"server should be marked enabled after command"
|
|
||||||
);
|
);
|
||||||
drop(guard);
|
drop(guard);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::sync::RwLock;
|
|||||||
|
|
||||||
use cc_switch_lib::{
|
use cc_switch_lib::{
|
||||||
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppState, AppType,
|
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppState, AppType,
|
||||||
MultiAppConfig, Provider, ProviderService,
|
MultiAppConfig, Provider, ProviderMeta, ProviderService,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[path = "support.rs"]
|
#[path = "support.rs"]
|
||||||
@@ -139,6 +139,188 @@ command = "say"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_packycode_gemini_updates_security_selected_type() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Gemini)
|
||||||
|
.expect("gemini manager");
|
||||||
|
manager.current = "packy-gemini".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"packy-gemini".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"packy-gemini".to_string(),
|
||||||
|
"PackyCode".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "pk-key",
|
||||||
|
"GOOGLE_GEMINI_BASE_URL": "https://www.packyapi.com"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Some("https://www.packyapi.com".to_string()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
ProviderService::switch(&state, AppType::Gemini, "packy-gemini")
|
||||||
|
.expect("switching to PackyCode Gemini should succeed");
|
||||||
|
|
||||||
|
let settings_path = home.join(".cc-switch").join("settings.json");
|
||||||
|
assert!(
|
||||||
|
settings_path.exists(),
|
||||||
|
"settings.json should exist at {}",
|
||||||
|
settings_path.display()
|
||||||
|
);
|
||||||
|
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(&raw).expect("parse settings.json after switch");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
value
|
||||||
|
.pointer("/security/auth/selectedType")
|
||||||
|
.and_then(|v| v.as_str()),
|
||||||
|
Some("gemini-api-key"),
|
||||||
|
"PackyCode Gemini should set security.auth.selectedType"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Gemini)
|
||||||
|
.expect("gemini manager");
|
||||||
|
manager.current = "packy-meta".to_string();
|
||||||
|
let mut provider = Provider::with_id(
|
||||||
|
"packy-meta".to_string(),
|
||||||
|
"Generic Gemini".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "pk-meta",
|
||||||
|
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Some("https://example.com".to_string()),
|
||||||
|
);
|
||||||
|
provider.meta = Some(ProviderMeta {
|
||||||
|
partner_promotion_key: Some("packycode".to_string()),
|
||||||
|
..ProviderMeta::default()
|
||||||
|
});
|
||||||
|
manager.providers.insert("packy-meta".to_string(), provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
ProviderService::switch(&state, AppType::Gemini, "packy-meta")
|
||||||
|
.expect("switching to partner meta provider should succeed");
|
||||||
|
|
||||||
|
let settings_path = home.join(".cc-switch").join("settings.json");
|
||||||
|
assert!(
|
||||||
|
settings_path.exists(),
|
||||||
|
"settings.json should exist at {}",
|
||||||
|
settings_path.display()
|
||||||
|
);
|
||||||
|
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(&raw).expect("parse settings.json after switch");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
value
|
||||||
|
.pointer("/security/auth/selectedType")
|
||||||
|
.and_then(|v| v.as_str()),
|
||||||
|
Some("gemini-api-key"),
|
||||||
|
"Partner meta should set security.auth.selectedType even without packy keywords"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_google_official_gemini_sets_oauth_security() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Gemini)
|
||||||
|
.expect("gemini manager");
|
||||||
|
manager.current = "google-official".to_string();
|
||||||
|
let mut provider = Provider::with_id(
|
||||||
|
"google-official".to_string(),
|
||||||
|
"Google".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": {}
|
||||||
|
}),
|
||||||
|
Some("https://ai.google.dev".to_string()),
|
||||||
|
);
|
||||||
|
provider.meta = Some(ProviderMeta {
|
||||||
|
partner_promotion_key: Some("google-official".to_string()),
|
||||||
|
..ProviderMeta::default()
|
||||||
|
});
|
||||||
|
manager
|
||||||
|
.providers
|
||||||
|
.insert("google-official".to_string(), provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
ProviderService::switch(&state, AppType::Gemini, "google-official")
|
||||||
|
.expect("switching to Google official Gemini should succeed");
|
||||||
|
|
||||||
|
let settings_path = home.join(".cc-switch").join("settings.json");
|
||||||
|
assert!(
|
||||||
|
settings_path.exists(),
|
||||||
|
"settings.json should exist at {}",
|
||||||
|
settings_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json");
|
||||||
|
assert_eq!(
|
||||||
|
value
|
||||||
|
.pointer("/security/auth/selectedType")
|
||||||
|
.and_then(|v| v.as_str()),
|
||||||
|
Some("oauth-personal"),
|
||||||
|
"Google official Gemini should set oauth-personal selectedType in app settings"
|
||||||
|
);
|
||||||
|
|
||||||
|
let gemini_settings = home.join(".gemini").join("settings.json");
|
||||||
|
assert!(
|
||||||
|
gemini_settings.exists(),
|
||||||
|
"Gemini settings.json should exist at {}",
|
||||||
|
gemini_settings.display()
|
||||||
|
);
|
||||||
|
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
|
||||||
|
let gemini_value: serde_json::Value =
|
||||||
|
serde_json::from_str(&gemini_raw).expect("parse gemini settings");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
gemini_value
|
||||||
|
.pointer("/security/auth/selectedType")
|
||||||
|
.and_then(|v| v.as_str()),
|
||||||
|
Some("oauth-personal"),
|
||||||
|
"Gemini settings json should also reflect oauth-personal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn provider_service_switch_claude_updates_live_and_state() {
|
fn provider_service_switch_claude_updates_live_and_state() {
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
@@ -321,8 +503,8 @@ fn provider_service_delete_codex_removes_provider_and_files() {
|
|||||||
let sanitized = sanitize_provider_name("DeleteCodex");
|
let sanitized = sanitize_provider_name("DeleteCodex");
|
||||||
let codex_dir = home.join(".codex");
|
let codex_dir = home.join(".codex");
|
||||||
std::fs::create_dir_all(&codex_dir).expect("create codex dir");
|
std::fs::create_dir_all(&codex_dir).expect("create codex dir");
|
||||||
let auth_path = codex_dir.join(format!("auth-{}.json", sanitized));
|
let auth_path = codex_dir.join(format!("auth-{sanitized}.json"));
|
||||||
let cfg_path = codex_dir.join(format!("config-{}.toml", sanitized));
|
let cfg_path = codex_dir.join(format!("config-{sanitized}.toml"));
|
||||||
std::fs::write(&auth_path, "{}").expect("seed auth file");
|
std::fs::write(&auth_path, "{}").expect("seed auth file");
|
||||||
std::fs::write(&cfg_path, "base_url = \"https://example\"").expect("seed config file");
|
std::fs::write(&cfg_path, "base_url = \"https://example\"").expect("seed config file");
|
||||||
|
|
||||||
@@ -384,7 +566,7 @@ fn provider_service_delete_claude_removes_provider_files() {
|
|||||||
let sanitized = sanitize_provider_name("DeleteClaude");
|
let sanitized = sanitize_provider_name("DeleteClaude");
|
||||||
let claude_dir = home.join(".claude");
|
let claude_dir = home.join(".claude");
|
||||||
std::fs::create_dir_all(&claude_dir).expect("create claude dir");
|
std::fs::create_dir_all(&claude_dir).expect("create claude dir");
|
||||||
let by_name = claude_dir.join(format!("settings-{}.json", sanitized));
|
let by_name = claude_dir.join(format!("settings-{sanitized}.json"));
|
||||||
let by_id = claude_dir.join("settings-delete.json");
|
let by_id = claude_dir.join("settings-delete.json");
|
||||||
std::fs::write(&by_name, "{}").expect("seed settings by name");
|
std::fs::write(&by_name, "{}").expect("seed settings by name");
|
||||||
std::fs::write(&by_id, "{}").expect("seed settings by id");
|
std::fs::write(&by_id, "{}").expect("seed settings by id");
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ pub fn ensure_test_home() -> &'static Path {
|
|||||||
/// 清理测试目录中生成的配置文件与缓存。
|
/// 清理测试目录中生成的配置文件与缓存。
|
||||||
pub fn reset_test_fs() {
|
pub fn reset_test_fs() {
|
||||||
let home = ensure_test_home();
|
let home = ensure_test_home();
|
||||||
for sub in [".claude", ".codex", ".cc-switch"] {
|
for sub in [".claude", ".codex", ".cc-switch", ".gemini"] {
|
||||||
let path = home.join(sub);
|
let path = home.join(sub);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
if let Err(err) = std::fs::remove_dir_all(&path) {
|
if let Err(err) = std::fs::remove_dir_all(&path) {
|
||||||
|
|||||||
129
src/App.tsx
129
src/App.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Plus, Settings, Edit3 } from "lucide-react";
|
import { Plus, Settings, Edit3 } from "lucide-react";
|
||||||
import type { Provider } from "@/types";
|
import type { Provider } from "@/types";
|
||||||
|
import type { EnvConflict } from "@/types/env";
|
||||||
import { useProvidersQuery } from "@/lib/query";
|
import { useProvidersQuery } from "@/lib/query";
|
||||||
import {
|
import {
|
||||||
providersApi,
|
providersApi,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
type AppId,
|
type AppId,
|
||||||
type ProviderSwitchEvent,
|
type ProviderSwitchEvent,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
||||||
import { useProviderActions } from "@/hooks/useProviderActions";
|
import { useProviderActions } from "@/hooks/useProviderActions";
|
||||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
import { AppSwitcher } from "@/components/AppSwitcher";
|
import { AppSwitcher } from "@/components/AppSwitcher";
|
||||||
@@ -19,9 +21,20 @@ import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
|
|||||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
import { SettingsDialog } from "@/components/settings/SettingsDialog";
|
import { SettingsDialog } from "@/components/settings/SettingsDialog";
|
||||||
import { UpdateBadge } from "@/components/UpdateBadge";
|
import { UpdateBadge } from "@/components/UpdateBadge";
|
||||||
|
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
|
||||||
import UsageScriptModal from "@/components/UsageScriptModal";
|
import UsageScriptModal from "@/components/UsageScriptModal";
|
||||||
import McpPanel from "@/components/mcp/McpPanel";
|
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
||||||
|
import PromptPanel from "@/components/prompts/PromptPanel";
|
||||||
|
import { SkillsPage } from "@/components/skills/SkillsPage";
|
||||||
|
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -31,9 +44,13 @@ function App() {
|
|||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
const [isMcpOpen, setIsMcpOpen] = useState(false);
|
const [isMcpOpen, setIsMcpOpen] = useState(false);
|
||||||
|
const [isPromptOpen, setIsPromptOpen] = useState(false);
|
||||||
|
const [isSkillsOpen, setIsSkillsOpen] = useState(false);
|
||||||
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
||||||
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
|
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
|
||||||
|
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
||||||
|
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading, refetch } = useProvidersQuery(activeApp);
|
const { data, isLoading, refetch } = useProvidersQuery(activeApp);
|
||||||
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
||||||
@@ -72,6 +89,58 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [activeApp, refetch]);
|
}, [activeApp, refetch]);
|
||||||
|
|
||||||
|
// 应用启动时检测所有应用的环境变量冲突
|
||||||
|
useEffect(() => {
|
||||||
|
const checkEnvOnStartup = async () => {
|
||||||
|
try {
|
||||||
|
const allConflicts = await checkAllEnvConflicts();
|
||||||
|
const flatConflicts = Object.values(allConflicts).flat();
|
||||||
|
|
||||||
|
if (flatConflicts.length > 0) {
|
||||||
|
setEnvConflicts(flatConflicts);
|
||||||
|
setShowEnvBanner(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[App] Failed to check environment conflicts on startup:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkEnvOnStartup();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 切换应用时检测当前应用的环境变量冲突
|
||||||
|
useEffect(() => {
|
||||||
|
const checkEnvOnSwitch = async () => {
|
||||||
|
try {
|
||||||
|
const conflicts = await checkEnvConflicts(activeApp);
|
||||||
|
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
// 合并新检测到的冲突
|
||||||
|
setEnvConflicts((prev) => {
|
||||||
|
const existingKeys = new Set(
|
||||||
|
prev.map((c) => `${c.varName}:${c.sourcePath}`),
|
||||||
|
);
|
||||||
|
const newConflicts = conflicts.filter(
|
||||||
|
(c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`),
|
||||||
|
);
|
||||||
|
return [...prev, ...newConflicts];
|
||||||
|
});
|
||||||
|
setShowEnvBanner(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[App] Failed to check environment conflicts on app switch:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkEnvOnSwitch();
|
||||||
|
}, [activeApp]);
|
||||||
|
|
||||||
// 打开网站链接
|
// 打开网站链接
|
||||||
const handleOpenWebsite = async (url: string) => {
|
const handleOpenWebsite = async (url: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -162,6 +231,30 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-gray-50 dark:bg-gray-950">
|
<div className="flex h-screen flex-col bg-gray-50 dark:bg-gray-950">
|
||||||
|
{/* 环境变量警告横幅 */}
|
||||||
|
{showEnvBanner && envConflicts.length > 0 && (
|
||||||
|
<EnvWarningBanner
|
||||||
|
conflicts={envConflicts}
|
||||||
|
onDismiss={() => setShowEnvBanner(false)}
|
||||||
|
onDeleted={async () => {
|
||||||
|
// 删除后重新检测
|
||||||
|
try {
|
||||||
|
const allConflicts = await checkAllEnvConflicts();
|
||||||
|
const flatConflicts = Object.values(allConflicts).flat();
|
||||||
|
setEnvConflicts(flatConflicts);
|
||||||
|
if (flatConflicts.length === 0) {
|
||||||
|
setShowEnvBanner(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[App] Failed to re-check conflicts after deletion:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<header className="flex-shrink-0 border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-800 dark:bg-gray-900">
|
<header className="flex-shrink-0 border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-800 dark:bg-gray-900">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -202,6 +295,13 @@ function App() {
|
|||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
||||||
|
<Button
|
||||||
|
variant="mcp"
|
||||||
|
onClick={() => setIsPromptOpen(true)}
|
||||||
|
className="min-w-[80px]"
|
||||||
|
>
|
||||||
|
{t("prompts.manage")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="mcp"
|
variant="mcp"
|
||||||
onClick={() => setIsMcpOpen(true)}
|
onClick={() => setIsMcpOpen(true)}
|
||||||
@@ -209,6 +309,13 @@ function App() {
|
|||||||
>
|
>
|
||||||
MCP
|
MCP
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="mcp"
|
||||||
|
onClick={() => setIsSkillsOpen(true)}
|
||||||
|
className="min-w-[80px]"
|
||||||
|
>
|
||||||
|
{t("skills.manage")}
|
||||||
|
</Button>
|
||||||
<Button onClick={() => setIsAddOpen(true)}>
|
<Button onClick={() => setIsAddOpen(true)}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
{t("header.addProvider")}
|
{t("header.addProvider")}
|
||||||
@@ -287,11 +394,25 @@ function App() {
|
|||||||
onImportSuccess={handleImportSuccess}
|
onImportSuccess={handleImportSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<McpPanel
|
<PromptPanel
|
||||||
open={isMcpOpen}
|
open={isPromptOpen}
|
||||||
onOpenChange={setIsMcpOpen}
|
onOpenChange={setIsPromptOpen}
|
||||||
appId={activeApp}
|
appId={activeApp}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<UnifiedMcpPanel open={isMcpOpen} onOpenChange={setIsMcpOpen} />
|
||||||
|
|
||||||
|
<Dialog open={isSkillsOpen} onOpenChange={setIsSkillsOpen}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[85vh] min-h-[600px] flex flex-col p-0">
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<VisuallyHidden>
|
||||||
|
<DialogTitle>{t("skills.title")}</DialogTitle>
|
||||||
|
</VisuallyHidden>
|
||||||
|
</DialogHeader>
|
||||||
|
<SkillsPage onClose={() => setIsSkillsOpen(false)} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<DeepLinkImportDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
import { ClaudeIcon, CodexIcon } from "./BrandIcons";
|
import { ClaudeIcon, CodexIcon, GeminiIcon } from "./BrandIcons";
|
||||||
|
|
||||||
interface AppSwitcherProps {
|
interface AppSwitcherProps {
|
||||||
activeApp: AppId;
|
activeApp: AppId;
|
||||||
@@ -46,6 +46,26 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
<CodexIcon size={16} />
|
<CodexIcon size={16} />
|
||||||
<span>Codex</span>
|
<span>Codex</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSwitch("gemini")}
|
||||||
|
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||||
|
activeApp === "gemini"
|
||||||
|
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
|
||||||
|
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GeminiIcon
|
||||||
|
size={16}
|
||||||
|
className={
|
||||||
|
activeApp === "gemini"
|
||||||
|
? "text-[#4285F4] dark:text-[#4285F4] transition-colors duration-200"
|
||||||
|
: "text-gray-500 dark:text-gray-400 group-hover:text-[#4285F4] dark:group-hover:text-[#4285F4] transition-colors duration-200"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>Gemini</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,3 +32,18 @@ export function CodexIcon({ size = 16, className = "" }: IconProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GeminiIcon({ size = 16, className = "" }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 1024 1024"
|
||||||
|
fill="currentColor"
|
||||||
|
className={className}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path d="M471.04 824.32Q512 918.4 512 1024q0-106.24.93-199.68.96-93.44 110.08-162.56t162.56-108.8Q918.4 512 1024 512q-106.24 0-199.68-39.68a524.8 524.8 0 0 1-162.56-110.08 524.8 524.8 0 0 1-110.08-162.56Q512 106.24 512 0q0 106.24-40.96 199.68-39.68 93.44-108.8 162.56a524.8 524.8 0 0 1-162.56 110.08Q106.24 512 0 512q106.24 0 199.68 40.96 93.44 39.68 162.56 108.8t108.8 162.56" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
204
src/components/DeepLinkImportDialog.tsx
Normal file
204
src/components/DeepLinkImportDialog.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
interface DeeplinkError {
|
||||||
|
url: string;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeepLinkImportDialog() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [request, setRequest] = useState<DeepLinkImportRequest | null>(null);
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Listen for deep link import events
|
||||||
|
const unlistenImport = listen<DeepLinkImportRequest>(
|
||||||
|
"deeplink-import",
|
||||||
|
(event) => {
|
||||||
|
console.log("Deep link import event received:", event.payload);
|
||||||
|
setRequest(event.payload);
|
||||||
|
setIsOpen(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listen for deep link error events
|
||||||
|
const unlistenError = listen<DeeplinkError>("deeplink-error", (event) => {
|
||||||
|
console.error("Deep link error:", event.payload);
|
||||||
|
toast.error(t("deeplink.parseError"), {
|
||||||
|
description: event.payload.error,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlistenImport.then((fn) => fn());
|
||||||
|
unlistenError.then((fn) => fn());
|
||||||
|
};
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!request) return;
|
||||||
|
|
||||||
|
setIsImporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deeplinkApi.importFromDeeplink(request);
|
||||||
|
|
||||||
|
// Invalidate provider queries to refresh the list
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["providers", request.app],
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(t("deeplink.importSuccess"), {
|
||||||
|
description: t("deeplink.importSuccessDescription", {
|
||||||
|
name: request.name,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
setRequest(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to import provider from deep link:", error);
|
||||||
|
toast.error(t("deeplink.importError"), {
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setRequest(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!request) return null;
|
||||||
|
|
||||||
|
// Mask API key for display (show first 4 chars + ***)
|
||||||
|
const maskedApiKey =
|
||||||
|
request.apiKey.length > 4
|
||||||
|
? `${request.apiKey.substring(0, 4)}${"*".repeat(20)}`
|
||||||
|
: "****";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
{/* 标题显式左对齐,避免默认居中样式影响 */}
|
||||||
|
<DialogHeader className="text-left sm:text-left">
|
||||||
|
<DialogTitle>{t("deeplink.confirmImport")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("deeplink.confirmImportDescription")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 主体内容整体右移,略大于标题内边距,让内容看起来不贴边 */}
|
||||||
|
<div className="space-y-4 px-8 py-4">
|
||||||
|
{/* App Type */}
|
||||||
|
<div className="grid grid-cols-3 items-center gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.app")}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm font-medium capitalize">
|
||||||
|
{request.app}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provider Name */}
|
||||||
|
<div className="grid grid-cols-3 items-center gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.providerName")}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm font-medium">{request.name}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Homepage */}
|
||||||
|
<div className="grid grid-cols-3 items-center gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.homepage")}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm break-all text-blue-600 dark:text-blue-400">
|
||||||
|
{request.homepage}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Endpoint */}
|
||||||
|
<div className="grid grid-cols-3 items-center gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.endpoint")}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm break-all">
|
||||||
|
{request.endpoint}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Key (masked) */}
|
||||||
|
<div className="grid grid-cols-3 items-center gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.apiKey")}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm font-mono text-muted-foreground">
|
||||||
|
{maskedApiKey}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model (if present) */}
|
||||||
|
{request.model && (
|
||||||
|
<div className="grid grid-cols-3 items-center gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.model")}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm font-mono">
|
||||||
|
{request.model}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes (if present) */}
|
||||||
|
{request.notes && (
|
||||||
|
<div className="grid grid-cols-3 items-start gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.notes")}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm text-muted-foreground">
|
||||||
|
{request.notes}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
|
{t("deeplink.warning")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isImporting}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleImport} disabled={isImporting}>
|
||||||
|
{isImporting ? t("deeplink.importing") : t("deeplink.import")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
src/components/MarkdownEditor.tsx
Normal file
159
src/components/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React, { useRef, useEffect } from "react";
|
||||||
|
import { EditorView, basicSetup } from "codemirror";
|
||||||
|
import { markdown } from "@codemirror/lang-markdown";
|
||||||
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
|
import { EditorState } from "@codemirror/state";
|
||||||
|
import { placeholder as placeholderExt } from "@codemirror/view";
|
||||||
|
|
||||||
|
interface MarkdownEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
darkMode?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
className?: string;
|
||||||
|
minHeight?: string;
|
||||||
|
maxHeight?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder: placeholderText = "",
|
||||||
|
darkMode = false,
|
||||||
|
readOnly = false,
|
||||||
|
className = "",
|
||||||
|
minHeight = "300px",
|
||||||
|
maxHeight,
|
||||||
|
}) => {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
|
// 定义基础主题
|
||||||
|
const baseTheme = EditorView.baseTheme({
|
||||||
|
"&": {
|
||||||
|
height: "100%",
|
||||||
|
minHeight,
|
||||||
|
maxHeight: maxHeight || "none",
|
||||||
|
},
|
||||||
|
".cm-scroller": {
|
||||||
|
overflow: "auto",
|
||||||
|
fontFamily:
|
||||||
|
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||||
|
fontSize: "14px",
|
||||||
|
},
|
||||||
|
"&light .cm-content, &dark .cm-content": {
|
||||||
|
padding: "12px 0",
|
||||||
|
},
|
||||||
|
"&light .cm-editor, &dark .cm-editor": {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
"&.cm-focused": {
|
||||||
|
outline: "none",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const extensions = [
|
||||||
|
basicSetup,
|
||||||
|
markdown(),
|
||||||
|
baseTheme,
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
EditorState.readOnly.of(readOnly),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!readOnly) {
|
||||||
|
extensions.push(
|
||||||
|
placeholderExt(placeholderText),
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged && onChange) {
|
||||||
|
onChange(update.state.doc.toString());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 只读模式下隐藏光标和高亮行
|
||||||
|
extensions.push(
|
||||||
|
EditorView.theme({
|
||||||
|
".cm-cursor, .cm-dropCursor": { border: "none" },
|
||||||
|
".cm-activeLine": { backgroundColor: "transparent !important" },
|
||||||
|
".cm-activeLineGutter": { backgroundColor: "transparent !important" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果启用深色模式,添加深色主题
|
||||||
|
if (darkMode) {
|
||||||
|
extensions.push(oneDark);
|
||||||
|
} else {
|
||||||
|
// 浅色模式下的简单样式调整,使其更融入 UI
|
||||||
|
extensions.push(
|
||||||
|
EditorView.theme(
|
||||||
|
{
|
||||||
|
"&": {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
".cm-content": {
|
||||||
|
color: "#374151", // text-gray-700
|
||||||
|
},
|
||||||
|
".cm-gutters": {
|
||||||
|
backgroundColor: "#f9fafb", // bg-gray-50
|
||||||
|
color: "#9ca3af", // text-gray-400
|
||||||
|
borderRight: "1px solid #e5e7eb", // border-gray-200
|
||||||
|
},
|
||||||
|
".cm-activeLineGutter": {
|
||||||
|
backgroundColor: "#e5e7eb",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ dark: false },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建初始状态
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: value,
|
||||||
|
extensions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建编辑器视图
|
||||||
|
const view = new EditorView({
|
||||||
|
state,
|
||||||
|
parent: editorRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
viewRef.current = view;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
view.destroy();
|
||||||
|
viewRef.current = null;
|
||||||
|
};
|
||||||
|
}, [darkMode, readOnly, minHeight, maxHeight, placeholderText]); // 添加 placeholderText 依赖以支持国际化切换
|
||||||
|
|
||||||
|
// 当 value 从外部改变时更新编辑器内容
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewRef.current && viewRef.current.state.doc.toString() !== value) {
|
||||||
|
const transaction = viewRef.current.state.update({
|
||||||
|
changes: {
|
||||||
|
from: 0,
|
||||||
|
to: viewRef.current.state.doc.length,
|
||||||
|
insert: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
viewRef.current.dispatch(transaction);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
className={`border rounded-md overflow-hidden ${
|
||||||
|
darkMode ? "border-gray-800" : "border-gray-200"
|
||||||
|
} ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarkdownEditor;
|
||||||
@@ -131,6 +131,86 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
|
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
|
|
||||||
|
// 🔧 输入时的格式化(宽松)- 只清理格式,不约束范围
|
||||||
|
const sanitizeNumberInput = (value: string): string => {
|
||||||
|
// 移除所有非数字字符
|
||||||
|
let cleaned = value.replace(/[^\d]/g, "");
|
||||||
|
|
||||||
|
// 移除前导零(除非输入的就是 "0")
|
||||||
|
if (cleaned.length > 1 && cleaned.startsWith("0")) {
|
||||||
|
cleaned = cleaned.replace(/^0+/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🔧 失焦时的验证(严格)- 仅确保有效整数
|
||||||
|
const validateTimeout = (value: string): number => {
|
||||||
|
// 转换为数字
|
||||||
|
const num = Number(value);
|
||||||
|
|
||||||
|
// 检查是否为有效数字
|
||||||
|
if (isNaN(num) || value.trim() === "") {
|
||||||
|
return 10; // 默认值
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为整数
|
||||||
|
if (!Number.isInteger(num)) {
|
||||||
|
toast.warning(
|
||||||
|
t("usageScript.timeoutMustBeInteger") || "超时时间必须为整数",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查负数
|
||||||
|
if (num < 0) {
|
||||||
|
toast.error(
|
||||||
|
t("usageScript.timeoutCannotBeNegative") || "超时时间不能为负数",
|
||||||
|
);
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.floor(num);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🔧 失焦时的验证(严格)- 自动查询间隔
|
||||||
|
const validateAndClampInterval = (value: string): number => {
|
||||||
|
// 转换为数字
|
||||||
|
const num = Number(value);
|
||||||
|
|
||||||
|
// 检查是否为有效数字
|
||||||
|
if (isNaN(num) || value.trim() === "") {
|
||||||
|
return 0; // 禁用自动查询
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为整数
|
||||||
|
if (!Number.isInteger(num)) {
|
||||||
|
toast.warning(
|
||||||
|
t("usageScript.intervalMustBeInteger") || "自动查询间隔必须为整数",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查负数
|
||||||
|
if (num < 0) {
|
||||||
|
toast.error(
|
||||||
|
t("usageScript.intervalCannotBeNegative") || "自动查询间隔不能为负数",
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 约束到 [0, 1440] 范围(最大24小时)
|
||||||
|
const clamped = Math.max(0, Math.min(1440, Math.floor(num)));
|
||||||
|
|
||||||
|
// 如果值被调整,显示提示
|
||||||
|
if (clamped !== num && num > 0) {
|
||||||
|
toast.info(
|
||||||
|
t("usageScript.intervalAdjusted", { value: clamped }) ||
|
||||||
|
`自动查询间隔已调整为 ${clamped} 分钟`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return clamped;
|
||||||
|
};
|
||||||
|
|
||||||
// 跟踪当前选择的模板类型(用于控制高级配置的显示)
|
// 跟踪当前选择的模板类型(用于控制高级配置的显示)
|
||||||
// 初始化:如果已有 accessToken 或 userId,说明是 NewAPI 模板
|
// 初始化:如果已有 accessToken 或 userId,说明是 NewAPI 模板
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
|
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
|
||||||
@@ -267,7 +347,8 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
|
|
||||||
// 判断是否应该显示凭证配置区域
|
// 判断是否应该显示凭证配置区域
|
||||||
const shouldShowCredentialsConfig =
|
const shouldShowCredentialsConfig =
|
||||||
selectedTemplate === TEMPLATE_KEYS.GENERAL || selectedTemplate === TEMPLATE_KEYS.NEW_API;
|
selectedTemplate === TEMPLATE_KEYS.GENERAL ||
|
||||||
|
selectedTemplate === TEMPLATE_KEYS.NEW_API;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
@@ -334,9 +415,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
|
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="usage-api-key">
|
<Label htmlFor="usage-api-key">API Key</Label>
|
||||||
API Key
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="usage-api-key"
|
id="usage-api-key"
|
||||||
@@ -353,18 +432,24 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowApiKey(!showApiKey)}
|
onClick={() => setShowApiKey(!showApiKey)}
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||||
aria-label={showApiKey ? t("apiKeyInput.hide") : t("apiKeyInput.show")}
|
aria-label={
|
||||||
|
showApiKey
|
||||||
|
? t("apiKeyInput.hide")
|
||||||
|
: t("apiKeyInput.show")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
{showApiKey ? (
|
||||||
|
<EyeOff size={16} />
|
||||||
|
) : (
|
||||||
|
<Eye size={16} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="usage-base-url">
|
<Label htmlFor="usage-base-url">Base URL</Label>
|
||||||
Base URL
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="usage-base-url"
|
id="usage-base-url"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -383,9 +468,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
{selectedTemplate === TEMPLATE_KEYS.NEW_API && (
|
{selectedTemplate === TEMPLATE_KEYS.NEW_API && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="usage-newapi-base-url">
|
<Label htmlFor="usage-newapi-base-url">Base URL</Label>
|
||||||
Base URL
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="usage-newapi-base-url"
|
id="usage-newapi-base-url"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -408,19 +491,34 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
type={showAccessToken ? "text" : "password"}
|
type={showAccessToken ? "text" : "password"}
|
||||||
value={script.accessToken || ""}
|
value={script.accessToken || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setScript({ ...script, accessToken: e.target.value })
|
setScript({
|
||||||
|
...script,
|
||||||
|
accessToken: e.target.value,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
placeholder={t("usageScript.accessTokenPlaceholder")}
|
placeholder={t(
|
||||||
|
"usageScript.accessTokenPlaceholder",
|
||||||
|
)}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
{script.accessToken && (
|
{script.accessToken && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowAccessToken(!showAccessToken)}
|
onClick={() =>
|
||||||
|
setShowAccessToken(!showAccessToken)
|
||||||
|
}
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||||
aria-label={showAccessToken ? t("apiKeyInput.hide") : t("apiKeyInput.show")}
|
aria-label={
|
||||||
|
showAccessToken
|
||||||
|
? t("apiKeyInput.hide")
|
||||||
|
: t("apiKeyInput.show")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{showAccessToken ? <EyeOff size={16} /> : <Eye size={16} />}
|
{showAccessToken ? (
|
||||||
|
<EyeOff size={16} />
|
||||||
|
) : (
|
||||||
|
<Eye size={16} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -448,9 +546,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
|
|
||||||
{/* 脚本编辑器 */}
|
{/* 脚本编辑器 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-2">
|
<Label className="mb-2">{t("usageScript.queryScript")}</Label>
|
||||||
{t("usageScript.queryScript")}
|
|
||||||
</Label>
|
|
||||||
<JsonEditor
|
<JsonEditor
|
||||||
value={script.code}
|
value={script.code}
|
||||||
onChange={(code) => setScript({ ...script, code })}
|
onChange={(code) => setScript({ ...script, code })}
|
||||||
@@ -474,16 +570,25 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
<Input
|
<Input
|
||||||
id="usage-timeout"
|
id="usage-timeout"
|
||||||
type="number"
|
type="number"
|
||||||
min={2}
|
value={script.timeout ?? ""}
|
||||||
max={30}
|
onChange={(e) => {
|
||||||
value={script.timeout || 10}
|
// 输入时:只清理格式,允许临时为空,避免强制回填默认值
|
||||||
onChange={(e) =>
|
const cleaned = sanitizeNumberInput(e.target.value);
|
||||||
setScript({
|
setScript((prev) => ({
|
||||||
...script,
|
...prev,
|
||||||
timeout: parseInt(e.target.value),
|
timeout:
|
||||||
})
|
cleaned === "" ? undefined : parseInt(cleaned, 10),
|
||||||
}
|
}));
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
// 失焦时:严格验证并约束范围
|
||||||
|
const validated = validateTimeout(e.target.value);
|
||||||
|
setScript({ ...script, timeout: validated });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("usageScript.timeoutHint") || "范围: 2-30 秒"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 🆕 自动查询间隔 */}
|
{/* 🆕 自动查询间隔 */}
|
||||||
@@ -497,13 +602,23 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
min={0}
|
min={0}
|
||||||
max={1440}
|
max={1440}
|
||||||
step={1}
|
step={1}
|
||||||
value={script.autoQueryInterval || 0}
|
value={script.autoQueryInterval ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
setScript({
|
// 输入时:只清理格式,允许临时为空
|
||||||
...script,
|
const cleaned = sanitizeNumberInput(e.target.value);
|
||||||
autoQueryInterval: parseInt(e.target.value) || 0,
|
setScript((prev) => ({
|
||||||
})
|
...prev,
|
||||||
}
|
autoQueryInterval:
|
||||||
|
cleaned === "" ? undefined : parseInt(cleaned, 10),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
// 失焦时:严格验证并约束范围
|
||||||
|
const validated = validateAndClampInterval(
|
||||||
|
e.target.value,
|
||||||
|
);
|
||||||
|
setScript({ ...script, autoQueryInterval: validated });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("usageScript.autoQueryIntervalHint")}
|
{t("usageScript.autoQueryIntervalHint")}
|
||||||
|
|||||||
274
src/components/env/EnvWarningBanner.tsx
vendored
Normal file
274
src/components/env/EnvWarningBanner.tsx
vendored
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AlertTriangle, ChevronDown, ChevronUp, X, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import type { EnvConflict } from "@/types/env";
|
||||||
|
import { deleteEnvVars } from "@/lib/api/env";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
interface EnvWarningBannerProps {
|
||||||
|
conflicts: EnvConflict[];
|
||||||
|
onDismiss: () => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnvWarningBanner({
|
||||||
|
conflicts,
|
||||||
|
onDismiss,
|
||||||
|
onDeleted,
|
||||||
|
}: EnvWarningBannerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [selectedConflicts, setSelectedConflicts] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
|
|
||||||
|
if (conflicts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSelection = (key: string) => {
|
||||||
|
const newSelection = new Set(selectedConflicts);
|
||||||
|
if (newSelection.has(key)) {
|
||||||
|
newSelection.delete(key);
|
||||||
|
} else {
|
||||||
|
newSelection.add(key);
|
||||||
|
}
|
||||||
|
setSelectedConflicts(newSelection);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (selectedConflicts.size === conflicts.length) {
|
||||||
|
setSelectedConflicts(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedConflicts(
|
||||||
|
new Set(conflicts.map((c) => `${c.varName}:${c.sourcePath}`)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setShowConfirmDialog(false);
|
||||||
|
setIsDeleting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const conflictsToDelete = conflicts.filter((c) =>
|
||||||
|
selectedConflicts.has(`${c.varName}:${c.sourcePath}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (conflictsToDelete.length === 0) {
|
||||||
|
toast.warning(t("env.error.noSelection"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupInfo = await deleteEnvVars(conflictsToDelete);
|
||||||
|
|
||||||
|
toast.success(t("env.delete.success"), {
|
||||||
|
description: t("env.backup.location", {
|
||||||
|
path: backupInfo.backupPath,
|
||||||
|
}),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清空选择并通知父组件
|
||||||
|
setSelectedConflicts(new Set());
|
||||||
|
onDeleted();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("删除环境变量失败:", error);
|
||||||
|
toast.error(t("env.delete.error"), {
|
||||||
|
description: String(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSourceDescription = (conflict: EnvConflict): string => {
|
||||||
|
if (conflict.sourceType === "system") {
|
||||||
|
if (conflict.sourcePath.includes("HKEY_CURRENT_USER")) {
|
||||||
|
return t("env.source.userRegistry");
|
||||||
|
} else if (conflict.sourcePath.includes("HKEY_LOCAL_MACHINE")) {
|
||||||
|
return t("env.source.systemRegistry");
|
||||||
|
} else {
|
||||||
|
return t("env.source.systemEnv");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return conflict.sourcePath;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-950/20 border-b border-yellow-200 dark:border-yellow-900/50">
|
||||||
|
<div className="container mx-auto px-4 py-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-yellow-900 dark:text-yellow-100">
|
||||||
|
{t("env.warning.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-yellow-800 dark:text-yellow-200 mt-0.5">
|
||||||
|
{t("env.warning.description", { count: conflicts.length })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="text-yellow-900 dark:text-yellow-100 hover:bg-yellow-100 dark:hover:bg-yellow-900/50"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<>
|
||||||
|
{t("env.actions.collapse")}
|
||||||
|
<ChevronUp className="h-4 w-4 ml-1" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t("env.actions.expand")}
|
||||||
|
<ChevronDown className="h-4 w-4 ml-1" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="text-yellow-900 dark:text-yellow-100 hover:bg-yellow-100 dark:hover:bg-yellow-900/50"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 pb-2 border-b border-yellow-200 dark:border-yellow-900/50">
|
||||||
|
<Checkbox
|
||||||
|
id="select-all"
|
||||||
|
checked={selectedConflicts.size === conflicts.length}
|
||||||
|
onCheckedChange={toggleSelectAll}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="select-all"
|
||||||
|
className="text-sm font-medium text-yellow-900 dark:text-yellow-100 cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("env.actions.selectAll")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-96 overflow-y-auto space-y-2">
|
||||||
|
{conflicts.map((conflict) => {
|
||||||
|
const key = `${conflict.varName}:${conflict.sourcePath}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-start gap-3 p-3 bg-white dark:bg-gray-900 rounded-md border border-yellow-200 dark:border-yellow-900/50"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={key}
|
||||||
|
checked={selectedConflicts.has(key)}
|
||||||
|
onCheckedChange={() => toggleSelection(key)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<label
|
||||||
|
htmlFor={key}
|
||||||
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||||
|
>
|
||||||
|
{conflict.varName}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 break-all">
|
||||||
|
{t("env.field.value")}: {conflict.varValue}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||||
|
{t("env.field.source")}:{" "}
|
||||||
|
{getSourceDescription(conflict)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2 border-t border-yellow-200 dark:border-yellow-900/50">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedConflicts(new Set())}
|
||||||
|
disabled={selectedConflicts.size === 0}
|
||||||
|
className="text-yellow-900 dark:text-yellow-100 border-yellow-300 dark:border-yellow-800"
|
||||||
|
>
|
||||||
|
{t("env.actions.clearSelection")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowConfirmDialog(true)}
|
||||||
|
disabled={selectedConflicts.size === 0 || isDeleting}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{isDeleting
|
||||||
|
? t("env.actions.deleting")
|
||||||
|
: t("env.actions.deleteSelected", {
|
||||||
|
count: selectedConflicts.size,
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
{t("env.confirm.title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="space-y-2">
|
||||||
|
<p>
|
||||||
|
{t("env.confirm.message", { count: selectedConflicts.size })}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("env.confirm.backupNotice")}
|
||||||
|
</p>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowConfirmDialog(false)}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete}>
|
||||||
|
{t("env.confirm.confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useState, useEffect } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -7,9 +7,10 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
AlertTriangle,
|
Wand2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { mcpApi, type AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api/types";
|
||||||
import { McpServer, McpServerSpec } from "@/types";
|
import { McpServer, McpServerSpec } from "@/types";
|
||||||
import { mcpPresets, getMcpPresetWithDescription } from "@/config/mcpPresets";
|
import { mcpPresets, getMcpPresetWithDescription } from "@/config/mcpPresets";
|
||||||
import McpWizardModal from "./McpWizardModal";
|
import McpWizardModal from "./McpWizardModal";
|
||||||
@@ -33,38 +34,40 @@ import {
|
|||||||
mcpServerToToml,
|
mcpServerToToml,
|
||||||
} from "@/utils/tomlUtils";
|
} from "@/utils/tomlUtils";
|
||||||
import { normalizeTomlText } from "@/utils/textNormalization";
|
import { normalizeTomlText } from "@/utils/textNormalization";
|
||||||
|
import { formatJSON, parseSmartMcpJson } from "@/utils/formatters";
|
||||||
import { useMcpValidation } from "./useMcpValidation";
|
import { useMcpValidation } from "./useMcpValidation";
|
||||||
|
import { useUpsertMcpServer } from "@/hooks/useMcp";
|
||||||
|
|
||||||
interface McpFormModalProps {
|
interface McpFormModalProps {
|
||||||
appId: AppId;
|
|
||||||
editingId?: string;
|
editingId?: string;
|
||||||
initialData?: McpServer;
|
initialData?: McpServer;
|
||||||
onSave: (
|
onSave: () => Promise<void>; // v3.7.0: 简化为仅用于关闭表单的回调
|
||||||
id: string,
|
|
||||||
server: McpServer,
|
|
||||||
options?: { syncOtherSide?: boolean },
|
|
||||||
) => Promise<void>;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
existingIds?: string[];
|
existingIds?: string[];
|
||||||
|
defaultFormat?: "json" | "toml"; // 默认配置格式(可选,默认为 JSON)
|
||||||
|
defaultEnabledApps?: AppId[]; // 默认启用到哪些应用(可选,默认为全部应用)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP 表单模态框组件(简化版)
|
* MCP 表单模态框组件(v3.7.0 完整重构版)
|
||||||
* Claude: 使用 JSON 格式
|
* - 支持 JSON 和 TOML 两种格式
|
||||||
* Codex: 使用 TOML 格式
|
* - 统一管理,通过复选框选择启用到哪些应用
|
||||||
*/
|
*/
|
||||||
const McpFormModal: React.FC<McpFormModalProps> = ({
|
const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||||
appId,
|
|
||||||
editingId,
|
editingId,
|
||||||
initialData,
|
initialData,
|
||||||
onSave,
|
onSave,
|
||||||
onClose,
|
onClose,
|
||||||
existingIds = [],
|
existingIds = [],
|
||||||
|
defaultFormat = "json",
|
||||||
|
defaultEnabledApps = ["claude", "codex", "gemini"],
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
|
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
|
||||||
useMcpValidation();
|
useMcpValidation();
|
||||||
|
|
||||||
|
const upsertMutation = useUpsertMcpServer();
|
||||||
|
|
||||||
const [formId, setFormId] = useState(
|
const [formId, setFormId] = useState(
|
||||||
() => editingId || initialData?.id || "",
|
() => editingId || initialData?.id || "",
|
||||||
);
|
);
|
||||||
@@ -76,6 +79,23 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
const [formDocs, setFormDocs] = useState(initialData?.docs || "");
|
const [formDocs, setFormDocs] = useState(initialData?.docs || "");
|
||||||
const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || "");
|
const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || "");
|
||||||
|
|
||||||
|
// 启用状态:编辑模式使用现有值,新增模式使用默认值
|
||||||
|
const [enabledApps, setEnabledApps] = useState<{
|
||||||
|
claude: boolean;
|
||||||
|
codex: boolean;
|
||||||
|
gemini: boolean;
|
||||||
|
}>(() => {
|
||||||
|
if (initialData?.apps) {
|
||||||
|
return { ...initialData.apps };
|
||||||
|
}
|
||||||
|
// 新增模式:根据 defaultEnabledApps 设置初始值
|
||||||
|
return {
|
||||||
|
claude: defaultEnabledApps.includes("claude"),
|
||||||
|
codex: defaultEnabledApps.includes("codex"),
|
||||||
|
gemini: defaultEnabledApps.includes("gemini"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// 编辑模式下禁止修改 ID
|
// 编辑模式下禁止修改 ID
|
||||||
const isEditing = !!editingId;
|
const isEditing = !!editingId;
|
||||||
|
|
||||||
@@ -92,11 +112,20 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
isEditing ? hasAdditionalInfo : false,
|
isEditing ? hasAdditionalInfo : false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 根据 appId 决定初始格式
|
// 配置格式:优先使用 defaultFormat,编辑模式下可从现有数据推断
|
||||||
|
const useTomlFormat = useMemo(() => {
|
||||||
|
if (initialData?.server) {
|
||||||
|
// 编辑模式:尝试从现有数据推断格式(这里简化处理,默认 JSON)
|
||||||
|
return defaultFormat === "toml";
|
||||||
|
}
|
||||||
|
return defaultFormat === "toml";
|
||||||
|
}, [defaultFormat, initialData]);
|
||||||
|
|
||||||
|
// 根据格式决定初始配置
|
||||||
const [formConfig, setFormConfig] = useState(() => {
|
const [formConfig, setFormConfig] = useState(() => {
|
||||||
const spec = initialData?.server;
|
const spec = initialData?.server;
|
||||||
if (!spec) return "";
|
if (!spec) return "";
|
||||||
if (appId === "codex") {
|
if (useTomlFormat) {
|
||||||
return mcpServerToToml(spec);
|
return mcpServerToToml(spec);
|
||||||
}
|
}
|
||||||
return JSON.stringify(spec, null, 2);
|
return JSON.stringify(spec, null, 2);
|
||||||
@@ -106,39 +135,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||||
const [idError, setIdError] = useState("");
|
const [idError, setIdError] = useState("");
|
||||||
const [syncOtherSide, setSyncOtherSide] = useState(false);
|
|
||||||
const [otherSideHasConflict, setOtherSideHasConflict] = useState(false);
|
|
||||||
|
|
||||||
// 判断是否使用 TOML 格式
|
// 判断是否使用 TOML 格式(向后兼容,后续可扩展为格式切换按钮)
|
||||||
const useToml = appId === "codex";
|
const useToml = useTomlFormat;
|
||||||
const syncTargetLabel =
|
|
||||||
appId === "claude" ? t("apps.codex") : t("apps.claude");
|
|
||||||
const otherAppType: AppId = appId === "claude" ? "codex" : "claude";
|
|
||||||
const syncCheckboxId = useMemo(() => `sync-other-side-${appId}`, [appId]);
|
|
||||||
|
|
||||||
// 检测另一侧是否有同名 MCP
|
|
||||||
useEffect(() => {
|
|
||||||
const checkOtherSide = async () => {
|
|
||||||
const currentId = formId.trim();
|
|
||||||
if (!currentId) {
|
|
||||||
setOtherSideHasConflict(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const otherConfig = await mcpApi.getConfig(otherAppType);
|
|
||||||
const hasConflict = Object.keys(otherConfig.servers || {}).includes(
|
|
||||||
currentId,
|
|
||||||
);
|
|
||||||
setOtherSideHasConflict(hasConflict);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("检查另一侧 MCP 配置失败:", error);
|
|
||||||
setOtherSideHasConflict(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkOtherSide();
|
|
||||||
}, [formId, otherAppType]);
|
|
||||||
|
|
||||||
const wizardInitialSpec = useMemo(() => {
|
const wizardInitialSpec = useMemo(() => {
|
||||||
const fallback = initialData?.server;
|
const fallback = initialData?.server;
|
||||||
@@ -249,15 +248,57 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// JSON validation (use hook's complete validation)
|
// JSON validation with smart parsing
|
||||||
const err = validateJsonConfig(value);
|
try {
|
||||||
if (err) {
|
const result = parseSmartMcpJson(value);
|
||||||
setConfigError(err);
|
|
||||||
|
// 验证解析后的配置对象
|
||||||
|
const configJson = JSON.stringify(result.config);
|
||||||
|
const validationErr = validateJsonConfig(configJson);
|
||||||
|
|
||||||
|
if (validationErr) {
|
||||||
|
setConfigError(validationErr);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自动填充提取的 id(仅当表单 id 为空且不在编辑模式时)
|
||||||
|
if (result.id && !formId.trim() && !isEditing) {
|
||||||
|
const uniqueId = ensureUniqueId(result.id);
|
||||||
|
setFormId(uniqueId);
|
||||||
|
|
||||||
|
// 如果 name 也为空,同时填充 name
|
||||||
|
if (!formName.trim()) {
|
||||||
|
setFormName(result.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 不在输入时自动格式化,保持用户输入的原样
|
||||||
|
// 格式清理将在提交时进行
|
||||||
|
|
||||||
setConfigError("");
|
setConfigError("");
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage = err?.message || String(err);
|
||||||
|
setConfigError(t("mcp.error.jsonInvalid") + ": " + errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormatJson = () => {
|
||||||
|
if (!formConfig.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formatted = formatJSON(formConfig);
|
||||||
|
setFormConfig(formatted);
|
||||||
|
toast.success(t("common.formatSuccess"));
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
toast.error(
|
||||||
|
t("common.formatError", {
|
||||||
|
error: errorMessage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWizardApply = (title: string, json: string) => {
|
const handleWizardApply = (title: string, json: string) => {
|
||||||
@@ -325,13 +366,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// JSON mode
|
// JSON mode
|
||||||
const jsonError = validateJsonConfig(formConfig);
|
|
||||||
setConfigError(jsonError);
|
|
||||||
if (jsonError) {
|
|
||||||
toast.error(t("mcp.error.jsonInvalid"), { duration: 3000 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formConfig.trim()) {
|
if (!formConfig.trim()) {
|
||||||
// Empty configuration
|
// Empty configuration
|
||||||
serverSpec = {
|
serverSpec = {
|
||||||
@@ -341,9 +375,12 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
serverSpec = JSON.parse(formConfig) as McpServerSpec;
|
// 使用智能解析器,支持带外层键的格式
|
||||||
|
const result = parseSmartMcpJson(formConfig);
|
||||||
|
serverSpec = result.config as McpServerSpec;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setConfigError(t("mcp.error.jsonInvalid"));
|
const errorMessage = e?.message || String(e);
|
||||||
|
setConfigError(t("mcp.error.jsonInvalid") + ": " + errorMessage);
|
||||||
toast.error(t("mcp.error.jsonInvalid"), { duration: 4000 });
|
toast.error(t("mcp.error.jsonInvalid"), { duration: 4000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -355,31 +392,29 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
toast.error(t("mcp.error.commandRequired"), { duration: 3000 });
|
toast.error(t("mcp.error.commandRequired"), { duration: 3000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (serverSpec?.type === "http" && !serverSpec?.url?.trim()) {
|
if (
|
||||||
|
(serverSpec?.type === "http" || serverSpec?.type === "sse") &&
|
||||||
|
!serverSpec?.url?.trim()
|
||||||
|
) {
|
||||||
toast.error(t("mcp.wizard.urlRequired"), { duration: 3000 });
|
toast.error(t("mcp.wizard.urlRequired"), { duration: 3000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
// 先处理 name 字段(必填)
|
||||||
|
const nameTrimmed = (formName || trimmedId).trim();
|
||||||
|
const finalName = nameTrimmed || trimmedId;
|
||||||
|
|
||||||
const entry: McpServer = {
|
const entry: McpServer = {
|
||||||
...(initialData ? { ...initialData } : {}),
|
...(initialData ? { ...initialData } : {}),
|
||||||
id: trimmedId,
|
id: trimmedId,
|
||||||
|
name: finalName,
|
||||||
server: serverSpec,
|
server: serverSpec,
|
||||||
|
// 使用表单中的启用状态(v3.7.0 完整重构)
|
||||||
|
apps: enabledApps,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 修复:新增 MCP 时默认启用(enabled=true)
|
|
||||||
// 编辑模式下保留原有的 enabled 状态
|
|
||||||
if (initialData?.enabled !== undefined) {
|
|
||||||
entry.enabled = initialData.enabled;
|
|
||||||
} else {
|
|
||||||
// 新增模式:默认启用
|
|
||||||
entry.enabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nameTrimmed = (formName || trimmedId).trim();
|
|
||||||
entry.name = nameTrimmed || trimmedId;
|
|
||||||
|
|
||||||
const descriptionTrimmed = formDescription.trim();
|
const descriptionTrimmed = formDescription.trim();
|
||||||
if (descriptionTrimmed) {
|
if (descriptionTrimmed) {
|
||||||
entry.description = descriptionTrimmed;
|
entry.description = descriptionTrimmed;
|
||||||
@@ -411,8 +446,10 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
delete entry.tags;
|
delete entry.tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显式等待父组件保存流程
|
// 保存到统一配置
|
||||||
await onSave(trimmedId, entry, { syncOtherSide });
|
await upsertMutation.mutateAsync(entry);
|
||||||
|
toast.success(t("common.success"));
|
||||||
|
await onSave(); // 通知父组件关闭表单
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const detail = extractErrorMessage(error);
|
const detail = extractErrorMessage(error);
|
||||||
const mapped = translateMcpBackendError(detail, t);
|
const mapped = translateMcpBackendError(detail, t);
|
||||||
@@ -424,11 +461,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getFormTitle = () => {
|
const getFormTitle = () => {
|
||||||
if (appId === "claude") {
|
return isEditing ? t("mcp.editServer") : t("mcp.addServer");
|
||||||
return isEditing ? t("mcp.editClaudeServer") : t("mcp.addClaudeServer");
|
|
||||||
} else {
|
|
||||||
return isEditing ? t("mcp.editCodexServer") : t("mcp.addCodexServer");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -514,6 +547,62 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 启用到哪些应用(v3.7.0 新增) */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
{t("mcp.form.enabledApps")}
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="enable-claude"
|
||||||
|
checked={enabledApps.claude}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
setEnabledApps({ ...enabledApps, claude: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="enable-claude"
|
||||||
|
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
{t("mcp.unifiedPanel.apps.claude")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="enable-codex"
|
||||||
|
checked={enabledApps.codex}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
setEnabledApps({ ...enabledApps, codex: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="enable-codex"
|
||||||
|
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
{t("mcp.unifiedPanel.apps.codex")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="enable-gemini"
|
||||||
|
checked={enabledApps.gemini}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
setEnabledApps({ ...enabledApps, gemini: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="enable-gemini"
|
||||||
|
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
{t("mcp.unifiedPanel.apps.gemini")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 可折叠的附加信息按钮 */}
|
{/* 可折叠的附加信息按钮 */}
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
@@ -590,7 +679,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
{/* 配置输入框(根据格式显示 JSON 或 TOML) */}
|
{/* 配置输入框(根据格式显示 JSON 或 TOML) */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{useToml
|
{useToml
|
||||||
? t("mcp.form.tomlConfig")
|
? t("mcp.form.tomlConfig")
|
||||||
: t("mcp.form.jsonConfig")}
|
: t("mcp.form.jsonConfig")}
|
||||||
@@ -615,6 +704,19 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
value={formConfig}
|
value={formConfig}
|
||||||
onChange={(e) => handleConfigChange(e.target.value)}
|
onChange={(e) => handleConfigChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{/* 格式化按钮(仅 JSON 模式) */}
|
||||||
|
{!useToml && (
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFormatJson}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Wand2 className="w-3.5 h-3.5" />
|
||||||
|
{t("common.format")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{configError && (
|
{configError && (
|
||||||
<div className="flex items-center gap-2 mt-2 text-red-500 dark:text-red-400 text-sm">
|
<div className="flex items-center gap-2 mt-2 text-red-500 dark:text-red-400 text-sm">
|
||||||
<AlertCircle size={16} />
|
<AlertCircle size={16} />
|
||||||
@@ -625,41 +727,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<DialogFooter className="flex-col sm:flex-row sm:justify-between gap-3 pt-4">
|
<DialogFooter className="flex justify-end gap-3 pt-4">
|
||||||
{/* 双端同步选项 */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
id={syncCheckboxId}
|
|
||||||
type="checkbox"
|
|
||||||
className="h-4 w-4 rounded border-border-default text-emerald-600 focus:ring-emerald-500 dark:bg-gray-800"
|
|
||||||
checked={syncOtherSide}
|
|
||||||
onChange={(event) => setSyncOtherSide(event.target.checked)}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={syncCheckboxId}
|
|
||||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
|
||||||
title={t("mcp.form.syncOtherSideHint", {
|
|
||||||
target: syncTargetLabel,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t("mcp.form.syncOtherSide", { target: syncTargetLabel })}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{syncOtherSide && otherSideHasConflict && (
|
|
||||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
|
|
||||||
<AlertTriangle size={14} />
|
|
||||||
<span className="text-xs font-medium">
|
|
||||||
{t("mcp.form.willOverwriteWarning", {
|
|
||||||
target: syncTargetLabel,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button type="button" variant="ghost" onClick={onClose}>
|
<Button type="button" variant="ghost" onClick={onClose}>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -676,7 +745,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
? t("common.save")
|
? t("common.save")
|
||||||
: t("common.add")}
|
: t("common.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Edit3, Trash2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { settingsApi } from "@/lib/api";
|
|
||||||
import { McpServer } from "@/types";
|
|
||||||
import { mcpPresets } from "@/config/mcpPresets";
|
|
||||||
import McpToggle from "./McpToggle";
|
|
||||||
|
|
||||||
interface McpListItemProps {
|
|
||||||
id: string;
|
|
||||||
server: McpServer;
|
|
||||||
onToggle: (id: string, enabled: boolean) => void;
|
|
||||||
onEdit: (id: string) => void;
|
|
||||||
onDelete: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MCP 列表项组件
|
|
||||||
* 每个 MCP 占一行,左侧是 Toggle 开关,中间是名称和详细信息,右侧是编辑和删除按钮
|
|
||||||
*/
|
|
||||||
const McpListItem: React.FC<McpListItemProps> = ({
|
|
||||||
id,
|
|
||||||
server,
|
|
||||||
onToggle,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// 仅当显式为 true 时视为启用;避免 undefined 被误判为启用
|
|
||||||
const enabled = server.enabled === true;
|
|
||||||
const name = server.name || id;
|
|
||||||
|
|
||||||
// 只显示 description,没有则留空
|
|
||||||
const description = server.description || "";
|
|
||||||
|
|
||||||
// 匹配预设元信息(用于展示文档链接等)
|
|
||||||
const meta = mcpPresets.find((p) => p.id === id);
|
|
||||||
const docsUrl = server.docs || meta?.docs;
|
|
||||||
const homepageUrl = server.homepage || meta?.homepage;
|
|
||||||
const tags = server.tags || meta?.tags;
|
|
||||||
|
|
||||||
const openDocs = async () => {
|
|
||||||
const url = docsUrl || homepageUrl;
|
|
||||||
if (!url) return;
|
|
||||||
try {
|
|
||||||
await settingsApi.openExternal(url);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-16 rounded-lg border border-border-default bg-card p-4 transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-sm">
|
|
||||||
<div className="flex items-center gap-4 h-full">
|
|
||||||
{/* 左侧:Toggle 开关 */}
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<McpToggle
|
|
||||||
enabled={enabled}
|
|
||||||
onChange={(newEnabled) => onToggle(id, newEnabled)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 中间:名称和详细信息 */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
|
|
||||||
{name}
|
|
||||||
</h3>
|
|
||||||
{description && (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{!description && tags && tags.length > 0 && (
|
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
|
|
||||||
{tags.join(", ")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{/* 预设标记已移除 */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧:操作按钮 */}
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
{docsUrl && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={openDocs}
|
|
||||||
title={t("mcp.presets.docs")}
|
|
||||||
>
|
|
||||||
{t("mcp.presets.docs")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onEdit(id)}
|
|
||||||
title={t("common.edit")}
|
|
||||||
>
|
|
||||||
<Edit3 size={16} />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onDelete(id)}
|
|
||||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
|
||||||
title={t("common.delete")}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default McpListItem;
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Plus, Server, Check } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { type AppId } from "@/lib/api";
|
|
||||||
import { McpServer } from "@/types";
|
|
||||||
import { useMcpActions } from "@/hooks/useMcpActions";
|
|
||||||
import McpListItem from "./McpListItem";
|
|
||||||
import McpFormModal from "./McpFormModal";
|
|
||||||
import { ConfirmDialog } from "../ConfirmDialog";
|
|
||||||
|
|
||||||
interface McpPanelProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
appId: AppId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MCP 管理面板
|
|
||||||
* 采用与主界面一致的设计风格,右上角添加按钮,每个 MCP 占一行
|
|
||||||
*/
|
|
||||||
const McpPanel: React.FC<McpPanelProps> = ({ open, onOpenChange, appId }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
|
||||||
const [confirmDialog, setConfirmDialog] = useState<{
|
|
||||||
isOpen: boolean;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
onConfirm: () => void;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Use MCP actions hook
|
|
||||||
const { servers, loading, reload, toggleEnabled, saveServer, deleteServer } =
|
|
||||||
useMcpActions(appId);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const setup = async () => {
|
|
||||||
try {
|
|
||||||
// Initialize: only import existing MCPs from corresponding client
|
|
||||||
if (appId === "claude") {
|
|
||||||
const mcpApi = await import("@/lib/api").then((m) => m.mcpApi);
|
|
||||||
await mcpApi.importFromClaude();
|
|
||||||
} else if (appId === "codex") {
|
|
||||||
const mcpApi = await import("@/lib/api").then((m) => m.mcpApi);
|
|
||||||
await mcpApi.importFromCodex();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("MCP initialization import failed (ignored)", e);
|
|
||||||
} finally {
|
|
||||||
await reload();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setup();
|
|
||||||
// Re-initialize when appId changes
|
|
||||||
}, [appId, reload]);
|
|
||||||
|
|
||||||
const handleEdit = (id: string) => {
|
|
||||||
setEditingId(id);
|
|
||||||
setIsFormOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
setEditingId(null);
|
|
||||||
setIsFormOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
|
||||||
setConfirmDialog({
|
|
||||||
isOpen: true,
|
|
||||||
title: t("mcp.confirm.deleteTitle"),
|
|
||||||
message: t("mcp.confirm.deleteMessage", { id }),
|
|
||||||
onConfirm: async () => {
|
|
||||||
try {
|
|
||||||
await deleteServer(id);
|
|
||||||
setConfirmDialog(null);
|
|
||||||
} catch (e) {
|
|
||||||
// Error already handled by useMcpActions
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async (
|
|
||||||
id: string,
|
|
||||||
server: McpServer,
|
|
||||||
options?: { syncOtherSide?: boolean },
|
|
||||||
) => {
|
|
||||||
await saveServer(id, server, options);
|
|
||||||
setIsFormOpen(false);
|
|
||||||
setEditingId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseForm = () => {
|
|
||||||
setIsFormOpen(false);
|
|
||||||
setEditingId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const serverEntries = useMemo(
|
|
||||||
() => Object.entries(servers) as Array<[string, McpServer]>,
|
|
||||||
[servers],
|
|
||||||
);
|
|
||||||
|
|
||||||
const enabledCount = useMemo(
|
|
||||||
() => serverEntries.filter(([_, server]) => server.enabled).length,
|
|
||||||
[serverEntries],
|
|
||||||
);
|
|
||||||
|
|
||||||
const panelTitle =
|
|
||||||
appId === "claude" ? t("mcp.claudeTitle") : t("mcp.codexTitle");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
|
|
||||||
<DialogHeader>
|
|
||||||
<div className="flex items-center justify-between pr-8">
|
|
||||||
<DialogTitle>{panelTitle}</DialogTitle>
|
|
||||||
<Button type="button" variant="mcp" onClick={handleAdd}>
|
|
||||||
<Plus size={16} />
|
|
||||||
{t("mcp.add")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* Info Section */}
|
|
||||||
<div className="flex-shrink-0 px-6 py-4">
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{t("mcp.serverCount", { count: Object.keys(servers).length })} ·{" "}
|
|
||||||
{t("mcp.enabledCount", { count: enabledCount })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content - Scrollable */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 pb-4">
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
|
||||||
{t("mcp.loading")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
(() => {
|
|
||||||
const hasAny = serverEntries.length > 0;
|
|
||||||
if (!hasAny) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
|
||||||
<Server
|
|
||||||
size={24}
|
|
||||||
className="text-gray-400 dark:text-gray-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
|
||||||
{t("mcp.empty")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
|
||||||
{t("mcp.emptyDescription")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* 已安装 */}
|
|
||||||
{serverEntries.map(([id, server]) => (
|
|
||||||
<McpListItem
|
|
||||||
key={`installed-${id}`}
|
|
||||||
id={id}
|
|
||||||
server={server}
|
|
||||||
onToggle={toggleEnabled}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* 预设已移至"新增 MCP"面板中展示与套用 */}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="mcp"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
<Check size={16} />
|
|
||||||
{t("common.done")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Form Modal */}
|
|
||||||
{isFormOpen && (
|
|
||||||
<McpFormModal
|
|
||||||
appId={appId}
|
|
||||||
editingId={editingId || undefined}
|
|
||||||
initialData={editingId ? servers[editingId] : undefined}
|
|
||||||
existingIds={Object.keys(servers)}
|
|
||||||
onSave={handleSave}
|
|
||||||
onClose={handleCloseForm}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Confirm Dialog */}
|
|
||||||
{confirmDialog && (
|
|
||||||
<ConfirmDialog
|
|
||||||
isOpen={confirmDialog.isOpen}
|
|
||||||
title={confirmDialog.title}
|
|
||||||
message={confirmDialog.message}
|
|
||||||
onConfirm={confirmDialog.onConfirm}
|
|
||||||
onCancel={() => setConfirmDialog(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default McpPanel;
|
|
||||||
@@ -80,13 +80,15 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
initialServer,
|
initialServer,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [wizardType, setWizardType] = useState<"stdio" | "http">("stdio");
|
const [wizardType, setWizardType] = useState<"stdio" | "http" | "sse">(
|
||||||
|
"stdio",
|
||||||
|
);
|
||||||
const [wizardTitle, setWizardTitle] = useState("");
|
const [wizardTitle, setWizardTitle] = useState("");
|
||||||
// stdio 字段
|
// stdio 字段
|
||||||
const [wizardCommand, setWizardCommand] = useState("");
|
const [wizardCommand, setWizardCommand] = useState("");
|
||||||
const [wizardArgs, setWizardArgs] = useState("");
|
const [wizardArgs, setWizardArgs] = useState("");
|
||||||
const [wizardEnv, setWizardEnv] = useState("");
|
const [wizardEnv, setWizardEnv] = useState("");
|
||||||
// http 字段
|
// http 和 sse 字段
|
||||||
const [wizardUrl, setWizardUrl] = useState("");
|
const [wizardUrl, setWizardUrl] = useState("");
|
||||||
const [wizardHeaders, setWizardHeaders] = useState("");
|
const [wizardHeaders, setWizardHeaders] = useState("");
|
||||||
|
|
||||||
@@ -115,7 +117,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// http 类型必需字段
|
// http 和 sse 类型必需字段
|
||||||
config.url = wizardUrl.trim();
|
config.url = wizardUrl.trim();
|
||||||
|
|
||||||
// 可选字段
|
// 可选字段
|
||||||
@@ -139,7 +141,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
toast.error(t("mcp.error.commandRequired"), { duration: 3000 });
|
toast.error(t("mcp.error.commandRequired"), { duration: 3000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (wizardType === "http" && !wizardUrl.trim()) {
|
if ((wizardType === "http" || wizardType === "sse") && !wizardUrl.trim()) {
|
||||||
toast.error(t("mcp.wizard.urlRequired"), { duration: 3000 });
|
toast.error(t("mcp.wizard.urlRequired"), { duration: 3000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -179,7 +181,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
|
|
||||||
setWizardType(resolvedType);
|
setWizardType(resolvedType);
|
||||||
|
|
||||||
if (resolvedType === "http") {
|
if (resolvedType === "http" || resolvedType === "sse") {
|
||||||
setWizardUrl(initialServer?.url ?? "");
|
setWizardUrl(initialServer?.url ?? "");
|
||||||
const headersCandidate = initialServer?.headers;
|
const headersCandidate = initialServer?.headers;
|
||||||
const headers =
|
const headers =
|
||||||
@@ -250,7 +252,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
value="stdio"
|
value="stdio"
|
||||||
checked={wizardType === "stdio"}
|
checked={wizardType === "stdio"}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setWizardType(e.target.value as "stdio" | "http")
|
setWizardType(e.target.value as "stdio" | "http" | "sse")
|
||||||
}
|
}
|
||||||
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
|
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
|
||||||
/>
|
/>
|
||||||
@@ -264,7 +266,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
value="http"
|
value="http"
|
||||||
checked={wizardType === "http"}
|
checked={wizardType === "http"}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setWizardType(e.target.value as "stdio" | "http")
|
setWizardType(e.target.value as "stdio" | "http" | "sse")
|
||||||
}
|
}
|
||||||
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
|
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
|
||||||
/>
|
/>
|
||||||
@@ -272,6 +274,20 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
{t("mcp.wizard.typeHttp")}
|
{t("mcp.wizard.typeHttp")}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value="sse"
|
||||||
|
checked={wizardType === "sse"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setWizardType(e.target.value as "stdio" | "http" | "sse")
|
||||||
|
}
|
||||||
|
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{t("mcp.wizard.typeSse")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -339,8 +355,8 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* HTTP 类型字段 */}
|
{/* HTTP 和 SSE 类型字段 */}
|
||||||
{wizardType === "http" && (
|
{(wizardType === "http" || wizardType === "sse") && (
|
||||||
<>
|
<>
|
||||||
{/* URL */}
|
{/* URL */}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
373
src/components/mcp/UnifiedMcpPanel.tsx
Normal file
373
src/components/mcp/UnifiedMcpPanel.tsx
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Plus, Server, Check } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { useAllMcpServers, useToggleMcpApp } from "@/hooks/useMcp";
|
||||||
|
import type { McpServer } from "@/types";
|
||||||
|
import type { AppId } from "@/lib/api/types";
|
||||||
|
import McpFormModal from "./McpFormModal";
|
||||||
|
import { ConfirmDialog } from "../ConfirmDialog";
|
||||||
|
import { useDeleteMcpServer } from "@/hooks/useMcp";
|
||||||
|
import { Edit3, Trash2 } from "lucide-react";
|
||||||
|
import { settingsApi } from "@/lib/api";
|
||||||
|
import { mcpPresets } from "@/config/mcpPresets";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface UnifiedMcpPanelProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一 MCP 管理面板
|
||||||
|
* v3.7.0 新架构:所有 MCP 服务器统一管理,每个服务器通过复选框控制应用到哪些客户端
|
||||||
|
*/
|
||||||
|
const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Queries and Mutations
|
||||||
|
const { data: serversMap, isLoading } = useAllMcpServers();
|
||||||
|
const toggleAppMutation = useToggleMcpApp();
|
||||||
|
const deleteServerMutation = useDeleteMcpServer();
|
||||||
|
|
||||||
|
// Convert serversMap to array for easier rendering
|
||||||
|
const serverEntries = useMemo((): Array<[string, McpServer]> => {
|
||||||
|
if (!serversMap) return [];
|
||||||
|
return Object.entries(serversMap);
|
||||||
|
}, [serversMap]);
|
||||||
|
|
||||||
|
// Count enabled servers per app
|
||||||
|
const enabledCounts = useMemo(() => {
|
||||||
|
const counts = { claude: 0, codex: 0, gemini: 0 };
|
||||||
|
serverEntries.forEach(([_, server]) => {
|
||||||
|
if (server.apps.claude) counts.claude++;
|
||||||
|
if (server.apps.codex) counts.codex++;
|
||||||
|
if (server.apps.gemini) counts.gemini++;
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}, [serverEntries]);
|
||||||
|
|
||||||
|
const handleToggleApp = async (
|
||||||
|
serverId: string,
|
||||||
|
app: AppId,
|
||||||
|
enabled: boolean,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await toggleAppMutation.mutateAsync({ serverId, app, enabled });
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("common.error"), {
|
||||||
|
description: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (id: string) => {
|
||||||
|
setEditingId(id);
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
setConfirmDialog({
|
||||||
|
isOpen: true,
|
||||||
|
title: t("mcp.unifiedPanel.deleteServer"),
|
||||||
|
message: t("mcp.unifiedPanel.deleteConfirm", { id }),
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await deleteServerMutation.mutateAsync(id);
|
||||||
|
setConfirmDialog(null);
|
||||||
|
toast.success(t("common.success"));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("common.error"), {
|
||||||
|
description: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setIsFormOpen(false);
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center justify-between pr-8">
|
||||||
|
<DialogTitle>{t("mcp.unifiedPanel.title")}</DialogTitle>
|
||||||
|
<Button type="button" variant="mcp" onClick={handleAdd}>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t("mcp.unifiedPanel.addServer")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Info Section */}
|
||||||
|
<div className="flex-shrink-0 px-6 py-4">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
|
||||||
|
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
|
||||||
|
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
|
||||||
|
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content - Scrollable */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 pb-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
|
{t("mcp.loading")}
|
||||||
|
</div>
|
||||||
|
) : serverEntries.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||||
|
<Server
|
||||||
|
size={24}
|
||||||
|
className="text-gray-400 dark:text-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
{t("mcp.unifiedPanel.noServers")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{t("mcp.emptyDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{serverEntries.map(([id, server]) => (
|
||||||
|
<UnifiedMcpListItem
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
server={server}
|
||||||
|
onToggleApp={handleToggleApp}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="mcp"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
<Check size={16} />
|
||||||
|
{t("common.done")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Form Modal */}
|
||||||
|
{isFormOpen && (
|
||||||
|
<McpFormModal
|
||||||
|
editingId={editingId || undefined}
|
||||||
|
initialData={
|
||||||
|
editingId && serversMap ? serversMap[editingId] : undefined
|
||||||
|
}
|
||||||
|
existingIds={serversMap ? Object.keys(serversMap) : []}
|
||||||
|
defaultFormat="json"
|
||||||
|
onSave={async () => {
|
||||||
|
setIsFormOpen(false);
|
||||||
|
setEditingId(null);
|
||||||
|
}}
|
||||||
|
onClose={handleCloseForm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirm Dialog */}
|
||||||
|
{confirmDialog && (
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={confirmDialog.isOpen}
|
||||||
|
title={confirmDialog.title}
|
||||||
|
message={confirmDialog.message}
|
||||||
|
onConfirm={confirmDialog.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一 MCP 列表项组件
|
||||||
|
* 展示服务器名称、描述,以及三个应用的复选框
|
||||||
|
*/
|
||||||
|
interface UnifiedMcpListItemProps {
|
||||||
|
id: string;
|
||||||
|
server: McpServer;
|
||||||
|
onToggleApp: (serverId: string, app: AppId, enabled: boolean) => void;
|
||||||
|
onEdit: (id: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||||
|
id,
|
||||||
|
server,
|
||||||
|
onToggleApp,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const name = server.name || id;
|
||||||
|
const description = server.description || "";
|
||||||
|
|
||||||
|
// 匹配预设元信息
|
||||||
|
const meta = mcpPresets.find((p) => p.id === id);
|
||||||
|
const docsUrl = server.docs || meta?.docs;
|
||||||
|
const homepageUrl = server.homepage || meta?.homepage;
|
||||||
|
const tags = server.tags || meta?.tags;
|
||||||
|
|
||||||
|
const openDocs = async () => {
|
||||||
|
const url = docsUrl || homepageUrl;
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
await settingsApi.openExternal(url);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-16 rounded-lg border border-border-default bg-card p-4 transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-sm">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* 左侧:服务器信息 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{name}
|
||||||
|
</h3>
|
||||||
|
{docsUrl && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={openDocs}
|
||||||
|
title={t("mcp.presets.docs")}
|
||||||
|
>
|
||||||
|
{t("mcp.presets.docs")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!description && tags && tags.length > 0 && (
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
|
||||||
|
{tags.join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 中间:应用开关 */}
|
||||||
|
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<label
|
||||||
|
htmlFor={`${id}-claude`}
|
||||||
|
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("mcp.unifiedPanel.apps.claude")}
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
id={`${id}-claude`}
|
||||||
|
checked={server.apps.claude}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
onToggleApp(id, "claude", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<label
|
||||||
|
htmlFor={`${id}-codex`}
|
||||||
|
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("mcp.unifiedPanel.apps.codex")}
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
id={`${id}-codex`}
|
||||||
|
checked={server.apps.codex}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
onToggleApp(id, "codex", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<label
|
||||||
|
htmlFor={`${id}-gemini`}
|
||||||
|
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("mcp.unifiedPanel.apps.gemini")}
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
id={`${id}-gemini`}
|
||||||
|
checked={server.apps.gemini}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
onToggleApp(id, "gemini", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:操作按钮 */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onEdit(id)}
|
||||||
|
title={t("common.edit")}
|
||||||
|
>
|
||||||
|
<Edit3 size={16} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onDelete(id)}
|
||||||
|
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||||
|
title={t("common.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnifiedMcpPanel;
|
||||||
@@ -41,7 +41,10 @@ export function useMcpValidation() {
|
|||||||
if (server.type === "stdio" && !server.command?.trim()) {
|
if (server.type === "stdio" && !server.command?.trim()) {
|
||||||
return t("mcp.error.commandRequired");
|
return t("mcp.error.commandRequired");
|
||||||
}
|
}
|
||||||
if (server.type === "http" && !server.url?.trim()) {
|
if (
|
||||||
|
(server.type === "http" || server.type === "sse") &&
|
||||||
|
!server.url?.trim()
|
||||||
|
) {
|
||||||
return t("mcp.wizard.urlRequired");
|
return t("mcp.wizard.urlRequired");
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -73,7 +76,7 @@ export function useMcpValidation() {
|
|||||||
if (typ === "stdio" && !(obj as any)?.command?.trim()) {
|
if (typ === "stdio" && !(obj as any)?.command?.trim()) {
|
||||||
return t("mcp.error.commandRequired");
|
return t("mcp.error.commandRequired");
|
||||||
}
|
}
|
||||||
if (typ === "http" && !(obj as any)?.url?.trim()) {
|
if ((typ === "http" || typ === "sse") && !(obj as any)?.url?.trim()) {
|
||||||
return t("mcp.wizard.urlRequired");
|
return t("mcp.wizard.urlRequired");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
160
src/components/prompts/PromptFormModal.tsx
Normal file
160
src/components/prompts/PromptFormModal.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||||
|
import type { Prompt, AppId } from "@/lib/api";
|
||||||
|
|
||||||
|
interface PromptFormModalProps {
|
||||||
|
appId: AppId;
|
||||||
|
editingId?: string;
|
||||||
|
initialData?: Prompt;
|
||||||
|
onSave: (id: string, prompt: Prompt) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PromptFormModal: React.FC<PromptFormModalProps> = ({
|
||||||
|
appId,
|
||||||
|
editingId,
|
||||||
|
initialData,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const appName = t(`apps.${appId}`);
|
||||||
|
const filenameMap: Record<AppId, string> = {
|
||||||
|
claude: "CLAUDE.md",
|
||||||
|
codex: "AGENTS.md",
|
||||||
|
gemini: "GEMINI.md",
|
||||||
|
};
|
||||||
|
const filename = filenameMap[appId];
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 检测初始暗色模式状态
|
||||||
|
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||||
|
|
||||||
|
// 监听 html 元素的 class 变化以实时响应主题切换
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
setName(initialData.name);
|
||||||
|
setDescription(initialData.description || "");
|
||||||
|
setContent(initialData.content);
|
||||||
|
}
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim() || !content.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const id = editingId || `prompt-${Date.now()}`;
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
const prompt: Prompt = {
|
||||||
|
id,
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
content: content.trim(),
|
||||||
|
enabled: initialData?.enabled || false,
|
||||||
|
createdAt: initialData?.createdAt || timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
};
|
||||||
|
await onSave(id, prompt);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
// Error handled by hook
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingId
|
||||||
|
? t("prompts.editTitle", { appName })
|
||||||
|
: t("prompts.addTitle", { appName })}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4 px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name">{t("prompts.name")}</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t("prompts.namePlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="description">{t("prompts.description")}</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={t("prompts.descriptionPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="content" className="mb-2 block">
|
||||||
|
{t("prompts.content")}
|
||||||
|
</Label>
|
||||||
|
<MarkdownEditor
|
||||||
|
value={content}
|
||||||
|
onChange={setContent}
|
||||||
|
placeholder={t("prompts.contentPlaceholder", { filename })}
|
||||||
|
darkMode={isDarkMode}
|
||||||
|
minHeight="300px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!name.trim() || !content.trim() || saving}
|
||||||
|
>
|
||||||
|
{saving ? t("common.saving") : t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PromptFormModal;
|
||||||
75
src/components/prompts/PromptListItem.tsx
Normal file
75
src/components/prompts/PromptListItem.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Edit3, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { Prompt } from "@/lib/api";
|
||||||
|
import PromptToggle from "./PromptToggle";
|
||||||
|
|
||||||
|
interface PromptListItemProps {
|
||||||
|
id: string;
|
||||||
|
prompt: Prompt;
|
||||||
|
onToggle: (id: string, enabled: boolean) => void;
|
||||||
|
onEdit: (id: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PromptListItem: React.FC<PromptListItemProps> = ({
|
||||||
|
id,
|
||||||
|
prompt,
|
||||||
|
onToggle,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const enabled = prompt.enabled === true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-16 rounded-lg border border-border-default bg-card p-4 transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-sm">
|
||||||
|
<div className="flex items-center gap-4 h-full">
|
||||||
|
{/* Toggle 开关 */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<PromptToggle
|
||||||
|
enabled={enabled}
|
||||||
|
onChange={(newEnabled) => onToggle(id, newEnabled)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
|
||||||
|
{prompt.name}
|
||||||
|
</h3>
|
||||||
|
{prompt.description && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{prompt.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onEdit(id)}
|
||||||
|
title={t("common.edit")}
|
||||||
|
>
|
||||||
|
<Edit3 size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onDelete(id)}
|
||||||
|
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||||
|
title={t("common.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PromptListItem;
|
||||||
177
src/components/prompts/PromptPanel.tsx
Normal file
177
src/components/prompts/PromptPanel.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Plus, FileText, Check } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { type AppId } from "@/lib/api";
|
||||||
|
import { usePromptActions } from "@/hooks/usePromptActions";
|
||||||
|
import PromptListItem from "./PromptListItem";
|
||||||
|
import PromptFormModal from "./PromptFormModal";
|
||||||
|
import { ConfirmDialog } from "../ConfirmDialog";
|
||||||
|
|
||||||
|
interface PromptPanelProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
appId: AppId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PromptPanel: React.FC<PromptPanelProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
appId,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
titleKey: string;
|
||||||
|
messageKey: string;
|
||||||
|
messageParams?: Record<string, unknown>;
|
||||||
|
onConfirm: () => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const { prompts, loading, reload, savePrompt, deletePrompt, toggleEnabled } =
|
||||||
|
usePromptActions(appId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) reload();
|
||||||
|
}, [open, reload]);
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (id: string) => {
|
||||||
|
setEditingId(id);
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
const prompt = prompts[id];
|
||||||
|
setConfirmDialog({
|
||||||
|
isOpen: true,
|
||||||
|
titleKey: "prompts.confirm.deleteTitle",
|
||||||
|
messageKey: "prompts.confirm.deleteMessage",
|
||||||
|
messageParams: { name: prompt?.name },
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await deletePrompt(id);
|
||||||
|
setConfirmDialog(null);
|
||||||
|
} catch (e) {
|
||||||
|
// Error handled by hook
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const promptEntries = useMemo(() => Object.entries(prompts), [prompts]);
|
||||||
|
|
||||||
|
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
|
||||||
|
|
||||||
|
const appName = t(`apps.${appId}`);
|
||||||
|
const panelTitle = t("prompts.title", { appName });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center justify-between pr-8">
|
||||||
|
<DialogTitle>{panelTitle}</DialogTitle>
|
||||||
|
<Button type="button" variant="mcp" onClick={handleAdd}>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t("prompts.add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0 px-6 py-4">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t("prompts.count", { count: promptEntries.length })} ·{" "}
|
||||||
|
{enabledPrompt
|
||||||
|
? t("prompts.enabledName", { name: enabledPrompt[1].name })
|
||||||
|
: t("prompts.noneEnabled")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 pb-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
|
{t("prompts.loading")}
|
||||||
|
</div>
|
||||||
|
) : promptEntries.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||||
|
<FileText
|
||||||
|
size={24}
|
||||||
|
className="text-gray-400 dark:text-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
{t("prompts.empty")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{t("prompts.emptyDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{promptEntries.map(([id, prompt]) => (
|
||||||
|
<PromptListItem
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
prompt={prompt}
|
||||||
|
onToggle={toggleEnabled}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="mcp"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
<Check size={16} />
|
||||||
|
{t("common.done")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{isFormOpen && (
|
||||||
|
<PromptFormModal
|
||||||
|
appId={appId}
|
||||||
|
editingId={editingId || undefined}
|
||||||
|
initialData={editingId ? prompts[editingId] : undefined}
|
||||||
|
onSave={savePrompt}
|
||||||
|
onClose={() => setIsFormOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmDialog && (
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={confirmDialog.isOpen}
|
||||||
|
title={t(confirmDialog.titleKey)}
|
||||||
|
message={t(confirmDialog.messageKey, confirmDialog.messageParams)}
|
||||||
|
onConfirm={confirmDialog.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PromptPanel;
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
interface McpToggleProps {
|
interface PromptToggleProps {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
onChange: (enabled: boolean) => void;
|
onChange: (enabled: boolean) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle 开关组件
|
* Toggle 开关组件(提示词专用)
|
||||||
* 启用时为淡绿色,禁用时为灰色
|
* 启用时为蓝色,禁用时为灰色
|
||||||
*/
|
*/
|
||||||
const McpToggle: React.FC<McpToggleProps> = ({
|
const PromptToggle: React.FC<PromptToggleProps> = ({
|
||||||
enabled,
|
enabled,
|
||||||
onChange,
|
onChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -23,8 +23,8 @@ const McpToggle: React.FC<McpToggleProps> = ({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => onChange(!enabled)}
|
onClick={() => onChange(!enabled)}
|
||||||
className={`
|
className={`
|
||||||
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/20
|
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/20
|
||||||
${enabled ? "bg-emerald-500 dark:bg-emerald-600" : "bg-gray-300 dark:bg-gray-600"}
|
${enabled ? "bg-blue-500 dark:bg-blue-600" : "bg-gray-300 dark:bg-gray-600"}
|
||||||
${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
|
${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
@@ -38,4 +38,4 @@ const McpToggle: React.FC<McpToggleProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default McpToggle;
|
export default PromptToggle;
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "@/components/providers/forms/ProviderForm";
|
} from "@/components/providers/forms/ProviderForm";
|
||||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||||
|
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||||
|
|
||||||
interface AddProviderDialogProps {
|
interface AddProviderDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -44,6 +45,7 @@ export function AddProviderDialog({
|
|||||||
// 构造基础提交数据
|
// 构造基础提交数据
|
||||||
const providerData: Omit<Provider, "id"> = {
|
const providerData: Omit<Provider, "id"> = {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
|
notes: values.notes?.trim() || undefined,
|
||||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||||
settingsConfig: parsedConfig,
|
settingsConfig: parsedConfig,
|
||||||
...(values.presetCategory ? { category: values.presetCategory } : {}),
|
...(values.presetCategory ? { category: values.presetCategory } : {}),
|
||||||
@@ -96,6 +98,21 @@ export function AddProviderDialog({
|
|||||||
preset.endpointCandidates.forEach(addUrl);
|
preset.endpointCandidates.forEach(addUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (appId === "gemini") {
|
||||||
|
const presets = geminiProviderPresets;
|
||||||
|
const presetIndex = parseInt(
|
||||||
|
values.presetId.replace("gemini-", ""),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!isNaN(presetIndex) &&
|
||||||
|
presetIndex >= 0 &&
|
||||||
|
presetIndex < presets.length
|
||||||
|
) {
|
||||||
|
const preset = presets[presetIndex];
|
||||||
|
if (Array.isArray(preset.endpointCandidates)) {
|
||||||
|
preset.endpointCandidates.forEach(addUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +131,11 @@ export function AddProviderDialog({
|
|||||||
addUrl(baseUrlMatch[1]);
|
addUrl(baseUrlMatch[1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (appId === "gemini") {
|
||||||
|
const env = parsedConfig.env as Record<string, any> | undefined;
|
||||||
|
if (env?.GOOGLE_GEMINI_BASE_URL) {
|
||||||
|
addUrl(env.GOOGLE_GEMINI_BASE_URL);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const urls = Array.from(urlSet);
|
const urls = Array.from(urlSet);
|
||||||
@@ -144,7 +166,9 @@ export function AddProviderDialog({
|
|||||||
const submitLabel =
|
const submitLabel =
|
||||||
appId === "claude"
|
appId === "claude"
|
||||||
? t("provider.addClaudeProvider")
|
? t("provider.addClaudeProvider")
|
||||||
: t("provider.addCodexProvider");
|
: appId === "codex"
|
||||||
|
? t("provider.addCodexProvider")
|
||||||
|
: t("provider.addGeminiProvider");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export function EditProviderDialog({
|
|||||||
const updatedProvider: Provider = {
|
const updatedProvider: Provider = {
|
||||||
...provider,
|
...provider,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
|
notes: values.notes?.trim() || undefined,
|
||||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||||
settingsConfig: parsedConfig,
|
settingsConfig: parsedConfig,
|
||||||
...(values.presetCategory ? { category: values.presetCategory } : {}),
|
...(values.presetCategory ? { category: values.presetCategory } : {}),
|
||||||
@@ -129,6 +130,7 @@ export function EditProviderDialog({
|
|||||||
onCancel={() => onOpenChange(false)}
|
onCancel={() => onOpenChange(false)}
|
||||||
initialData={{
|
initialData={{
|
||||||
name: provider.name,
|
name: provider.name,
|
||||||
|
notes: provider.notes,
|
||||||
websiteUrl: provider.websiteUrl,
|
websiteUrl: provider.websiteUrl,
|
||||||
// 若读取到实时配置则优先使用
|
// 若读取到实时配置则优先使用
|
||||||
settingsConfig: initialSettingsConfig,
|
settingsConfig: initialSettingsConfig,
|
||||||
|
|||||||
@@ -33,14 +33,23 @@ interface ProviderCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||||
|
// 优先级 1: 备注
|
||||||
|
if (provider.notes?.trim()) {
|
||||||
|
return provider.notes.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先级 2: 官网地址
|
||||||
if (provider.websiteUrl) {
|
if (provider.websiteUrl) {
|
||||||
return provider.websiteUrl;
|
return provider.websiteUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 优先级 3: 从配置中提取请求地址
|
||||||
const config = provider.settingsConfig;
|
const config = provider.settingsConfig;
|
||||||
|
|
||||||
if (config && typeof config === "object") {
|
if (config && typeof config === "object") {
|
||||||
const envBase = (config as Record<string, any>)?.env?.ANTHROPIC_BASE_URL;
|
const envBase =
|
||||||
|
(config as Record<string, any>)?.env?.ANTHROPIC_BASE_URL ||
|
||||||
|
(config as Record<string, any>)?.env?.GOOGLE_GEMINI_BASE_URL;
|
||||||
if (typeof envBase === "string" && envBase.trim()) {
|
if (typeof envBase === "string" && envBase.trim()) {
|
||||||
return envBase;
|
return envBase;
|
||||||
}
|
}
|
||||||
@@ -81,10 +90,24 @@ export function ProviderCard({
|
|||||||
return extractApiUrl(provider, fallbackUrlText);
|
return extractApiUrl(provider, fallbackUrlText);
|
||||||
}, [provider, fallbackUrlText]);
|
}, [provider, fallbackUrlText]);
|
||||||
|
|
||||||
|
// 判断是否为可点击的 URL(备注不可点击)
|
||||||
|
const isClickableUrl = useMemo(() => {
|
||||||
|
// 如果有备注,则不可点击
|
||||||
|
if (provider.notes?.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 如果显示的是回退文本,也不可点击
|
||||||
|
if (displayUrl === fallbackUrlText) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 其他情况(官网地址或请求地址)可点击
|
||||||
|
return true;
|
||||||
|
}, [provider.notes, displayUrl, fallbackUrlText]);
|
||||||
|
|
||||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||||
|
|
||||||
const handleOpenWebsite = () => {
|
const handleOpenWebsite = () => {
|
||||||
if (!displayUrl || displayUrl === fallbackUrlText) {
|
if (!isClickableUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onOpenWebsite(displayUrl);
|
onOpenWebsite(displayUrl);
|
||||||
@@ -147,6 +170,17 @@ export function ProviderCard({
|
|||||||
<h3 className="text-base font-semibold leading-none">
|
<h3 className="text-base font-semibold leading-none">
|
||||||
{provider.name}
|
{provider.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
{provider.category === "third_party" &&
|
||||||
|
provider.meta?.isPartner && (
|
||||||
|
<span
|
||||||
|
className="text-yellow-500 dark:text-yellow-400"
|
||||||
|
title={t("provider.officialPartner", {
|
||||||
|
defaultValue: "官方合作伙伴",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
⭐
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full bg-green-500/10 px-2 py-0.5 text-xs font-medium text-green-500 dark:text-green-400 transition-opacity duration-200",
|
"rounded-full bg-green-500/10 px-2 py-0.5 text-xs font-medium text-green-500 dark:text-green-400 transition-opacity duration-200",
|
||||||
@@ -161,8 +195,14 @@ export function ProviderCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpenWebsite}
|
onClick={handleOpenWebsite}
|
||||||
className="inline-flex items-center text-sm text-blue-500 transition-colors hover:underline dark:text-blue-400"
|
className={cn(
|
||||||
|
"inline-flex items-center text-sm max-w-[280px]",
|
||||||
|
isClickableUrl
|
||||||
|
? "text-blue-500 transition-colors hover:underline dark:text-blue-400 cursor-pointer"
|
||||||
|
: "text-muted-foreground cursor-default",
|
||||||
|
)}
|
||||||
title={displayUrl}
|
title={displayUrl}
|
||||||
|
disabled={!isClickableUrl}
|
||||||
>
|
>
|
||||||
<span className="truncate">{displayUrl}</span>
|
<span className="truncate">{displayUrl}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -46,6 +46,20 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="notes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("provider.notes")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder={t("provider.notesPlaceholder")} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ interface ClaudeFormFieldsProps {
|
|||||||
onBaseUrlChange: (url: string) => void;
|
onBaseUrlChange: (url: string) => void;
|
||||||
isEndpointModalOpen: boolean;
|
isEndpointModalOpen: boolean;
|
||||||
onEndpointModalToggle: (open: boolean) => void;
|
onEndpointModalToggle: (open: boolean) => void;
|
||||||
onCustomEndpointsChange: (endpoints: string[]) => void;
|
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
||||||
|
|
||||||
// Model Selector
|
// Model Selector
|
||||||
shouldShowModelSelector: boolean;
|
shouldShowModelSelector: boolean;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { CodexAuthSection, CodexConfigSection } from "./CodexConfigSections";
|
import { CodexAuthSection, CodexConfigSection } from "./CodexConfigSections";
|
||||||
import { CodexQuickWizardModal } from "./CodexQuickWizardModal";
|
|
||||||
import { CodexCommonConfigModal } from "./CodexCommonConfigModal";
|
import { CodexCommonConfigModal } from "./CodexCommonConfigModal";
|
||||||
|
|
||||||
interface CodexConfigEditorProps {
|
interface CodexConfigEditorProps {
|
||||||
@@ -27,14 +26,6 @@ interface CodexConfigEditorProps {
|
|||||||
authError: string;
|
authError: string;
|
||||||
|
|
||||||
configError: string; // config.toml 错误提示
|
configError: string; // config.toml 错误提示
|
||||||
|
|
||||||
onWebsiteUrlChange?: (url: string) => void; // 更新网址回调
|
|
||||||
|
|
||||||
isTemplateModalOpen?: boolean; // 模态框状态
|
|
||||||
|
|
||||||
setIsTemplateModalOpen?: (open: boolean) => void; // 设置模态框状态
|
|
||||||
|
|
||||||
onNameChange?: (name: string) => void; // 更新供应商名称回调
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||||
@@ -50,21 +41,9 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
commonConfigError,
|
commonConfigError,
|
||||||
authError,
|
authError,
|
||||||
configError,
|
configError,
|
||||||
onWebsiteUrlChange,
|
|
||||||
onNameChange,
|
|
||||||
isTemplateModalOpen: externalTemplateModalOpen,
|
|
||||||
setIsTemplateModalOpen: externalSetTemplateModalOpen,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||||
|
|
||||||
// Use internal state or external state
|
|
||||||
const [internalTemplateModalOpen, setInternalTemplateModalOpen] =
|
|
||||||
useState(false);
|
|
||||||
const isTemplateModalOpen =
|
|
||||||
externalTemplateModalOpen ?? internalTemplateModalOpen;
|
|
||||||
const setIsTemplateModalOpen =
|
|
||||||
externalSetTemplateModalOpen ?? setInternalTemplateModalOpen;
|
|
||||||
|
|
||||||
// Auto-open common config modal if there's an error
|
// Auto-open common config modal if there's an error
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||||
@@ -72,23 +51,6 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
}
|
}
|
||||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||||
|
|
||||||
const handleQuickWizardApply = (
|
|
||||||
auth: string,
|
|
||||||
config: string,
|
|
||||||
extras: { websiteUrl?: string; displayName?: string },
|
|
||||||
) => {
|
|
||||||
onAuthChange(auth);
|
|
||||||
onConfigChange(config);
|
|
||||||
|
|
||||||
if (onWebsiteUrlChange && extras.websiteUrl) {
|
|
||||||
onWebsiteUrlChange(extras.websiteUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onNameChange && extras.displayName) {
|
|
||||||
onNameChange(extras.displayName);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Auth JSON Section */}
|
{/* Auth JSON Section */}
|
||||||
@@ -110,13 +72,6 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
configError={configError}
|
configError={configError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Quick Wizard Modal */}
|
|
||||||
<CodexQuickWizardModal
|
|
||||||
isOpen={isTemplateModalOpen}
|
|
||||||
onClose={() => setIsTemplateModalOpen(false)}
|
|
||||||
onApply={handleQuickWizardApply}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Common Config Modal */}
|
{/* Common Config Modal */}
|
||||||
<CodexCommonConfigModal
|
<CodexCommonConfigModal
|
||||||
isOpen={isCommonConfigModalOpen}
|
isOpen={isCommonConfigModalOpen}
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ interface CodexFormFieldsProps {
|
|||||||
onBaseUrlChange: (url: string) => void;
|
onBaseUrlChange: (url: string) => void;
|
||||||
isEndpointModalOpen: boolean;
|
isEndpointModalOpen: boolean;
|
||||||
onEndpointModalToggle: (open: boolean) => void;
|
onEndpointModalToggle: (open: boolean) => void;
|
||||||
onCustomEndpointsChange: (endpoints: string[]) => void;
|
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
||||||
|
|
||||||
|
// Model Name
|
||||||
|
shouldShowModelField?: boolean;
|
||||||
|
modelName?: string;
|
||||||
|
onModelNameChange?: (model: string) => void;
|
||||||
|
|
||||||
// Speed Test Endpoints
|
// Speed Test Endpoints
|
||||||
speedTestEndpoints: EndpointCandidate[];
|
speedTestEndpoints: EndpointCandidate[];
|
||||||
@@ -45,6 +50,9 @@ export function CodexFormFields({
|
|||||||
isEndpointModalOpen,
|
isEndpointModalOpen,
|
||||||
onEndpointModalToggle,
|
onEndpointModalToggle,
|
||||||
onCustomEndpointsChange,
|
onCustomEndpointsChange,
|
||||||
|
shouldShowModelField = true,
|
||||||
|
modelName = "",
|
||||||
|
onModelNameChange,
|
||||||
speedTestEndpoints,
|
speedTestEndpoints,
|
||||||
}: CodexFormFieldsProps) {
|
}: CodexFormFieldsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -85,6 +93,33 @@ export function CodexFormFields({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Codex Model Name 输入框 */}
|
||||||
|
{shouldShowModelField && onModelNameChange && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="codexModelName"
|
||||||
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{t("codexConfig.modelName", { defaultValue: "模型名称" })}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="codexModelName"
|
||||||
|
type="text"
|
||||||
|
value={modelName}
|
||||||
|
onChange={(e) => onModelNameChange(e.target.value)}
|
||||||
|
placeholder={t("codexConfig.modelNamePlaceholder", {
|
||||||
|
defaultValue: "例如: gpt-5-codex",
|
||||||
|
})}
|
||||||
|
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t("codexConfig.modelNameHint", {
|
||||||
|
defaultValue: "指定使用的模型,将自动更新到 config.toml 中",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 端点测速弹窗 - Codex */}
|
{/* 端点测速弹窗 - Codex */}
|
||||||
{shouldShowSpeedTest && isEndpointModalOpen && (
|
{shouldShowSpeedTest && isEndpointModalOpen && (
|
||||||
<EndpointSpeedTest
|
<EndpointSpeedTest
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
import React, { useState, useRef } from "react";
|
|
||||||
import { Save } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
generateThirdPartyAuth,
|
|
||||||
generateThirdPartyConfig,
|
|
||||||
} from "@/config/codexProviderPresets";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
|
|
||||||
interface CodexQuickWizardModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onApply: (
|
|
||||||
auth: string,
|
|
||||||
config: string,
|
|
||||||
extras: {
|
|
||||||
websiteUrl?: string;
|
|
||||||
displayName?: string;
|
|
||||||
},
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CodexQuickWizardModal - Codex quick configuration wizard
|
|
||||||
* Helps users quickly generate auth.json and config.toml
|
|
||||||
*/
|
|
||||||
export const CodexQuickWizardModal: React.FC<CodexQuickWizardModalProps> = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onApply,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [templateApiKey, setTemplateApiKey] = useState("");
|
|
||||||
const [templateProviderName, setTemplateProviderName] = useState("");
|
|
||||||
const [templateBaseUrl, setTemplateBaseUrl] = useState("");
|
|
||||||
const [templateWebsiteUrl, setTemplateWebsiteUrl] = useState("");
|
|
||||||
const [templateModelName, setTemplateModelName] = useState("gpt-5-codex");
|
|
||||||
const [templateDisplayName, setTemplateDisplayName] = useState("");
|
|
||||||
|
|
||||||
const apiKeyInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const baseUrlInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const modelNameInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const displayNameInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setTemplateApiKey("");
|
|
||||||
setTemplateProviderName("");
|
|
||||||
setTemplateBaseUrl("");
|
|
||||||
setTemplateWebsiteUrl("");
|
|
||||||
setTemplateModelName("gpt-5-codex");
|
|
||||||
setTemplateDisplayName("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
resetForm();
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyTemplate = () => {
|
|
||||||
const requiredInputs = [
|
|
||||||
displayNameInputRef.current,
|
|
||||||
apiKeyInputRef.current,
|
|
||||||
baseUrlInputRef.current,
|
|
||||||
modelNameInputRef.current,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const input of requiredInputs) {
|
|
||||||
if (input && !input.checkValidity()) {
|
|
||||||
input.reportValidity();
|
|
||||||
input.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedKey = templateApiKey.trim();
|
|
||||||
const trimmedBaseUrl = templateBaseUrl.trim();
|
|
||||||
const trimmedModel = templateModelName.trim();
|
|
||||||
|
|
||||||
const auth = generateThirdPartyAuth(trimmedKey);
|
|
||||||
const config = generateThirdPartyConfig(
|
|
||||||
templateProviderName || "custom",
|
|
||||||
trimmedBaseUrl,
|
|
||||||
trimmedModel,
|
|
||||||
);
|
|
||||||
|
|
||||||
onApply(JSON.stringify(auth, null, 2), config, {
|
|
||||||
websiteUrl: templateWebsiteUrl.trim(),
|
|
||||||
displayName: templateDisplayName.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
resetForm();
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
applyTemplate();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
|
||||||
<DialogContent
|
|
||||||
zIndex="nested"
|
|
||||||
className="max-w-2xl max-h-[90vh] flex flex-col p-0"
|
|
||||||
>
|
|
||||||
<DialogHeader className="px-6 pt-6 pb-0">
|
|
||||||
<DialogTitle>{t("codexConfig.quickWizard")}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 space-y-4 overflow-auto px-6 py-4">
|
|
||||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
|
|
||||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
|
||||||
{t("codexConfig.wizardHint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* API Key */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("codexConfig.apiKeyLabel")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={templateApiKey}
|
|
||||||
ref={apiKeyInputRef}
|
|
||||||
onChange={(e) => setTemplateApiKey(e.target.value)}
|
|
||||||
onKeyDown={handleInputKeyDown}
|
|
||||||
pattern=".*\S.*"
|
|
||||||
title={t("common.enterValidValue")}
|
|
||||||
placeholder={t("codexConfig.apiKeyPlaceholder")}
|
|
||||||
required
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Display Name */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("codexConfig.supplierNameLabel")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={templateDisplayName}
|
|
||||||
ref={displayNameInputRef}
|
|
||||||
onChange={(e) => setTemplateDisplayName(e.target.value)}
|
|
||||||
onKeyDown={handleInputKeyDown}
|
|
||||||
placeholder={t("codexConfig.supplierNamePlaceholder")}
|
|
||||||
required
|
|
||||||
pattern=".*\S.*"
|
|
||||||
title={t("common.enterValidValue")}
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{t("codexConfig.supplierNameHint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Provider Name */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("codexConfig.supplierCodeLabel")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={templateProviderName}
|
|
||||||
onChange={(e) => setTemplateProviderName(e.target.value)}
|
|
||||||
onKeyDown={handleInputKeyDown}
|
|
||||||
placeholder={t("codexConfig.supplierCodePlaceholder")}
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{t("codexConfig.supplierCodeHint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Base URL */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("codexConfig.apiUrlLabel")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="url"
|
|
||||||
value={templateBaseUrl}
|
|
||||||
ref={baseUrlInputRef}
|
|
||||||
onChange={(e) => setTemplateBaseUrl(e.target.value)}
|
|
||||||
onKeyDown={handleInputKeyDown}
|
|
||||||
placeholder={t("codexConfig.apiUrlPlaceholder")}
|
|
||||||
required
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Website URL */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("codexConfig.websiteLabel")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="url"
|
|
||||||
value={templateWebsiteUrl}
|
|
||||||
onChange={(e) => setTemplateWebsiteUrl(e.target.value)}
|
|
||||||
onKeyDown={handleInputKeyDown}
|
|
||||||
placeholder={t("codexConfig.websitePlaceholder")}
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{t("codexConfig.websiteHint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Model Name */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("codexConfig.modelNameLabel")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={templateModelName}
|
|
||||||
ref={modelNameInputRef}
|
|
||||||
onChange={(e) => setTemplateModelName(e.target.value)}
|
|
||||||
onKeyDown={handleInputKeyDown}
|
|
||||||
pattern=".*\S.*"
|
|
||||||
title={t("common.enterValidValue")}
|
|
||||||
placeholder={t("codexConfig.modelNamePlaceholder")}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview */}
|
|
||||||
{(templateApiKey || templateProviderName || templateBaseUrl) && (
|
|
||||||
<div className="space-y-2 border-t border-border-default pt-4 ">
|
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("codexConfig.configPreview")}
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
||||||
auth.json
|
|
||||||
</label>
|
|
||||||
<pre className="overflow-x-auto rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
|
||||||
{JSON.stringify(
|
|
||||||
generateThirdPartyAuth(templateApiKey),
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
||||||
config.toml
|
|
||||||
</label>
|
|
||||||
<pre className="whitespace-pre-wrap rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
|
||||||
{templateProviderName && templateBaseUrl
|
|
||||||
? generateThirdPartyConfig(
|
|
||||||
templateProviderName,
|
|
||||||
templateBaseUrl,
|
|
||||||
templateModelName,
|
|
||||||
)
|
|
||||||
: ""}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={handleClose}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
applyTemplate();
|
|
||||||
}}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
{t("codexConfig.applyConfig")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -146,11 +146,6 @@ export function CommonConfigEditor({
|
|||||||
<Wand2 className="w-3.5 h-3.5" />
|
<Wand2 className="w-3.5 h-3.5" />
|
||||||
{t("common.format", { defaultValue: "格式化" })}
|
{t("common.format", { defaultValue: "格式化" })}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("claudeConfig.fullSettingsHint", {
|
|
||||||
defaultValue: "请填写完整的 Claude Code 配置",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { CustomEndpoint, EndpointCandidate } from "@/types";
|
|||||||
const ENDPOINT_TIMEOUT_SECS = {
|
const ENDPOINT_TIMEOUT_SECS = {
|
||||||
codex: 12,
|
codex: 12,
|
||||||
claude: 8,
|
claude: 8,
|
||||||
|
gemini: 8, // 新增 gemini
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
interface TestResult {
|
interface TestResult {
|
||||||
@@ -35,7 +36,8 @@ interface EndpointSpeedTestProps {
|
|||||||
initialEndpoints: EndpointCandidate[];
|
initialEndpoints: EndpointCandidate[];
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
// 当自定义端点列表变化时回传(仅包含 isCustom 的条目)
|
// 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目)
|
||||||
|
// 编辑模式:不使用此回调,端点直接保存到后端
|
||||||
onCustomEndpointsChange?: (urls: string[]) => void;
|
onCustomEndpointsChange?: (urls: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,25 +102,31 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
const [autoSelect, setAutoSelect] = useState(true);
|
const [autoSelect, setAutoSelect] = useState(true);
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const [lastError, setLastError] = useState<string | null>(null);
|
const [lastError, setLastError] = useState<string | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// 记录初始的自定义端点,用于对比变化
|
||||||
|
const [initialCustomUrls, setInitialCustomUrls] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
const normalizedSelected = normalizeEndpointUrl(value);
|
const normalizedSelected = normalizeEndpointUrl(value);
|
||||||
|
|
||||||
const hasEndpoints = entries.length > 0;
|
const hasEndpoints = entries.length > 0;
|
||||||
|
const isEditMode = Boolean(providerId); // 编辑模式有 providerId
|
||||||
|
|
||||||
// 加载保存的自定义端点(按正在编辑的供应商)
|
// 编辑模式:加载已保存的自定义端点
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const loadCustomEndpoints = async () => {
|
const loadCustomEndpoints = async () => {
|
||||||
try {
|
try {
|
||||||
if (!providerId) return;
|
if (!providerId) return; // 新建模式不加载
|
||||||
|
|
||||||
const customEndpoints = await vscodeApi.getCustomEndpoints(
|
const customEndpoints = await vscodeApi.getCustomEndpoints(
|
||||||
appId,
|
appId,
|
||||||
providerId,
|
providerId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 检查是否已取消
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const candidates: EndpointCandidate[] = customEndpoints.map(
|
const candidates: EndpointCandidate[] = customEndpoints.map(
|
||||||
@@ -128,6 +136,13 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 记录初始的自定义端点
|
||||||
|
const customUrls = new Set(
|
||||||
|
customEndpoints.map((ep) => normalizeEndpointUrl(ep.url)),
|
||||||
|
);
|
||||||
|
setInitialCustomUrls(customUrls);
|
||||||
|
|
||||||
|
// 合并自定义端点与初始端点
|
||||||
setEntries((prev) => {
|
setEntries((prev) => {
|
||||||
const map = new Map<string, EndpointEntry>();
|
const map = new Map<string, EndpointEntry>();
|
||||||
|
|
||||||
@@ -136,7 +151,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
map.set(entry.url, entry);
|
map.set(entry.url, entry);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 合并自定义端点
|
// 添加从后端加载的自定义端点
|
||||||
candidates.forEach((candidate) => {
|
candidates.forEach((candidate) => {
|
||||||
const sanitized = normalizeEndpointUrl(candidate.url);
|
const sanitized = normalizeEndpointUrl(candidate.url);
|
||||||
if (sanitized && !map.has(sanitized)) {
|
if (sanitized && !map.has(sanitized)) {
|
||||||
@@ -160,60 +175,20 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (visible) {
|
// 只在编辑模式下加载
|
||||||
|
if (providerId) {
|
||||||
loadCustomEndpoints();
|
loadCustomEndpoints();
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [appId, visible, providerId, t]);
|
}, [appId, providerId, t, initialEndpoints]);
|
||||||
|
|
||||||
|
// 新建模式:将自定义端点变化透传给父组件(仅限 isCustom)
|
||||||
|
// 编辑模式:不使用此回调,端点已通过 API 直接保存
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEntries((prev) => {
|
if (!onCustomEndpointsChange || isEditMode) return; // 编辑模式不使用回调
|
||||||
const map = new Map<string, EndpointEntry>();
|
|
||||||
prev.forEach((entry) => {
|
|
||||||
map.set(entry.url, entry);
|
|
||||||
});
|
|
||||||
|
|
||||||
let changed = false;
|
|
||||||
|
|
||||||
const mergeCandidate = (candidate: EndpointCandidate) => {
|
|
||||||
const sanitized = candidate.url
|
|
||||||
? normalizeEndpointUrl(candidate.url)
|
|
||||||
: "";
|
|
||||||
if (!sanitized) return;
|
|
||||||
const existing = map.get(sanitized);
|
|
||||||
if (existing) return;
|
|
||||||
|
|
||||||
map.set(sanitized, {
|
|
||||||
id: candidate.id ?? randomId(),
|
|
||||||
url: sanitized,
|
|
||||||
isCustom: candidate.isCustom ?? false,
|
|
||||||
latency: null,
|
|
||||||
status: undefined,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
changed = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
initialEndpoints.forEach(mergeCandidate);
|
|
||||||
|
|
||||||
if (normalizedSelected && !map.has(normalizedSelected)) {
|
|
||||||
mergeCandidate({ url: normalizedSelected, isCustom: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!changed) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(map.values());
|
|
||||||
});
|
|
||||||
}, [initialEndpoints, normalizedSelected]);
|
|
||||||
|
|
||||||
// 将自定义端点变化透传给父组件(仅限 isCustom)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!onCustomEndpointsChange) return;
|
|
||||||
try {
|
try {
|
||||||
const customUrls = Array.from(
|
const customUrls = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
@@ -227,8 +202,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
// 仅在 entries 变化时同步
|
}, [entries, onCustomEndpointsChange, isEditMode]);
|
||||||
}, [entries, onCustomEndpointsChange]);
|
|
||||||
|
|
||||||
const sortedEntries = useMemo(() => {
|
const sortedEntries = useMemo(() => {
|
||||||
return entries.slice().sort((a: TestResult, b: TestResult) => {
|
return entries.slice().sort((a: TestResult, b: TestResult) => {
|
||||||
@@ -267,7 +241,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
let sanitized = "";
|
let sanitized = "";
|
||||||
if (!errorMsg && parsed) {
|
if (!errorMsg && parsed) {
|
||||||
sanitized = normalizeEndpointUrl(parsed.toString());
|
sanitized = normalizeEndpointUrl(parsed.toString());
|
||||||
// 使用当前 entries 做去重校验,避免依赖可能过期的 addError
|
// 使用当前 entries 做去重校验
|
||||||
const isDuplicate = entries.some((entry) => entry.url === sanitized);
|
const isDuplicate = entries.some((entry) => entry.url === sanitized);
|
||||||
if (isDuplicate) {
|
if (isDuplicate) {
|
||||||
errorMsg = t("endpointTest.urlExists");
|
errorMsg = t("endpointTest.urlExists");
|
||||||
@@ -280,8 +254,9 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setAddError(null);
|
setAddError(null);
|
||||||
|
setLastError(null);
|
||||||
|
|
||||||
// 更新本地状态(延迟提交,不立即保存到后端)
|
// 更新本地状态(延迟保存,点击保存按钮时统一处理)
|
||||||
setEntries((prev) => {
|
setEntries((prev) => {
|
||||||
if (prev.some((e) => e.url === sanitized)) return prev;
|
if (prev.some((e) => e.url === sanitized)) return prev;
|
||||||
return [
|
return [
|
||||||
@@ -302,14 +277,14 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setCustomUrl("");
|
setCustomUrl("");
|
||||||
}, [customUrl, entries, normalizedSelected, onChange]);
|
}, [customUrl, entries, normalizedSelected, onChange, t]);
|
||||||
|
|
||||||
const handleRemoveEndpoint = useCallback(
|
const handleRemoveEndpoint = useCallback(
|
||||||
(entry: EndpointEntry) => {
|
(entry: EndpointEntry) => {
|
||||||
// 清空之前的错误提示
|
// 清空之前的错误提示
|
||||||
setLastError(null);
|
setLastError(null);
|
||||||
|
|
||||||
// 更新本地状态(延迟提交,不立即从后端删除)
|
// 更新本地状态(延迟保存,点击保存按钮时统一处理)
|
||||||
setEntries((prev) => {
|
setEntries((prev) => {
|
||||||
const next = prev.filter((item) => item.id !== entry.id);
|
const next = prev.filter((item) => item.id !== entry.id);
|
||||||
if (entry.url === normalizedSelected) {
|
if (entry.url === normalizedSelected) {
|
||||||
@@ -404,6 +379,58 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
[normalizedSelected, onChange],
|
[normalizedSelected, onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 保存端点变更
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
// 编辑模式:对比初始端点和当前端点,批量保存变更
|
||||||
|
if (isEditMode && providerId) {
|
||||||
|
setIsSaving(true);
|
||||||
|
setLastError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取当前的自定义端点
|
||||||
|
const currentCustomUrls = new Set(
|
||||||
|
entries
|
||||||
|
.filter((e) => e.isCustom)
|
||||||
|
.map((e) => normalizeEndpointUrl(e.url)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 找出新增的端点
|
||||||
|
const toAdd = Array.from(currentCustomUrls).filter(
|
||||||
|
(url) => !initialCustomUrls.has(url),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 找出删除的端点
|
||||||
|
const toRemove = Array.from(initialCustomUrls).filter(
|
||||||
|
(url) => !currentCustomUrls.has(url),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 批量添加
|
||||||
|
for (const url of toAdd) {
|
||||||
|
await vscodeApi.addCustomEndpoint(appId, providerId, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除
|
||||||
|
for (const url of toRemove) {
|
||||||
|
await vscodeApi.removeCustomEndpoint(appId, providerId, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新初始端点列表
|
||||||
|
setInitialCustomUrls(currentCustomUrls);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : t("endpointTest.saveFailed");
|
||||||
|
setLastError(message);
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭弹窗
|
||||||
|
onClose();
|
||||||
|
}, [isEditMode, providerId, entries, initialCustomUrls, appId, onClose, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={visible} onOpenChange={(open) => !open && onClose()}>
|
<Dialog open={visible} onOpenChange={(open) => !open && onClose()}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
@@ -579,10 +606,32 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="gap-2">
|
||||||
<Button type="button" onClick={onClose} className="gap-2">
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
{t("common.saving")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Save className="w-4 h-4" />
|
<Save className="w-4 h-4" />
|
||||||
{t("common.save")}
|
{t("common.save")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
122
src/components/providers/forms/GeminiCommonConfigModal.tsx
Normal file
122
src/components/providers/forms/GeminiCommonConfigModal.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Save, Wand2 } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { formatJSON } from "@/utils/formatters";
|
||||||
|
|
||||||
|
interface GeminiCommonConfigModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeminiCommonConfigModal - Common Gemini configuration editor modal
|
||||||
|
* Allows editing of common JSON configuration shared across Gemini providers
|
||||||
|
*/
|
||||||
|
export const GeminiCommonConfigModal: React.FC<
|
||||||
|
GeminiCommonConfigModalProps
|
||||||
|
> = ({ isOpen, onClose, value, onChange, error }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleFormat = () => {
|
||||||
|
if (!value.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formatted = formatJSON(value);
|
||||||
|
onChange(formatted);
|
||||||
|
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
toast.error(
|
||||||
|
t("common.formatError", {
|
||||||
|
defaultValue: "格式化失败:{{error}}",
|
||||||
|
error: errorMessage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent
|
||||||
|
zIndex="nested"
|
||||||
|
className="max-w-2xl max-h-[90vh] flex flex-col p-0"
|
||||||
|
>
|
||||||
|
<DialogHeader className="px-6 pt-6 pb-0">
|
||||||
|
<DialogTitle>
|
||||||
|
{t("geminiConfig.editCommonConfigTitle", {
|
||||||
|
defaultValue: "编辑 Gemini 通用配置片段",
|
||||||
|
})}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t("geminiConfig.commonConfigHint", {
|
||||||
|
defaultValue:
|
||||||
|
"通用配置片段将合并到所有启用它的 Gemini 供应商配置中",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={`{
|
||||||
|
"timeout": 30000,
|
||||||
|
"maxRetries": 3,
|
||||||
|
"customField": "value"
|
||||||
|
}`}
|
||||||
|
rows={12}
|
||||||
|
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-border-active transition-colors resize-y"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
lang="en"
|
||||||
|
inputMode="text"
|
||||||
|
data-gramm="false"
|
||||||
|
data-gramm_editor="false"
|
||||||
|
data-enable-grammarly="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFormat}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Wand2 className="w-3.5 h-3.5" />
|
||||||
|
{t("common.format", { defaultValue: "格式化" })}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={onClose} className="gap-2">
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
76
src/components/providers/forms/GeminiConfigEditor.tsx
Normal file
76
src/components/providers/forms/GeminiConfigEditor.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { GeminiEnvSection, GeminiConfigSection } from "./GeminiConfigSections";
|
||||||
|
import { GeminiCommonConfigModal } from "./GeminiCommonConfigModal";
|
||||||
|
|
||||||
|
interface GeminiConfigEditorProps {
|
||||||
|
envValue: string;
|
||||||
|
configValue: string;
|
||||||
|
onEnvChange: (value: string) => void;
|
||||||
|
onConfigChange: (value: string) => void;
|
||||||
|
onEnvBlur?: () => void;
|
||||||
|
useCommonConfig: boolean;
|
||||||
|
onCommonConfigToggle: (checked: boolean) => void;
|
||||||
|
commonConfigSnippet: string;
|
||||||
|
onCommonConfigSnippetChange: (value: string) => void;
|
||||||
|
commonConfigError: string;
|
||||||
|
envError: string;
|
||||||
|
configError: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||||
|
envValue,
|
||||||
|
configValue,
|
||||||
|
onEnvChange,
|
||||||
|
onConfigChange,
|
||||||
|
onEnvBlur,
|
||||||
|
useCommonConfig,
|
||||||
|
onCommonConfigToggle,
|
||||||
|
commonConfigSnippet,
|
||||||
|
onCommonConfigSnippetChange,
|
||||||
|
commonConfigError,
|
||||||
|
envError,
|
||||||
|
configError,
|
||||||
|
}) => {
|
||||||
|
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Auto-open common config modal if there's an error
|
||||||
|
useEffect(() => {
|
||||||
|
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||||
|
setIsCommonConfigModalOpen(true);
|
||||||
|
}
|
||||||
|
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Env Section */}
|
||||||
|
<GeminiEnvSection
|
||||||
|
value={envValue}
|
||||||
|
onChange={onEnvChange}
|
||||||
|
onBlur={onEnvBlur}
|
||||||
|
error={envError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Config JSON Section */}
|
||||||
|
<GeminiConfigSection
|
||||||
|
value={configValue}
|
||||||
|
onChange={onConfigChange}
|
||||||
|
useCommonConfig={useCommonConfig}
|
||||||
|
onCommonConfigToggle={onCommonConfigToggle}
|
||||||
|
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
||||||
|
commonConfigError={commonConfigError}
|
||||||
|
configError={configError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Common Config Modal */}
|
||||||
|
<GeminiCommonConfigModal
|
||||||
|
isOpen={isCommonConfigModalOpen}
|
||||||
|
onClose={() => setIsCommonConfigModalOpen(false)}
|
||||||
|
value={commonConfigSnippet}
|
||||||
|
onChange={onCommonConfigSnippetChange}
|
||||||
|
error={commonConfigError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GeminiConfigEditor;
|
||||||
237
src/components/providers/forms/GeminiConfigSections.tsx
Normal file
237
src/components/providers/forms/GeminiConfigSections.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Wand2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { formatJSON } from "@/utils/formatters";
|
||||||
|
|
||||||
|
interface GeminiEnvSectionProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeminiEnvSection - .env editor section for Gemini environment variables
|
||||||
|
*/
|
||||||
|
export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
error,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleFormat = () => {
|
||||||
|
if (!value.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 重新格式化 .env 内容
|
||||||
|
const formatted = value
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.join("\n");
|
||||||
|
onChange(formatted);
|
||||||
|
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
toast.error(
|
||||||
|
t("common.formatError", {
|
||||||
|
defaultValue: "格式化失败:{{error}}",
|
||||||
|
error: errorMessage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="geminiEnv"
|
||||||
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
id="geminiEnv"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
|
||||||
|
GEMINI_API_KEY=sk-your-api-key-here
|
||||||
|
GEMINI_MODEL=gemini-2.5-pro`}
|
||||||
|
rows={6}
|
||||||
|
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[8rem]"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
lang="en"
|
||||||
|
inputMode="text"
|
||||||
|
data-gramm="false"
|
||||||
|
data-gramm_editor="false"
|
||||||
|
data-enable-grammarly="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFormat}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Wand2 className="w-3.5 h-3.5" />
|
||||||
|
{t("common.format", { defaultValue: "格式化" })}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!error && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t("geminiConfig.envFileHint", {
|
||||||
|
defaultValue: "使用 .env 格式配置 Gemini 环境变量",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GeminiConfigSectionProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
useCommonConfig: boolean;
|
||||||
|
onCommonConfigToggle: (checked: boolean) => void;
|
||||||
|
onEditCommonConfig: () => void;
|
||||||
|
commonConfigError?: string;
|
||||||
|
configError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeminiConfigSection - Config JSON editor section with common config support
|
||||||
|
*/
|
||||||
|
export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
useCommonConfig,
|
||||||
|
onCommonConfigToggle,
|
||||||
|
onEditCommonConfig,
|
||||||
|
commonConfigError,
|
||||||
|
configError,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleFormat = () => {
|
||||||
|
if (!value.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formatted = formatJSON(value);
|
||||||
|
onChange(formatted);
|
||||||
|
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
toast.error(
|
||||||
|
t("common.formatError", {
|
||||||
|
defaultValue: "格式化失败:{{error}}",
|
||||||
|
error: errorMessage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label
|
||||||
|
htmlFor="geminiConfig"
|
||||||
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{t("geminiConfig.configJson", {
|
||||||
|
defaultValue: "配置文件 (config.json)",
|
||||||
|
})}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useCommonConfig}
|
||||||
|
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||||
|
/>
|
||||||
|
{t("geminiConfig.writeCommonConfig", {
|
||||||
|
defaultValue: "写入通用配置",
|
||||||
|
})}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEditCommonConfig}
|
||||||
|
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
{t("geminiConfig.editCommonConfig", {
|
||||||
|
defaultValue: "编辑通用配置",
|
||||||
|
})}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{commonConfigError && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||||
|
{commonConfigError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
id="geminiConfig"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={`{
|
||||||
|
"timeout": 30000,
|
||||||
|
"maxRetries": 3
|
||||||
|
}`}
|
||||||
|
rows={8}
|
||||||
|
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[10rem]"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
lang="en"
|
||||||
|
inputMode="text"
|
||||||
|
data-gramm="false"
|
||||||
|
data-gramm_editor="false"
|
||||||
|
data-enable-grammarly="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFormat}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Wand2 className="w-3.5 h-3.5" />
|
||||||
|
{t("common.format", { defaultValue: "格式化" })}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{configError && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400">
|
||||||
|
{configError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!configError && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t("geminiConfig.configJsonHint", {
|
||||||
|
defaultValue: "使用 JSON 格式配置 Gemini 扩展参数(可选)",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
150
src/components/providers/forms/GeminiFormFields.tsx
Normal file
150
src/components/providers/forms/GeminiFormFields.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FormLabel } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Info } from "lucide-react";
|
||||||
|
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||||
|
import { ApiKeySection, EndpointField } from "./shared";
|
||||||
|
import type { ProviderCategory } from "@/types";
|
||||||
|
|
||||||
|
interface EndpointCandidate {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeminiFormFieldsProps {
|
||||||
|
providerId?: string;
|
||||||
|
// API Key
|
||||||
|
shouldShowApiKey: boolean;
|
||||||
|
apiKey: string;
|
||||||
|
onApiKeyChange: (key: string) => void;
|
||||||
|
category?: ProviderCategory;
|
||||||
|
shouldShowApiKeyLink: boolean;
|
||||||
|
websiteUrl: string;
|
||||||
|
isPartner?: boolean;
|
||||||
|
partnerPromotionKey?: string;
|
||||||
|
|
||||||
|
// Base URL
|
||||||
|
shouldShowSpeedTest: boolean;
|
||||||
|
baseUrl: string;
|
||||||
|
onBaseUrlChange: (url: string) => void;
|
||||||
|
isEndpointModalOpen: boolean;
|
||||||
|
onEndpointModalToggle: (open: boolean) => void;
|
||||||
|
onCustomEndpointsChange: (endpoints: string[]) => void;
|
||||||
|
|
||||||
|
// Model
|
||||||
|
shouldShowModelField: boolean;
|
||||||
|
model: string;
|
||||||
|
onModelChange: (value: string) => void;
|
||||||
|
|
||||||
|
// Speed Test Endpoints
|
||||||
|
speedTestEndpoints: EndpointCandidate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeminiFormFields({
|
||||||
|
providerId,
|
||||||
|
shouldShowApiKey,
|
||||||
|
apiKey,
|
||||||
|
onApiKeyChange,
|
||||||
|
category,
|
||||||
|
shouldShowApiKeyLink,
|
||||||
|
websiteUrl,
|
||||||
|
isPartner,
|
||||||
|
partnerPromotionKey,
|
||||||
|
shouldShowSpeedTest,
|
||||||
|
baseUrl,
|
||||||
|
onBaseUrlChange,
|
||||||
|
isEndpointModalOpen,
|
||||||
|
onEndpointModalToggle,
|
||||||
|
onCustomEndpointsChange,
|
||||||
|
shouldShowModelField,
|
||||||
|
model,
|
||||||
|
onModelChange,
|
||||||
|
speedTestEndpoints,
|
||||||
|
}: GeminiFormFieldsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// 检测是否为 Google 官方(使用 OAuth)
|
||||||
|
const isGoogleOfficial =
|
||||||
|
partnerPromotionKey?.toLowerCase() === "google-official";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Google OAuth 提示 */}
|
||||||
|
{isGoogleOfficial && (
|
||||||
|
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Info className="h-5 w-5 flex-shrink-0 text-blue-600 dark:text-blue-400" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||||
|
{t("provider.form.gemini.oauthTitle", {
|
||||||
|
defaultValue: "OAuth 认证模式",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
{t("provider.form.gemini.oauthHint", {
|
||||||
|
defaultValue:
|
||||||
|
"Google 官方使用 OAuth 个人认证,无需填写 API Key。首次使用时会自动打开浏览器进行登录。",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* API Key 输入框 */}
|
||||||
|
{shouldShowApiKey && !isGoogleOfficial && (
|
||||||
|
<ApiKeySection
|
||||||
|
value={apiKey}
|
||||||
|
onChange={onApiKeyChange}
|
||||||
|
category={category}
|
||||||
|
shouldShowLink={shouldShowApiKeyLink}
|
||||||
|
websiteUrl={websiteUrl}
|
||||||
|
isPartner={isPartner}
|
||||||
|
partnerPromotionKey={partnerPromotionKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Base URL 输入框(统一使用与 Codex 相同的样式与交互) */}
|
||||||
|
{shouldShowSpeedTest && (
|
||||||
|
<EndpointField
|
||||||
|
id="baseUrl"
|
||||||
|
label={t("providerForm.apiEndpoint", { defaultValue: "API 端点" })}
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={onBaseUrlChange}
|
||||||
|
placeholder={t("providerForm.apiEndpointPlaceholder", {
|
||||||
|
defaultValue: "https://your-api-endpoint.com/",
|
||||||
|
})}
|
||||||
|
onManageClick={() => onEndpointModalToggle(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Model 输入框 */}
|
||||||
|
{shouldShowModelField && (
|
||||||
|
<div>
|
||||||
|
<FormLabel htmlFor="gemini-model">
|
||||||
|
{t("provider.form.gemini.model", { defaultValue: "模型" })}
|
||||||
|
</FormLabel>
|
||||||
|
<Input
|
||||||
|
id="gemini-model"
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => onModelChange(e.target.value)}
|
||||||
|
placeholder="gemini-2.5-pro"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 端点测速弹窗 */}
|
||||||
|
{shouldShowSpeedTest && isEndpointModalOpen && (
|
||||||
|
<EndpointSpeedTest
|
||||||
|
appId="gemini"
|
||||||
|
providerId={providerId}
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={onBaseUrlChange}
|
||||||
|
initialEndpoints={speedTestEndpoints}
|
||||||
|
visible={isEndpointModalOpen}
|
||||||
|
onClose={() => onEndpointModalToggle(false)}
|
||||||
|
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,14 +15,21 @@ import {
|
|||||||
codexProviderPresets,
|
codexProviderPresets,
|
||||||
type CodexProviderPreset,
|
type CodexProviderPreset,
|
||||||
} from "@/config/codexProviderPresets";
|
} from "@/config/codexProviderPresets";
|
||||||
|
import {
|
||||||
|
geminiProviderPresets,
|
||||||
|
type GeminiProviderPreset,
|
||||||
|
} from "@/config/geminiProviderPresets";
|
||||||
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
||||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||||
|
import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
||||||
import CodexConfigEditor from "./CodexConfigEditor";
|
import CodexConfigEditor from "./CodexConfigEditor";
|
||||||
import { CommonConfigEditor } from "./CommonConfigEditor";
|
import { CommonConfigEditor } from "./CommonConfigEditor";
|
||||||
|
import GeminiConfigEditor from "./GeminiConfigEditor";
|
||||||
import { ProviderPresetSelector } from "./ProviderPresetSelector";
|
import { ProviderPresetSelector } from "./ProviderPresetSelector";
|
||||||
import { BasicFormFields } from "./BasicFormFields";
|
import { BasicFormFields } from "./BasicFormFields";
|
||||||
import { ClaudeFormFields } from "./ClaudeFormFields";
|
import { ClaudeFormFields } from "./ClaudeFormFields";
|
||||||
import { CodexFormFields } from "./CodexFormFields";
|
import { CodexFormFields } from "./CodexFormFields";
|
||||||
|
import { GeminiFormFields } from "./GeminiFormFields";
|
||||||
import {
|
import {
|
||||||
useProviderCategory,
|
useProviderCategory,
|
||||||
useApiKeyState,
|
useApiKeyState,
|
||||||
@@ -35,14 +42,27 @@ import {
|
|||||||
useCodexCommonConfig,
|
useCodexCommonConfig,
|
||||||
useSpeedTestEndpoints,
|
useSpeedTestEndpoints,
|
||||||
useCodexTomlValidation,
|
useCodexTomlValidation,
|
||||||
|
useGeminiConfigState,
|
||||||
|
useGeminiCommonConfig,
|
||||||
} from "./hooks";
|
} from "./hooks";
|
||||||
|
|
||||||
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
|
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
|
||||||
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
||||||
|
const GEMINI_DEFAULT_CONFIG = JSON.stringify(
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
GOOGLE_GEMINI_BASE_URL: "",
|
||||||
|
GEMINI_API_KEY: "",
|
||||||
|
GEMINI_MODEL: "gemini-2.5-pro",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
type PresetEntry = {
|
type PresetEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
preset: ProviderPreset | CodexProviderPreset;
|
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ProviderFormProps {
|
interface ProviderFormProps {
|
||||||
@@ -54,6 +74,7 @@ interface ProviderFormProps {
|
|||||||
initialData?: {
|
initialData?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
websiteUrl?: string;
|
websiteUrl?: string;
|
||||||
|
notes?: string;
|
||||||
settingsConfig?: Record<string, unknown>;
|
settingsConfig?: Record<string, unknown>;
|
||||||
category?: ProviderCategory;
|
category?: ProviderCategory;
|
||||||
meta?: ProviderMeta;
|
meta?: ProviderMeta;
|
||||||
@@ -80,18 +101,19 @@ export function ProviderForm({
|
|||||||
id: string;
|
id: string;
|
||||||
category?: ProviderCategory;
|
category?: ProviderCategory;
|
||||||
isPartner?: boolean;
|
isPartner?: boolean;
|
||||||
|
partnerPromotionKey?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
|
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
|
||||||
|
const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
// 新建供应商:收集端点测速弹窗中的"自定义端点",提交时一次性落盘到 meta.custom_endpoints
|
// 新建供应商:收集端点测速弹窗中的"自定义端点",提交时一次性落盘到 meta.custom_endpoints
|
||||||
// 编辑供应商:从 initialData.meta.custom_endpoints 恢复端点列表
|
// 编辑供应商:端点已通过 API 直接保存,不再需要此状态
|
||||||
const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(
|
const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(
|
||||||
() => {
|
() => {
|
||||||
if (!initialData?.meta?.custom_endpoints) {
|
// 仅在新建模式下使用
|
||||||
|
if (initialData) return [];
|
||||||
return [];
|
return [];
|
||||||
}
|
|
||||||
// 从 Record<string, CustomEndpoint> 中提取 URL 列表
|
|
||||||
return Object.keys(initialData.meta.custom_endpoints);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -107,10 +129,8 @@ export function ProviderForm({
|
|||||||
setSelectedPresetId(initialData ? null : "custom");
|
setSelectedPresetId(initialData ? null : "custom");
|
||||||
setActivePreset(null);
|
setActivePreset(null);
|
||||||
|
|
||||||
// 重新初始化 draftCustomEndpoints(编辑模式时从 meta 恢复)
|
// 编辑模式不需要恢复 draftCustomEndpoints,端点已通过 API 管理
|
||||||
if (initialData?.meta?.custom_endpoints) {
|
if (!initialData) {
|
||||||
setDraftCustomEndpoints(Object.keys(initialData.meta.custom_endpoints));
|
|
||||||
} else {
|
|
||||||
setDraftCustomEndpoints([]);
|
setDraftCustomEndpoints([]);
|
||||||
}
|
}
|
||||||
}, [appId, initialData]);
|
}, [appId, initialData]);
|
||||||
@@ -119,10 +139,13 @@ export function ProviderForm({
|
|||||||
() => ({
|
() => ({
|
||||||
name: initialData?.name ?? "",
|
name: initialData?.name ?? "",
|
||||||
websiteUrl: initialData?.websiteUrl ?? "",
|
websiteUrl: initialData?.websiteUrl ?? "",
|
||||||
|
notes: initialData?.notes ?? "",
|
||||||
settingsConfig: initialData?.settingsConfig
|
settingsConfig: initialData?.settingsConfig
|
||||||
? JSON.stringify(initialData.settingsConfig, null, 2)
|
? JSON.stringify(initialData.settingsConfig, null, 2)
|
||||||
: appId === "codex"
|
: appId === "codex"
|
||||||
? CODEX_DEFAULT_CONFIG
|
? CODEX_DEFAULT_CONFIG
|
||||||
|
: appId === "gemini"
|
||||||
|
? GEMINI_DEFAULT_CONFIG
|
||||||
: CLAUDE_DEFAULT_CONFIG,
|
: CLAUDE_DEFAULT_CONFIG,
|
||||||
}),
|
}),
|
||||||
[initialData, appId],
|
[initialData, appId],
|
||||||
@@ -144,17 +167,20 @@ export function ProviderForm({
|
|||||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
category,
|
category,
|
||||||
|
appType: appId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 使用 Base URL hook (仅 Claude 模式)
|
// 使用 Base URL hook (Claude, Codex, Gemini)
|
||||||
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
|
const { baseUrl, handleClaudeBaseUrlChange, handleGeminiBaseUrlChange } =
|
||||||
|
useBaseUrlState({
|
||||||
appType: appId,
|
appType: appId,
|
||||||
category,
|
category,
|
||||||
settingsConfig: form.watch("settingsConfig"),
|
settingsConfig: form.watch("settingsConfig"),
|
||||||
codexConfig: "",
|
codexConfig: "",
|
||||||
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
|
onSettingsConfigChange: (config) =>
|
||||||
|
form.setValue("settingsConfig", config),
|
||||||
onCodexConfigChange: () => {
|
onCodexConfigChange: () => {
|
||||||
// Codex 使用 useCodexConfigState 管理 Base URL
|
/* noop */
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,10 +202,12 @@ export function ProviderForm({
|
|||||||
codexConfig,
|
codexConfig,
|
||||||
codexApiKey,
|
codexApiKey,
|
||||||
codexBaseUrl,
|
codexBaseUrl,
|
||||||
|
codexModelName,
|
||||||
codexAuthError,
|
codexAuthError,
|
||||||
setCodexAuth,
|
setCodexAuth,
|
||||||
handleCodexApiKeyChange,
|
handleCodexApiKeyChange,
|
||||||
handleCodexBaseUrlChange,
|
handleCodexBaseUrlChange,
|
||||||
|
handleCodexModelNameChange,
|
||||||
handleCodexConfigChange: originalHandleCodexConfigChange,
|
handleCodexConfigChange: originalHandleCodexConfigChange,
|
||||||
resetCodexConfig,
|
resetCodexConfig,
|
||||||
} = useCodexConfigState({ initialData });
|
} = useCodexConfigState({ initialData });
|
||||||
@@ -197,10 +225,13 @@ export function ProviderForm({
|
|||||||
[originalHandleCodexConfigChange, debouncedValidate],
|
[originalHandleCodexConfigChange, debouncedValidate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] =
|
// Codex 新建模式:初始化时自动填充模板
|
||||||
useState(false);
|
useEffect(() => {
|
||||||
const [isCodexTemplateModalOpen, setIsCodexTemplateModalOpen] =
|
if (appId === "codex" && !initialData && selectedPresetId === "custom") {
|
||||||
useState(false);
|
const template = getCodexCustomTemplate();
|
||||||
|
resetCodexConfig(template.auth, template.config);
|
||||||
|
}
|
||||||
|
}, [appId, initialData, selectedPresetId, resetCodexConfig]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset(defaultValues);
|
form.reset(defaultValues);
|
||||||
@@ -208,16 +239,16 @@ export function ProviderForm({
|
|||||||
|
|
||||||
const presetCategoryLabels: Record<string, string> = useMemo(
|
const presetCategoryLabels: Record<string, string> = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
official: t("providerPreset.categoryOfficial", {
|
official: t("providerForm.categoryOfficial", {
|
||||||
defaultValue: "官方",
|
defaultValue: "官方",
|
||||||
}),
|
}),
|
||||||
cn_official: t("providerPreset.categoryCnOfficial", {
|
cn_official: t("providerForm.categoryCnOfficial", {
|
||||||
defaultValue: "国内官方",
|
defaultValue: "国内官方",
|
||||||
}),
|
}),
|
||||||
aggregator: t("providerPreset.categoryAggregator", {
|
aggregator: t("providerForm.categoryAggregation", {
|
||||||
defaultValue: "聚合服务",
|
defaultValue: "聚合服务",
|
||||||
}),
|
}),
|
||||||
third_party: t("providerPreset.categoryThirdParty", {
|
third_party: t("providerForm.categoryThirdParty", {
|
||||||
defaultValue: "第三方",
|
defaultValue: "第三方",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -230,6 +261,11 @@ export function ProviderForm({
|
|||||||
id: `codex-${index}`,
|
id: `codex-${index}`,
|
||||||
preset,
|
preset,
|
||||||
}));
|
}));
|
||||||
|
} else if (appId === "gemini") {
|
||||||
|
return geminiProviderPresets.map<PresetEntry>((preset, index) => ({
|
||||||
|
id: `gemini-${index}`,
|
||||||
|
preset,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
return providerPresets.map<PresetEntry>((preset, index) => ({
|
return providerPresets.map<PresetEntry>((preset, index) => ({
|
||||||
id: `claude-${index}`,
|
id: `claude-${index}`,
|
||||||
@@ -277,6 +313,35 @@ export function ProviderForm({
|
|||||||
initialData: appId === "codex" ? initialData : undefined,
|
initialData: appId === "codex" ? initialData : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 使用 Gemini 配置 hook (仅 Gemini 模式)
|
||||||
|
const {
|
||||||
|
geminiEnv,
|
||||||
|
geminiConfig,
|
||||||
|
geminiModel,
|
||||||
|
envError,
|
||||||
|
configError: geminiConfigError,
|
||||||
|
handleGeminiEnvChange,
|
||||||
|
handleGeminiConfigChange,
|
||||||
|
resetGeminiConfig,
|
||||||
|
envStringToObj,
|
||||||
|
envObjToString,
|
||||||
|
} = useGeminiConfigState({
|
||||||
|
initialData: appId === "gemini" ? initialData : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用 Gemini 通用配置 hook (仅 Gemini 模式)
|
||||||
|
const {
|
||||||
|
useCommonConfig: useGeminiCommonConfigFlag,
|
||||||
|
commonConfigSnippet: geminiCommonConfigSnippet,
|
||||||
|
commonConfigError: geminiCommonConfigError,
|
||||||
|
handleCommonConfigToggle: handleGeminiCommonConfigToggle,
|
||||||
|
handleCommonConfigSnippetChange: handleGeminiCommonConfigSnippetChange,
|
||||||
|
} = useGeminiCommonConfig({
|
||||||
|
configValue: geminiConfig,
|
||||||
|
onConfigChange: handleGeminiConfigChange,
|
||||||
|
initialData: appId === "gemini" ? initialData : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = (values: ProviderFormData) => {
|
const handleSubmit = (values: ProviderFormData) => {
|
||||||
@@ -288,7 +353,7 @@ export function ProviderForm({
|
|||||||
type: "manual",
|
type: "manual",
|
||||||
message: t("providerForm.fillParameter", {
|
message: t("providerForm.fillParameter", {
|
||||||
label: validation.missingField.label,
|
label: validation.missingField.label,
|
||||||
defaultValue: `<EFBFBD><EFBFBD><EFBFBD>填写 ${validation.missingField.label}`,
|
defaultValue: `请填写 ${validation.missingField.label}`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -310,6 +375,20 @@ export function ProviderForm({
|
|||||||
// 如果解析失败,使用表单中的配置
|
// 如果解析失败,使用表单中的配置
|
||||||
settingsConfig = values.settingsConfig.trim();
|
settingsConfig = values.settingsConfig.trim();
|
||||||
}
|
}
|
||||||
|
} else if (appId === "gemini") {
|
||||||
|
// Gemini: 组合 env 和 config
|
||||||
|
try {
|
||||||
|
const envObj = envStringToObj(geminiEnv);
|
||||||
|
const configObj = geminiConfig.trim() ? JSON.parse(geminiConfig) : {};
|
||||||
|
const combined = {
|
||||||
|
env: envObj,
|
||||||
|
config: configObj,
|
||||||
|
};
|
||||||
|
settingsConfig = JSON.stringify(combined);
|
||||||
|
} catch (err) {
|
||||||
|
// 如果解析失败,使用表单中的配置
|
||||||
|
settingsConfig = values.settingsConfig.trim();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Claude: 使用表单配置
|
// Claude: 使用表单配置
|
||||||
settingsConfig = values.settingsConfig.trim();
|
settingsConfig = values.settingsConfig.trim();
|
||||||
@@ -333,30 +412,20 @@ export function ProviderForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 meta 字段:基于 draftCustomEndpoints 生成 custom_endpoints
|
// 处理 meta 字段:仅在新建模式下从 draftCustomEndpoints 生成 custom_endpoints
|
||||||
// 注意:不使用 customEndpointsMap,因为它包含了候选端点(预设、Base URL 等)
|
// 编辑模式:端点已通过 API 直接保存,不在此处理
|
||||||
// 而我们只需要保存用户真正添加的自定义端点
|
if (!isEditMode && draftCustomEndpoints.length > 0) {
|
||||||
const customEndpointsToSave: Record<
|
const customEndpointsToSave: Record<
|
||||||
string,
|
string,
|
||||||
import("@/types").CustomEndpoint
|
import("@/types").CustomEndpoint
|
||||||
> | null =
|
> = draftCustomEndpoints.reduce(
|
||||||
draftCustomEndpoints.length > 0
|
|
||||||
? draftCustomEndpoints.reduce(
|
|
||||||
(acc, url) => {
|
(acc, url) => {
|
||||||
// 尝试从 initialData.meta 中获取原有的端点元数据(保留 addedAt 和 lastUsed)
|
|
||||||
const existing = initialData?.meta?.custom_endpoints?.[url];
|
|
||||||
if (existing) {
|
|
||||||
acc[url] = existing;
|
|
||||||
} else {
|
|
||||||
// 新端点:使用当前时间戳
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
acc[url] = { url, addedAt: now, lastUsed: undefined };
|
acc[url] = { url, addedAt: now, lastUsed: undefined };
|
||||||
}
|
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, import("@/types").CustomEndpoint>,
|
{} as Record<string, import("@/types").CustomEndpoint>,
|
||||||
)
|
);
|
||||||
: null;
|
|
||||||
|
|
||||||
// 检测是否需要清空端点(重要:区分"用户清空端点"和"用户没有修改端点")
|
// 检测是否需要清空端点(重要:区分"用户清空端点"和"用户没有修改端点")
|
||||||
const hadEndpoints =
|
const hadEndpoints =
|
||||||
@@ -366,13 +435,29 @@ export function ProviderForm({
|
|||||||
hadEndpoints && draftCustomEndpoints.length === 0;
|
hadEndpoints && draftCustomEndpoints.length === 0;
|
||||||
|
|
||||||
// 如果用户明确清空了端点,传递空对象(而不是 null)让后端知道要删除
|
// 如果用户明确清空了端点,传递空对象(而不是 null)让后端知道要删除
|
||||||
const mergedMeta = needsClearEndpoints
|
let mergedMeta = needsClearEndpoints
|
||||||
? mergeProviderMeta(initialData?.meta, {})
|
? mergeProviderMeta(initialData?.meta, {})
|
||||||
: mergeProviderMeta(initialData?.meta, customEndpointsToSave);
|
: mergeProviderMeta(initialData?.meta, customEndpointsToSave);
|
||||||
|
|
||||||
if (mergedMeta) {
|
// 添加合作伙伴标识与促销 key
|
||||||
|
if (activePreset?.isPartner) {
|
||||||
|
mergedMeta = {
|
||||||
|
...(mergedMeta ?? {}),
|
||||||
|
isPartner: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activePreset?.partnerPromotionKey) {
|
||||||
|
mergedMeta = {
|
||||||
|
...(mergedMeta ?? {}),
|
||||||
|
partnerPromotionKey: activePreset.partnerPromotionKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mergedMeta !== undefined) {
|
||||||
payload.meta = mergedMeta;
|
payload.meta = mergedMeta;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onSubmit(payload);
|
onSubmit(payload);
|
||||||
};
|
};
|
||||||
@@ -425,6 +510,20 @@ export function ProviderForm({
|
|||||||
formWebsiteUrl: form.watch("websiteUrl") || "",
|
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 使用 API Key 链接 hook (Gemini)
|
||||||
|
const {
|
||||||
|
shouldShowApiKeyLink: shouldShowGeminiApiKeyLink,
|
||||||
|
websiteUrl: geminiWebsiteUrl,
|
||||||
|
isPartner: isGeminiPartner,
|
||||||
|
partnerPromotionKey: geminiPartnerPromotionKey,
|
||||||
|
} = useApiKeyLink({
|
||||||
|
appId: "gemini",
|
||||||
|
category,
|
||||||
|
selectedPresetId,
|
||||||
|
presetEntries,
|
||||||
|
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||||||
|
});
|
||||||
|
|
||||||
// 使用端点测速候选 hook
|
// 使用端点测速候选 hook
|
||||||
const speedTestEndpoints = useSpeedTestEndpoints({
|
const speedTestEndpoints = useSpeedTestEndpoints({
|
||||||
appId,
|
appId,
|
||||||
@@ -441,9 +540,14 @@ export function ProviderForm({
|
|||||||
setActivePreset(null);
|
setActivePreset(null);
|
||||||
form.reset(defaultValues);
|
form.reset(defaultValues);
|
||||||
|
|
||||||
// Codex 自定义模式:重置为空配置
|
// Codex 自定义模式:加载模板
|
||||||
if (appId === "codex") {
|
if (appId === "codex") {
|
||||||
resetCodexConfig({}, "");
|
const template = getCodexCustomTemplate();
|
||||||
|
resetCodexConfig(template.auth, template.config);
|
||||||
|
}
|
||||||
|
// Gemini 自定义模式:重置为空配置
|
||||||
|
if (appId === "gemini") {
|
||||||
|
resetGeminiConfig({}, {});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -457,6 +561,7 @@ export function ProviderForm({
|
|||||||
id: value,
|
id: value,
|
||||||
category: entry.preset.category,
|
category: entry.preset.category,
|
||||||
isPartner: entry.preset.isPartner,
|
isPartner: entry.preset.isPartner,
|
||||||
|
partnerPromotionKey: entry.preset.partnerPromotionKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (appId === "codex") {
|
if (appId === "codex") {
|
||||||
@@ -476,6 +581,23 @@ export function ProviderForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (appId === "gemini") {
|
||||||
|
const preset = entry.preset as GeminiProviderPreset;
|
||||||
|
const env = (preset.settingsConfig as any)?.env ?? {};
|
||||||
|
const config = (preset.settingsConfig as any)?.config ?? {};
|
||||||
|
|
||||||
|
// 重置 Gemini 配置
|
||||||
|
resetGeminiConfig(env, config);
|
||||||
|
|
||||||
|
// 更新表单其他字段
|
||||||
|
form.reset({
|
||||||
|
name: preset.name,
|
||||||
|
websiteUrl: preset.websiteUrl ?? "",
|
||||||
|
settingsConfig: JSON.stringify(preset.settingsConfig, null, 2),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const preset = entry.preset as ProviderPreset;
|
const preset = entry.preset as ProviderPreset;
|
||||||
const config = applyTemplateValues(
|
const config = applyTemplateValues(
|
||||||
preset.settingsConfig,
|
preset.settingsConfig,
|
||||||
@@ -505,12 +627,6 @@ export function ProviderForm({
|
|||||||
presetCategoryLabels={presetCategoryLabels}
|
presetCategoryLabels={presetCategoryLabels}
|
||||||
onPresetChange={handlePresetChange}
|
onPresetChange={handlePresetChange}
|
||||||
category={category}
|
category={category}
|
||||||
appId={appId}
|
|
||||||
onOpenWizard={
|
|
||||||
appId === "codex"
|
|
||||||
? () => setIsCodexTemplateModalOpen(true)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -541,7 +657,9 @@ export function ProviderForm({
|
|||||||
onBaseUrlChange={handleClaudeBaseUrlChange}
|
onBaseUrlChange={handleClaudeBaseUrlChange}
|
||||||
isEndpointModalOpen={isEndpointModalOpen}
|
isEndpointModalOpen={isEndpointModalOpen}
|
||||||
onEndpointModalToggle={setIsEndpointModalOpen}
|
onEndpointModalToggle={setIsEndpointModalOpen}
|
||||||
onCustomEndpointsChange={setDraftCustomEndpoints}
|
onCustomEndpointsChange={
|
||||||
|
isEditMode ? undefined : setDraftCustomEndpoints
|
||||||
|
}
|
||||||
shouldShowModelSelector={category !== "official"}
|
shouldShowModelSelector={category !== "official"}
|
||||||
claudeModel={claudeModel}
|
claudeModel={claudeModel}
|
||||||
defaultHaikuModel={defaultHaikuModel}
|
defaultHaikuModel={defaultHaikuModel}
|
||||||
@@ -568,12 +686,57 @@ export function ProviderForm({
|
|||||||
onBaseUrlChange={handleCodexBaseUrlChange}
|
onBaseUrlChange={handleCodexBaseUrlChange}
|
||||||
isEndpointModalOpen={isCodexEndpointModalOpen}
|
isEndpointModalOpen={isCodexEndpointModalOpen}
|
||||||
onEndpointModalToggle={setIsCodexEndpointModalOpen}
|
onEndpointModalToggle={setIsCodexEndpointModalOpen}
|
||||||
onCustomEndpointsChange={setDraftCustomEndpoints}
|
onCustomEndpointsChange={
|
||||||
|
isEditMode ? undefined : setDraftCustomEndpoints
|
||||||
|
}
|
||||||
|
shouldShowModelField={category !== "official"}
|
||||||
|
modelName={codexModelName}
|
||||||
|
onModelNameChange={handleCodexModelNameChange}
|
||||||
speedTestEndpoints={speedTestEndpoints}
|
speedTestEndpoints={speedTestEndpoints}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 配置编辑器:Claude 使用通用配置编辑器,Codex 使用专用编辑器 */}
|
{/* Gemini 专属字段 */}
|
||||||
|
{appId === "gemini" && (
|
||||||
|
<GeminiFormFields
|
||||||
|
providerId={providerId}
|
||||||
|
shouldShowApiKey={shouldShowApiKey(
|
||||||
|
form.watch("settingsConfig"),
|
||||||
|
isEditMode,
|
||||||
|
)}
|
||||||
|
apiKey={apiKey}
|
||||||
|
onApiKeyChange={handleApiKeyChange}
|
||||||
|
category={category}
|
||||||
|
shouldShowApiKeyLink={shouldShowGeminiApiKeyLink}
|
||||||
|
websiteUrl={geminiWebsiteUrl}
|
||||||
|
isPartner={isGeminiPartner}
|
||||||
|
partnerPromotionKey={geminiPartnerPromotionKey}
|
||||||
|
shouldShowSpeedTest={shouldShowSpeedTest}
|
||||||
|
baseUrl={baseUrl}
|
||||||
|
onBaseUrlChange={handleGeminiBaseUrlChange}
|
||||||
|
isEndpointModalOpen={isEndpointModalOpen}
|
||||||
|
onEndpointModalToggle={setIsEndpointModalOpen}
|
||||||
|
onCustomEndpointsChange={setDraftCustomEndpoints}
|
||||||
|
shouldShowModelField={true}
|
||||||
|
model={geminiModel}
|
||||||
|
onModelChange={(model) => {
|
||||||
|
// 同时更新 form.settingsConfig 和 geminiEnv
|
||||||
|
const config = JSON.parse(form.watch("settingsConfig") || "{}");
|
||||||
|
if (!config.env) config.env = {};
|
||||||
|
config.env.GEMINI_MODEL = model;
|
||||||
|
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
|
// 同步更新 geminiEnv,确保提交时不丢失
|
||||||
|
const envObj = envStringToObj(geminiEnv);
|
||||||
|
envObj.GEMINI_MODEL = model.trim();
|
||||||
|
const newEnv = envObjToString(envObj);
|
||||||
|
handleGeminiEnvChange(newEnv);
|
||||||
|
}}
|
||||||
|
speedTestEndpoints={speedTestEndpoints}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */}
|
||||||
{appId === "codex" ? (
|
{appId === "codex" ? (
|
||||||
<>
|
<>
|
||||||
<CodexConfigEditor
|
<CodexConfigEditor
|
||||||
@@ -588,10 +751,34 @@ export function ProviderForm({
|
|||||||
commonConfigError={codexCommonConfigError}
|
commonConfigError={codexCommonConfigError}
|
||||||
authError={codexAuthError}
|
authError={codexAuthError}
|
||||||
configError={codexConfigError}
|
configError={codexConfigError}
|
||||||
onWebsiteUrlChange={(url) => form.setValue("websiteUrl", url)}
|
/>
|
||||||
onNameChange={(name) => form.setValue("name", name)}
|
{/* 配置验证错误显示 */}
|
||||||
isTemplateModalOpen={isCodexTemplateModalOpen}
|
<FormField
|
||||||
setIsTemplateModalOpen={setIsCodexTemplateModalOpen}
|
control={form.control}
|
||||||
|
name="settingsConfig"
|
||||||
|
render={() => (
|
||||||
|
<FormItem className="space-y-0">
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : appId === "gemini" ? (
|
||||||
|
<>
|
||||||
|
<GeminiConfigEditor
|
||||||
|
envValue={geminiEnv}
|
||||||
|
configValue={geminiConfig}
|
||||||
|
onEnvChange={handleGeminiEnvChange}
|
||||||
|
onConfigChange={handleGeminiConfigChange}
|
||||||
|
useCommonConfig={useGeminiCommonConfigFlag}
|
||||||
|
onCommonConfigToggle={handleGeminiCommonConfigToggle}
|
||||||
|
commonConfigSnippet={geminiCommonConfigSnippet}
|
||||||
|
onCommonConfigSnippetChange={
|
||||||
|
handleGeminiCommonConfigSnippetChange
|
||||||
|
}
|
||||||
|
commonConfigError={geminiCommonConfigError}
|
||||||
|
envError={envError}
|
||||||
|
configError={geminiConfigError}
|
||||||
/>
|
/>
|
||||||
{/* 配置验证错误显示 */}
|
{/* 配置验证错误显示 */}
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FormLabel } from "@/components/ui/form";
|
import { FormLabel } from "@/components/ui/form";
|
||||||
import { ClaudeIcon, CodexIcon } from "@/components/BrandIcons";
|
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
|
||||||
import { Zap, Star } from "lucide-react";
|
import { Zap, Star } from "lucide-react";
|
||||||
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
||||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||||
|
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
|
||||||
import type { ProviderCategory } from "@/types";
|
import type { ProviderCategory } from "@/types";
|
||||||
import type { AppId } from "@/lib/api";
|
|
||||||
|
|
||||||
type PresetEntry = {
|
type PresetEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
preset: ProviderPreset | CodexProviderPreset;
|
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ProviderPresetSelectorProps {
|
interface ProviderPresetSelectorProps {
|
||||||
@@ -18,9 +18,7 @@ interface ProviderPresetSelectorProps {
|
|||||||
categoryKeys: string[];
|
categoryKeys: string[];
|
||||||
presetCategoryLabels: Record<string, string>;
|
presetCategoryLabels: Record<string, string>;
|
||||||
onPresetChange: (value: string) => void;
|
onPresetChange: (value: string) => void;
|
||||||
category?: ProviderCategory; // 新增:当前选中的分类
|
category?: ProviderCategory; // 当前选中的分类
|
||||||
appId?: AppId;
|
|
||||||
onOpenWizard?: () => void; // Codex 专用:打开配置向导
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderPresetSelector({
|
export function ProviderPresetSelector({
|
||||||
@@ -30,8 +28,6 @@ export function ProviderPresetSelector({
|
|||||||
presetCategoryLabels,
|
presetCategoryLabels,
|
||||||
onPresetChange,
|
onPresetChange,
|
||||||
category,
|
category,
|
||||||
appId,
|
|
||||||
onOpenWizard,
|
|
||||||
}: ProviderPresetSelectorProps) {
|
}: ProviderPresetSelectorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -55,23 +51,6 @@ export function ProviderPresetSelector({
|
|||||||
defaultValue: "💡 第三方供应商需要填写 API Key 和请求地址",
|
defaultValue: "💡 第三方供应商需要填写 API Key 和请求地址",
|
||||||
});
|
});
|
||||||
case "custom":
|
case "custom":
|
||||||
// Codex 自定义:在此位置显示"手动配置…或者 使用配置向导"
|
|
||||||
if (appId === "codex" && onOpenWizard) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{t("providerForm.manualConfig")}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenWizard}
|
|
||||||
className="ml-1 text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 underline-offset-2 hover:underline"
|
|
||||||
aria-label={t("providerForm.openConfigWizard")}
|
|
||||||
>
|
|
||||||
{t("providerForm.useConfigWizard")}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// 其他情况沿用原提示
|
|
||||||
return t("providerForm.customApiKeyHint", {
|
return t("providerForm.customApiKeyHint", {
|
||||||
defaultValue: "💡 自定义配置需手动填写所有必要字段",
|
defaultValue: "💡 自定义配置需手动填写所有必要字段",
|
||||||
});
|
});
|
||||||
@@ -83,7 +62,9 @@ export function ProviderPresetSelector({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 渲染预设按钮的图标
|
// 渲染预设按钮的图标
|
||||||
const renderPresetIcon = (preset: ProviderPreset | CodexProviderPreset) => {
|
const renderPresetIcon = (
|
||||||
|
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
|
||||||
|
) => {
|
||||||
const iconType = preset.theme?.icon;
|
const iconType = preset.theme?.icon;
|
||||||
if (!iconType) return null;
|
if (!iconType) return null;
|
||||||
|
|
||||||
@@ -92,6 +73,8 @@ export function ProviderPresetSelector({
|
|||||||
return <ClaudeIcon size={14} />;
|
return <ClaudeIcon size={14} />;
|
||||||
case "codex":
|
case "codex":
|
||||||
return <CodexIcon size={14} />;
|
return <CodexIcon size={14} />;
|
||||||
|
case "gemini":
|
||||||
|
return <GeminiIcon size={14} />;
|
||||||
case "generic":
|
case "generic":
|
||||||
return <Zap size={14} />;
|
return <Zap size={14} />;
|
||||||
default:
|
default:
|
||||||
@@ -102,7 +85,7 @@ export function ProviderPresetSelector({
|
|||||||
// 获取预设按钮的样式类名
|
// 获取预设按钮的样式类名
|
||||||
const getPresetButtonClass = (
|
const getPresetButtonClass = (
|
||||||
isSelected: boolean,
|
isSelected: boolean,
|
||||||
preset: ProviderPreset | CodexProviderPreset,
|
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
|
||||||
) => {
|
) => {
|
||||||
const baseClass =
|
const baseClass =
|
||||||
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
|
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
|
||||||
@@ -122,7 +105,7 @@ export function ProviderPresetSelector({
|
|||||||
// 获取预设按钮的内联样式(用于自定义背景色)
|
// 获取预设按钮的内联样式(用于自定义背景色)
|
||||||
const getPresetButtonStyle = (
|
const getPresetButtonStyle = (
|
||||||
isSelected: boolean,
|
isSelected: boolean,
|
||||||
preset: ProviderPreset | CodexProviderPreset,
|
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
|
||||||
) => {
|
) => {
|
||||||
if (!isSelected || !preset.theme?.backgroundColor) {
|
if (!isSelected || !preset.theme?.backgroundColor) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ export { useCommonConfigSnippet } from "./useCommonConfigSnippet";
|
|||||||
export { useCodexCommonConfig } from "./useCodexCommonConfig";
|
export { useCodexCommonConfig } from "./useCodexCommonConfig";
|
||||||
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
|
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
|
||||||
export { useCodexTomlValidation } from "./useCodexTomlValidation";
|
export { useCodexTomlValidation } from "./useCodexTomlValidation";
|
||||||
|
export { useGeminiConfigState } from "./useGeminiConfigState";
|
||||||
|
export { useGeminiCommonConfig } from "./useGeminiCommonConfig";
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import type { AppId } from "@/lib/api";
|
|||||||
import type { ProviderCategory } from "@/types";
|
import type { ProviderCategory } from "@/types";
|
||||||
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
||||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||||
|
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
|
||||||
|
|
||||||
type PresetEntry = {
|
type PresetEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
preset: ProviderPreset | CodexProviderPreset;
|
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface UseApiKeyLinkProps {
|
interface UseApiKeyLinkProps {
|
||||||
@@ -73,9 +74,7 @@ export function useApiKeyLink({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
shouldShowApiKeyLink:
|
shouldShowApiKeyLink:
|
||||||
appId === "claude"
|
appId === "claude" || appId === "codex" || appId === "gemini"
|
||||||
? shouldShowApiKeyLink
|
|
||||||
: appId === "codex"
|
|
||||||
? shouldShowApiKeyLink
|
? shouldShowApiKeyLink
|
||||||
: false,
|
: false,
|
||||||
websiteUrl: getWebsiteUrl,
|
websiteUrl: getWebsiteUrl,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface UseApiKeyStateProps {
|
|||||||
onConfigChange: (config: string) => void;
|
onConfigChange: (config: string) => void;
|
||||||
selectedPresetId: string | null;
|
selectedPresetId: string | null;
|
||||||
category?: ProviderCategory;
|
category?: ProviderCategory;
|
||||||
|
appType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,10 +23,11 @@ export function useApiKeyState({
|
|||||||
onConfigChange,
|
onConfigChange,
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
category,
|
category,
|
||||||
|
appType,
|
||||||
}: UseApiKeyStateProps) {
|
}: UseApiKeyStateProps) {
|
||||||
const [apiKey, setApiKey] = useState(() => {
|
const [apiKey, setApiKey] = useState(() => {
|
||||||
if (initialConfig) {
|
if (initialConfig) {
|
||||||
return getApiKeyFromConfig(initialConfig);
|
return getApiKeyFromConfig(initialConfig, appType);
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
@@ -38,7 +40,7 @@ export function useApiKeyState({
|
|||||||
initialConfig || "{}",
|
initialConfig || "{}",
|
||||||
key.trim(),
|
key.trim(),
|
||||||
{
|
{
|
||||||
// 最佳实践:仅在"新增模式"且"非官方类别"时补齐缺失字段
|
// 最佳实践:仅在“新增模式”且“非官方类别”时补齐缺失字段
|
||||||
// - 新增模式:selectedPresetId !== null
|
// - 新增模式:selectedPresetId !== null
|
||||||
// - 非官方类别:category !== undefined && category !== "official"
|
// - 非官方类别:category !== undefined && category !== "official"
|
||||||
// - 官方类别:不创建字段(UI 也会禁用输入框)
|
// - 官方类别:不创建字段(UI 也会禁用输入框)
|
||||||
@@ -47,21 +49,23 @@ export function useApiKeyState({
|
|||||||
selectedPresetId !== null &&
|
selectedPresetId !== null &&
|
||||||
category !== undefined &&
|
category !== undefined &&
|
||||||
category !== "official",
|
category !== "official",
|
||||||
|
appType,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
onConfigChange(configString);
|
onConfigChange(configString);
|
||||||
},
|
},
|
||||||
[initialConfig, selectedPresetId, category, onConfigChange],
|
[initialConfig, selectedPresetId, category, appType, onConfigChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showApiKey = useCallback(
|
const showApiKey = useCallback(
|
||||||
(config: string, isEditMode: boolean) => {
|
(config: string, isEditMode: boolean) => {
|
||||||
return (
|
return (
|
||||||
selectedPresetId !== null || (isEditMode && hasApiKeyField(config))
|
selectedPresetId !== null ||
|
||||||
|
(isEditMode && hasApiKeyField(config, appType))
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[selectedPresetId],
|
[selectedPresetId, appType],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
import type { ProviderCategory } from "@/types";
|
import type { ProviderCategory } from "@/types";
|
||||||
|
|
||||||
interface UseBaseUrlStateProps {
|
interface UseBaseUrlStateProps {
|
||||||
appType: "claude" | "codex";
|
appType: "claude" | "codex" | "gemini";
|
||||||
category: ProviderCategory | undefined;
|
category: ProviderCategory | undefined;
|
||||||
settingsConfig: string;
|
settingsConfig: string;
|
||||||
codexConfig?: string;
|
codexConfig?: string;
|
||||||
@@ -28,6 +28,7 @@ export function useBaseUrlState({
|
|||||||
}: UseBaseUrlStateProps) {
|
}: UseBaseUrlStateProps) {
|
||||||
const [baseUrl, setBaseUrl] = useState("");
|
const [baseUrl, setBaseUrl] = useState("");
|
||||||
const [codexBaseUrl, setCodexBaseUrl] = useState("");
|
const [codexBaseUrl, setCodexBaseUrl] = useState("");
|
||||||
|
const [geminiBaseUrl, setGeminiBaseUrl] = useState("");
|
||||||
const isUpdatingRef = useRef(false);
|
const isUpdatingRef = useRef(false);
|
||||||
|
|
||||||
// 从配置同步到 state(Claude)
|
// 从配置同步到 state(Claude)
|
||||||
@@ -62,6 +63,27 @@ export function useBaseUrlState({
|
|||||||
}
|
}
|
||||||
}, [appType, category, codexConfig, codexBaseUrl]);
|
}, [appType, category, codexConfig, codexBaseUrl]);
|
||||||
|
|
||||||
|
// 从Claude配置同步到 state(Gemini)
|
||||||
|
useEffect(() => {
|
||||||
|
if (appType !== "gemini") return;
|
||||||
|
// 只有 official 类别不显示 Base URL 输入框,其他类别都需要回填
|
||||||
|
if (category === "official") return;
|
||||||
|
if (isUpdatingRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(settingsConfig || "{}");
|
||||||
|
const envUrl: unknown = config?.env?.GOOGLE_GEMINI_BASE_URL;
|
||||||
|
const nextUrl =
|
||||||
|
typeof envUrl === "string" ? envUrl.trim().replace(/\/+$/, "") : "";
|
||||||
|
if (nextUrl !== geminiBaseUrl) {
|
||||||
|
setGeminiBaseUrl(nextUrl);
|
||||||
|
setBaseUrl(nextUrl); // 也更新 baseUrl 用于 UI
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [appType, category, settingsConfig, geminiBaseUrl]);
|
||||||
|
|
||||||
// 处理 Claude Base URL 变化
|
// 处理 Claude Base URL 变化
|
||||||
const handleClaudeBaseUrlChange = useCallback(
|
const handleClaudeBaseUrlChange = useCallback(
|
||||||
(url: string) => {
|
(url: string) => {
|
||||||
@@ -111,12 +133,41 @@ export function useBaseUrlState({
|
|||||||
[codexConfig, onCodexConfigChange],
|
[codexConfig, onCodexConfigChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 处理 Gemini Base URL 变化
|
||||||
|
const handleGeminiBaseUrlChange = useCallback(
|
||||||
|
(url: string) => {
|
||||||
|
const sanitized = url.trim().replace(/\/+$/, "");
|
||||||
|
setGeminiBaseUrl(sanitized);
|
||||||
|
setBaseUrl(sanitized); // 也更新 baseUrl 用于 UI
|
||||||
|
isUpdatingRef.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(settingsConfig || "{}");
|
||||||
|
if (!config.env) {
|
||||||
|
config.env = {};
|
||||||
|
}
|
||||||
|
config.env.GOOGLE_GEMINI_BASE_URL = sanitized;
|
||||||
|
onSettingsConfigChange(JSON.stringify(config, null, 2));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
isUpdatingRef.current = false;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[settingsConfig, onSettingsConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
setBaseUrl,
|
setBaseUrl,
|
||||||
codexBaseUrl,
|
codexBaseUrl,
|
||||||
setCodexBaseUrl,
|
setCodexBaseUrl,
|
||||||
|
geminiBaseUrl,
|
||||||
|
setGeminiBaseUrl,
|
||||||
handleClaudeBaseUrlChange,
|
handleClaudeBaseUrlChange,
|
||||||
handleCodexBaseUrlChange,
|
handleCodexBaseUrlChange,
|
||||||
|
handleGeminiBaseUrlChange,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import {
|
|||||||
updateTomlCommonConfigSnippet,
|
updateTomlCommonConfigSnippet,
|
||||||
hasTomlCommonConfigSnippet,
|
hasTomlCommonConfigSnippet,
|
||||||
} from "@/utils/providerConfigUtils";
|
} from "@/utils/providerConfigUtils";
|
||||||
|
import { configApi } from "@/lib/api";
|
||||||
|
|
||||||
const CODEX_COMMON_CONFIG_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
|
const LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
|
||||||
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
|
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
|
||||||
# Add your common TOML configuration here`;
|
# Add your common TOML configuration here`;
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ interface UseCodexCommonConfigProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理 Codex 通用配置片段 (TOML 格式)
|
* 管理 Codex 通用配置片段 (TOML 格式)
|
||||||
|
* 从 config.json 读取和保存,支持从 localStorage 平滑迁移
|
||||||
*/
|
*/
|
||||||
export function useCodexCommonConfig({
|
export function useCodexCommonConfig({
|
||||||
codexConfig,
|
codexConfig,
|
||||||
@@ -26,31 +28,69 @@ export function useCodexCommonConfig({
|
|||||||
}: UseCodexCommonConfigProps) {
|
}: UseCodexCommonConfigProps) {
|
||||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
||||||
() => {
|
DEFAULT_CODEX_COMMON_CONFIG_SNIPPET,
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const stored = window.localStorage.getItem(
|
|
||||||
CODEX_COMMON_CONFIG_STORAGE_KEY,
|
|
||||||
);
|
|
||||||
if (stored && stored.trim()) {
|
|
||||||
return stored;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore localStorage 读取失败
|
|
||||||
}
|
|
||||||
return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET;
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
const [commonConfigError, setCommonConfigError] = useState("");
|
const [commonConfigError, setCommonConfigError] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// 用于跟踪是否正在通过通用配置更新
|
// 用于跟踪是否正在通过通用配置更新
|
||||||
const isUpdatingFromCommonConfig = useRef(false);
|
const isUpdatingFromCommonConfig = useRef(false);
|
||||||
|
|
||||||
|
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const loadSnippet = async () => {
|
||||||
|
try {
|
||||||
|
// 使用统一 API 加载
|
||||||
|
const snippet = await configApi.getCommonConfigSnippet("codex");
|
||||||
|
|
||||||
|
if (snippet && snippet.trim()) {
|
||||||
|
if (mounted) {
|
||||||
|
setCommonConfigSnippetState(snippet);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果 config.json 中没有,尝试从 localStorage 迁移
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
try {
|
||||||
|
const legacySnippet =
|
||||||
|
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||||
|
if (legacySnippet && legacySnippet.trim()) {
|
||||||
|
// 迁移到 config.json
|
||||||
|
await configApi.setCommonConfigSnippet("codex", legacySnippet);
|
||||||
|
if (mounted) {
|
||||||
|
setCommonConfigSnippetState(legacySnippet);
|
||||||
|
}
|
||||||
|
// 清理 localStorage
|
||||||
|
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||||
|
console.log(
|
||||||
|
"[迁移] Codex 通用配置已从 localStorage 迁移到 config.json",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("加载 Codex 通用配置失败:", error);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSnippet();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 初始化时检查通用配置片段(编辑模式)
|
// 初始化时检查通用配置片段(编辑模式)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialData?.settingsConfig) {
|
if (initialData?.settingsConfig && !isLoading) {
|
||||||
const config =
|
const config =
|
||||||
typeof initialData.settingsConfig.config === "string"
|
typeof initialData.settingsConfig.config === "string"
|
||||||
? initialData.settingsConfig.config
|
? initialData.settingsConfig.config
|
||||||
@@ -58,24 +98,7 @@ export function useCodexCommonConfig({
|
|||||||
const hasCommon = hasTomlCommonConfigSnippet(config, commonConfigSnippet);
|
const hasCommon = hasTomlCommonConfigSnippet(config, commonConfigSnippet);
|
||||||
setUseCommonConfig(hasCommon);
|
setUseCommonConfig(hasCommon);
|
||||||
}
|
}
|
||||||
}, [initialData, commonConfigSnippet]);
|
}, [initialData, commonConfigSnippet, isLoading]);
|
||||||
|
|
||||||
// 同步本地存储的通用配置片段
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
try {
|
|
||||||
if (commonConfigSnippet.trim()) {
|
|
||||||
window.localStorage.setItem(
|
|
||||||
CODEX_COMMON_CONFIG_STORAGE_KEY,
|
|
||||||
commonConfigSnippet,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
window.localStorage.removeItem(CODEX_COMMON_CONFIG_STORAGE_KEY);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, [commonConfigSnippet]);
|
|
||||||
|
|
||||||
// 处理通用配置开关
|
// 处理通用配置开关
|
||||||
const handleCommonConfigToggle = useCallback(
|
const handleCommonConfigToggle = useCallback(
|
||||||
@@ -114,6 +137,12 @@ export function useCodexCommonConfig({
|
|||||||
|
|
||||||
if (!value.trim()) {
|
if (!value.trim()) {
|
||||||
setCommonConfigError("");
|
setCommonConfigError("");
|
||||||
|
// 保存到 config.json(清空)
|
||||||
|
configApi.setCommonConfigSnippet("codex", "").catch((error) => {
|
||||||
|
console.error("保存 Codex 通用配置失败:", error);
|
||||||
|
setCommonConfigError(`保存失败: ${error}`);
|
||||||
|
});
|
||||||
|
|
||||||
if (useCommonConfig) {
|
if (useCommonConfig) {
|
||||||
const { updatedConfig } = updateTomlCommonConfigSnippet(
|
const { updatedConfig } = updateTomlCommonConfigSnippet(
|
||||||
codexConfig,
|
codexConfig,
|
||||||
@@ -128,6 +157,11 @@ export function useCodexCommonConfig({
|
|||||||
|
|
||||||
// TOML 格式校验较为复杂,暂时不做校验,直接清空错误
|
// TOML 格式校验较为复杂,暂时不做校验,直接清空错误
|
||||||
setCommonConfigError("");
|
setCommonConfigError("");
|
||||||
|
// 保存到 config.json
|
||||||
|
configApi.setCommonConfigSnippet("codex", value).catch((error) => {
|
||||||
|
console.error("保存 Codex 通用配置失败:", error);
|
||||||
|
setCommonConfigError(`保存失败: ${error}`);
|
||||||
|
});
|
||||||
|
|
||||||
// 若当前启用通用配置,需要替换为最新片段
|
// 若当前启用通用配置,需要替换为最新片段
|
||||||
if (useCommonConfig) {
|
if (useCommonConfig) {
|
||||||
@@ -165,7 +199,7 @@ export function useCodexCommonConfig({
|
|||||||
|
|
||||||
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isUpdatingFromCommonConfig.current) {
|
if (isUpdatingFromCommonConfig.current || isLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasCommon = hasTomlCommonConfigSnippet(
|
const hasCommon = hasTomlCommonConfigSnippet(
|
||||||
@@ -173,12 +207,13 @@ export function useCodexCommonConfig({
|
|||||||
commonConfigSnippet,
|
commonConfigSnippet,
|
||||||
);
|
);
|
||||||
setUseCommonConfig(hasCommon);
|
setUseCommonConfig(hasCommon);
|
||||||
}, [codexConfig, commonConfigSnippet]);
|
}, [codexConfig, commonConfigSnippet, isLoading]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
useCommonConfig,
|
useCommonConfig,
|
||||||
commonConfigSnippet,
|
commonConfigSnippet,
|
||||||
commonConfigError,
|
commonConfigError,
|
||||||
|
isLoading,
|
||||||
handleCommonConfigToggle,
|
handleCommonConfigToggle,
|
||||||
handleCommonConfigSnippetChange,
|
handleCommonConfigSnippetChange,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useState, useCallback, useEffect, useRef } from "react";
|
|||||||
import {
|
import {
|
||||||
extractCodexBaseUrl,
|
extractCodexBaseUrl,
|
||||||
setCodexBaseUrl as setCodexBaseUrlInConfig,
|
setCodexBaseUrl as setCodexBaseUrlInConfig,
|
||||||
|
extractCodexModelName,
|
||||||
|
setCodexModelName as setCodexModelNameInConfig,
|
||||||
} from "@/utils/providerConfigUtils";
|
} from "@/utils/providerConfigUtils";
|
||||||
import { normalizeTomlText } from "@/utils/textNormalization";
|
import { normalizeTomlText } from "@/utils/textNormalization";
|
||||||
|
|
||||||
@@ -20,9 +22,11 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
|||||||
const [codexConfig, setCodexConfigState] = useState("");
|
const [codexConfig, setCodexConfigState] = useState("");
|
||||||
const [codexApiKey, setCodexApiKey] = useState("");
|
const [codexApiKey, setCodexApiKey] = useState("");
|
||||||
const [codexBaseUrl, setCodexBaseUrl] = useState("");
|
const [codexBaseUrl, setCodexBaseUrl] = useState("");
|
||||||
|
const [codexModelName, setCodexModelName] = useState("");
|
||||||
const [codexAuthError, setCodexAuthError] = useState("");
|
const [codexAuthError, setCodexAuthError] = useState("");
|
||||||
|
|
||||||
const isUpdatingCodexBaseUrlRef = useRef(false);
|
const isUpdatingCodexBaseUrlRef = useRef(false);
|
||||||
|
const isUpdatingCodexModelNameRef = useRef(false);
|
||||||
|
|
||||||
// 初始化 Codex 配置(编辑模式)
|
// 初始化 Codex 配置(编辑模式)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,6 +51,12 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
|||||||
setCodexBaseUrl(initialBaseUrl);
|
setCodexBaseUrl(initialBaseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 提取 Model Name
|
||||||
|
const initialModelName = extractCodexModelName(configStr);
|
||||||
|
if (initialModelName) {
|
||||||
|
setCodexModelName(initialModelName);
|
||||||
|
}
|
||||||
|
|
||||||
// 提取 API Key
|
// 提取 API Key
|
||||||
try {
|
try {
|
||||||
if (auth && typeof auth.OPENAI_API_KEY === "string") {
|
if (auth && typeof auth.OPENAI_API_KEY === "string") {
|
||||||
@@ -69,6 +79,17 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
|||||||
}
|
}
|
||||||
}, [codexConfig, codexBaseUrl]);
|
}, [codexConfig, codexBaseUrl]);
|
||||||
|
|
||||||
|
// 与 TOML 配置保持模型名称同步
|
||||||
|
useEffect(() => {
|
||||||
|
if (isUpdatingCodexModelNameRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const extracted = extractCodexModelName(codexConfig) || "";
|
||||||
|
if (extracted !== codexModelName) {
|
||||||
|
setCodexModelName(extracted);
|
||||||
|
}
|
||||||
|
}, [codexConfig, codexModelName]);
|
||||||
|
|
||||||
// 获取 API Key(从 auth JSON)
|
// 获取 API Key(从 auth JSON)
|
||||||
const getCodexAuthApiKey = useCallback((authString: string): string => {
|
const getCodexAuthApiKey = useCallback((authString: string): string => {
|
||||||
try {
|
try {
|
||||||
@@ -157,7 +178,26 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
|||||||
[setCodexConfig],
|
[setCodexConfig],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 处理 config 变化(同步 Base URL)
|
// 处理 Codex Model Name 变化
|
||||||
|
const handleCodexModelNameChange = useCallback(
|
||||||
|
(modelName: string) => {
|
||||||
|
const trimmed = modelName.trim();
|
||||||
|
setCodexModelName(trimmed);
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdatingCodexModelNameRef.current = true;
|
||||||
|
setCodexConfig((prev) => setCodexModelNameInConfig(prev, trimmed));
|
||||||
|
setTimeout(() => {
|
||||||
|
isUpdatingCodexModelNameRef.current = false;
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
[setCodexConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理 config 变化(同步 Base URL 和 Model Name)
|
||||||
const handleCodexConfigChange = useCallback(
|
const handleCodexConfigChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
// 归一化中文/全角/弯引号,避免 TOML 解析报错
|
// 归一化中文/全角/弯引号,避免 TOML 解析报错
|
||||||
@@ -170,8 +210,15 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
|||||||
setCodexBaseUrl(extracted);
|
setCodexBaseUrl(extracted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isUpdatingCodexModelNameRef.current) {
|
||||||
|
const extractedModel = extractCodexModelName(normalized) || "";
|
||||||
|
if (extractedModel !== codexModelName) {
|
||||||
|
setCodexModelName(extractedModel);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[setCodexConfig, codexBaseUrl],
|
[setCodexConfig, codexBaseUrl, codexModelName],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 重置配置(用于预设切换)
|
// 重置配置(用于预设切换)
|
||||||
@@ -186,6 +233,13 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
|||||||
setCodexBaseUrl(baseUrl);
|
setCodexBaseUrl(baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modelName = extractCodexModelName(config);
|
||||||
|
if (modelName) {
|
||||||
|
setCodexModelName(modelName);
|
||||||
|
} else {
|
||||||
|
setCodexModelName("");
|
||||||
|
}
|
||||||
|
|
||||||
// 提取 API Key
|
// 提取 API Key
|
||||||
try {
|
try {
|
||||||
if (auth && typeof auth.OPENAI_API_KEY === "string") {
|
if (auth && typeof auth.OPENAI_API_KEY === "string") {
|
||||||
@@ -205,11 +259,13 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
|||||||
codexConfig,
|
codexConfig,
|
||||||
codexApiKey,
|
codexApiKey,
|
||||||
codexBaseUrl,
|
codexBaseUrl,
|
||||||
|
codexModelName,
|
||||||
codexAuthError,
|
codexAuthError,
|
||||||
setCodexAuth,
|
setCodexAuth,
|
||||||
setCodexConfig,
|
setCodexConfig,
|
||||||
handleCodexApiKeyChange,
|
handleCodexApiKeyChange,
|
||||||
handleCodexBaseUrlChange,
|
handleCodexBaseUrlChange,
|
||||||
|
handleCodexModelNameChange,
|
||||||
handleCodexConfigChange,
|
handleCodexConfigChange,
|
||||||
resetCodexConfig,
|
resetCodexConfig,
|
||||||
getCodexAuthApiKey,
|
getCodexAuthApiKey,
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import {
|
|||||||
hasCommonConfigSnippet,
|
hasCommonConfigSnippet,
|
||||||
validateJsonConfig,
|
validateJsonConfig,
|
||||||
} from "@/utils/providerConfigUtils";
|
} from "@/utils/providerConfigUtils";
|
||||||
|
import { configApi } from "@/lib/api";
|
||||||
|
|
||||||
const COMMON_CONFIG_STORAGE_KEY = "cc-switch:common-config-snippet";
|
const LEGACY_STORAGE_KEY = "cc-switch:common-config-snippet";
|
||||||
const DEFAULT_COMMON_CONFIG_SNIPPET = `{
|
const DEFAULT_COMMON_CONFIG_SNIPPET = `{
|
||||||
"includeCoAuthoredBy": false
|
"includeCoAuthoredBy": false
|
||||||
}`;
|
}`;
|
||||||
@@ -20,6 +21,7 @@ interface UseCommonConfigSnippetProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理 Claude 通用配置片段
|
* 管理 Claude 通用配置片段
|
||||||
|
* 从 config.json 读取和保存,支持从 localStorage 平滑迁移
|
||||||
*/
|
*/
|
||||||
export function useCommonConfigSnippet({
|
export function useCommonConfigSnippet({
|
||||||
settingsConfig,
|
settingsConfig,
|
||||||
@@ -28,29 +30,69 @@ export function useCommonConfigSnippet({
|
|||||||
}: UseCommonConfigSnippetProps) {
|
}: UseCommonConfigSnippetProps) {
|
||||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
||||||
() => {
|
DEFAULT_COMMON_CONFIG_SNIPPET,
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return DEFAULT_COMMON_CONFIG_SNIPPET;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const stored = window.localStorage.getItem(COMMON_CONFIG_STORAGE_KEY);
|
|
||||||
if (stored && stored.trim()) {
|
|
||||||
return stored;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore localStorage 读取失败
|
|
||||||
}
|
|
||||||
return DEFAULT_COMMON_CONFIG_SNIPPET;
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
const [commonConfigError, setCommonConfigError] = useState("");
|
const [commonConfigError, setCommonConfigError] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// 用于跟踪是否正在通过通用配置更新
|
// 用于跟踪是否正在通过通用配置更新
|
||||||
const isUpdatingFromCommonConfig = useRef(false);
|
const isUpdatingFromCommonConfig = useRef(false);
|
||||||
|
|
||||||
|
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const loadSnippet = async () => {
|
||||||
|
try {
|
||||||
|
// 使用统一 API 加载
|
||||||
|
const snippet = await configApi.getCommonConfigSnippet("claude");
|
||||||
|
|
||||||
|
if (snippet && snippet.trim()) {
|
||||||
|
if (mounted) {
|
||||||
|
setCommonConfigSnippetState(snippet);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果 config.json 中没有,尝试从 localStorage 迁移
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
try {
|
||||||
|
const legacySnippet =
|
||||||
|
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||||
|
if (legacySnippet && legacySnippet.trim()) {
|
||||||
|
// 迁移到 config.json
|
||||||
|
await configApi.setCommonConfigSnippet("claude", legacySnippet);
|
||||||
|
if (mounted) {
|
||||||
|
setCommonConfigSnippetState(legacySnippet);
|
||||||
|
}
|
||||||
|
// 清理 localStorage
|
||||||
|
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||||
|
console.log(
|
||||||
|
"[迁移] Claude 通用配置已从 localStorage 迁移到 config.json",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("加载通用配置失败:", error);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSnippet();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 初始化时检查通用配置片段(编辑模式)
|
// 初始化时检查通用配置片段(编辑模式)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialData) {
|
if (initialData && !isLoading) {
|
||||||
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
|
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
|
||||||
const hasCommon = hasCommonConfigSnippet(
|
const hasCommon = hasCommonConfigSnippet(
|
||||||
configString,
|
configString,
|
||||||
@@ -58,24 +100,7 @@ export function useCommonConfigSnippet({
|
|||||||
);
|
);
|
||||||
setUseCommonConfig(hasCommon);
|
setUseCommonConfig(hasCommon);
|
||||||
}
|
}
|
||||||
}, [initialData, commonConfigSnippet]);
|
}, [initialData, commonConfigSnippet, isLoading]);
|
||||||
|
|
||||||
// 同步本地存储的通用配置片段
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
try {
|
|
||||||
if (commonConfigSnippet.trim()) {
|
|
||||||
window.localStorage.setItem(
|
|
||||||
COMMON_CONFIG_STORAGE_KEY,
|
|
||||||
commonConfigSnippet,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
window.localStorage.removeItem(COMMON_CONFIG_STORAGE_KEY);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, [commonConfigSnippet]);
|
|
||||||
|
|
||||||
// 处理通用配置开关
|
// 处理通用配置开关
|
||||||
const handleCommonConfigToggle = useCallback(
|
const handleCommonConfigToggle = useCallback(
|
||||||
@@ -113,6 +138,12 @@ export function useCommonConfigSnippet({
|
|||||||
|
|
||||||
if (!value.trim()) {
|
if (!value.trim()) {
|
||||||
setCommonConfigError("");
|
setCommonConfigError("");
|
||||||
|
// 保存到 config.json(清空)
|
||||||
|
configApi.setCommonConfigSnippet("claude", "").catch((error) => {
|
||||||
|
console.error("保存通用配置失败:", error);
|
||||||
|
setCommonConfigError(`保存失败: ${error}`);
|
||||||
|
});
|
||||||
|
|
||||||
if (useCommonConfig) {
|
if (useCommonConfig) {
|
||||||
const { updatedConfig } = updateCommonConfigSnippet(
|
const { updatedConfig } = updateCommonConfigSnippet(
|
||||||
settingsConfig,
|
settingsConfig,
|
||||||
@@ -131,6 +162,11 @@ export function useCommonConfigSnippet({
|
|||||||
setCommonConfigError(validationError);
|
setCommonConfigError(validationError);
|
||||||
} else {
|
} else {
|
||||||
setCommonConfigError("");
|
setCommonConfigError("");
|
||||||
|
// 保存到 config.json
|
||||||
|
configApi.setCommonConfigSnippet("claude", value).catch((error) => {
|
||||||
|
console.error("保存通用配置失败:", error);
|
||||||
|
setCommonConfigError(`保存失败: ${error}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 若当前启用通用配置且格式正确,需要替换为最新片段
|
// 若当前启用通用配置且格式正确,需要替换为最新片段
|
||||||
@@ -169,7 +205,7 @@ export function useCommonConfigSnippet({
|
|||||||
|
|
||||||
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isUpdatingFromCommonConfig.current) {
|
if (isUpdatingFromCommonConfig.current || isLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasCommon = hasCommonConfigSnippet(
|
const hasCommon = hasCommonConfigSnippet(
|
||||||
@@ -177,12 +213,13 @@ export function useCommonConfigSnippet({
|
|||||||
commonConfigSnippet,
|
commonConfigSnippet,
|
||||||
);
|
);
|
||||||
setUseCommonConfig(hasCommon);
|
setUseCommonConfig(hasCommon);
|
||||||
}, [settingsConfig, commonConfigSnippet]);
|
}, [settingsConfig, commonConfigSnippet, isLoading]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
useCommonConfig,
|
useCommonConfig,
|
||||||
commonConfigSnippet,
|
commonConfigSnippet,
|
||||||
commonConfigError,
|
commonConfigError,
|
||||||
|
isLoading,
|
||||||
handleCommonConfigToggle,
|
handleCommonConfigToggle,
|
||||||
handleCommonConfigSnippetChange,
|
handleCommonConfigSnippetChange,
|
||||||
};
|
};
|
||||||
|
|||||||
333
src/components/providers/forms/hooks/useGeminiCommonConfig.ts
Normal file
333
src/components/providers/forms/hooks/useGeminiCommonConfig.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { configApi } from "@/lib/api";
|
||||||
|
|
||||||
|
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
|
||||||
|
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = `{
|
||||||
|
"timeout": 30000,
|
||||||
|
"maxRetries": 3
|
||||||
|
}`;
|
||||||
|
|
||||||
|
interface UseGeminiCommonConfigProps {
|
||||||
|
configValue: string;
|
||||||
|
onConfigChange: (config: string) => void;
|
||||||
|
initialData?: {
|
||||||
|
settingsConfig?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深度合并两个对象(用于合并通用配置)
|
||||||
|
*/
|
||||||
|
function deepMerge(target: any, source: any): any {
|
||||||
|
if (typeof target !== "object" || target === null) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
if (typeof source !== "object" || source === null) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
if (Array.isArray(source)) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { ...target };
|
||||||
|
for (const key of Object.keys(source)) {
|
||||||
|
if (typeof source[key] === "object" && !Array.isArray(source[key])) {
|
||||||
|
result[key] = deepMerge(result[key], source[key]);
|
||||||
|
} else {
|
||||||
|
result[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从配置中移除通用配置片段(递归比较)
|
||||||
|
*/
|
||||||
|
function removeCommonConfig(config: any, commonConfig: any): any {
|
||||||
|
if (typeof config !== "object" || config === null) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
if (typeof commonConfig !== "object" || commonConfig === null) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { ...config };
|
||||||
|
for (const key of Object.keys(commonConfig)) {
|
||||||
|
if (result[key] === undefined) continue;
|
||||||
|
|
||||||
|
// 如果值完全相等,删除该键
|
||||||
|
if (JSON.stringify(result[key]) === JSON.stringify(commonConfig[key])) {
|
||||||
|
delete result[key];
|
||||||
|
} else if (
|
||||||
|
typeof result[key] === "object" &&
|
||||||
|
!Array.isArray(result[key]) &&
|
||||||
|
typeof commonConfig[key] === "object" &&
|
||||||
|
!Array.isArray(commonConfig[key])
|
||||||
|
) {
|
||||||
|
// 递归移除嵌套对象
|
||||||
|
result[key] = removeCommonConfig(result[key], commonConfig[key]);
|
||||||
|
// 如果移除后对象为空,删除该键
|
||||||
|
if (Object.keys(result[key]).length === 0) {
|
||||||
|
delete result[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查配置中是否包含通用配置片段
|
||||||
|
*/
|
||||||
|
function hasCommonConfigSnippet(config: any, commonConfig: any): boolean {
|
||||||
|
if (typeof config !== "object" || config === null) return false;
|
||||||
|
if (typeof commonConfig !== "object" || commonConfig === null) return false;
|
||||||
|
|
||||||
|
for (const key of Object.keys(commonConfig)) {
|
||||||
|
if (config[key] === undefined) return false;
|
||||||
|
if (JSON.stringify(config[key]) !== JSON.stringify(commonConfig[key])) {
|
||||||
|
// 检查嵌套对象
|
||||||
|
if (
|
||||||
|
typeof config[key] === "object" &&
|
||||||
|
!Array.isArray(config[key]) &&
|
||||||
|
typeof commonConfig[key] === "object" &&
|
||||||
|
!Array.isArray(commonConfig[key])
|
||||||
|
) {
|
||||||
|
if (!hasCommonConfigSnippet(config[key], commonConfig[key])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理 Gemini 通用配置片段 (JSON 格式)
|
||||||
|
* 从 config.json 读取和保存,支持从 localStorage 平滑迁移
|
||||||
|
*/
|
||||||
|
export function useGeminiCommonConfig({
|
||||||
|
configValue,
|
||||||
|
onConfigChange,
|
||||||
|
initialData,
|
||||||
|
}: UseGeminiCommonConfigProps) {
|
||||||
|
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||||
|
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
||||||
|
DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET,
|
||||||
|
);
|
||||||
|
const [commonConfigError, setCommonConfigError] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// 用于跟踪是否正在通过通用配置更新
|
||||||
|
const isUpdatingFromCommonConfig = useRef(false);
|
||||||
|
|
||||||
|
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const loadSnippet = async () => {
|
||||||
|
try {
|
||||||
|
// 使用统一 API 加载
|
||||||
|
const snippet = await configApi.getCommonConfigSnippet("gemini");
|
||||||
|
|
||||||
|
if (snippet && snippet.trim()) {
|
||||||
|
if (mounted) {
|
||||||
|
setCommonConfigSnippetState(snippet);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果 config.json 中没有,尝试从 localStorage 迁移
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
try {
|
||||||
|
const legacySnippet =
|
||||||
|
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||||
|
if (legacySnippet && legacySnippet.trim()) {
|
||||||
|
// 迁移到 config.json
|
||||||
|
await configApi.setCommonConfigSnippet("gemini", legacySnippet);
|
||||||
|
if (mounted) {
|
||||||
|
setCommonConfigSnippetState(legacySnippet);
|
||||||
|
}
|
||||||
|
// 清理 localStorage
|
||||||
|
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||||
|
console.log(
|
||||||
|
"[迁移] Gemini 通用配置已从 localStorage 迁移到 config.json",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("加载 Gemini 通用配置失败:", error);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSnippet();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 初始化时检查通用配置片段(编辑模式)
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData?.settingsConfig && !isLoading) {
|
||||||
|
try {
|
||||||
|
const config =
|
||||||
|
typeof initialData.settingsConfig.config === "object"
|
||||||
|
? initialData.settingsConfig.config
|
||||||
|
: {};
|
||||||
|
const commonConfigObj = JSON.parse(commonConfigSnippet);
|
||||||
|
const hasCommon = hasCommonConfigSnippet(config, commonConfigObj);
|
||||||
|
setUseCommonConfig(hasCommon);
|
||||||
|
} catch {
|
||||||
|
// ignore parse error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialData, commonConfigSnippet, isLoading]);
|
||||||
|
|
||||||
|
// 处理通用配置开关
|
||||||
|
const handleCommonConfigToggle = useCallback(
|
||||||
|
(checked: boolean) => {
|
||||||
|
try {
|
||||||
|
const configObj = configValue.trim() ? JSON.parse(configValue) : {};
|
||||||
|
const commonConfigObj = JSON.parse(commonConfigSnippet);
|
||||||
|
|
||||||
|
let updatedConfig: any;
|
||||||
|
if (checked) {
|
||||||
|
// 合并通用配置
|
||||||
|
updatedConfig = deepMerge(configObj, commonConfigObj);
|
||||||
|
} else {
|
||||||
|
// 移除通用配置
|
||||||
|
updatedConfig = removeCommonConfig(configObj, commonConfigObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCommonConfigError("");
|
||||||
|
setUseCommonConfig(checked);
|
||||||
|
|
||||||
|
// 标记正在通过通用配置更新
|
||||||
|
isUpdatingFromCommonConfig.current = true;
|
||||||
|
onConfigChange(JSON.stringify(updatedConfig, null, 2));
|
||||||
|
|
||||||
|
// 在下一个事件循环中重置标记
|
||||||
|
setTimeout(() => {
|
||||||
|
isUpdatingFromCommonConfig.current = false;
|
||||||
|
}, 0);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
setCommonConfigError(`配置合并失败: ${errorMessage}`);
|
||||||
|
setUseCommonConfig(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[configValue, commonConfigSnippet, onConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理通用配置片段变化
|
||||||
|
const handleCommonConfigSnippetChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
const previousSnippet = commonConfigSnippet;
|
||||||
|
setCommonConfigSnippetState(value);
|
||||||
|
|
||||||
|
if (!value.trim()) {
|
||||||
|
setCommonConfigError("");
|
||||||
|
// 保存到 config.json(清空)
|
||||||
|
configApi.setCommonConfigSnippet("gemini", "").catch((error) => {
|
||||||
|
console.error("保存 Gemini 通用配置失败:", error);
|
||||||
|
setCommonConfigError(`保存失败: ${error}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (useCommonConfig) {
|
||||||
|
// 移除旧的通用配置
|
||||||
|
try {
|
||||||
|
const configObj = configValue.trim() ? JSON.parse(configValue) : {};
|
||||||
|
const previousCommonConfigObj = JSON.parse(previousSnippet);
|
||||||
|
const updatedConfig = removeCommonConfig(
|
||||||
|
configObj,
|
||||||
|
previousCommonConfigObj,
|
||||||
|
);
|
||||||
|
onConfigChange(JSON.stringify(updatedConfig, null, 2));
|
||||||
|
setUseCommonConfig(false);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验 JSON 格式
|
||||||
|
try {
|
||||||
|
JSON.parse(value);
|
||||||
|
setCommonConfigError("");
|
||||||
|
// 保存到 config.json
|
||||||
|
configApi.setCommonConfigSnippet("gemini", value).catch((error) => {
|
||||||
|
console.error("保存 Gemini 通用配置失败:", error);
|
||||||
|
setCommonConfigError(`保存失败: ${error}`);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setCommonConfigError("通用配置片段格式错误(必须是有效的 JSON)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若当前启用通用配置,需要替换为最新片段
|
||||||
|
if (useCommonConfig) {
|
||||||
|
try {
|
||||||
|
const configObj = configValue.trim() ? JSON.parse(configValue) : {};
|
||||||
|
const previousCommonConfigObj = JSON.parse(previousSnippet);
|
||||||
|
const newCommonConfigObj = JSON.parse(value);
|
||||||
|
|
||||||
|
// 先移除旧的通用配置
|
||||||
|
const withoutOld = removeCommonConfig(
|
||||||
|
configObj,
|
||||||
|
previousCommonConfigObj,
|
||||||
|
);
|
||||||
|
// 再合并新的通用配置
|
||||||
|
const withNew = deepMerge(withoutOld, newCommonConfigObj);
|
||||||
|
|
||||||
|
// 标记正在通过通用配置更新,避免触发状态检查
|
||||||
|
isUpdatingFromCommonConfig.current = true;
|
||||||
|
onConfigChange(JSON.stringify(withNew, null, 2));
|
||||||
|
|
||||||
|
// 在下一个事件循环中重置标记
|
||||||
|
setTimeout(() => {
|
||||||
|
isUpdatingFromCommonConfig.current = false;
|
||||||
|
}, 0);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
setCommonConfigError(`配置替换失败: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[commonConfigSnippet, configValue, useCommonConfig, onConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isUpdatingFromCommonConfig.current || isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const configObj = configValue.trim() ? JSON.parse(configValue) : {};
|
||||||
|
const commonConfigObj = JSON.parse(commonConfigSnippet);
|
||||||
|
const hasCommon = hasCommonConfigSnippet(configObj, commonConfigObj);
|
||||||
|
setUseCommonConfig(hasCommon);
|
||||||
|
} catch {
|
||||||
|
// ignore parse error
|
||||||
|
}
|
||||||
|
}, [configValue, commonConfigSnippet, isLoading]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
useCommonConfig,
|
||||||
|
commonConfigSnippet,
|
||||||
|
commonConfigError,
|
||||||
|
isLoading,
|
||||||
|
handleCommonConfigToggle,
|
||||||
|
handleCommonConfigSnippetChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
232
src/components/providers/forms/hooks/useGeminiConfigState.ts
Normal file
232
src/components/providers/forms/hooks/useGeminiConfigState.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
|
||||||
|
interface UseGeminiConfigStateProps {
|
||||||
|
initialData?: {
|
||||||
|
settingsConfig?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理 Gemini 配置状态
|
||||||
|
* Gemini 配置包含两部分:env (环境变量) 和 config (扩展配置 JSON)
|
||||||
|
*/
|
||||||
|
export function useGeminiConfigState({
|
||||||
|
initialData,
|
||||||
|
}: UseGeminiConfigStateProps) {
|
||||||
|
const [geminiEnv, setGeminiEnvState] = useState("");
|
||||||
|
const [geminiConfig, setGeminiConfigState] = useState("");
|
||||||
|
const [geminiApiKey, setGeminiApiKey] = useState("");
|
||||||
|
const [geminiBaseUrl, setGeminiBaseUrl] = useState("");
|
||||||
|
const [geminiModel, setGeminiModel] = useState("");
|
||||||
|
const [envError, setEnvError] = useState("");
|
||||||
|
const [configError, setConfigError] = useState("");
|
||||||
|
|
||||||
|
// 将 JSON env 对象转换为 .env 格式字符串
|
||||||
|
const envObjToString = useCallback(
|
||||||
|
(envObj: Record<string, unknown>): string => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (typeof envObj.GOOGLE_GEMINI_BASE_URL === "string") {
|
||||||
|
lines.push(`GOOGLE_GEMINI_BASE_URL=${envObj.GOOGLE_GEMINI_BASE_URL}`);
|
||||||
|
}
|
||||||
|
if (typeof envObj.GEMINI_API_KEY === "string") {
|
||||||
|
lines.push(`GEMINI_API_KEY=${envObj.GEMINI_API_KEY}`);
|
||||||
|
}
|
||||||
|
if (typeof envObj.GEMINI_MODEL === "string") {
|
||||||
|
lines.push(`GEMINI_MODEL=${envObj.GEMINI_MODEL}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 将 .env 格式字符串转换为 JSON env 对象
|
||||||
|
const envStringToObj = useCallback(
|
||||||
|
(envString: string): Record<string, string> => {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
const lines = envString.split("\n");
|
||||||
|
lines.forEach((line) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) return;
|
||||||
|
const equalIndex = trimmed.indexOf("=");
|
||||||
|
if (equalIndex > 0) {
|
||||||
|
const key = trimmed.substring(0, equalIndex).trim();
|
||||||
|
const value = trimmed.substring(equalIndex + 1).trim();
|
||||||
|
env[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return env;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 初始化 Gemini 配置(编辑模式)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialData) return;
|
||||||
|
|
||||||
|
const config = initialData.settingsConfig;
|
||||||
|
if (typeof config === "object" && config !== null) {
|
||||||
|
// 设置 env
|
||||||
|
const env = (config as any).env || {};
|
||||||
|
setGeminiEnvState(envObjToString(env));
|
||||||
|
|
||||||
|
// 设置 config
|
||||||
|
const configObj = (config as any).config || {};
|
||||||
|
setGeminiConfigState(JSON.stringify(configObj, null, 2));
|
||||||
|
|
||||||
|
// 提取 API Key、Base URL 和 Model
|
||||||
|
if (typeof env.GEMINI_API_KEY === "string") {
|
||||||
|
setGeminiApiKey(env.GEMINI_API_KEY);
|
||||||
|
}
|
||||||
|
if (typeof env.GOOGLE_GEMINI_BASE_URL === "string") {
|
||||||
|
setGeminiBaseUrl(env.GOOGLE_GEMINI_BASE_URL);
|
||||||
|
}
|
||||||
|
if (typeof env.GEMINI_MODEL === "string") {
|
||||||
|
setGeminiModel(env.GEMINI_MODEL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialData, envObjToString]);
|
||||||
|
|
||||||
|
// 从 geminiEnv 中提取并同步 API Key、Base URL 和 Model
|
||||||
|
useEffect(() => {
|
||||||
|
const envObj = envStringToObj(geminiEnv);
|
||||||
|
const extractedKey = envObj.GEMINI_API_KEY || "";
|
||||||
|
const extractedBaseUrl = envObj.GOOGLE_GEMINI_BASE_URL || "";
|
||||||
|
const extractedModel = envObj.GEMINI_MODEL || "";
|
||||||
|
|
||||||
|
if (extractedKey !== geminiApiKey) {
|
||||||
|
setGeminiApiKey(extractedKey);
|
||||||
|
}
|
||||||
|
if (extractedBaseUrl !== geminiBaseUrl) {
|
||||||
|
setGeminiBaseUrl(extractedBaseUrl);
|
||||||
|
}
|
||||||
|
if (extractedModel !== geminiModel) {
|
||||||
|
setGeminiModel(extractedModel);
|
||||||
|
}
|
||||||
|
}, [geminiEnv, envStringToObj, geminiApiKey, geminiBaseUrl, geminiModel]);
|
||||||
|
|
||||||
|
// 验证 Gemini Config JSON
|
||||||
|
const validateGeminiConfig = useCallback((value: string): string => {
|
||||||
|
if (!value.trim()) return ""; // 空值允许
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return "Config must be a JSON object";
|
||||||
|
} catch {
|
||||||
|
return "Invalid JSON format";
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 设置 env
|
||||||
|
const setGeminiEnv = useCallback((value: string) => {
|
||||||
|
setGeminiEnvState(value);
|
||||||
|
// .env 格式较宽松,不做严格校验
|
||||||
|
setEnvError("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 设置 config (支持函数更新)
|
||||||
|
const setGeminiConfig = useCallback(
|
||||||
|
(value: string | ((prev: string) => string)) => {
|
||||||
|
const newValue =
|
||||||
|
typeof value === "function" ? value(geminiConfig) : value;
|
||||||
|
setGeminiConfigState(newValue);
|
||||||
|
setConfigError(validateGeminiConfig(newValue));
|
||||||
|
},
|
||||||
|
[geminiConfig, validateGeminiConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理 Gemini API Key 输入并写回 env
|
||||||
|
const handleGeminiApiKeyChange = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
const trimmed = key.trim();
|
||||||
|
setGeminiApiKey(trimmed);
|
||||||
|
|
||||||
|
const envObj = envStringToObj(geminiEnv);
|
||||||
|
envObj.GEMINI_API_KEY = trimmed;
|
||||||
|
const newEnv = envObjToString(envObj);
|
||||||
|
setGeminiEnv(newEnv);
|
||||||
|
},
|
||||||
|
[geminiEnv, envStringToObj, envObjToString, setGeminiEnv],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理 Gemini Base URL 变化
|
||||||
|
const handleGeminiBaseUrlChange = useCallback(
|
||||||
|
(url: string) => {
|
||||||
|
const sanitized = url.trim().replace(/\/+$/, "");
|
||||||
|
setGeminiBaseUrl(sanitized);
|
||||||
|
|
||||||
|
const envObj = envStringToObj(geminiEnv);
|
||||||
|
envObj.GOOGLE_GEMINI_BASE_URL = sanitized;
|
||||||
|
const newEnv = envObjToString(envObj);
|
||||||
|
setGeminiEnv(newEnv);
|
||||||
|
},
|
||||||
|
[geminiEnv, envStringToObj, envObjToString, setGeminiEnv],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理 env 变化
|
||||||
|
const handleGeminiEnvChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setGeminiEnv(value);
|
||||||
|
},
|
||||||
|
[setGeminiEnv],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理 config 变化
|
||||||
|
const handleGeminiConfigChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setGeminiConfig(value);
|
||||||
|
},
|
||||||
|
[setGeminiConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 重置配置(用于预设切换)
|
||||||
|
const resetGeminiConfig = useCallback(
|
||||||
|
(env: Record<string, unknown>, config: Record<string, unknown>) => {
|
||||||
|
const envString = envObjToString(env);
|
||||||
|
const configString = JSON.stringify(config, null, 2);
|
||||||
|
|
||||||
|
setGeminiEnv(envString);
|
||||||
|
setGeminiConfig(configString);
|
||||||
|
|
||||||
|
// 提取 API Key、Base URL 和 Model
|
||||||
|
if (typeof env.GEMINI_API_KEY === "string") {
|
||||||
|
setGeminiApiKey(env.GEMINI_API_KEY);
|
||||||
|
} else {
|
||||||
|
setGeminiApiKey("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof env.GOOGLE_GEMINI_BASE_URL === "string") {
|
||||||
|
setGeminiBaseUrl(env.GOOGLE_GEMINI_BASE_URL);
|
||||||
|
} else {
|
||||||
|
setGeminiBaseUrl("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof env.GEMINI_MODEL === "string") {
|
||||||
|
setGeminiModel(env.GEMINI_MODEL);
|
||||||
|
} else {
|
||||||
|
setGeminiModel("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[envObjToString, setGeminiEnv, setGeminiConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
geminiEnv,
|
||||||
|
geminiConfig,
|
||||||
|
geminiApiKey,
|
||||||
|
geminiBaseUrl,
|
||||||
|
geminiModel,
|
||||||
|
envError,
|
||||||
|
configError,
|
||||||
|
setGeminiEnv,
|
||||||
|
setGeminiConfig,
|
||||||
|
handleGeminiApiKeyChange,
|
||||||
|
handleGeminiBaseUrlChange,
|
||||||
|
handleGeminiEnvChange,
|
||||||
|
handleGeminiConfigChange,
|
||||||
|
resetGeminiConfig,
|
||||||
|
envStringToObj,
|
||||||
|
envObjToString,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { ProviderCategory } from "@/types";
|
|||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||||
|
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||||
|
|
||||||
interface UseProviderCategoryProps {
|
interface UseProviderCategoryProps {
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
@@ -41,7 +42,7 @@ export function useProviderCategory({
|
|||||||
if (!selectedPresetId) return;
|
if (!selectedPresetId) return;
|
||||||
|
|
||||||
// 从预设 ID 提取索引
|
// 从预设 ID 提取索引
|
||||||
const match = selectedPresetId.match(/^(claude|codex)-(\d+)$/);
|
const match = selectedPresetId.match(/^(claude|codex|gemini)-(\d+)$/);
|
||||||
if (!match) return;
|
if (!match) return;
|
||||||
|
|
||||||
const [, type, indexStr] = match;
|
const [, type, indexStr] = match;
|
||||||
@@ -61,6 +62,11 @@ export function useProviderCategory({
|
|||||||
preset.category || (preset.isOfficial ? "official" : undefined),
|
preset.category || (preset.isOfficial ? "official" : undefined),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (type === "gemini" && appId === "gemini") {
|
||||||
|
const preset = geminiProviderPresets[index];
|
||||||
|
if (preset) {
|
||||||
|
setCategory(preset.category || undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [appId, selectedPresetId, isEditMode, initialCategory]);
|
}, [appId, selectedPresetId, isEditMode, initialCategory]);
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ interface UseSpeedTestEndpointsProps {
|
|||||||
* 收集端点测速弹窗的初始端点列表
|
* 收集端点测速弹窗的初始端点列表
|
||||||
*
|
*
|
||||||
* 收集来源:
|
* 收集来源:
|
||||||
* 1. 编辑模式:从 meta.custom_endpoints 读取已保存的端点(优先)
|
* 1. 当前选中的 Base URL
|
||||||
* 2. 当前选中的 Base URL
|
* 2. 编辑模式下的初始数据 URL
|
||||||
* 3. 编辑模式下的初始数据 URL
|
* 3. 预设中的 endpointCandidates
|
||||||
* 4. 预设中的 endpointCandidates
|
*
|
||||||
|
* 注意:已保存的自定义端点通过 getCustomEndpoints API 在 EndpointSpeedTest 组件中加载,
|
||||||
|
* 不在此处读取,避免重复导入。
|
||||||
*/
|
*/
|
||||||
export function useSpeedTestEndpoints({
|
export function useSpeedTestEndpoints({
|
||||||
appId,
|
appId,
|
||||||
@@ -39,53 +41,58 @@ export function useSpeedTestEndpoints({
|
|||||||
initialData,
|
initialData,
|
||||||
}: UseSpeedTestEndpointsProps) {
|
}: UseSpeedTestEndpointsProps) {
|
||||||
const claudeEndpoints = useMemo<EndpointCandidate[]>(() => {
|
const claudeEndpoints = useMemo<EndpointCandidate[]>(() => {
|
||||||
if (appId !== "claude") return [];
|
// Reuse this branch for Claude and Gemini (non-Codex)
|
||||||
|
if (appId !== "claude" && appId !== "gemini") return [];
|
||||||
|
|
||||||
const map = new Map<string, EndpointCandidate>();
|
const map = new Map<string, EndpointCandidate>();
|
||||||
// 所有端点都标记为 isCustom: true,给用户完全的管理自由
|
// 候选端点标记为 isCustom: false,表示来自预设或配置
|
||||||
const add = (url?: string) => {
|
// 已保存的自定义端点会在 EndpointSpeedTest 组件中通过 API 加载
|
||||||
|
const add = (url?: string, isCustom = false) => {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
const sanitized = url.trim().replace(/\/+$/, "");
|
const sanitized = url.trim().replace(/\/+$/, "");
|
||||||
if (!sanitized || map.has(sanitized)) return;
|
if (!sanitized || map.has(sanitized)) return;
|
||||||
map.set(sanitized, { url: sanitized, isCustom: true });
|
map.set(sanitized, { url: sanitized, isCustom });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. 编辑模式:从 meta.custom_endpoints 读取已保存的端点(优先)
|
// 1. 当前 Base URL
|
||||||
if (initialData?.meta?.custom_endpoints) {
|
|
||||||
const customEndpoints = initialData.meta.custom_endpoints;
|
|
||||||
for (const url of Object.keys(customEndpoints)) {
|
|
||||||
add(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 当前 Base URL
|
|
||||||
if (baseUrl) {
|
if (baseUrl) {
|
||||||
add(baseUrl);
|
add(baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 编辑模式:初始数据中的 URL
|
// 2. 编辑模式:初始数据中的 URL
|
||||||
if (initialData && typeof initialData.settingsConfig === "object") {
|
if (initialData && typeof initialData.settingsConfig === "object") {
|
||||||
const configEnv = initialData.settingsConfig as {
|
const configEnv = initialData.settingsConfig as {
|
||||||
env?: { ANTHROPIC_BASE_URL?: string };
|
env?: { ANTHROPIC_BASE_URL?: string; GOOGLE_GEMINI_BASE_URL?: string };
|
||||||
};
|
};
|
||||||
const envUrl = configEnv.env?.ANTHROPIC_BASE_URL;
|
const envUrls = [
|
||||||
if (typeof envUrl === "string") {
|
configEnv.env?.ANTHROPIC_BASE_URL,
|
||||||
add(envUrl);
|
configEnv.env?.GOOGLE_GEMINI_BASE_URL,
|
||||||
}
|
];
|
||||||
|
envUrls.forEach((u) => {
|
||||||
|
if (typeof u === "string") add(u);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 预设中的 endpointCandidates(也允许用户删除)
|
// 3. 预设中的 endpointCandidates
|
||||||
if (selectedPresetId && selectedPresetId !== "custom") {
|
if (selectedPresetId && selectedPresetId !== "custom") {
|
||||||
const entry = presetEntries.find((item) => item.id === selectedPresetId);
|
const entry = presetEntries.find((item) => item.id === selectedPresetId);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
const preset = entry.preset as ProviderPreset;
|
const preset = entry.preset as ProviderPreset & {
|
||||||
// 添加预设自己的 baseUrl
|
settingsConfig?: { env?: { GOOGLE_GEMINI_BASE_URL?: string } };
|
||||||
const presetEnv = preset.settingsConfig as {
|
endpointCandidates?: string[];
|
||||||
env?: { ANTHROPIC_BASE_URL?: string };
|
|
||||||
};
|
};
|
||||||
if (presetEnv.env?.ANTHROPIC_BASE_URL) {
|
// 添加预设自己的 baseUrl(兼容 Claude/Gemini)
|
||||||
add(presetEnv.env.ANTHROPIC_BASE_URL);
|
const presetEnv = preset.settingsConfig as {
|
||||||
}
|
env?: {
|
||||||
|
ANTHROPIC_BASE_URL?: string;
|
||||||
|
GOOGLE_GEMINI_BASE_URL?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const presetUrls = [
|
||||||
|
presetEnv?.env?.ANTHROPIC_BASE_URL,
|
||||||
|
presetEnv?.env?.GOOGLE_GEMINI_BASE_URL,
|
||||||
|
];
|
||||||
|
presetUrls.forEach((u) => add(u));
|
||||||
// 添加预设的候选端点
|
// 添加预设的候选端点
|
||||||
if (preset.endpointCandidates) {
|
if (preset.endpointCandidates) {
|
||||||
preset.endpointCandidates.forEach((url) => add(url));
|
preset.endpointCandidates.forEach((url) => add(url));
|
||||||
@@ -100,28 +107,21 @@ export function useSpeedTestEndpoints({
|
|||||||
if (appId !== "codex") return [];
|
if (appId !== "codex") return [];
|
||||||
|
|
||||||
const map = new Map<string, EndpointCandidate>();
|
const map = new Map<string, EndpointCandidate>();
|
||||||
// 所有端点都标记为 isCustom: true,给用户完全的管理自由
|
// 候选端点标记为 isCustom: false,表示来自预设或配置
|
||||||
const add = (url?: string) => {
|
// 已保存的自定义端点会在 EndpointSpeedTest 组件中通过 API 加载
|
||||||
|
const add = (url?: string, isCustom = false) => {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
const sanitized = url.trim().replace(/\/+$/, "");
|
const sanitized = url.trim().replace(/\/+$/, "");
|
||||||
if (!sanitized || map.has(sanitized)) return;
|
if (!sanitized || map.has(sanitized)) return;
|
||||||
map.set(sanitized, { url: sanitized, isCustom: true });
|
map.set(sanitized, { url: sanitized, isCustom });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. 编辑模式:从 meta.custom_endpoints 读取已保存的端点(优先)
|
// 1. 当前 Codex Base URL
|
||||||
if (initialData?.meta?.custom_endpoints) {
|
|
||||||
const customEndpoints = initialData.meta.custom_endpoints;
|
|
||||||
for (const url of Object.keys(customEndpoints)) {
|
|
||||||
add(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 当前 Codex Base URL
|
|
||||||
if (codexBaseUrl) {
|
if (codexBaseUrl) {
|
||||||
add(codexBaseUrl);
|
add(codexBaseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 编辑模式:初始数据中的 URL
|
// 2. 编辑模式:初始数据中的 URL
|
||||||
const initialCodexConfig = initialData?.settingsConfig as
|
const initialCodexConfig = initialData?.settingsConfig as
|
||||||
| {
|
| {
|
||||||
config?: string;
|
config?: string;
|
||||||
@@ -134,7 +134,7 @@ export function useSpeedTestEndpoints({
|
|||||||
add(match[1]);
|
add(match[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 预设中的 endpointCandidates(也允许用户删除)
|
// 3. 预设中的 endpointCandidates
|
||||||
if (selectedPresetId && selectedPresetId !== "custom") {
|
if (selectedPresetId && selectedPresetId !== "custom") {
|
||||||
const entry = presetEntries.find((item) => item.id === selectedPresetId);
|
const entry = presetEntries.find((item) => item.id === selectedPresetId);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface EndpointFieldProps {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
hint: string;
|
hint?: string;
|
||||||
showManageButton?: boolean;
|
showManageButton?: boolean;
|
||||||
onManageClick?: () => void;
|
onManageClick?: () => void;
|
||||||
manageButtonLabel?: string;
|
manageButtonLabel?: string;
|
||||||
@@ -55,9 +55,11 @@ export function EndpointField({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
|
{hint ? (
|
||||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">{hint}</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">{hint}</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
218
src/components/skills/RepoManager.tsx
Normal file
218
src/components/skills/RepoManager.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Trash2, ExternalLink, Plus } from "lucide-react";
|
||||||
|
import { settingsApi } from "@/lib/api";
|
||||||
|
import type { Skill, SkillRepo } from "@/lib/api/skills";
|
||||||
|
|
||||||
|
interface RepoManagerProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
repos: SkillRepo[];
|
||||||
|
skills: Skill[];
|
||||||
|
onAdd: (repo: SkillRepo) => Promise<void>;
|
||||||
|
onRemove: (owner: string, name: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepoManager({
|
||||||
|
open: isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
repos,
|
||||||
|
skills,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
}: RepoManagerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [repoUrl, setRepoUrl] = useState("");
|
||||||
|
const [branch, setBranch] = useState("");
|
||||||
|
const [skillsPath, setSkillsPath] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const getSkillCount = (repo: SkillRepo) =>
|
||||||
|
skills.filter(
|
||||||
|
(skill) =>
|
||||||
|
skill.repoOwner === repo.owner &&
|
||||||
|
skill.repoName === repo.name &&
|
||||||
|
(skill.repoBranch || "main") === (repo.branch || "main"),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const parseRepoUrl = (
|
||||||
|
url: string,
|
||||||
|
): { owner: string; name: string } | null => {
|
||||||
|
// 支持格式:
|
||||||
|
// - https://github.com/owner/name
|
||||||
|
// - owner/name
|
||||||
|
// - https://github.com/owner/name.git
|
||||||
|
|
||||||
|
let cleaned = url.trim();
|
||||||
|
cleaned = cleaned.replace(/^https?:\/\/github\.com\//, "");
|
||||||
|
cleaned = cleaned.replace(/\.git$/, "");
|
||||||
|
|
||||||
|
const parts = cleaned.split("/");
|
||||||
|
if (parts.length === 2 && parts[0] && parts[1]) {
|
||||||
|
return { owner: parts[0], name: parts[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
const parsed = parseRepoUrl(repoUrl);
|
||||||
|
if (!parsed) {
|
||||||
|
setError(t("skills.repo.invalidUrl"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onAdd({
|
||||||
|
owner: parsed.owner,
|
||||||
|
name: parsed.name,
|
||||||
|
branch: branch || "main",
|
||||||
|
enabled: true,
|
||||||
|
skillsPath: skillsPath.trim() || undefined, // 仅在有值时传递
|
||||||
|
});
|
||||||
|
|
||||||
|
setRepoUrl("");
|
||||||
|
setBranch("");
|
||||||
|
setSkillsPath("");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenRepo = async (owner: string, name: string) => {
|
||||||
|
try {
|
||||||
|
await settingsApi.openExternal(`https://github.com/${owner}/${name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to open URL:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col p-0">
|
||||||
|
{/* 固定头部 */}
|
||||||
|
<DialogHeader className="flex-shrink-0 border-b border-border-default px-6 py-4">
|
||||||
|
<DialogTitle>{t("skills.repo.title")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("skills.repo.description")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 可滚动内容区域 */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4">
|
||||||
|
{/* 添加仓库表单 */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="repo-url">{t("skills.repo.url")}</Label>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Input
|
||||||
|
id="repo-url"
|
||||||
|
placeholder={t("skills.repo.urlPlaceholder")}
|
||||||
|
value={repoUrl}
|
||||||
|
onChange={(e) => setRepoUrl(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
id="branch"
|
||||||
|
placeholder={t("skills.repo.branchPlaceholder")}
|
||||||
|
value={branch}
|
||||||
|
onChange={(e) => setBranch(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="skills-path"
|
||||||
|
placeholder={t("skills.repo.pathPlaceholder")}
|
||||||
|
value={skillsPath}
|
||||||
|
onChange={(e) => setSkillsPath(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="w-full sm:w-auto sm:px-4"
|
||||||
|
variant="mcp"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
{t("skills.repo.add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 仓库列表 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium">{t("skills.repo.list")}</h4>
|
||||||
|
{repos.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("skills.repo.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{repos.map((repo) => (
|
||||||
|
<div
|
||||||
|
key={`${repo.owner}/${repo.name}`}
|
||||||
|
className="flex items-center justify-between rounded-xl border border-border-default bg-card px-4 py-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-foreground">
|
||||||
|
{repo.owner}/{repo.name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t("skills.repo.branch")}: {repo.branch || "main"}
|
||||||
|
{repo.skillsPath && (
|
||||||
|
<>
|
||||||
|
<span className="mx-2">•</span>
|
||||||
|
{t("skills.repo.path")}: {repo.skillsPath}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
|
||||||
|
{t("skills.repo.skillCount", {
|
||||||
|
count: getSkillCount(repo),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOpenRepo(repo.owner, repo.name)}
|
||||||
|
title={t("common.view", { defaultValue: "查看" })}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(repo.owner, repo.name)}
|
||||||
|
title={t("common.delete")}
|
||||||
|
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
src/components/skills/SkillCard.tsx
Normal file
145
src/components/skills/SkillCard.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ExternalLink, Download, Trash2, Loader2 } from "lucide-react";
|
||||||
|
import { settingsApi } from "@/lib/api";
|
||||||
|
import type { Skill } from "@/lib/api/skills";
|
||||||
|
|
||||||
|
interface SkillCardProps {
|
||||||
|
skill: Skill;
|
||||||
|
onInstall: (directory: string) => Promise<void>;
|
||||||
|
onUninstall: (directory: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onInstall(skill.directory);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUninstall = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onUninstall(skill.directory);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenGithub = async () => {
|
||||||
|
if (skill.readmeUrl) {
|
||||||
|
try {
|
||||||
|
await settingsApi.openExternal(skill.readmeUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to open URL:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDirectory =
|
||||||
|
Boolean(skill.directory) &&
|
||||||
|
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col h-full border-border-default bg-card transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-md">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-base font-semibold truncate">
|
||||||
|
{skill.name}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
{showDirectory && (
|
||||||
|
<CardDescription className="text-xs truncate">
|
||||||
|
{skill.directory}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
{skill.repoOwner && skill.repoName && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0 text-[10px] px-1.5 py-0 h-4 border-border-default"
|
||||||
|
>
|
||||||
|
{skill.repoOwner}/{skill.repoName}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{skill.installed && (
|
||||||
|
<Badge
|
||||||
|
variant="default"
|
||||||
|
className="shrink-0 bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
|
||||||
|
>
|
||||||
|
{t("skills.installed")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 pt-0">
|
||||||
|
<p className="text-sm text-muted-foreground/90 line-clamp-4 leading-relaxed">
|
||||||
|
{skill.description || t("skills.noDescription")}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex gap-2 pt-3 border-t border-border-default">
|
||||||
|
{skill.readmeUrl && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleOpenGithub}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
{t("skills.view")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{skill.installed ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleUninstall}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700 dark:border-red-900/50 dark:text-red-400 dark:hover:bg-red-950/50 dark:hover:text-red-300"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
)}
|
||||||
|
{loading ? t("skills.uninstalling") : t("skills.uninstall")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="mcp"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleInstall}
|
||||||
|
disabled={loading || !skill.repoOwner}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
)}
|
||||||
|
{loading ? t("skills.installing") : t("skills.install")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
src/components/skills/SkillsPage.tsx
Normal file
190
src/components/skills/SkillsPage.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { RefreshCw, Settings } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { SkillCard } from "./SkillCard";
|
||||||
|
import { RepoManager } from "./RepoManager";
|
||||||
|
import { skillsApi, type Skill, type SkillRepo } from "@/lib/api/skills";
|
||||||
|
|
||||||
|
interface SkillsPageProps {
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillsPage({ onClose: _onClose }: SkillsPageProps = {}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [skills, setSkills] = useState<Skill[]>([]);
|
||||||
|
const [repos, setRepos] = useState<SkillRepo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
|
||||||
|
|
||||||
|
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await skillsApi.getAll();
|
||||||
|
setSkills(data);
|
||||||
|
if (afterLoad) {
|
||||||
|
afterLoad(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("skills.loadFailed"), {
|
||||||
|
description: error instanceof Error ? error.message : t("common.error"),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRepos = async () => {
|
||||||
|
try {
|
||||||
|
const data = await skillsApi.getRepos();
|
||||||
|
setRepos(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load repos:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([loadSkills(), loadRepos()]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInstall = async (directory: string) => {
|
||||||
|
try {
|
||||||
|
await skillsApi.install(directory);
|
||||||
|
toast.success(t("skills.installSuccess", { name: directory }));
|
||||||
|
await loadSkills();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("skills.installFailed"), {
|
||||||
|
description: error instanceof Error ? error.message : t("common.error"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUninstall = async (directory: string) => {
|
||||||
|
try {
|
||||||
|
await skillsApi.uninstall(directory);
|
||||||
|
toast.success(t("skills.uninstallSuccess", { name: directory }));
|
||||||
|
await loadSkills();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("skills.uninstallFailed"), {
|
||||||
|
description: error instanceof Error ? error.message : t("common.error"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddRepo = async (repo: SkillRepo) => {
|
||||||
|
await skillsApi.addRepo(repo);
|
||||||
|
|
||||||
|
let repoSkillCount = 0;
|
||||||
|
await Promise.all([
|
||||||
|
loadRepos(),
|
||||||
|
loadSkills((data) => {
|
||||||
|
repoSkillCount = data.filter(
|
||||||
|
(skill) =>
|
||||||
|
skill.repoOwner === repo.owner &&
|
||||||
|
skill.repoName === repo.name &&
|
||||||
|
(skill.repoBranch || "main") === (repo.branch || "main"),
|
||||||
|
).length;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
t("skills.repo.addSuccess", {
|
||||||
|
owner: repo.owner,
|
||||||
|
name: repo.name,
|
||||||
|
count: repoSkillCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveRepo = async (owner: string, name: string) => {
|
||||||
|
await skillsApi.removeRepo(owner, name);
|
||||||
|
toast.success(t("skills.repo.removeSuccess", { owner, name }));
|
||||||
|
await Promise.all([loadRepos(), loadSkills()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0 bg-background">
|
||||||
|
{/* 顶部操作栏(固定区域) */}
|
||||||
|
<div className="flex-shrink-0 border-b border-border-default bg-muted/20 px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between pr-8">
|
||||||
|
<h1 className="text-lg font-semibold leading-tight tracking-tight text-gray-900 dark:text-gray-100">
|
||||||
|
{t("skills.title")}
|
||||||
|
</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="mcp"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => loadSkills()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
{loading ? t("skills.refreshing") : t("skills.refresh")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="mcp"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRepoManagerOpen(true)}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
{t("skills.repoManager")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
<p className="mt-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t("skills.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 技能网格(可滚动详情区域) */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-6 bg-muted/10">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : skills.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||||
|
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{t("skills.empty")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t("skills.emptyDescription")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
onClick={() => setRepoManagerOpen(true)}
|
||||||
|
className="mt-3 text-sm font-normal"
|
||||||
|
>
|
||||||
|
{t("skills.addRepo")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<SkillCard
|
||||||
|
key={skill.key}
|
||||||
|
skill={skill}
|
||||||
|
onInstall={handleInstall}
|
||||||
|
onUninstall={handleUninstall}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 仓库管理对话框 */}
|
||||||
|
<RepoManager
|
||||||
|
open={repoManagerOpen}
|
||||||
|
onOpenChange={setRepoManagerOpen}
|
||||||
|
repos={repos}
|
||||||
|
skills={skills}
|
||||||
|
onAdd={handleAddRepo}
|
||||||
|
onRemove={handleRemoveRepo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
86
src/components/ui/card.tsx
Normal file
86
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user