mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c847fff768 | |||
| 1eb0a0d7ac | |||
| 3bd3845ec0 | |||
| b0d0a2c466 | |||
| 73013c10af | |||
| 1a0872c153 | |||
| 255a7f570a | |||
| fb44fb136f | |||
| 58d3bb89d2 | |||
| 403227c901 | |||
| 5bcf5bf382 | |||
| ad6f5b388b | |||
| 2844f7c557 | |||
| 882c73234f | |||
| b70de25de4 | |||
| 88dbeb5335 | |||
| 966d7b5782 | |||
| 42a92c712a | |||
| 2f0998c6c8 | |||
| 938e2eb563 | |||
| 2cc36b3950 | |||
| e06c6176d9 | |||
| 9b4485e111 | |||
| f349d85e85 | |||
| 58a13cc69a | |||
| d765364a18 | |||
| 5c6956b6e2 | |||
| cb1b45ae4e | |||
| e4df1a32a5 | |||
| 2494eaaa32 | |||
| 45b9cf1df0 | |||
| de3a22535d | |||
| 36d6d48002 | |||
| 864884926a | |||
| 093ff0ba29 | |||
| 21754a7349 | |||
| 7997b2c7b3 | |||
| b8538b211d | |||
| 7ea2c3452b | |||
| a30d72bb68 | |||
| 58ecc44ee6 | |||
| 5658d93924 | |||
| e4d24f2df9 | |||
| 07d022ba9f | |||
| f3343992f2 | |||
| 53f40b2d7a | |||
| 1393f89797 | |||
| f9d80b8dc3 | |||
| 8b92982112 | |||
| 74b4d4ecbb | |||
| 99c910e58e | |||
| 8f7423f011 | |||
| c56523c9c0 | |||
| 6aef472fd2 | |||
| 4a8883ecc3 | |||
| 6dd809701b | |||
| f363bb1dd0 | |||
| a2becf0917 | |||
| debe4232bc | |||
| 49a2e52b20 | |||
| ed9d9b5436 | |||
| 390839a8d5 | |||
| 3dcbe313be |
+239
-776
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,485 @@
|
||||
# OpenCode 第四应用支持实现计划
|
||||
|
||||
> **范围说明**:本计划暂不包含统一供应商(UniversalProvider)对 OpenCode 的支持,以降低初期实现复杂度。
|
||||
|
||||
## 概述
|
||||
|
||||
为 CC Switch 添加 OpenCode 支持,这是第四个受管理的 CLI 应用。OpenCode 的核心差异在于采用**累加式**供应商管理(多供应商共存,应用内热切换),而非现有三应用的**替换式**管理。
|
||||
|
||||
## 关键设计决策
|
||||
|
||||
| 特性 | Claude/Codex/Gemini | OpenCode |
|
||||
|------|---------------------|----------|
|
||||
| 供应商模式 | 替换式(单一活跃) | 累加式(多供应商共存) |
|
||||
| UI 按钮 | 启用/切换 | 添加/删除 |
|
||||
| is_current | 需要 | 不需要 |
|
||||
| 代理/故障转移 | 支持 | 不支持 |
|
||||
| API 格式字段 | 无 | 需要(npm 包名) |
|
||||
| 配置文件 | 各自独立 | `~/.config/opencode/opencode.json` |
|
||||
|
||||
## 配置文件格式
|
||||
|
||||
### 供应商配置
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"provider-id": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Provider Name",
|
||||
"options": {
|
||||
"baseURL": "https://api.example.com/v1",
|
||||
"apiKey": "{env:API_KEY}"
|
||||
},
|
||||
"models": {
|
||||
"model-id": { "name": "Model Name" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MCP 配置
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"remote-server": {
|
||||
"type": "remote",
|
||||
"url": "https://example.com/mcp",
|
||||
"enabled": true
|
||||
},
|
||||
"local-server": {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "my-mcp-command"],
|
||||
"enabled": true,
|
||||
"environment": { "KEY": "value" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实现步骤
|
||||
|
||||
### Phase 1: 后端数据结构扩展
|
||||
|
||||
#### 1.1 AppType 枚举扩展
|
||||
**文件**: `src-tauri/src/app_config.rs`
|
||||
|
||||
```rust
|
||||
pub enum AppType {
|
||||
Claude,
|
||||
Codex,
|
||||
Gemini,
|
||||
OpenCode, // 新增
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 McpApps / SkillApps 扩展
|
||||
**文件**: `src-tauri/src/app_config.rs`
|
||||
|
||||
```rust
|
||||
pub struct McpApps {
|
||||
pub claude: bool,
|
||||
pub codex: bool,
|
||||
pub gemini: bool,
|
||||
pub opencode: bool, // 新增
|
||||
}
|
||||
|
||||
pub struct SkillApps {
|
||||
pub claude: bool,
|
||||
pub codex: bool,
|
||||
pub gemini: bool,
|
||||
pub opencode: bool, // 新增
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 数据库 Schema 迁移
|
||||
**文件**: `src-tauri/src/database/schema.rs`
|
||||
|
||||
- `SCHEMA_VERSION` 递增
|
||||
- 添加迁移:
|
||||
```sql
|
||||
ALTER TABLE mcp_servers ADD COLUMN enabled_opencode BOOLEAN NOT NULL DEFAULT 0;
|
||||
ALTER TABLE skills ADD COLUMN enabled_opencode BOOLEAN NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
### Phase 2: OpenCode 供应商数据结构
|
||||
|
||||
#### 2.1 OpenCode 专属配置结构
|
||||
**文件**: `src-tauri/src/provider.rs`(或新建 `opencode_provider.rs`)
|
||||
|
||||
```rust
|
||||
/// OpenCode 供应商的 settings_config 结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeProviderConfig {
|
||||
/// AI SDK 包名,如 "@ai-sdk/openai-compatible"
|
||||
pub npm: String,
|
||||
/// 供应商选项
|
||||
pub options: OpenCodeProviderOptions,
|
||||
/// 模型定义
|
||||
pub models: HashMap<String, OpenCodeModel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeProviderOptions {
|
||||
#[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
#[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeModel {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<OpenCodeModelLimit>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeModelLimit {
|
||||
pub context: Option<u64>,
|
||||
pub output: Option<u64>,
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: OpenCode Live 配置读写
|
||||
|
||||
#### 3.1 新建 OpenCode 配置模块
|
||||
**文件**: `src-tauri/src/opencode_config.rs`
|
||||
|
||||
核心功能:
|
||||
- `get_opencode_config_path()` → `~/.config/opencode/opencode.json`
|
||||
- `read_opencode_config()` → 读取整个配置文件
|
||||
- `write_opencode_config()` → 原子写入配置文件
|
||||
- `get_providers()` → 获取 `provider` 对象
|
||||
- `set_provider(id, config)` → 添加/更新供应商
|
||||
- `remove_provider(id)` → 删除供应商
|
||||
- `get_mcp_servers()` → 获取 `mcp` 对象
|
||||
- `set_mcp_server(id, config)` → 添加/更新 MCP 服务器
|
||||
- `remove_mcp_server(id)` → 删除 MCP 服务器
|
||||
|
||||
### Phase 4: MCP 同步模块
|
||||
|
||||
#### 4.1 新建 OpenCode MCP 同步
|
||||
**文件**: `src-tauri/src/mcp/opencode.rs`
|
||||
|
||||
```rust
|
||||
/// 同步所有 enabled_opencode=true 的服务器到 OpenCode 配置
|
||||
pub fn sync_enabled_to_opencode(config: &MultiAppConfig) -> Result<(), AppError>
|
||||
|
||||
/// 同步单个服务器
|
||||
pub fn sync_single_server_to_opencode(
|
||||
config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value
|
||||
) -> Result<(), AppError>
|
||||
|
||||
/// 从 OpenCode 配置移除服务器
|
||||
pub fn remove_server_from_opencode(id: &str) -> Result<(), AppError>
|
||||
|
||||
/// 从 OpenCode 配置导入服务器
|
||||
pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
||||
```
|
||||
|
||||
**格式转换**:
|
||||
| CC Switch 统一格式 | OpenCode 格式 |
|
||||
|-------------------|---------------|
|
||||
| `type: "stdio"` | `type: "local"` |
|
||||
| `command` + `args` | `command: [cmd, ...args]` |
|
||||
| `env` | `environment` |
|
||||
| `type: "sse"/"http"` | `type: "remote"` |
|
||||
| `url` | `url` |
|
||||
|
||||
### Phase 5: 供应商服务层
|
||||
|
||||
#### 5.1 OpenCode 供应商服务
|
||||
**文件**: `src-tauri/src/services/provider/opencode.rs`
|
||||
|
||||
核心方法:
|
||||
```rust
|
||||
/// 获取所有 OpenCode 供应商
|
||||
pub fn list(state: &AppState) -> Result<IndexMap<String, Provider>, AppError>
|
||||
|
||||
/// 添加供应商(同时写入 live 配置)
|
||||
pub fn add(state: &AppState, provider: Provider) -> Result<bool, AppError>
|
||||
|
||||
/// 更新供应商
|
||||
pub fn update(state: &AppState, provider: Provider) -> Result<bool, AppError>
|
||||
|
||||
/// 删除供应商(同时从 live 配置移除)
|
||||
pub fn delete(state: &AppState, id: &str) -> Result<(), AppError>
|
||||
|
||||
/// 从 live 配置导入供应商到数据库
|
||||
pub fn import_from_live(state: &AppState) -> Result<usize, AppError>
|
||||
```
|
||||
|
||||
**关键差异**:
|
||||
- 不需要 `switch()` 方法
|
||||
- 不需要 `is_current` 管理
|
||||
- `add()` 自动写入 live
|
||||
- `delete()` 自动从 live 移除
|
||||
|
||||
### Phase 6: Tauri 命令扩展
|
||||
|
||||
#### 6.1 更新现有命令
|
||||
**文件**: `src-tauri/src/commands/providers.rs`
|
||||
|
||||
- 所有命令支持 `app_type = "opencode"`
|
||||
- OpenCode 特定逻辑分支
|
||||
|
||||
#### 6.2 新增 OpenCode 专属命令(如需要)
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn opencode_sync_all_providers(state: State<'_, AppState>) -> Result<(), AppError>
|
||||
```
|
||||
|
||||
### Phase 7: 前端类型定义
|
||||
|
||||
#### 7.1 TypeScript 类型扩展
|
||||
**文件**: `src/types.ts`
|
||||
|
||||
```typescript
|
||||
// AppId 扩展
|
||||
type AppId = "claude" | "codex" | "gemini" | "opencode";
|
||||
|
||||
// OpenCode 专属配置
|
||||
interface OpenCodeProviderConfig {
|
||||
npm: string; // AI SDK 包名
|
||||
options: {
|
||||
baseURL?: string;
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
models: Record<string, OpenCodeModel>;
|
||||
}
|
||||
|
||||
interface OpenCodeModel {
|
||||
name: string;
|
||||
limit?: {
|
||||
context?: number;
|
||||
output?: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### 7.2 MCP 应用状态扩展
|
||||
**文件**: `src/types.ts`
|
||||
|
||||
```typescript
|
||||
interface McpApps {
|
||||
claude: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
opencode: boolean; // 新增
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 8: 前端预设配置
|
||||
|
||||
#### 8.1 新建 OpenCode 供应商预设
|
||||
**文件**: `src/config/opencodeProviderPresets.ts`
|
||||
|
||||
```typescript
|
||||
export const opencodeProviderPresets: ProviderPreset[] = [
|
||||
{
|
||||
name: "OpenAI",
|
||||
npmPackage: "@ai-sdk/openai",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai",
|
||||
options: { apiKey: "{env:OPENAI_API_KEY}" },
|
||||
models: {
|
||||
"gpt-4o": { name: "GPT-4o" },
|
||||
"gpt-4o-mini": { name: "GPT-4o Mini" },
|
||||
},
|
||||
},
|
||||
theme: { icon: "openai", iconColor: "#00A67E" },
|
||||
},
|
||||
{
|
||||
name: "Anthropic",
|
||||
npmPackage: "@ai-sdk/anthropic",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
options: { apiKey: "{env:ANTHROPIC_API_KEY}" },
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": { name: "Claude Sonnet 4" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OpenAI Compatible",
|
||||
npmPackage: "@ai-sdk/openai-compatible",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
baseURL: "",
|
||||
apiKey: "{env:API_KEY}",
|
||||
},
|
||||
models: {},
|
||||
},
|
||||
isCustomTemplate: true,
|
||||
},
|
||||
// ... 更多预设
|
||||
];
|
||||
|
||||
// npm 包选项
|
||||
export const opencodeNpmPackages = [
|
||||
{ value: "@ai-sdk/openai", label: "OpenAI" },
|
||||
{ value: "@ai-sdk/anthropic", label: "Anthropic" },
|
||||
{ value: "@ai-sdk/openai-compatible", label: "OpenAI Compatible" },
|
||||
{ value: "@ai-sdk/google", label: "Google" },
|
||||
{ value: "@ai-sdk/azure", label: "Azure OpenAI" },
|
||||
{ value: "@ai-sdk/amazon-bedrock", label: "Amazon Bedrock" },
|
||||
// ... 更多选项
|
||||
];
|
||||
```
|
||||
|
||||
### Phase 9: 前端 UI 组件
|
||||
|
||||
#### 9.1 OpenCode 供应商表单
|
||||
**文件**: `src/components/providers/forms/OpenCodeFormFields.tsx`
|
||||
|
||||
新增字段:
|
||||
- npm 包选择器(下拉框 + 自定义输入)
|
||||
- options 编辑器(baseURL, apiKey, headers)
|
||||
- models 编辑器(动态添加/删除模型)
|
||||
|
||||
#### 9.2 供应商卡片按钮适配
|
||||
**文件**: `src/components/providers/ProviderActions.tsx`
|
||||
|
||||
```tsx
|
||||
// OpenCode 使用不同的主按钮
|
||||
if (appId === "opencode") {
|
||||
return (
|
||||
<Button onClick={onAdd}>
|
||||
{isInConfig ? t("provider.removeFromConfig") : t("provider.addToConfig")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 9.3 隐藏 OpenCode 不需要的功能
|
||||
|
||||
在以下组件中检查 `appId !== "opencode"`:
|
||||
- 代理设置面板
|
||||
- 故障转移队列
|
||||
- 供应商切换逻辑
|
||||
|
||||
### Phase 10: 国际化
|
||||
|
||||
#### 10.1 新增翻译 Key
|
||||
**文件**: `src/locales/zh/translation.json` & `en/translation.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"app.opencode": "OpenCode",
|
||||
"provider.addToConfig": "添加到配置",
|
||||
"provider.removeFromConfig": "从配置移除",
|
||||
"provider.inConfig": "已添加",
|
||||
"provider.npmPackage": "AI SDK 包",
|
||||
"provider.models": "模型配置",
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键文件清单
|
||||
|
||||
### 后端(Rust)
|
||||
| 操作 | 文件路径 |
|
||||
|------|---------|
|
||||
| 修改 | `src-tauri/src/app_config.rs` |
|
||||
| 修改 | `src-tauri/src/database/schema.rs` |
|
||||
| 修改 | `src-tauri/src/database/dao/mcp.rs` |
|
||||
| 修改 | `src-tauri/src/database/dao/providers.rs` |
|
||||
| 修改 | `src-tauri/src/services/provider/mod.rs` |
|
||||
| 修改 | `src-tauri/src/services/mcp.rs` |
|
||||
| 修改 | `src-tauri/src/commands/providers.rs` |
|
||||
| 修改 | `src-tauri/src/commands/mcp.rs` |
|
||||
| 修改 | `src-tauri/src/mcp/mod.rs` |
|
||||
| 新建 | `src-tauri/src/opencode_config.rs` |
|
||||
| 新建 | `src-tauri/src/mcp/opencode.rs` |
|
||||
| 新建 | `src-tauri/src/services/provider/opencode.rs` |
|
||||
|
||||
### 前端(TypeScript/React)
|
||||
| 操作 | 文件路径 |
|
||||
|------|---------|
|
||||
| 修改 | `src/types.ts` |
|
||||
| 修改 | `src/lib/api/types.ts` |
|
||||
| 修改 | `src/lib/api/providers.ts` |
|
||||
| 修改 | `src/components/providers/ProviderActions.tsx` |
|
||||
| 修改 | `src/components/providers/ProviderCard.tsx` |
|
||||
| 修改 | `src/components/providers/AddProviderDialog.tsx` |
|
||||
| 修改 | `src/components/providers/forms/ProviderForm.tsx` |
|
||||
| 修改 | `src/App.tsx` |
|
||||
| 新建 | `src/config/opencodeProviderPresets.ts` |
|
||||
| 新建 | `src/components/providers/forms/OpenCodeFormFields.tsx` |
|
||||
|
||||
### 国际化
|
||||
| 操作 | 文件路径 |
|
||||
|------|---------|
|
||||
| 修改 | `src/locales/zh/translation.json` |
|
||||
| 修改 | `src/locales/en/translation.json` |
|
||||
| 修改 | `src/locales/ja/translation.json` |
|
||||
|
||||
---
|
||||
|
||||
## 验证计划
|
||||
|
||||
### 单元测试
|
||||
1. OpenCode 配置读写测试
|
||||
2. MCP 格式转换测试(stdio ↔ local, sse ↔ remote)
|
||||
3. 供应商 CRUD 操作测试
|
||||
|
||||
### 集成测试
|
||||
1. 添加 OpenCode 供应商 → 验证写入 `~/.config/opencode/opencode.json`
|
||||
2. 删除供应商 → 验证从配置文件移除
|
||||
3. MCP 同步测试 → 验证格式正确转换
|
||||
4. 从 live 配置导入 → 验证正确解析
|
||||
|
||||
### 手动测试
|
||||
1. UI 流程:添加预设 → 编辑 → 删除
|
||||
2. 切换应用 Tab → OpenCode 显示正确的 UI(无代理/故障转移)
|
||||
3. 托盘菜单正确显示 OpenCode 供应商
|
||||
4. 深链接导入 OpenCode 供应商
|
||||
|
||||
---
|
||||
|
||||
## 风险评估
|
||||
|
||||
1. **数据库迁移**:需要在升级时自动执行 `ALTER TABLE` 语句
|
||||
2. **配置文件冲突**:OpenCode 可能有自己的配置,需要合并而非覆盖
|
||||
3. **MCP 格式差异**:`stdio` → `local` 转换需要处理边界情况
|
||||
4. **UI 一致性**:OpenCode 的"添加/删除"模式需要与其他应用的"启用/切换"清晰区分
|
||||
|
||||
---
|
||||
|
||||
## 补充说明
|
||||
|
||||
### 托盘菜单特殊处理
|
||||
|
||||
由于 OpenCode 采用累加式管理,托盘菜单行为需要调整:
|
||||
|
||||
- **现有三应用**:托盘菜单显示 `CheckMenuItem`(单选,切换当前供应商)
|
||||
- **OpenCode**:显示当前所有启用的供应商(普通 MenuItem,无勾选逻辑),点击打开主界面
|
||||
|
||||
**修改文件**:`src-tauri/src/tray.rs`(`TRAY_SECTIONS` 常量)
|
||||
|
||||
### 数据库约束更新
|
||||
|
||||
`proxy_config` 表的 CHECK 约束需要扩展:
|
||||
```sql
|
||||
CHECK (app_type IN ('claude','codex','gemini','opencode'))
|
||||
```
|
||||
|
||||
### Settings 结构体扩展
|
||||
|
||||
**文件**:`src-tauri/src/settings.rs`
|
||||
|
||||
需要添加:
|
||||
- `current_provider_opencode: Option<String>` - 对 OpenCode 可能无意义,但保持结构一致
|
||||
- `opencode_config_dir: Option<String>` - 自定义配置目录
|
||||
@@ -13,6 +13,8 @@ pub struct McpApps {
|
||||
pub codex: bool,
|
||||
#[serde(default)]
|
||||
pub gemini: bool,
|
||||
#[serde(default)]
|
||||
pub opencode: bool,
|
||||
}
|
||||
|
||||
impl McpApps {
|
||||
@@ -22,6 +24,7 @@ impl McpApps {
|
||||
AppType::Claude => self.claude,
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::OpenCode => self.opencode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +34,7 @@ impl McpApps {
|
||||
AppType::Claude => self.claude = enabled,
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,12 +50,15 @@ impl McpApps {
|
||||
if self.gemini {
|
||||
apps.push(AppType::Gemini);
|
||||
}
|
||||
if self.opencode {
|
||||
apps.push(AppType::OpenCode);
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude && !self.codex && !self.gemini
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +71,8 @@ pub struct SkillApps {
|
||||
pub codex: bool,
|
||||
#[serde(default)]
|
||||
pub gemini: bool,
|
||||
#[serde(default)]
|
||||
pub opencode: bool,
|
||||
}
|
||||
|
||||
impl SkillApps {
|
||||
@@ -73,6 +82,7 @@ impl SkillApps {
|
||||
AppType::Claude => self.claude,
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::OpenCode => self.opencode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +92,7 @@ impl SkillApps {
|
||||
AppType::Claude => self.claude = enabled,
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,12 +108,15 @@ impl SkillApps {
|
||||
if self.gemini {
|
||||
apps.push(AppType::Gemini);
|
||||
}
|
||||
if self.opencode {
|
||||
apps.push(AppType::OpenCode);
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude && !self.codex && !self.gemini
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode
|
||||
}
|
||||
|
||||
/// 仅启用指定应用(其他应用设为禁用)
|
||||
@@ -205,6 +219,9 @@ pub struct McpRoot {
|
||||
pub codex: McpConfig,
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub gemini: McpConfig,
|
||||
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json)
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub opencode: McpConfig,
|
||||
}
|
||||
|
||||
impl Default for McpRoot {
|
||||
@@ -216,6 +233,7 @@ impl Default for McpRoot {
|
||||
claude: McpConfig::default(),
|
||||
codex: McpConfig::default(),
|
||||
gemini: McpConfig::default(),
|
||||
opencode: McpConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,6 +254,8 @@ pub struct PromptRoot {
|
||||
pub codex: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub gemini: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub opencode: PromptConfig,
|
||||
}
|
||||
|
||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||
@@ -249,7 +269,8 @@ use crate::provider::ProviderManager;
|
||||
pub enum AppType {
|
||||
Claude,
|
||||
Codex,
|
||||
Gemini, // 新增
|
||||
Gemini,
|
||||
OpenCode,
|
||||
}
|
||||
|
||||
impl AppType {
|
||||
@@ -257,7 +278,8 @@ impl AppType {
|
||||
match self {
|
||||
AppType::Claude => "claude",
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini", // 新增
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::OpenCode => "opencode",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,11 +292,12 @@ impl FromStr for AppType {
|
||||
match normalized.as_str() {
|
||||
"claude" => Ok(AppType::Claude),
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini), // 新增
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
other => Err(AppError::localized(
|
||||
"unsupported_app",
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -291,6 +314,9 @@ pub struct CommonConfigSnippets {
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gemini: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode: Option<String>,
|
||||
}
|
||||
|
||||
impl CommonConfigSnippets {
|
||||
@@ -300,6 +326,7 @@ impl CommonConfigSnippets {
|
||||
AppType::Claude => self.claude.as_ref(),
|
||||
AppType::Codex => self.codex.as_ref(),
|
||||
AppType::Gemini => self.gemini.as_ref(),
|
||||
AppType::OpenCode => self.opencode.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +336,7 @@ impl CommonConfigSnippets {
|
||||
AppType::Claude => self.claude = snippet,
|
||||
AppType::Codex => self.codex = snippet,
|
||||
AppType::Gemini => self.gemini = snippet,
|
||||
AppType::OpenCode => self.opencode = snippet,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,7 +375,8 @@ impl Default for MultiAppConfig {
|
||||
let mut apps = HashMap::new();
|
||||
apps.insert("claude".to_string(), ProviderManager::default());
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
|
||||
apps.insert("gemini".to_string(), ProviderManager::default());
|
||||
apps.insert("opencode".to_string(), ProviderManager::default());
|
||||
|
||||
Self {
|
||||
version: 2,
|
||||
@@ -506,6 +535,7 @@ impl MultiAppConfig {
|
||||
AppType::Claude => &self.mcp.claude,
|
||||
AppType::Codex => &self.mcp.codex,
|
||||
AppType::Gemini => &self.mcp.gemini,
|
||||
AppType::OpenCode => &self.mcp.opencode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,6 +545,7 @@ impl MultiAppConfig {
|
||||
AppType::Claude => &mut self.mcp.claude,
|
||||
AppType::Codex => &mut self.mcp.codex,
|
||||
AppType::Gemini => &mut self.mcp.gemini,
|
||||
AppType::OpenCode => &mut self.mcp.opencode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,6 +559,7 @@ impl MultiAppConfig {
|
||||
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)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
@@ -547,6 +579,7 @@ impl MultiAppConfig {
|
||||
if !self.prompts.claude.prompts.is_empty()
|
||||
|| !self.prompts.codex.prompts.is_empty()
|
||||
|| !self.prompts.gemini.prompts.is_empty()
|
||||
|| !self.prompts.opencode.prompts.is_empty()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -554,7 +587,12 @@ impl MultiAppConfig {
|
||||
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
|
||||
|
||||
let mut imported = false;
|
||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
for app in [
|
||||
AppType::Claude,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
] {
|
||||
// 复用已有的单应用导入逻辑
|
||||
if Self::auto_import_prompt_if_exists(self, app)? {
|
||||
imported = true;
|
||||
@@ -623,6 +661,7 @@ impl MultiAppConfig {
|
||||
AppType::Claude => &mut config.prompts.claude.prompts,
|
||||
AppType::Codex => &mut config.prompts.codex.prompts,
|
||||
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||
AppType::OpenCode => &mut config.prompts.opencode.prompts,
|
||||
};
|
||||
|
||||
prompts.insert(id, prompt);
|
||||
@@ -656,6 +695,7 @@ impl MultiAppConfig {
|
||||
AppType::Claude => &self.mcp.claude.servers,
|
||||
AppType::Codex => &self.mcp.codex.servers,
|
||||
AppType::Gemini => &self.mcp.gemini.servers,
|
||||
AppType::OpenCode => &self.mcp.opencode.servers,
|
||||
};
|
||||
|
||||
for (id, entry) in old_servers {
|
||||
|
||||
@@ -65,6 +65,30 @@ fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
|
||||
// 非 Windows 平台不做任何处理
|
||||
}
|
||||
|
||||
/// 检测路径是否为 WSL 网络路径(如 \\wsl$\Ubuntu\... 或 \\wsl.localhost\Ubuntu\...)
|
||||
/// WSL 环境运行的是 Linux,不需要 cmd /c 包装
|
||||
/// 注意:仅检测直接 UNC 路径,映射磁盘符(如 Z: -> \\wsl$\...)无法检测
|
||||
#[cfg(windows)]
|
||||
fn is_wsl_path(path: &Path) -> bool {
|
||||
use std::path::{Component, Prefix};
|
||||
if let Some(Component::Prefix(prefix)) = path.components().next() {
|
||||
match prefix.kind() {
|
||||
Prefix::UNC(server, _) | Prefix::VerbatimUNC(server, _) => {
|
||||
let s = server.to_string_lossy();
|
||||
s.eq_ignore_ascii_case("wsl$") || s.eq_ignore_ascii_case("wsl.localhost")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn is_wsl_path(_path: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpStatus {
|
||||
@@ -371,6 +395,11 @@ pub fn set_mcp_servers_map(
|
||||
};
|
||||
|
||||
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
|
||||
// 检测目标路径是否为 WSL,若是则跳过 cmd /c 包装
|
||||
let is_wsl_target = is_wsl_path(&path);
|
||||
if is_wsl_target {
|
||||
log::info!("检测到 WSL 路径,跳过 cmd /c 包装: {}", path.display());
|
||||
}
|
||||
let mut out: Map<String, Value> = Map::new();
|
||||
for (id, spec) in servers.iter() {
|
||||
let mut obj = if let Some(map) = spec.as_object() {
|
||||
@@ -397,8 +426,10 @@ pub fn set_mcp_servers_map(
|
||||
obj.remove("homepage");
|
||||
obj.remove("docs");
|
||||
|
||||
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
|
||||
wrap_command_for_windows(&mut obj);
|
||||
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式(WSL 路径除外)
|
||||
if !is_wsl_target {
|
||||
wrap_command_for_windows(&mut obj);
|
||||
}
|
||||
|
||||
out.insert(id.clone(), Value::Object(obj));
|
||||
}
|
||||
@@ -545,4 +576,68 @@ mod tests {
|
||||
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 WSL 路径检测功能
|
||||
#[test]
|
||||
fn test_is_wsl_path_wsl_dollar() {
|
||||
// wsl$ 格式 - 各种发行版
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Debian\home\user\.claude")));
|
||||
assert!(is_wsl_path(Path::new(
|
||||
r"\\wsl$\openSUSE-Leap-15.2\home\user"
|
||||
)));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\kali-linux\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Arch\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Alpine\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Fedora\home\user")));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// 非 Windows 平台始终返回 false
|
||||
assert!(!is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_wsl_path_wsl_localhost() {
|
||||
// wsl.localhost 格式
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(is_wsl_path(Path::new(
|
||||
r"\\wsl.localhost\Ubuntu\home\user\.claude"
|
||||
)));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl.localhost\Debian\home\user")));
|
||||
assert!(is_wsl_path(Path::new(
|
||||
r"\\wsl.localhost\openSUSE-Leap-15.2\home\user"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_wsl_path_case_insensitive() {
|
||||
// 大小写不敏感
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(is_wsl_path(Path::new(r"\\WSL$\Ubuntu\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\Wsl$\Ubuntu\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\WSL.LOCALHOST\Ubuntu\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\Wsl.Localhost\Ubuntu\home\user")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_wsl_path_non_wsl() {
|
||||
// 非 WSL 路径
|
||||
assert!(!is_wsl_path(Path::new(r"C:\Users\user\.claude")));
|
||||
assert!(!is_wsl_path(Path::new(r"D:\Workspace\project")));
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(!is_wsl_path(Path::new(r"\\server\share\path")));
|
||||
assert!(!is_wsl_path(Path::new(r"\\localhost\c$\Users")));
|
||||
assert!(!is_wsl_path(Path::new(r"\\192.168.1.1\share")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,15 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
let config_path = crate::opencode_config::get_opencode_config_path();
|
||||
let exists = config_path.exists();
|
||||
let path = crate::opencode_config::get_opencode_dir()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +76,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||
AppType::Claude => config::get_claude_config_dir(),
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
};
|
||||
|
||||
Ok(dir.to_string_lossy().to_string())
|
||||
@@ -79,6 +89,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
|
||||
AppType::Claude => config::get_claude_config_dir(),
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
};
|
||||
|
||||
if !config_dir.exists() {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
//! 全局出站代理相关命令
|
||||
//!
|
||||
//! 提供获取、设置和测试全局代理的 Tauri 命令。
|
||||
|
||||
use crate::proxy::http_client;
|
||||
use crate::store::AppState;
|
||||
use serde::Serialize;
|
||||
use std::net::{Ipv4Addr, SocketAddrV4, TcpStream};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// 获取全局代理 URL
|
||||
///
|
||||
/// 返回当前配置的代理 URL,null 表示直连。
|
||||
#[tauri::command]
|
||||
pub fn get_global_proxy_url(state: tauri::State<'_, AppState>) -> Result<Option<String>, String> {
|
||||
let result = state.db.get_global_proxy_url().map_err(|e| e.to_string())?;
|
||||
log::debug!(
|
||||
"[GlobalProxy] [GP-010] Read from database: {}",
|
||||
result
|
||||
.as_ref()
|
||||
.map(|u| http_client::mask_url(u))
|
||||
.unwrap_or_else(|| "None".to_string())
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 设置全局代理 URL
|
||||
///
|
||||
/// - 传入非空字符串:启用代理
|
||||
/// - 传入空字符串:清除代理(直连)
|
||||
///
|
||||
/// 执行顺序:先验证 → 写 DB → 再应用
|
||||
/// 这样确保 DB 写失败时不会出现运行态与持久化不一致的问题
|
||||
#[tauri::command]
|
||||
pub fn set_global_proxy_url(state: tauri::State<'_, AppState>, url: String) -> Result<(), String> {
|
||||
// 调试:显示接收到的 URL 信息(不包含敏感内容)
|
||||
let has_auth = url.contains('@') && (url.starts_with("http://") || url.starts_with("socks"));
|
||||
log::debug!(
|
||||
"[GlobalProxy] [GP-011] Received URL: length={}, has_auth={}",
|
||||
url.len(),
|
||||
has_auth
|
||||
);
|
||||
|
||||
let url_opt = if url.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(url.as_str())
|
||||
};
|
||||
|
||||
// 1. 先验证代理配置是否有效(不应用)
|
||||
http_client::validate_proxy(url_opt)?;
|
||||
|
||||
// 2. 验证成功后保存到数据库
|
||||
state
|
||||
.db
|
||||
.set_global_proxy_url(url_opt)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 3. DB 写入成功后再应用到运行态
|
||||
http_client::apply_proxy(url_opt)?;
|
||||
|
||||
log::info!(
|
||||
"[GlobalProxy] [GP-009] Configuration updated: {}",
|
||||
url_opt
|
||||
.map(http_client::mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 代理测试结果
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyTestResult {
|
||||
/// 是否连接成功
|
||||
pub success: bool,
|
||||
/// 延迟(毫秒)
|
||||
pub latency_ms: u64,
|
||||
/// 错误信息
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 测试代理连接
|
||||
///
|
||||
/// 通过指定的代理 URL 发送测试请求,返回连接结果和延迟。
|
||||
/// 使用多个测试目标,任一成功即认为代理可用。
|
||||
#[tauri::command]
|
||||
pub async fn test_proxy_url(url: String) -> Result<ProxyTestResult, String> {
|
||||
if url.trim().is_empty() {
|
||||
return Err("Proxy URL is empty".to_string());
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
// 构建带代理的临时客户端
|
||||
let proxy = reqwest::Proxy::all(&url).map_err(|e| format!("Invalid proxy URL: {e}"))?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build client: {e}"))?;
|
||||
|
||||
// 使用多个测试目标,提高兼容性
|
||||
// 优先使用 httpbin(专门用于 HTTP 测试),回退到其他公共端点
|
||||
let test_urls = [
|
||||
"https://httpbin.org/get",
|
||||
"https://www.google.com",
|
||||
"https://api.anthropic.com",
|
||||
];
|
||||
|
||||
let mut last_error = None;
|
||||
|
||||
for test_url in test_urls {
|
||||
match client.head(test_url).send().await {
|
||||
Ok(resp) => {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
log::debug!(
|
||||
"[GlobalProxy] Test successful: {} -> {} via {} ({}ms)",
|
||||
http_client::mask_url(&url),
|
||||
test_url,
|
||||
resp.status(),
|
||||
latency
|
||||
);
|
||||
return Ok(ProxyTestResult {
|
||||
success: true,
|
||||
latency_ms: latency,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("[GlobalProxy] Test to {test_url} failed: {e}");
|
||||
last_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有测试目标都失败
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
let error_msg = last_error
|
||||
.map(|e| e.to_string())
|
||||
.unwrap_or_else(|| "All test targets failed".to_string());
|
||||
|
||||
log::debug!(
|
||||
"[GlobalProxy] Test failed: {} -> {} ({}ms)",
|
||||
http_client::mask_url(&url),
|
||||
error_msg,
|
||||
latency
|
||||
);
|
||||
|
||||
Ok(ProxyTestResult {
|
||||
success: false,
|
||||
latency_ms: latency,
|
||||
error: Some(error_msg),
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取当前出站代理状态
|
||||
///
|
||||
/// 返回当前是否启用了出站代理以及代理 URL。
|
||||
#[tauri::command]
|
||||
pub fn get_upstream_proxy_status() -> UpstreamProxyStatus {
|
||||
let url = http_client::get_current_proxy_url();
|
||||
UpstreamProxyStatus {
|
||||
enabled: url.is_some(),
|
||||
proxy_url: url,
|
||||
}
|
||||
}
|
||||
|
||||
/// 出站代理状态信息
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpstreamProxyStatus {
|
||||
/// 是否启用代理
|
||||
pub enabled: bool,
|
||||
/// 代理 URL
|
||||
pub proxy_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 检测到的代理信息
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DetectedProxy {
|
||||
/// 代理 URL
|
||||
pub url: String,
|
||||
/// 代理类型 (http/socks5)
|
||||
pub proxy_type: String,
|
||||
/// 端口
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// 常见代理端口配置
|
||||
/// 格式:(端口, 主要类型, 是否同时支持 http 和 socks5)
|
||||
/// 对于 mixed 端口,会同时返回两种协议供用户选择
|
||||
const PROXY_PORTS: &[(u16, &str, bool)] = &[
|
||||
(7890, "http", true), // Clash (mixed mode)
|
||||
(7891, "socks5", false), // Clash SOCKS only
|
||||
(1080, "socks5", false), // 通用 SOCKS5
|
||||
(8080, "http", false), // 通用 HTTP
|
||||
(8888, "http", false), // Charles/Fiddler
|
||||
(3128, "http", false), // Squid
|
||||
(10808, "socks5", false), // V2Ray SOCKS
|
||||
(10809, "http", false), // V2Ray HTTP
|
||||
];
|
||||
|
||||
/// 扫描本地代理
|
||||
///
|
||||
/// 检测常见端口是否有代理服务在运行。
|
||||
/// 使用异步任务避免阻塞 UI 线程。
|
||||
#[tauri::command]
|
||||
pub async fn scan_local_proxies() -> Vec<DetectedProxy> {
|
||||
// 使用 spawn_blocking 避免阻塞主线程
|
||||
tokio::task::spawn_blocking(|| {
|
||||
let mut found = Vec::new();
|
||||
|
||||
for &(port, primary_type, is_mixed) in PROXY_PORTS {
|
||||
let addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port);
|
||||
if TcpStream::connect_timeout(&addr.into(), Duration::from_millis(100)).is_ok() {
|
||||
// 添加主要类型
|
||||
found.push(DetectedProxy {
|
||||
url: format!("{primary_type}://127.0.0.1:{port}"),
|
||||
proxy_type: primary_type.to_string(),
|
||||
port,
|
||||
});
|
||||
// 对于 mixed 端口,同时添加另一种协议
|
||||
if is_mixed {
|
||||
let alt_type = if primary_type == "http" {
|
||||
"socks5"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
found.push(DetectedProxy {
|
||||
url: format!("{alt_type}://127.0.0.1:{port}"),
|
||||
proxy_type: alt_type.to_string(),
|
||||
port,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
found
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -122,6 +122,7 @@ pub async fn upsert_mcp_server_in_config(
|
||||
new_server.apps.claude = true;
|
||||
new_server.apps.codex = true;
|
||||
new_server.apps.gemini = true;
|
||||
new_server.apps.opencode = true;
|
||||
}
|
||||
|
||||
McpService::upsert_server(&state, new_server)
|
||||
@@ -200,5 +201,6 @@ pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, S
|
||||
total += McpService::import_from_claude(&state).unwrap_or(0);
|
||||
total += McpService::import_from_codex(&state).unwrap_or(0);
|
||||
total += McpService::import_from_gemini(&state).unwrap_or(0);
|
||||
total += McpService::import_from_opencode(&state).unwrap_or(0);
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
||||
use crate::services::ProviderService;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use tauri::AppHandle;
|
||||
use tauri::State;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -87,15 +92,14 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
||||
let tools = vec!["claude", "codex", "gemini"];
|
||||
let mut results = Vec::new();
|
||||
|
||||
// 用于获取远程版本的 client
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("cc-switch/1.0")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
for tool in tools {
|
||||
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
||||
let (local_version, local_error) = {
|
||||
let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) {
|
||||
try_get_version_wsl(tool, &distro)
|
||||
} else {
|
||||
// 先尝试直接执行
|
||||
let direct_result = try_get_version(tool);
|
||||
|
||||
@@ -183,7 +187,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
if out.status.success() {
|
||||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||
if raw.is_empty() {
|
||||
(None, Some("未安装或无法执行".to_string()))
|
||||
(None, Some("not installed or not executable".to_string()))
|
||||
} else {
|
||||
(Some(extract_version(raw)), None)
|
||||
}
|
||||
@@ -192,7 +196,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
(
|
||||
None,
|
||||
Some(if err.is_empty() {
|
||||
"未安装或无法执行".to_string()
|
||||
"not installed or not executable".to_string()
|
||||
} else {
|
||||
err
|
||||
}),
|
||||
@@ -203,6 +207,88 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验 WSL 发行版名称是否合法
|
||||
/// WSL 发行版名称只允许字母、数字、连字符和下划线
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_valid_wsl_distro_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 64
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<String>) {
|
||||
use std::process::Command;
|
||||
|
||||
// 防御性断言:tool 只能是预定义的值
|
||||
debug_assert!(
|
||||
["claude", "codex", "gemini"].contains(&tool),
|
||||
"unexpected tool name: {tool}"
|
||||
);
|
||||
|
||||
// 校验 distro 名称,防止命令注入
|
||||
if !is_valid_wsl_distro_name(distro) {
|
||||
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
|
||||
}
|
||||
|
||||
let output = Command::new("wsl.exe")
|
||||
.args([
|
||||
"-d",
|
||||
distro,
|
||||
"--",
|
||||
"sh",
|
||||
"-lc",
|
||||
&format!("{tool} --version"),
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
if out.status.success() {
|
||||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||
if raw.is_empty() {
|
||||
(
|
||||
None,
|
||||
Some(format!("[WSL:{distro}] not installed or not executable")),
|
||||
)
|
||||
} else {
|
||||
(Some(extract_version(raw)), None)
|
||||
}
|
||||
} else {
|
||||
let err = if stderr.is_empty() { stdout } else { stderr };
|
||||
(
|
||||
None,
|
||||
Some(format!(
|
||||
"[WSL:{distro}] {}",
|
||||
if err.is_empty() {
|
||||
"not installed or not executable".to_string()
|
||||
} else {
|
||||
err
|
||||
}
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(e) => (None, Some(format!("[WSL:{distro}] exec failed: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 非 Windows 平台的 WSL 版本检测存根
|
||||
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
|
||||
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<String>) {
|
||||
(
|
||||
None,
|
||||
Some("WSL check not supported on this platform".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
/// 扫描常见路径查找 CLI
|
||||
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
use std::process::Command;
|
||||
@@ -298,5 +384,328 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
(None, Some("未安装或无法执行".to_string()))
|
||||
(None, Some("not installed or not executable".to_string()))
|
||||
}
|
||||
|
||||
fn wsl_distro_for_tool(tool: &str) -> Option<String> {
|
||||
let override_dir = match tool {
|
||||
"claude" => crate::settings::get_claude_override_dir(),
|
||||
"codex" => crate::settings::get_codex_override_dir(),
|
||||
"gemini" => crate::settings::get_gemini_override_dir(),
|
||||
_ => None,
|
||||
}?;
|
||||
|
||||
wsl_distro_from_path(&override_dir)
|
||||
}
|
||||
|
||||
/// 从 UNC 路径中提取 WSL 发行版名称
|
||||
/// 支持 `\\wsl$\Ubuntu\...` 和 `\\wsl.localhost\Ubuntu\...` 两种格式
|
||||
#[cfg(target_os = "windows")]
|
||||
fn wsl_distro_from_path(path: &Path) -> Option<String> {
|
||||
use std::path::{Component, Prefix};
|
||||
let Some(Component::Prefix(prefix)) = path.components().next() else {
|
||||
return None;
|
||||
};
|
||||
match prefix.kind() {
|
||||
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
|
||||
let server_name = server.to_string_lossy();
|
||||
if server_name.eq_ignore_ascii_case("wsl$")
|
||||
|| server_name.eq_ignore_ascii_case("wsl.localhost")
|
||||
{
|
||||
let distro = share.to_string_lossy().to_string();
|
||||
if !distro.is_empty() {
|
||||
return Some(distro);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 非 Windows 平台不支持 WSL 路径解析
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn wsl_distro_from_path(_path: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 打开指定提供商的终端
|
||||
///
|
||||
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
|
||||
/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端
|
||||
#[allow(non_snake_case)]
|
||||
#[tauri::command]
|
||||
pub async fn open_provider_terminal(
|
||||
state: State<'_, crate::store::AppState>,
|
||||
app: String,
|
||||
#[allow(non_snake_case)] providerId: String,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
|
||||
// 获取提供商配置
|
||||
let providers = ProviderService::list(state.inner(), app_type.clone())
|
||||
.map_err(|e| format!("获取提供商列表失败: {e}"))?;
|
||||
|
||||
let provider = providers
|
||||
.get(&providerId)
|
||||
.ok_or_else(|| format!("提供商 {providerId} 不存在"))?;
|
||||
|
||||
// 从提供商配置中提取环境变量
|
||||
let config = &provider.settings_config;
|
||||
let env_vars = extract_env_vars_from_config(config, &app_type);
|
||||
|
||||
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
|
||||
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 从提供商配置中提取环境变量
|
||||
fn extract_env_vars_from_config(
|
||||
config: &serde_json::Value,
|
||||
app_type: &AppType,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut env_vars = Vec::new();
|
||||
|
||||
let Some(obj) = config.as_object() else {
|
||||
return env_vars;
|
||||
};
|
||||
|
||||
// 处理 env 字段(Claude/Gemini 通用)
|
||||
if let Some(env) = obj.get("env").and_then(|v| v.as_object()) {
|
||||
for (key, value) in env {
|
||||
if let Some(str_val) = value.as_str() {
|
||||
env_vars.push((key.clone(), str_val.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 base_url: 根据应用类型添加对应的环境变量
|
||||
let base_url_key = match app_type {
|
||||
AppType::Claude => Some("ANTHROPIC_BASE_URL"),
|
||||
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(key) = base_url_key {
|
||||
if let Some(url_str) = env.get(key).and_then(|v| v.as_str()) {
|
||||
env_vars.push((key.to_string(), url_str.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Codex 使用 auth 字段转换为 OPENAI_API_KEY
|
||||
if *app_type == AppType::Codex {
|
||||
if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) {
|
||||
env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini 使用 api_key 字段转换为 GEMINI_API_KEY
|
||||
if *app_type == AppType::Gemini {
|
||||
if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) {
|
||||
env_vars.push(("GEMINI_API_KEY".to_string(), api_key.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
env_vars
|
||||
}
|
||||
|
||||
/// 创建临时配置文件并启动 claude 终端
|
||||
/// 使用 --settings 参数传入提供商特定的 API 配置
|
||||
fn launch_terminal_with_env(
|
||||
env_vars: Vec<(String, String)>,
|
||||
provider_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let config_file = temp_dir.join(format!(
|
||||
"claude_{}_{}.json",
|
||||
provider_id,
|
||||
std::process::id()
|
||||
));
|
||||
|
||||
// 创建并写入配置文件
|
||||
write_claude_config(&config_file, &env_vars)?;
|
||||
|
||||
// 转义配置文件路径用于 shell
|
||||
let config_path_escaped = escape_shell_path(&config_file);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
launch_macos_terminal(&config_file, &config_path_escaped)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
launch_linux_terminal(&config_file, &config_path_escaped)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
launch_windows_terminal(&temp_dir, &config_file)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
Err("不支持的操作系统".to_string())
|
||||
}
|
||||
|
||||
/// 写入 claude 配置文件
|
||||
fn write_claude_config(
|
||||
config_file: &std::path::Path,
|
||||
env_vars: &[(String, String)],
|
||||
) -> Result<(), String> {
|
||||
let mut config_obj = serde_json::Map::new();
|
||||
let mut env_obj = serde_json::Map::new();
|
||||
|
||||
for (key, value) in env_vars {
|
||||
env_obj.insert(key.clone(), serde_json::Value::String(value.clone()));
|
||||
}
|
||||
|
||||
config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj));
|
||||
|
||||
let config_json =
|
||||
serde_json::to_string_pretty(&config_obj).map_err(|e| format!("序列化配置失败: {e}"))?;
|
||||
|
||||
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
|
||||
}
|
||||
|
||||
/// 转义 shell 路径
|
||||
fn escape_shell_path(path: &std::path::Path) -> String {
|
||||
path.to_string_lossy()
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('$', "\\$")
|
||||
.replace(' ', "\\ ")
|
||||
}
|
||||
|
||||
/// 生成 bash 包装脚本,用于清理临时文件
|
||||
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
|
||||
format!(
|
||||
"bash -c 'trap \"rm -f \\\"{config_path}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{escaped_path}\"; claude --settings \"{escaped_path}\"; exec bash --norc --noprofile'"
|
||||
)
|
||||
}
|
||||
|
||||
/// macOS: 使用 Terminal.app 启动
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_terminal(
|
||||
config_file: &std::path::Path,
|
||||
config_path_escaped: &str,
|
||||
) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let config_path_for_script = config_file.to_string_lossy().replace('\"', "\\\"");
|
||||
|
||||
let shell_script = generate_wrapper_script(&config_path_for_script, config_path_escaped);
|
||||
|
||||
let script = format!(
|
||||
r#"tell application "Terminal"
|
||||
activate
|
||||
do script "{}"
|
||||
end tell"#,
|
||||
shell_script.replace('\"', "\\\"")
|
||||
);
|
||||
|
||||
Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(&script)
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 macOS 终端失败: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Linux: 尝试使用常见终端启动
|
||||
#[cfg(target_os = "linux")]
|
||||
fn launch_linux_terminal(
|
||||
config_file: &std::path::Path,
|
||||
config_path_escaped: &str,
|
||||
) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let terminals = [
|
||||
"gnome-terminal",
|
||||
"konsole",
|
||||
"xfce4-terminal",
|
||||
"mate-terminal",
|
||||
"lxterminal",
|
||||
"alacritty",
|
||||
"kitty",
|
||||
];
|
||||
|
||||
let config_path_for_bash = config_file.to_string_lossy();
|
||||
let shell_cmd = generate_wrapper_script(&config_path_for_bash, config_path_escaped);
|
||||
|
||||
let mut last_error = String::from("未找到可用的终端");
|
||||
|
||||
for terminal in terminals {
|
||||
// 检查终端是否存在
|
||||
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|
||||
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
|
||||
{
|
||||
let result = match terminal {
|
||||
"gnome-terminal" | "mate-terminal" => Command::new(terminal)
|
||||
.arg("--")
|
||||
.arg("bash")
|
||||
.arg("-c")
|
||||
.arg(&shell_cmd)
|
||||
.spawn(),
|
||||
_ => Command::new(terminal)
|
||||
.arg("-e")
|
||||
.arg("bash")
|
||||
.arg("-c")
|
||||
.arg(&shell_cmd)
|
||||
.spawn(),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => {
|
||||
last_error = format!("启动 {} 失败: {}", terminal, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理配置文件
|
||||
let _ = std::fs::remove_file(config_file);
|
||||
Err(last_error)
|
||||
}
|
||||
|
||||
/// Windows: 创建临时批处理文件启动
|
||||
#[cfg(target_os = "windows")]
|
||||
fn launch_windows_terminal(
|
||||
temp_dir: &std::path::Path,
|
||||
config_file: &std::path::Path,
|
||||
) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
|
||||
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
|
||||
|
||||
let content = format!(
|
||||
"@echo off
|
||||
echo Using provider-specific claude config:
|
||||
echo {}
|
||||
claude --settings \"{}\"
|
||||
del \"{}\" >nul 2>&1
|
||||
del \"%~f0\" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo Press any key to close...
|
||||
pause >nul
|
||||
)",
|
||||
config_path_for_batch, config_path_for_batch, config_path_for_batch
|
||||
);
|
||||
|
||||
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
||||
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy()])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 Windows 终端失败: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ mod config;
|
||||
mod deeplink;
|
||||
mod env;
|
||||
mod failover;
|
||||
mod global_proxy;
|
||||
mod import_export;
|
||||
mod mcp;
|
||||
mod misc;
|
||||
@@ -20,6 +21,7 @@ pub use config::*;
|
||||
pub use deeplink::*;
|
||||
pub use env::*;
|
||||
pub use failover::*;
|
||||
pub use global_proxy::*;
|
||||
pub use import_export::*;
|
||||
pub use mcp::*;
|
||||
pub use misc::*;
|
||||
|
||||
@@ -60,6 +60,16 @@ pub fn delete_provider(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Remove provider from live config only (for additive mode apps like OpenCode)
|
||||
/// Does NOT delete from database - provider remains in the list
|
||||
#[tauri::command]
|
||||
pub fn remove_provider_from_live_config(app: String, id: String) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
ProviderService::remove_from_live_config(app_type, &id)
|
||||
.map(|_| true)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 切换供应商
|
||||
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
ProviderService::switch(state, app_type, id)
|
||||
@@ -133,6 +143,7 @@ pub async fn testUsageScript(
|
||||
#[allow(non_snake_case)] baseUrl: Option<String>,
|
||||
#[allow(non_snake_case)] accessToken: Option<String>,
|
||||
#[allow(non_snake_case)] userId: Option<String>,
|
||||
#[allow(non_snake_case)] templateType: Option<String>,
|
||||
) -> Result<crate::provider::UsageResult, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
ProviderService::test_usage_script(
|
||||
@@ -145,6 +156,7 @@ pub async fn testUsageScript(
|
||||
baseUrl.as_deref(),
|
||||
accessToken.as_deref(),
|
||||
userId.as_deref(),
|
||||
templateType.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
@@ -323,3 +335,27 @@ pub fn sync_universal_provider(
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OpenCode 专属命令
|
||||
// ============================================================================
|
||||
|
||||
/// 从 OpenCode live 配置导入供应商到数据库
|
||||
///
|
||||
/// 这是 OpenCode 特有的功能,因为 OpenCode 使用累加模式,
|
||||
/// 用户可能已经在 opencode.json 中配置了供应商。
|
||||
#[tauri::command]
|
||||
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
|
||||
crate::services::provider::import_opencode_providers_from_live(state.inner())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取 OpenCode live 配置中的供应商 ID 列表
|
||||
///
|
||||
/// 用于前端判断供应商是否已添加到 opencode.json
|
||||
#[tauri::command]
|
||||
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
|
||||
crate::opencode_config::get_providers()
|
||||
.map(|providers| providers.keys().cloned().collect())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -59,3 +59,24 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
|
||||
pub async fn get_auto_launch_status() -> Result<bool, String> {
|
||||
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
|
||||
}
|
||||
|
||||
/// 获取整流器配置
|
||||
#[tauri::command]
|
||||
pub async fn get_rectifier_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> Result<crate::proxy::types::RectifierConfig, String> {
|
||||
state.db.get_rectifier_config().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置整流器配置
|
||||
#[tauri::command]
|
||||
pub async fn set_rectifier_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
config: crate::proxy::types::RectifierConfig,
|
||||
) -> Result<bool, String> {
|
||||
state
|
||||
.db
|
||||
.set_rectifier_config(&config)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ fn parse_app_type(app: &str) -> Result<AppType, String> {
|
||||
"claude" => Ok(AppType::Claude),
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
_ => Err(format!("不支持的 app 类型: {app}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ impl Database {
|
||||
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini
|
||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
|
||||
FROM mcp_servers
|
||||
ORDER BY name ASC, id ASC"
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -30,6 +30,7 @@ impl Database {
|
||||
let enabled_claude: bool = row.get(7)?;
|
||||
let enabled_codex: bool = row.get(8)?;
|
||||
let enabled_gemini: bool = row.get(9)?;
|
||||
let enabled_opencode: bool = row.get(10)?;
|
||||
|
||||
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
|
||||
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
@@ -44,6 +45,7 @@ impl Database {
|
||||
claude: enabled_claude,
|
||||
codex: enabled_codex,
|
||||
gemini: enabled_gemini,
|
||||
opencode: enabled_opencode,
|
||||
},
|
||||
description,
|
||||
homepage,
|
||||
@@ -68,8 +70,8 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO mcp_servers (
|
||||
id, name, server_config, description, homepage, docs, tags,
|
||||
enabled_claude, enabled_codex, enabled_gemini
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
params![
|
||||
server.id,
|
||||
server.name,
|
||||
@@ -84,6 +86,7 @@ impl Database {
|
||||
server.apps.claude,
|
||||
server.apps.codex,
|
||||
server.apps.gemini,
|
||||
server.apps.opencode,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
@@ -63,6 +63,41 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 全局出站代理 ---
|
||||
|
||||
/// 全局代理 URL 的存储键名
|
||||
const GLOBAL_PROXY_URL_KEY: &'static str = "global_proxy_url";
|
||||
|
||||
/// 获取全局出站代理 URL
|
||||
///
|
||||
/// 返回 None 表示未配置或已清除代理(直连)
|
||||
/// 返回 Some(url) 表示已配置代理
|
||||
pub fn get_global_proxy_url(&self) -> Result<Option<String>, AppError> {
|
||||
self.get_setting(Self::GLOBAL_PROXY_URL_KEY)
|
||||
}
|
||||
|
||||
/// 设置全局出站代理 URL
|
||||
///
|
||||
/// - 传入非空字符串:启用代理
|
||||
/// - 传入空字符串或 None:清除代理设置(直连)
|
||||
pub fn set_global_proxy_url(&self, url: Option<&str>) -> Result<(), AppError> {
|
||||
match url {
|
||||
Some(u) if !u.trim().is_empty() => {
|
||||
self.set_setting(Self::GLOBAL_PROXY_URL_KEY, u.trim())
|
||||
}
|
||||
_ => {
|
||||
// 清除代理设置
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"DELETE FROM settings WHERE key = ?1",
|
||||
params![Self::GLOBAL_PROXY_URL_KEY],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
|
||||
|
||||
/// 获取指定应用的代理接管状态
|
||||
@@ -128,4 +163,27 @@ impl Database {
|
||||
log::info!("已清除所有代理接管状态");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- 整流器配置 ---
|
||||
|
||||
/// 获取整流器配置
|
||||
///
|
||||
/// 返回整流器配置,如果不存在则返回默认值(全部启用)
|
||||
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
|
||||
match self.get_setting("rectifier_config")? {
|
||||
Some(json) => serde_json::from_str(&json)
|
||||
.map_err(|e| AppError::Database(format!("解析整流器配置失败: {e}"))),
|
||||
None => Ok(crate::proxy::types::RectifierConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新整流器配置
|
||||
pub fn set_rectifier_config(
|
||||
&self,
|
||||
config: &crate::proxy::types::RectifierConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let json = serde_json::to_string(config)
|
||||
.map_err(|e| AppError::Database(format!("序列化整流器配置失败: {e}")))?;
|
||||
self.set_setting("rectifier_config", &json)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
|
||||
//!
|
||||
//! v3.10.0+ 统一管理架构:
|
||||
//! - Skills 使用统一的 id 主键,支持三应用启用标志
|
||||
//! - Skills 使用统一的 id 主键,支持四应用启用标志
|
||||
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
|
||||
|
||||
use crate::app_config::{InstalledSkill, SkillApps};
|
||||
@@ -22,7 +22,7 @@ impl Database {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
|
||||
FROM skills ORDER BY name ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -42,8 +42,9 @@ impl Database {
|
||||
claude: row.get(8)?,
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
opencode: row.get(11)?,
|
||||
},
|
||||
installed_at: row.get(11)?,
|
||||
installed_at: row.get(12)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -62,7 +63,7 @@ impl Database {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
|
||||
FROM skills WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -81,8 +82,9 @@ impl Database {
|
||||
claude: row.get(8)?,
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
opencode: row.get(11)?,
|
||||
},
|
||||
installed_at: row.get(11)?,
|
||||
installed_at: row.get(12)?,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -99,8 +101,8 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO skills
|
||||
(id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
skill.id,
|
||||
skill.name,
|
||||
@@ -113,6 +115,7 @@ impl Database {
|
||||
skill.apps.claude,
|
||||
skill.apps.codex,
|
||||
skill.apps.gemini,
|
||||
skill.apps.opencode,
|
||||
skill.installed_at,
|
||||
],
|
||||
)
|
||||
@@ -142,8 +145,8 @@ impl Database {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let affected = conn
|
||||
.execute(
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3 WHERE id = ?4",
|
||||
params![apps.claude, apps.codex, apps.gemini, id],
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5",
|
||||
params![apps.claude, apps.codex, apps.gemini, apps.opencode, id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
|
||||
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 3;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 4;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
|
||||
@@ -58,7 +58,7 @@ impl Database {
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
|
||||
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
)
|
||||
@@ -85,6 +85,7 @@ impl Database {
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
@@ -346,6 +347,11 @@ impl Database {
|
||||
Self::migrate_v2_to_v3(conn)?;
|
||||
Self::set_user_version(conn, 3)?;
|
||||
}
|
||||
3 => {
|
||||
log::info!("迁移数据库从 v3 到 v4(OpenCode 支持)");
|
||||
Self::migrate_v3_to_v4(conn)?;
|
||||
Self::set_user_version(conn, 4)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -849,6 +855,30 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v3 -> v4 迁移:添加 OpenCode 支持
|
||||
///
|
||||
/// 为 mcp_servers 和 skills 表添加 enabled_opencode 列。
|
||||
fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> {
|
||||
// 为 mcp_servers 表添加 enabled_opencode 列
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"mcp_servers",
|
||||
"enabled_opencode",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
// 为 skills 表添加 enabled_opencode 列
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"skills",
|
||||
"enabled_opencode",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
log::info!("v3 -> v4 迁移完成:已添加 OpenCode 支持");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
|
||||
@@ -166,6 +166,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
};
|
||||
|
||||
for app in apps_str.split(',') {
|
||||
@@ -173,6 +174,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
"claude" => apps.claude = true,
|
||||
"codex" => apps.codex = true,
|
||||
"gemini" => apps.gemini = true,
|
||||
"opencode" => apps.opencode = true,
|
||||
other => {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app in 'apps': {other}"
|
||||
|
||||
@@ -55,7 +55,7 @@ pub struct DeepLinkImportRequest {
|
||||
/// Provider homepage URL
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub homepage: Option<String>,
|
||||
/// API endpoint/base URL
|
||||
/// API endpoint/base URL (supports comma-separated multiple URLs)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint: Option<String>,
|
||||
/// API key
|
||||
|
||||
@@ -101,9 +101,13 @@ fn parse_provider_deeplink(
|
||||
validate_url(hp, "homepage")?;
|
||||
}
|
||||
}
|
||||
// Validate each endpoint (supports comma-separated multiple URLs)
|
||||
if let Some(ref ep) = endpoint {
|
||||
if !ep.is_empty() {
|
||||
validate_url(ep, "endpoint")?;
|
||||
for (i, url) in ep.split(',').enumerate() {
|
||||
let trimmed = url.trim();
|
||||
if !trimmed.is_empty() {
|
||||
validate_url(trimmed, &format!("endpoint[{i}]"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,12 +33,12 @@ pub fn import_provider_from_deeplink(
|
||||
}
|
||||
|
||||
// Step 1: Merge config file if provided (v3.8+)
|
||||
let merged_request = parse_and_merge_config(&request)?;
|
||||
let mut merged_request = parse_and_merge_config(&request)?;
|
||||
|
||||
// Extract required fields (now as Option)
|
||||
let app_str = merged_request
|
||||
.app
|
||||
.as_ref()
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
|
||||
|
||||
let api_key = merged_request.api_key.as_ref().ok_or_else(|| {
|
||||
@@ -51,14 +51,29 @@ pub fn import_provider_from_deeplink(
|
||||
));
|
||||
}
|
||||
|
||||
let endpoint = merged_request.endpoint.as_ref().ok_or_else(|| {
|
||||
// Get endpoint: supports comma-separated multiple URLs (first is primary)
|
||||
let endpoint_str = merged_request.endpoint.as_ref().ok_or_else(|| {
|
||||
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
|
||||
})?;
|
||||
|
||||
if endpoint.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Endpoint cannot be empty".to_string(),
|
||||
));
|
||||
// Parse endpoints: split by comma, first is primary
|
||||
let all_endpoints: Vec<String> = endpoint_str
|
||||
.split(',')
|
||||
.map(|e| e.trim().to_string())
|
||||
.filter(|e| !e.is_empty())
|
||||
.collect();
|
||||
|
||||
let primary_endpoint = all_endpoints
|
||||
.first()
|
||||
.ok_or_else(|| AppError::InvalidInput("Endpoint cannot be empty".to_string()))?;
|
||||
|
||||
// Auto-infer homepage from endpoint if not provided
|
||||
if merged_request
|
||||
.homepage
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.is_empty())
|
||||
{
|
||||
merged_request.homepage = infer_homepage_from_endpoint(primary_endpoint);
|
||||
}
|
||||
|
||||
let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
|
||||
@@ -73,11 +88,11 @@ pub fn import_provider_from_deeplink(
|
||||
|
||||
let name = merged_request
|
||||
.name
|
||||
.as_ref()
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
|
||||
|
||||
// Parse app type
|
||||
let app_type = AppType::from_str(app_str)
|
||||
let app_type = AppType::from_str(&app_str)
|
||||
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
|
||||
|
||||
// Build provider configuration based on app type
|
||||
@@ -97,6 +112,21 @@ pub fn import_provider_from_deeplink(
|
||||
// Use ProviderService to add the provider
|
||||
ProviderService::add(state, app_type.clone(), provider)?;
|
||||
|
||||
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
|
||||
for ep in all_endpoints.iter().skip(1) {
|
||||
let normalized = ep.trim().trim_end_matches('/').to_string();
|
||||
if !normalized.is_empty() {
|
||||
if let Err(e) = ProviderService::add_custom_endpoint(
|
||||
state,
|
||||
app_type.clone(),
|
||||
&provider_id,
|
||||
normalized.clone(),
|
||||
) {
|
||||
log::warn!("Failed to add custom endpoint '{normalized}': {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If enabled=true, set as current provider
|
||||
if merged_request.enabled.unwrap_or(false) {
|
||||
ProviderService::switch(state, app_type.clone(), &provider_id)?;
|
||||
@@ -115,6 +145,7 @@ pub(crate) fn build_provider_from_request(
|
||||
AppType::Claude => build_claude_settings(request),
|
||||
AppType::Codex => build_codex_settings(request),
|
||||
AppType::Gemini => build_gemini_settings(request),
|
||||
AppType::OpenCode => build_opencode_settings(request),
|
||||
};
|
||||
|
||||
// Build usage script configuration if provided
|
||||
@@ -138,6 +169,16 @@ pub(crate) fn build_provider_from_request(
|
||||
Ok(provider)
|
||||
}
|
||||
|
||||
/// Get primary endpoint from request (first one if comma-separated)
|
||||
fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
|
||||
request
|
||||
.endpoint
|
||||
.as_ref()
|
||||
.and_then(|ep| ep.split(',').next())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Build provider meta with usage script configuration
|
||||
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
||||
// Check if any usage script fields are provided
|
||||
@@ -165,6 +206,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
||||
|
||||
// Build UsageScript - use provider's API key and endpoint as defaults
|
||||
// Note: use primary endpoint only (first one if comma-separated)
|
||||
let usage_script = UsageScript {
|
||||
enabled,
|
||||
language: "javascript".to_string(),
|
||||
@@ -174,12 +216,17 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
.usage_api_key
|
||||
.clone()
|
||||
.or_else(|| request.api_key.clone()),
|
||||
base_url: request
|
||||
.usage_base_url
|
||||
.clone()
|
||||
.or_else(|| request.endpoint.clone()),
|
||||
base_url: request.usage_base_url.clone().or_else(|| {
|
||||
let primary = get_primary_endpoint(request);
|
||||
if primary.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(primary)
|
||||
}
|
||||
}),
|
||||
access_token: request.usage_access_token.clone(),
|
||||
user_id: request.usage_user_id.clone(),
|
||||
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||||
auto_query_interval: request.usage_auto_interval,
|
||||
};
|
||||
|
||||
@@ -198,7 +245,7 @@ fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
);
|
||||
env.insert(
|
||||
"ANTHROPIC_BASE_URL".to_string(),
|
||||
json!(request.endpoint.clone().unwrap_or_default()),
|
||||
json!(get_primary_endpoint(request)),
|
||||
);
|
||||
|
||||
// Add default model if provided
|
||||
@@ -271,11 +318,8 @@ fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
.unwrap_or("gpt-5-codex")
|
||||
.to_string();
|
||||
|
||||
// Endpoint: normalize trailing slashes
|
||||
let endpoint = request
|
||||
.endpoint
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
// Endpoint: normalize trailing slashes (use primary endpoint only)
|
||||
let endpoint = get_primary_endpoint(request)
|
||||
.trim()
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
@@ -309,7 +353,7 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
|
||||
env.insert(
|
||||
"GOOGLE_GEMINI_BASE_URL".to_string(),
|
||||
json!(request.endpoint),
|
||||
json!(get_primary_endpoint(request)),
|
||||
);
|
||||
|
||||
// Add model if provided
|
||||
@@ -320,6 +364,33 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
json!({ "env": env })
|
||||
}
|
||||
|
||||
/// Build OpenCode settings configuration
|
||||
fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
let endpoint = get_primary_endpoint(request);
|
||||
|
||||
// Build options object
|
||||
let mut options = serde_json::Map::new();
|
||||
if !endpoint.is_empty() {
|
||||
options.insert("baseURL".to_string(), json!(endpoint));
|
||||
}
|
||||
if let Some(api_key) = &request.api_key {
|
||||
options.insert("apiKey".to_string(), json!(api_key));
|
||||
}
|
||||
|
||||
// Build models object
|
||||
let mut models = serde_json::Map::new();
|
||||
if let Some(model) = &request.model {
|
||||
models.insert(model.clone(), json!({ "name": model }));
|
||||
}
|
||||
|
||||
// Default to openai-compatible npm package
|
||||
json!({
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"options": options,
|
||||
"models": models
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Config Merge Logic
|
||||
// =============================================================================
|
||||
@@ -523,7 +594,11 @@ fn merge_gemini_config(
|
||||
}
|
||||
|
||||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
|
||||
if let Some(base_url) = config
|
||||
.get("GOOGLE_GEMINI_BASE_URL")
|
||||
.or_else(|| config.get("GEMINI_BASE_URL"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
request.endpoint = Some(base_url.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,3 +404,57 @@ fn test_parse_skill_deeplink() {
|
||||
assert_eq!(request.directory.unwrap(), "skills");
|
||||
assert_eq!(request.branch.unwrap(), "dev");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Multiple Endpoints Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_endpoints_comma_separated() {
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com,https%3A%2F%2Fapi2.example.com,https%3A%2F%2Fapi3.example.com&apiKey=sk-test";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
assert!(request.endpoint.is_some());
|
||||
let endpoint = request.endpoint.unwrap();
|
||||
// Should contain all endpoints comma-separated
|
||||
assert!(endpoint.contains("https://api1.example.com"));
|
||||
assert!(endpoint.contains("https://api2.example.com"));
|
||||
assert!(endpoint.contains("https://api3.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_single_endpoint_backward_compatible() {
|
||||
// Old format with single endpoint should still work
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
request.endpoint,
|
||||
Some("https://api.example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_endpoints_with_spaces_trimmed() {
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com%20,%20https%3A%2F%2Fapi2.example.com&apiKey=sk-test";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
// Validation should pass (spaces are trimmed during validation)
|
||||
assert!(request.endpoint.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_homepage_from_endpoint_without_homepage() {
|
||||
// Test that homepage is auto-inferred from endpoint when not provided
|
||||
assert_eq!(
|
||||
infer_homepage_from_endpoint("https://api.cubence.com/v1"),
|
||||
Some("https://cubence.com".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
infer_homepage_from_endpoint("https://cubence.com"),
|
||||
Some("https://cubence.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
mod init_status;
|
||||
mod mcp;
|
||||
mod opencode_config;
|
||||
mod panic_hook;
|
||||
mod prompt;
|
||||
mod prompt_files;
|
||||
@@ -27,6 +28,7 @@ mod usage_script;
|
||||
|
||||
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 commands::open_provider_terminal;
|
||||
pub use commands::*;
|
||||
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
||||
pub use database::Database;
|
||||
@@ -481,6 +483,17 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 2.1 OpenCode 供应商导入(累加式模式,需特殊处理)
|
||||
// OpenCode 与其他应用不同:配置文件中可同时存在多个供应商
|
||||
// 需要遍历 provider 字段下的每个供应商并导入
|
||||
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} OpenCode provider(s) from live config");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No OpenCode providers found to import"),
|
||||
Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"),
|
||||
}
|
||||
|
||||
// 3. 导入 MCP 服务器配置(表空时触发)
|
||||
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
|
||||
log::info!("MCP table empty, importing from live configurations...");
|
||||
@@ -508,6 +521,14 @@ pub fn run() {
|
||||
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
|
||||
}
|
||||
|
||||
match crate::services::mcp::McpService::import_from_opencode(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} MCP server(s) from OpenCode");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No OpenCode MCP servers found to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import OpenCode MCP: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 导入提示词文件(表空时触发)
|
||||
@@ -643,6 +664,37 @@ pub fn run() {
|
||||
let skill_service = SkillService::new();
|
||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||
|
||||
// 初始化全局出站代理 HTTP 客户端
|
||||
{
|
||||
let db = &app.state::<AppState>().db;
|
||||
let proxy_url = db.get_global_proxy_url().ok().flatten();
|
||||
|
||||
if let Err(e) = crate::proxy::http_client::init(proxy_url.as_deref()) {
|
||||
log::error!(
|
||||
"[GlobalProxy] [GP-005] Failed to initialize with saved config: {e}"
|
||||
);
|
||||
|
||||
// 清除无效的代理配置
|
||||
if proxy_url.is_some() {
|
||||
log::warn!(
|
||||
"[GlobalProxy] [GP-006] Clearing invalid proxy config from database"
|
||||
);
|
||||
if let Err(clear_err) = db.set_global_proxy_url(None) {
|
||||
log::error!(
|
||||
"[GlobalProxy] [GP-007] Failed to clear invalid config: {clear_err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用直连模式重新初始化
|
||||
if let Err(fallback_err) = crate::proxy::http_client::init(None) {
|
||||
log::error!(
|
||||
"[GlobalProxy] [GP-008] Failed to initialize direct connection: {fallback_err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 异常退出恢复 + 代理状态自动恢复
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@@ -680,6 +732,7 @@ pub fn run() {
|
||||
commands::add_provider,
|
||||
commands::update_provider,
|
||||
commands::delete_provider,
|
||||
commands::remove_provider_from_live_config,
|
||||
commands::switch_provider,
|
||||
commands::import_default_config,
|
||||
commands::get_claude_config_status,
|
||||
@@ -702,6 +755,8 @@ pub fn run() {
|
||||
commands::read_live_provider_settings,
|
||||
commands::get_settings,
|
||||
commands::save_settings,
|
||||
commands::get_rectifier_config,
|
||||
commands::set_rectifier_config,
|
||||
commands::restart_app,
|
||||
commands::check_for_updates,
|
||||
commands::is_portable_mode,
|
||||
@@ -832,12 +887,23 @@ pub fn run() {
|
||||
commands::get_stream_check_config,
|
||||
commands::save_stream_check_config,
|
||||
commands::get_tool_versions,
|
||||
// Provider terminal
|
||||
commands::open_provider_terminal,
|
||||
// Universal Provider management
|
||||
commands::get_universal_providers,
|
||||
commands::get_universal_provider,
|
||||
commands::upsert_universal_provider,
|
||||
commands::delete_universal_provider,
|
||||
commands::sync_universal_provider,
|
||||
// OpenCode specific
|
||||
commands::import_opencode_providers_from_live,
|
||||
commands::get_opencode_live_provider_ids,
|
||||
// Global upstream proxy
|
||||
commands::get_global_proxy_url,
|
||||
commands::set_global_proxy_url,
|
||||
commands::test_proxy_url,
|
||||
commands::get_upstream_proxy_status,
|
||||
commands::scan_local_proxies,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
|
||||
@@ -91,6 +91,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
|
||||
@@ -235,6 +235,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
|
||||
@@ -87,6 +87,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: true,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
//! - `claude` - Claude MCP 同步和导入
|
||||
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
|
||||
//! - `gemini` - Gemini MCP 同步和导入
|
||||
//! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换)
|
||||
|
||||
mod claude;
|
||||
mod codex;
|
||||
mod gemini;
|
||||
mod opencode;
|
||||
mod validation;
|
||||
|
||||
// 重新导出公共 API
|
||||
@@ -26,3 +28,6 @@ pub use gemini::{
|
||||
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
|
||||
sync_single_server_to_gemini,
|
||||
};
|
||||
pub use opencode::{
|
||||
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
//! OpenCode MCP 同步和导入模块
|
||||
//!
|
||||
//! 本模块处理 CC Switch 统一 MCP 格式与 OpenCode 格式之间的转换。
|
||||
//!
|
||||
//! ## 格式差异
|
||||
//!
|
||||
//! | CC Switch 统一格式 | OpenCode 格式 |
|
||||
//! |----------------------|---------------------|
|
||||
//! | `type: "stdio"` | `type: "local"` |
|
||||
//! | `command` + `args` | `command: [cmd, ...args]` |
|
||||
//! | `env` | `environment` |
|
||||
//! | `type: "sse"/"http"` | `type: "remote"` |
|
||||
//! | `url` | `url` |
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::app_config::{McpApps, McpServer, MultiAppConfig};
|
||||
use crate::error::AppError;
|
||||
use crate::opencode_config;
|
||||
|
||||
use super::validation::validate_server_spec;
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Check if OpenCode MCP sync should proceed
|
||||
fn should_sync_opencode_mcp() -> bool {
|
||||
// Skip if OpenCode config directory doesn't exist
|
||||
opencode_config::get_opencode_dir().exists()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Format Conversion: CC Switch → OpenCode
|
||||
// ============================================================================
|
||||
|
||||
/// Convert CC Switch unified format to OpenCode format
|
||||
///
|
||||
/// Conversion rules:
|
||||
/// - `stdio` → `local`, command+args → command array, env → environment
|
||||
/// - `sse`/`http` → `remote`, url preserved
|
||||
pub fn convert_to_opencode_format(spec: &Value) -> Result<Value, AppError> {
|
||||
let obj = spec
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?;
|
||||
|
||||
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
|
||||
match typ {
|
||||
"stdio" => {
|
||||
// Convert to "local" type
|
||||
result.insert("type".into(), json!("local"));
|
||||
|
||||
// Merge command and args into a single array
|
||||
let cmd = obj.get("command").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let mut command_arr = vec![json!(cmd)];
|
||||
|
||||
if let Some(args) = obj.get("args").and_then(|v| v.as_array()) {
|
||||
for arg in args {
|
||||
command_arr.push(arg.clone());
|
||||
}
|
||||
}
|
||||
result.insert("command".into(), Value::Array(command_arr));
|
||||
|
||||
// Convert env → environment
|
||||
if let Some(env) = obj.get("env") {
|
||||
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
|
||||
result.insert("environment".into(), env.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Add enabled flag (OpenCode expects this)
|
||||
result.insert("enabled".into(), json!(true));
|
||||
}
|
||||
"sse" | "http" => {
|
||||
// Convert to "remote" type
|
||||
result.insert("type".into(), json!("remote"));
|
||||
|
||||
// Preserve url
|
||||
if let Some(url) = obj.get("url") {
|
||||
result.insert("url".into(), url.clone());
|
||||
}
|
||||
|
||||
// Convert headers if present
|
||||
if let Some(headers) = obj.get("headers") {
|
||||
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
|
||||
{
|
||||
result.insert("headers".into(), headers.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Add enabled flag
|
||||
result.insert("enabled".into(), json!(true));
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::McpValidation(format!(
|
||||
"Unknown MCP type: {}",
|
||||
typ
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Value::Object(result))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Format Conversion: OpenCode → CC Switch
|
||||
// ============================================================================
|
||||
|
||||
/// Convert OpenCode format to CC Switch unified format
|
||||
///
|
||||
/// Conversion rules:
|
||||
/// - `local` → `stdio`, command array → command+args, environment → env
|
||||
/// - `remote` → `sse`, url preserved
|
||||
pub fn convert_from_opencode_format(spec: &Value) -> Result<Value, AppError> {
|
||||
let obj = spec
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::McpValidation("OpenCode MCP spec must be a JSON object".into()))?;
|
||||
|
||||
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("local");
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
|
||||
match typ {
|
||||
"local" => {
|
||||
// Convert to "stdio" type
|
||||
result.insert("type".into(), json!("stdio"));
|
||||
|
||||
// Split command array into command and args
|
||||
if let Some(cmd_arr) = obj.get("command").and_then(|v| v.as_array()) {
|
||||
if !cmd_arr.is_empty() {
|
||||
// First element is the command
|
||||
if let Some(cmd) = cmd_arr.first().and_then(|v| v.as_str()) {
|
||||
result.insert("command".into(), json!(cmd));
|
||||
}
|
||||
|
||||
// Rest are args
|
||||
if cmd_arr.len() > 1 {
|
||||
let args: Vec<Value> = cmd_arr[1..].to_vec();
|
||||
result.insert("args".into(), Value::Array(args));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert environment → env
|
||||
if let Some(env) = obj.get("environment") {
|
||||
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
|
||||
result.insert("env".into(), env.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
"remote" => {
|
||||
// Convert to "sse" type (default remote protocol)
|
||||
result.insert("type".into(), json!("sse"));
|
||||
|
||||
// Preserve url
|
||||
if let Some(url) = obj.get("url") {
|
||||
result.insert("url".into(), url.clone());
|
||||
}
|
||||
|
||||
// Preserve headers
|
||||
if let Some(headers) = obj.get("headers") {
|
||||
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
|
||||
{
|
||||
result.insert("headers".into(), headers.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::McpValidation(format!(
|
||||
"Unknown OpenCode MCP type: {}",
|
||||
typ
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Value::Object(result))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API: Sync Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Sync a single MCP server to OpenCode live config
|
||||
pub fn sync_single_server_to_opencode(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
if !should_sync_opencode_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Convert to OpenCode format
|
||||
let opencode_spec = convert_to_opencode_format(server_spec)?;
|
||||
|
||||
// Set in OpenCode config
|
||||
opencode_config::set_mcp_server(id, opencode_spec)
|
||||
}
|
||||
|
||||
/// Remove a single MCP server from OpenCode live config
|
||||
pub fn remove_server_from_opencode(id: &str) -> Result<(), AppError> {
|
||||
if !should_sync_opencode_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
opencode_config::remove_mcp_server(id)
|
||||
}
|
||||
|
||||
/// Import MCP servers from OpenCode config to unified structure
|
||||
///
|
||||
/// Existing servers will have OpenCode app enabled without overwriting other fields.
|
||||
pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
let mcp_map = opencode_config::get_mcp_servers()?;
|
||||
if mcp_map.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Ensure servers map exists
|
||||
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||
|
||||
let mut changed = 0;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (id, spec) in mcp_map {
|
||||
// Convert from OpenCode format to unified format
|
||||
let unified_spec = match convert_from_opencode_format(&spec) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("Skip invalid OpenCode MCP server '{}': {}", id, e);
|
||||
errors.push(format!("{}: {}", id, e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Validate the converted spec
|
||||
if let Err(e) = validate_server_spec(&unified_spec) {
|
||||
log::warn!("Skip invalid MCP server '{}' after conversion: {}", id, e);
|
||||
errors.push(format!("{}: {}", id, e));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = servers.get_mut(&id) {
|
||||
// Existing server: just enable OpenCode app
|
||||
if !existing.apps.opencode {
|
||||
existing.apps.opencode = true;
|
||||
changed += 1;
|
||||
log::info!("MCP server '{}' enabled for OpenCode", id);
|
||||
}
|
||||
} else {
|
||||
// New server: default to only OpenCode enabled
|
||||
servers.insert(
|
||||
id.clone(),
|
||||
McpServer {
|
||||
id: id.clone(),
|
||||
name: id.clone(),
|
||||
server: unified_spec,
|
||||
apps: McpApps {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: true,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
);
|
||||
changed += 1;
|
||||
log::info!("Imported new MCP server '{}' from OpenCode", id);
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
log::warn!(
|
||||
"Import completed with {} failures: {:?}",
|
||||
errors.len(),
|
||||
errors
|
||||
);
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_convert_stdio_to_local() {
|
||||
let spec = json!({
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
|
||||
"env": { "HOME": "/Users/test" }
|
||||
});
|
||||
|
||||
let result = convert_to_opencode_format(&spec).unwrap();
|
||||
assert_eq!(result["type"], "local");
|
||||
assert_eq!(result["command"][0], "npx");
|
||||
assert_eq!(result["command"][1], "-y");
|
||||
assert_eq!(
|
||||
result["command"][2],
|
||||
"@modelcontextprotocol/server-filesystem"
|
||||
);
|
||||
assert_eq!(result["environment"]["HOME"], "/Users/test");
|
||||
assert_eq!(result["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_sse_to_remote() {
|
||||
let spec = json!({
|
||||
"type": "sse",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer xxx" }
|
||||
});
|
||||
|
||||
let result = convert_to_opencode_format(&spec).unwrap();
|
||||
assert_eq!(result["type"], "remote");
|
||||
assert_eq!(result["url"], "https://example.com/mcp");
|
||||
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
|
||||
assert_eq!(result["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_local_to_stdio() {
|
||||
let spec = json!({
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "@modelcontextprotocol/server-filesystem"],
|
||||
"environment": { "HOME": "/Users/test" }
|
||||
});
|
||||
|
||||
let result = convert_from_opencode_format(&spec).unwrap();
|
||||
assert_eq!(result["type"], "stdio");
|
||||
assert_eq!(result["command"], "npx");
|
||||
assert_eq!(result["args"][0], "-y");
|
||||
assert_eq!(result["args"][1], "@modelcontextprotocol/server-filesystem");
|
||||
assert_eq!(result["env"]["HOME"], "/Users/test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_remote_to_sse() {
|
||||
let spec = json!({
|
||||
"type": "remote",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer xxx" }
|
||||
});
|
||||
|
||||
let result = convert_from_opencode_format(&spec).unwrap();
|
||||
assert_eq!(result["type"], "sse");
|
||||
assert_eq!(result["url"], "https://example.com/mcp");
|
||||
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! OpenCode 配置文件读写模块
|
||||
//!
|
||||
//! 处理 `~/.config/opencode/opencode.json` 配置文件的读写操作。
|
||||
//! OpenCode 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
|
||||
//!
|
||||
//! ## 配置文件格式
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "$schema": "https://opencode.ai/config.json",
|
||||
//! "provider": {
|
||||
//! "my-provider": {
|
||||
//! "npm": "@ai-sdk/openai-compatible",
|
||||
//! "options": { "baseURL": "...", "apiKey": "{env:API_KEY}" },
|
||||
//! "models": { "gpt-4o": { "name": "GPT-4o" } }
|
||||
//! }
|
||||
//! },
|
||||
//! "mcp": {
|
||||
//! "my-server": { "type": "local", "command": ["..."] }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use crate::config::write_json_file;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::OpenCodeProviderConfig;
|
||||
use crate::settings::get_opencode_override_dir;
|
||||
use indexmap::IndexMap;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
// ============================================================================
|
||||
// Path Functions
|
||||
// ============================================================================
|
||||
|
||||
/// 获取 OpenCode 配置目录
|
||||
///
|
||||
/// 默认路径: `~/.config/opencode/`
|
||||
/// 可通过 settings.opencode_config_dir 覆盖
|
||||
pub fn get_opencode_dir() -> PathBuf {
|
||||
if let Some(override_dir) = get_opencode_override_dir() {
|
||||
return override_dir;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows: %APPDATA%\opencode
|
||||
dirs::data_dir()
|
||||
.map(|d| d.join("opencode"))
|
||||
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Unix: ~/.config/opencode
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".config").join("opencode"))
|
||||
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 OpenCode 配置文件路径
|
||||
///
|
||||
/// 返回 `~/.config/opencode/opencode.json`
|
||||
pub fn get_opencode_config_path() -> PathBuf {
|
||||
get_opencode_dir().join("opencode.json")
|
||||
}
|
||||
|
||||
/// 获取 OpenCode 环境变量文件路径(如果存在)
|
||||
///
|
||||
/// 返回 `~/.config/opencode/.env`
|
||||
#[allow(dead_code)]
|
||||
pub fn get_opencode_env_path() -> PathBuf {
|
||||
get_opencode_dir().join(".env")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Core Read/Write Functions
|
||||
// ============================================================================
|
||||
|
||||
/// 读取 OpenCode 配置文件
|
||||
///
|
||||
/// 返回完整的配置 JSON 对象
|
||||
pub fn read_opencode_config() -> Result<Value, AppError> {
|
||||
let path = get_opencode_config_path();
|
||||
|
||||
if !path.exists() {
|
||||
// Return empty config with schema
|
||||
return Ok(json!({
|
||||
"$schema": "https://opencode.ai/config.json"
|
||||
}));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
|
||||
}
|
||||
|
||||
/// 写入 OpenCode 配置文件(原子写入)
|
||||
///
|
||||
/// 使用临时文件 + 重命名确保原子性
|
||||
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
||||
let path = get_opencode_config_path();
|
||||
// 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况)
|
||||
write_json_file(&path, config)?;
|
||||
|
||||
log::debug!("OpenCode config written to {:?}", path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider Functions (Untyped - for raw JSON operations)
|
||||
// ============================================================================
|
||||
|
||||
/// 获取所有供应商配置(原始 JSON)
|
||||
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
||||
let config = read_opencode_config()?;
|
||||
Ok(config
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// 设置供应商配置(原始 JSON)
|
||||
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
||||
let mut full_config = read_opencode_config()?;
|
||||
|
||||
if full_config.get("provider").is_none() {
|
||||
full_config["provider"] = json!({});
|
||||
}
|
||||
|
||||
if let Some(providers) = full_config
|
||||
.get_mut("provider")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
providers.insert(id.to_string(), config);
|
||||
}
|
||||
|
||||
write_opencode_config(&full_config)
|
||||
}
|
||||
|
||||
/// 删除供应商配置
|
||||
pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
|
||||
if let Some(providers) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
|
||||
providers.remove(id);
|
||||
}
|
||||
|
||||
write_opencode_config(&config)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider Functions (Typed - using OpenCodeProviderConfig)
|
||||
// ============================================================================
|
||||
|
||||
/// 获取所有供应商配置(类型化)
|
||||
pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>, AppError> {
|
||||
let providers = get_providers()?;
|
||||
let mut result = IndexMap::new();
|
||||
|
||||
for (id, value) in providers {
|
||||
match serde_json::from_value::<OpenCodeProviderConfig>(value.clone()) {
|
||||
Ok(config) => {
|
||||
result.insert(id, config);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to parse provider '{}': {}", id, e);
|
||||
// Skip invalid providers but continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 设置供应商配置(类型化)
|
||||
pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> {
|
||||
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
set_provider(id, value)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP Functions
|
||||
// ============================================================================
|
||||
|
||||
/// 获取所有 MCP 服务器配置
|
||||
pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
|
||||
let config = read_opencode_config()?;
|
||||
Ok(config
|
||||
.get("mcp")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// 设置 MCP 服务器配置
|
||||
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
||||
let mut full_config = read_opencode_config()?;
|
||||
|
||||
if full_config.get("mcp").is_none() {
|
||||
full_config["mcp"] = json!({});
|
||||
}
|
||||
|
||||
if let Some(mcp) = full_config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
||||
mcp.insert(id.to_string(), config);
|
||||
}
|
||||
|
||||
write_opencode_config(&full_config)
|
||||
}
|
||||
|
||||
/// 删除 MCP 服务器配置
|
||||
pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
|
||||
if let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
||||
mcp.remove(id);
|
||||
}
|
||||
|
||||
write_opencode_config(&config)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
use crate::opencode_config::get_opencode_dir;
|
||||
|
||||
/// 返回指定应用所使用的提示词文件路径。
|
||||
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
@@ -12,12 +13,14 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
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(),
|
||||
AppType::OpenCode => get_opencode_dir(),
|
||||
};
|
||||
|
||||
let filename = match app {
|
||||
AppType::Claude => "CLAUDE.md",
|
||||
AppType::Codex => "AGENTS.md",
|
||||
AppType::Gemini => "GEMINI.md",
|
||||
AppType::OpenCode => "AGENTS.md",
|
||||
};
|
||||
|
||||
Ok(base_dir.join(filename))
|
||||
|
||||
@@ -98,6 +98,10 @@ pub struct UsageScript {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: Option<String>,
|
||||
/// 模板类型(用于后端判断验证规则)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "templateType")]
|
||||
pub template_type: Option<String>,
|
||||
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "autoQueryInterval")]
|
||||
@@ -147,6 +151,9 @@ pub struct ProviderMeta {
|
||||
/// 用量查询脚本配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_script: Option<UsageScript>,
|
||||
/// 请求地址管理:测速后自动选择最佳端点
|
||||
#[serde(rename = "endpointAutoSelect", skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint_auto_select: Option<bool>,
|
||||
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
|
||||
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
|
||||
pub is_partner: Option<bool>,
|
||||
@@ -455,3 +462,95 @@ requires_openai_auth = true"#
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OpenCode 供应商配置结构
|
||||
// ============================================================================
|
||||
|
||||
/// OpenCode 供应商的 settings_config 结构
|
||||
///
|
||||
/// OpenCode 使用 AI SDK 包名来指定供应商类型,与其他应用的配置格式不同。
|
||||
/// 配置示例:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "npm": "@ai-sdk/openai-compatible",
|
||||
/// "options": { "baseURL": "https://api.example.com/v1", "apiKey": "sk-xxx" },
|
||||
/// "models": { "gpt-4o": { "name": "GPT-4o" } }
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeProviderConfig {
|
||||
/// AI SDK 包名,如 "@ai-sdk/openai-compatible", "@ai-sdk/anthropic"
|
||||
pub npm: String,
|
||||
|
||||
/// 供应商名称(可选,用于显示)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// 供应商选项(API 密钥、基础 URL 等)
|
||||
#[serde(default)]
|
||||
pub options: OpenCodeProviderOptions,
|
||||
|
||||
/// 模型定义映射
|
||||
#[serde(default)]
|
||||
pub models: HashMap<String, OpenCodeModel>,
|
||||
}
|
||||
|
||||
impl Default for OpenCodeProviderConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
npm: "@ai-sdk/openai-compatible".to_string(),
|
||||
name: None,
|
||||
options: OpenCodeProviderOptions::default(),
|
||||
models: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenCode 供应商选项
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct OpenCodeProviderOptions {
|
||||
/// API 基础 URL
|
||||
#[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
|
||||
/// API 密钥(支持环境变量引用,如 "{env:API_KEY}")
|
||||
#[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
|
||||
/// 自定义请求头
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
|
||||
/// 额外选项(timeout, setCacheKey 等)
|
||||
/// 使用 flatten 捕获所有未明确定义的字段
|
||||
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// OpenCode 模型定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeModel {
|
||||
/// 模型显示名称
|
||||
pub name: String,
|
||||
|
||||
/// 模型限制(上下文和输出 token 数)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<OpenCodeModelLimit>,
|
||||
|
||||
/// 模型额外选项(provider 路由等)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub options: Option<HashMap<String, Value>>,
|
||||
}
|
||||
|
||||
/// OpenCode 模型限制
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct OpenCodeModelLimit {
|
||||
/// 上下文 token 限制
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<u64>,
|
||||
|
||||
/// 输出 token 限制
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -319,7 +319,11 @@ impl CircuitBreaker {
|
||||
}
|
||||
}
|
||||
|
||||
fn release_half_open_permit(&self) {
|
||||
/// 仅释放 HalfOpen permit,不影响健康统计
|
||||
///
|
||||
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
|
||||
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
|
||||
pub fn release_half_open_permit(&self) {
|
||||
let mut current = self.half_open_requests.load(Ordering::SeqCst);
|
||||
loop {
|
||||
if current == 0 {
|
||||
|
||||
@@ -7,15 +7,15 @@ use super::{
|
||||
error::*,
|
||||
failover_switch::FailoverSwitchManager,
|
||||
provider_router::ProviderRouter,
|
||||
providers::{get_adapter, ProviderAdapter},
|
||||
types::ProxyStatus,
|
||||
providers::{get_adapter, ProviderAdapter, ProviderType},
|
||||
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
|
||||
types::{ProxyStatus, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use crate::{app_config::AppType, provider::Provider};
|
||||
use reqwest::{Client, Response};
|
||||
use reqwest::Response;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Headers 黑名单 - 不透传到上游的 Headers
|
||||
@@ -81,8 +81,6 @@ pub struct ForwardError {
|
||||
}
|
||||
|
||||
pub struct RequestForwarder {
|
||||
client: Option<Client>,
|
||||
client_init_error: Option<String>,
|
||||
/// 共享的 ProviderRouter(持有熔断器状态)
|
||||
router: Arc<ProviderRouter>,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
@@ -93,6 +91,10 @@ pub struct RequestForwarder {
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
||||
current_provider_id_at_start: String,
|
||||
/// 整流器配置
|
||||
rectifier_config: RectifierConfig,
|
||||
/// 非流式请求超时(秒)
|
||||
non_streaming_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl RequestForwarder {
|
||||
@@ -107,52 +109,17 @@ impl RequestForwarder {
|
||||
current_provider_id_at_start: String,
|
||||
_streaming_first_byte_timeout: u64,
|
||||
_streaming_idle_timeout: u64,
|
||||
rectifier_config: RectifierConfig,
|
||||
) -> Self {
|
||||
// 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作
|
||||
// 参考 Claude Code Hub 的 undici 全局超时设计
|
||||
const GLOBAL_TIMEOUT_SECS: u64 = 1800;
|
||||
|
||||
let timeout_secs = if non_streaming_timeout > 0 {
|
||||
non_streaming_timeout
|
||||
} else {
|
||||
GLOBAL_TIMEOUT_SECS
|
||||
};
|
||||
|
||||
// 注意:这里不能用 expect/unwrap。
|
||||
// release 配置为 panic=abort,一旦 build 失败会导致整个应用闪退。
|
||||
// 常见原因:用户环境变量里存在不合法/不支持的代理(HTTP(S)_PROXY/ALL_PROXY 等)。
|
||||
let (client, client_init_error) = match Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
{
|
||||
Ok(client) => (Some(client), None),
|
||||
Err(e) => {
|
||||
// 降级:忽略系统/环境代理,避免因代理配置问题导致整个应用崩溃
|
||||
match Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.no_proxy()
|
||||
.build()
|
||||
{
|
||||
Ok(client) => (Some(client), Some(e.to_string())),
|
||||
Err(fallback_err) => (
|
||||
None,
|
||||
Some(format!(
|
||||
"Failed to create HTTP client: {e}; no_proxy fallback failed: {fallback_err}"
|
||||
)),
|
||||
),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
client,
|
||||
client_init_error,
|
||||
router,
|
||||
status,
|
||||
current_providers,
|
||||
failover_manager,
|
||||
app_handle,
|
||||
current_provider_id_at_start,
|
||||
rectifier_config,
|
||||
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +135,7 @@ impl RequestForwarder {
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
endpoint: &str,
|
||||
body: Value,
|
||||
mut body: Value,
|
||||
headers: axum::http::HeaderMap,
|
||||
providers: Vec<Provider>,
|
||||
) -> Result<ForwardResult, ForwardError> {
|
||||
@@ -187,6 +154,9 @@ impl RequestForwarder {
|
||||
let mut last_provider = None;
|
||||
let mut attempted_providers = 0usize;
|
||||
|
||||
// 整流器重试标记:确保整流最多触发一次
|
||||
let mut rectifier_retried = false;
|
||||
|
||||
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
||||
let bypass_circuit_breaker = providers.len() == 1;
|
||||
|
||||
@@ -281,6 +251,205 @@ impl RequestForwarder {
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
// 检测是否需要触发整流器(仅 Claude/ClaudeAuth 供应商)
|
||||
let provider_type = ProviderType::from_app_type_and_config(app_type, provider);
|
||||
let is_anthropic_provider = matches!(
|
||||
provider_type,
|
||||
ProviderType::Claude | ProviderType::ClaudeAuth
|
||||
);
|
||||
|
||||
if is_anthropic_provider {
|
||||
let error_message = extract_error_message(&e);
|
||||
if should_rectify_thinking_signature(
|
||||
error_message.as_deref(),
|
||||
&self.rectifier_config,
|
||||
) {
|
||||
// 已经重试过:直接返回错误(不可重试客户端错误)
|
||||
if rectifier_retried {
|
||||
log::warn!("[{app_type_str}] [RECT-005] 整流器已触发过,不再重试");
|
||||
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(e.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: e,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
// 首次触发:整流请求体
|
||||
let rectified = rectify_anthropic_request(&mut body);
|
||||
|
||||
// 整流未生效:直接返回错误(不可重试客户端错误)
|
||||
if !rectified.applied {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
|
||||
);
|
||||
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(e.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: e,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
|
||||
app_type_str,
|
||||
rectified.removed_thinking_blocks,
|
||||
rectified.removed_redacted_thinking_blocks,
|
||||
rectified.removed_signature_fields
|
||||
);
|
||||
|
||||
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
|
||||
let _ = std::mem::replace(&mut rectifier_retried, true);
|
||||
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||
// 记录成功
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 更新当前应用类型使用的 provider
|
||||
{
|
||||
let mut current_providers =
|
||||
self.current_providers.write().await;
|
||||
current_providers.insert(
|
||||
app_type_str.to_string(),
|
||||
(provider.id.clone(), provider.name.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
// 更新成功统计
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.success_requests += 1;
|
||||
status.last_error = None;
|
||||
let should_switch =
|
||||
self.current_provider_id_at_start.as_str()
|
||||
!= provider.id.as_str();
|
||||
if should_switch {
|
||||
status.failover_count += 1;
|
||||
|
||||
// 异步触发供应商切换,更新 UI/托盘
|
||||
let fm = self.failover_manager.clone();
|
||||
let ah = self.app_handle.clone();
|
||||
let pid = provider.id.clone();
|
||||
let pname = provider.name.clone();
|
||||
let at = app_type_str.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = fm
|
||||
.try_switch(ah.as_ref(), &at, &pid, &pname)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
});
|
||||
}
|
||||
Err(retry_err) => {
|
||||
// 整流重试仍失败:区分错误类型决定是否记录熔断器
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
|
||||
);
|
||||
|
||||
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
|
||||
let is_provider_error = match &retry_err {
|
||||
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
|
||||
true
|
||||
}
|
||||
ProxyError::UpstreamError { status, .. } => *status >= 500,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_provider_error {
|
||||
// Provider 问题:记录失败到熔断器
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
false,
|
||||
Some(retry_err.to_string()),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
// 客户端问题:仅释放 permit,不记录熔断器
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(retry_err.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: retry_err,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 失败:记录失败并更新熔断器
|
||||
let _ = self
|
||||
.router
|
||||
@@ -416,16 +585,17 @@ impl RequestForwarder {
|
||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
|
||||
|
||||
// 构建请求
|
||||
let client = self.client.as_ref().ok_or_else(|| {
|
||||
ProxyError::ForwardFailed(
|
||||
self.client_init_error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "HTTP client is not initialized".to_string()),
|
||||
)
|
||||
})?;
|
||||
// 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置)
|
||||
let client = super::http_client::get();
|
||||
let mut request = client.post(&url);
|
||||
|
||||
// 只有当 timeout > 0 时才设置请求超时
|
||||
// Duration::ZERO 在 reqwest 中表示"立刻超时"而不是"禁用超时"
|
||||
// 故障转移关闭时会传入 0,此时应该使用 client 的默认超时(600秒)
|
||||
if !self.non_streaming_timeout.is_zero() {
|
||||
request = request.timeout(self.non_streaming_timeout);
|
||||
}
|
||||
|
||||
// 过滤黑名单 Headers,保护隐私并避免冲突
|
||||
for (key, value) in headers {
|
||||
if HEADER_BLACKLIST
|
||||
@@ -541,3 +711,11 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 ProxyError 中提取错误消息
|
||||
fn extract_error_message(error: &ProxyError) -> Option<String> {
|
||||
match error {
|
||||
ProxyError::UpstreamError { body, .. } => body.clone(),
|
||||
_ => Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
use crate::app_config::AppType;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::{
|
||||
extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig,
|
||||
extract_session_id,
|
||||
forwarder::RequestForwarder,
|
||||
server::ProxyState,
|
||||
types::{AppProxyConfig, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use axum::http::HeaderMap;
|
||||
@@ -54,6 +57,8 @@ pub struct RequestContext {
|
||||
pub app_type: AppType,
|
||||
/// Session ID(从客户端请求提取或新生成)
|
||||
pub session_id: String,
|
||||
/// 整流器配置
|
||||
pub rectifier_config: RectifierConfig,
|
||||
}
|
||||
|
||||
impl RequestContext {
|
||||
@@ -86,6 +91,9 @@ impl RequestContext {
|
||||
.await
|
||||
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||
|
||||
// 从数据库读取整流器配置
|
||||
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
|
||||
|
||||
let current_provider_id =
|
||||
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
||||
|
||||
@@ -147,6 +155,7 @@ impl RequestContext {
|
||||
app_type_str,
|
||||
app_type,
|
||||
session_id,
|
||||
rectifier_config,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -206,6 +215,7 @@ impl RequestContext {
|
||||
self.current_provider_id.clone(),
|
||||
first_byte_timeout,
|
||||
idle_timeout,
|
||||
self.rectifier_config.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
//! 全局 HTTP 客户端模块
|
||||
//!
|
||||
//! 提供支持全局代理配置的 HTTP 客户端。
|
||||
//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use reqwest::Client;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 全局 HTTP 客户端实例
|
||||
static GLOBAL_CLIENT: OnceCell<RwLock<Client>> = OnceCell::new();
|
||||
|
||||
/// 当前代理 URL(用于日志和状态查询)
|
||||
static CURRENT_PROXY_URL: OnceCell<RwLock<Option<String>>> = OnceCell::new();
|
||||
|
||||
/// 初始化全局 HTTP 客户端
|
||||
///
|
||||
/// 应在应用启动时调用一次。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_url` - 代理 URL,如 `http://127.0.0.1:7890` 或 `socks5://127.0.0.1:1080`
|
||||
/// 传入 None 或空字符串表示直连
|
||||
pub fn init(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||
let client = build_client(effective_url)?;
|
||||
|
||||
// 尝试初始化全局客户端,如果已存在则记录警告并使用 apply_proxy 更新
|
||||
if GLOBAL_CLIENT.set(RwLock::new(client.clone())).is_err() {
|
||||
log::warn!(
|
||||
"[GlobalProxy] [GP-003] Already initialized, updating instead: {}",
|
||||
effective_url
|
||||
.map(mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
// 已初始化,改用 apply_proxy 更新
|
||||
return apply_proxy(proxy_url);
|
||||
}
|
||||
|
||||
// 初始化代理 URL 记录
|
||||
let _ = CURRENT_PROXY_URL.set(RwLock::new(effective_url.map(|s| s.to_string())));
|
||||
|
||||
log::info!(
|
||||
"[GlobalProxy] Initialized: {}",
|
||||
effective_url
|
||||
.map(mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证代理配置(不应用)
|
||||
///
|
||||
/// 只验证代理 URL 是否有效,不实际更新全局客户端。
|
||||
/// 用于在持久化之前验证配置的有效性。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_url` - 代理 URL,None 或空字符串表示直连
|
||||
///
|
||||
/// # Returns
|
||||
/// 验证成功返回 Ok(()),失败返回错误信息
|
||||
pub fn validate_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||
// 只调用 build_client 来验证,但不应用
|
||||
build_client(effective_url)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 应用代理配置(假设已验证)
|
||||
///
|
||||
/// 直接应用代理配置到全局客户端,不做额外验证。
|
||||
/// 应在 validate_proxy 成功后调用。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_url` - 代理 URL,None 或空字符串表示直连
|
||||
pub fn apply_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||
let new_client = build_client(effective_url)?;
|
||||
|
||||
// 更新客户端
|
||||
if let Some(lock) = GLOBAL_CLIENT.get() {
|
||||
let mut client = lock.write().map_err(|e| {
|
||||
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
|
||||
"Failed to update proxy: lock poisoned".to_string()
|
||||
})?;
|
||||
*client = new_client;
|
||||
} else {
|
||||
// 如果还没初始化,则初始化
|
||||
return init(proxy_url);
|
||||
}
|
||||
|
||||
// 更新代理 URL 记录
|
||||
if let Some(lock) = CURRENT_PROXY_URL.get() {
|
||||
let mut url = lock.write().map_err(|e| {
|
||||
log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}");
|
||||
"Failed to update proxy URL record: lock poisoned".to_string()
|
||||
})?;
|
||||
*url = effective_url.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[GlobalProxy] Applied: {}",
|
||||
effective_url
|
||||
.map(mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新代理配置(热更新)
|
||||
///
|
||||
/// 可在运行时调用以更改代理设置,无需重启应用。
|
||||
/// 注意:此函数同时验证和应用,如果需要先验证后持久化再应用,
|
||||
/// 请使用 validate_proxy + apply_proxy 组合。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_url` - 新的代理 URL,None 或空字符串表示直连
|
||||
#[allow(dead_code)]
|
||||
pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||
let new_client = build_client(effective_url)?;
|
||||
|
||||
// 更新客户端
|
||||
if let Some(lock) = GLOBAL_CLIENT.get() {
|
||||
let mut client = lock.write().map_err(|e| {
|
||||
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
|
||||
"Failed to update proxy: lock poisoned".to_string()
|
||||
})?;
|
||||
*client = new_client;
|
||||
} else {
|
||||
// 如果还没初始化,则初始化
|
||||
return init(proxy_url);
|
||||
}
|
||||
|
||||
// 更新代理 URL 记录
|
||||
if let Some(lock) = CURRENT_PROXY_URL.get() {
|
||||
let mut url = lock.write().map_err(|e| {
|
||||
log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}");
|
||||
"Failed to update proxy URL record: lock poisoned".to_string()
|
||||
})?;
|
||||
*url = effective_url.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[GlobalProxy] Updated: {}",
|
||||
effective_url
|
||||
.map(mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取全局 HTTP 客户端
|
||||
///
|
||||
/// 返回配置了代理的客户端(如果已配置代理),否则返回直连客户端。
|
||||
pub fn get() -> Client {
|
||||
GLOBAL_CLIENT
|
||||
.get()
|
||||
.and_then(|lock| lock.read().ok())
|
||||
.map(|c| c.clone())
|
||||
.unwrap_or_else(|| {
|
||||
// 如果还没初始化,创建一个默认客户端(配置与 build_client 一致)
|
||||
log::warn!("[GlobalProxy] [GP-004] Client not initialized, using fallback");
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(600))
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.pool_max_idle_per_host(10)
|
||||
.tcp_keepalive(Duration::from_secs(60))
|
||||
.no_proxy()
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取当前代理 URL
|
||||
///
|
||||
/// 返回当前配置的代理 URL,None 表示直连。
|
||||
pub fn get_current_proxy_url() -> Option<String> {
|
||||
CURRENT_PROXY_URL
|
||||
.get()
|
||||
.and_then(|lock| lock.read().ok())
|
||||
.and_then(|url| url.clone())
|
||||
}
|
||||
|
||||
/// 检查是否正在使用代理
|
||||
#[allow(dead_code)]
|
||||
pub fn is_proxy_enabled() -> bool {
|
||||
get_current_proxy_url().is_some()
|
||||
}
|
||||
|
||||
/// 构建 HTTP 客户端
|
||||
fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
||||
let mut builder = Client::builder()
|
||||
.timeout(Duration::from_secs(600))
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.pool_max_idle_per_host(10)
|
||||
.tcp_keepalive(Duration::from_secs(60));
|
||||
|
||||
// 有代理地址则使用代理,否则直连
|
||||
if let Some(url) = proxy_url {
|
||||
// 先验证 URL 格式和 scheme
|
||||
let parsed = url::Url::parse(url)
|
||||
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
|
||||
|
||||
let scheme = parsed.scheme();
|
||||
if !["http", "https", "socks5", "socks5h"].contains(&scheme) {
|
||||
return Err(format!(
|
||||
"Invalid proxy scheme '{}' in URL '{}'. Supported: http, https, socks5, socks5h",
|
||||
scheme,
|
||||
mask_url(url)
|
||||
));
|
||||
}
|
||||
|
||||
let proxy = reqwest::Proxy::all(url)
|
||||
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
|
||||
builder = builder.proxy(proxy);
|
||||
log::debug!("[GlobalProxy] Proxy configured: {}", mask_url(url));
|
||||
} else {
|
||||
builder = builder.no_proxy();
|
||||
log::debug!("[GlobalProxy] Direct connection (no proxy)");
|
||||
}
|
||||
|
||||
builder
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))
|
||||
}
|
||||
|
||||
/// 隐藏 URL 中的敏感信息(用于日志)
|
||||
pub fn mask_url(url: &str) -> String {
|
||||
if let Ok(parsed) = url::Url::parse(url) {
|
||||
// 隐藏用户名和密码,保留 scheme、host 和端口
|
||||
let host = parsed.host_str().unwrap_or("?");
|
||||
match parsed.port() {
|
||||
Some(port) => format!("{}://{}:{}", parsed.scheme(), host, port),
|
||||
None => format!("{}://{}", parsed.scheme(), host),
|
||||
}
|
||||
} else {
|
||||
// URL 解析失败,返回部分内容
|
||||
if url.len() > 20 {
|
||||
format!("{}...", &url[..20])
|
||||
} else {
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mask_url() {
|
||||
assert_eq!(mask_url("http://127.0.0.1:7890"), "http://127.0.0.1:7890");
|
||||
assert_eq!(
|
||||
mask_url("http://user:pass@127.0.0.1:7890"),
|
||||
"http://127.0.0.1:7890"
|
||||
);
|
||||
assert_eq!(
|
||||
mask_url("socks5://admin:secret@proxy.example.com:1080"),
|
||||
"socks5://proxy.example.com:1080"
|
||||
);
|
||||
// 无端口的 URL 不应显示 ":?"
|
||||
assert_eq!(
|
||||
mask_url("http://proxy.example.com"),
|
||||
"http://proxy.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
mask_url("https://user:pass@proxy.example.com"),
|
||||
"https://proxy.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_client_direct() {
|
||||
let result = build_client(None);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_client_with_http_proxy() {
|
||||
let result = build_client(Some("http://127.0.0.1:7890"));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_client_with_socks5_proxy() {
|
||||
let result = build_client(Some("socks5://127.0.0.1:1080"));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_client_invalid_url() {
|
||||
// reqwest::Proxy::all 对某些无效 URL 不会立即报错
|
||||
// 使用明确无效的 scheme 来触发错误
|
||||
let result = build_client(Some("invalid-scheme://127.0.0.1:7890"));
|
||||
assert!(result.is_err(), "Should reject invalid proxy scheme");
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod handler_config;
|
||||
pub mod handler_context;
|
||||
mod handlers;
|
||||
mod health;
|
||||
pub mod http_client;
|
||||
pub mod log_codes;
|
||||
pub mod model_mapper;
|
||||
pub mod provider_router;
|
||||
@@ -20,6 +21,7 @@ pub mod response_handler;
|
||||
pub mod response_processor;
|
||||
pub(crate) mod server;
|
||||
pub mod session;
|
||||
pub mod thinking_rectifier;
|
||||
pub(crate) mod types;
|
||||
pub mod usage;
|
||||
|
||||
|
||||
@@ -151,6 +151,24 @@ impl ProviderRouter {
|
||||
self.reset_circuit_breaker(&circuit_key).await;
|
||||
}
|
||||
|
||||
/// 仅释放 HalfOpen permit,不影响健康统计(neutral 接口)
|
||||
///
|
||||
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
|
||||
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
|
||||
pub async fn release_permit_neutral(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
used_half_open_permit: bool,
|
||||
) {
|
||||
if !used_half_open_permit {
|
||||
return;
|
||||
}
|
||||
let circuit_key = format!("{app_type}:{provider_id}");
|
||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||
breaker.release_half_open_permit();
|
||||
}
|
||||
|
||||
/// 更新所有熔断器的配置(热更新)
|
||||
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
|
||||
let breakers = self.circuit_breakers.read().await;
|
||||
@@ -325,4 +343,55 @@ mod tests {
|
||||
|
||||
assert!(router.allow_provider_request("b", "claude").await.allowed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_release_permit_neutral_frees_half_open_slot() {
|
||||
let db = Arc::new(Database::memory().unwrap());
|
||||
|
||||
// 配置熔断器:1 次失败即熔断,0 秒超时立即进入 HalfOpen
|
||||
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
||||
failure_threshold: 1,
|
||||
timeout_seconds: 0,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let provider_a =
|
||||
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.add_to_failover_queue("claude", "a").unwrap();
|
||||
|
||||
// 启用自动故障转移
|
||||
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
|
||||
config.auto_failover_enabled = true;
|
||||
db.update_proxy_config_for_app(config).await.unwrap();
|
||||
|
||||
let router = ProviderRouter::new(db.clone());
|
||||
|
||||
// 触发熔断:1 次失败
|
||||
router
|
||||
.record_result("a", "claude", false, false, Some("fail".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 第一次请求:获取 HalfOpen 探测名额
|
||||
let first = router.allow_provider_request("a", "claude").await;
|
||||
assert!(first.allowed);
|
||||
assert!(first.used_half_open_permit);
|
||||
|
||||
// 第二次请求应被拒绝(名额已被占用)
|
||||
let second = router.allow_provider_request("a", "claude").await;
|
||||
assert!(!second.allowed);
|
||||
|
||||
// 使用 release_permit_neutral 释放名额(不影响健康统计)
|
||||
router
|
||||
.release_permit_neutral("a", "claude", first.used_half_open_permit)
|
||||
.await;
|
||||
|
||||
// 第三次请求应被允许(名额已释放)
|
||||
let third = router.allow_provider_request("a", "claude").await;
|
||||
assert!(third.allowed);
|
||||
assert!(third.used_half_open_permit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,10 @@ impl ProviderType {
|
||||
}
|
||||
ProviderType::Gemini
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy, but return a default type for completeness
|
||||
ProviderType::Codex // Fallback to Codex-like type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +180,10 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy, fallback to Codex adapter
|
||||
Box::new(CodexAdapter::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
//! Thinking Signature 整流器
|
||||
//!
|
||||
//! 用于自动修复 Anthropic API 中因签名校验失败导致的请求错误。
|
||||
//! 当上游 API 返回签名相关错误时,系统会自动移除有问题的签名字段并重试请求。
|
||||
|
||||
use super::types::RectifierConfig;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 整流结果
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RectifyResult {
|
||||
/// 是否应用了整流
|
||||
pub applied: bool,
|
||||
/// 移除的 thinking block 数量
|
||||
pub removed_thinking_blocks: usize,
|
||||
/// 移除的 redacted_thinking block 数量
|
||||
pub removed_redacted_thinking_blocks: usize,
|
||||
/// 移除的 signature 字段数量
|
||||
pub removed_signature_fields: usize,
|
||||
}
|
||||
|
||||
/// 检测是否需要触发 thinking 签名整流器
|
||||
///
|
||||
/// 返回 `true` 表示需要触发整流器,`false` 表示不需要。
|
||||
/// 会检查配置开关。
|
||||
pub fn should_rectify_thinking_signature(
|
||||
error_message: Option<&str>,
|
||||
config: &RectifierConfig,
|
||||
) -> bool {
|
||||
// 检查总开关
|
||||
if !config.enabled {
|
||||
return false;
|
||||
}
|
||||
// 检查子开关
|
||||
if !config.request_thinking_signature {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检测错误类型
|
||||
let Some(msg) = error_message else {
|
||||
return false;
|
||||
};
|
||||
let lower = msg.to_lowercase();
|
||||
|
||||
// 场景1: thinking block 中的签名无效
|
||||
// 错误示例: "Invalid 'signature' in 'thinking' block"
|
||||
if lower.contains("invalid")
|
||||
&& lower.contains("signature")
|
||||
&& lower.contains("thinking")
|
||||
&& lower.contains("block")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景2: assistant 消息必须以 thinking block 开头
|
||||
// 错误示例: "must start with a thinking block"
|
||||
if lower.contains("must start with a thinking block") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景3: expected thinking or redacted_thinking, found tool_use
|
||||
// 错误示例: "Expected `thinking` or `redacted_thinking`, but found `tool_use`"
|
||||
if lower.contains("expected")
|
||||
&& (lower.contains("thinking") || lower.contains("redacted_thinking"))
|
||||
&& lower.contains("found")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景4: signature 字段必需但缺失
|
||||
// 错误示例: "signature: Field required"
|
||||
if lower.contains("signature") && lower.contains("field required") {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 对 Anthropic 请求体做最小侵入整流
|
||||
///
|
||||
/// - 移除 messages[*].content 中的 thinking/redacted_thinking block
|
||||
/// - 移除非 thinking block 上遗留的 signature 字段
|
||||
/// - 特定条件下删除顶层 thinking 字段
|
||||
///
|
||||
/// 注意:该函数会原地修改 body 对象
|
||||
pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
|
||||
let mut result = RectifyResult::default();
|
||||
|
||||
let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) {
|
||||
Some(m) => m,
|
||||
None => return result,
|
||||
};
|
||||
|
||||
// 遍历所有消息
|
||||
for msg in messages.iter_mut() {
|
||||
let content = match msg.get_mut("content").and_then(|c| c.as_array_mut()) {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let mut new_content = Vec::with_capacity(content.len());
|
||||
let mut content_modified = false;
|
||||
|
||||
for block in content.iter() {
|
||||
let block_type = block.get("type").and_then(|t| t.as_str());
|
||||
|
||||
match block_type {
|
||||
Some("thinking") => {
|
||||
result.removed_thinking_blocks += 1;
|
||||
content_modified = true;
|
||||
continue;
|
||||
}
|
||||
Some("redacted_thinking") => {
|
||||
result.removed_redacted_thinking_blocks += 1;
|
||||
content_modified = true;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 移除非 thinking block 上的 signature 字段
|
||||
if block.get("signature").is_some() {
|
||||
let mut block_clone = block.clone();
|
||||
if let Some(obj) = block_clone.as_object_mut() {
|
||||
obj.remove("signature");
|
||||
result.removed_signature_fields += 1;
|
||||
content_modified = true;
|
||||
new_content.push(Value::Object(obj.clone()));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
new_content.push(block.clone());
|
||||
}
|
||||
|
||||
if content_modified {
|
||||
result.applied = true;
|
||||
*content = new_content;
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底处理:thinking 启用 + 工具调用链路中最后一条 assistant 消息未以 thinking 开头
|
||||
let messages_snapshot: Vec<Value> = body
|
||||
.get("messages")
|
||||
.and_then(|m| m.as_array())
|
||||
.map(|a| a.to_vec())
|
||||
.unwrap_or_default();
|
||||
|
||||
if should_remove_top_level_thinking(body, &messages_snapshot) {
|
||||
if let Some(obj) = body.as_object_mut() {
|
||||
obj.remove("thinking");
|
||||
result.applied = true;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 判断是否需要删除顶层 thinking 字段
|
||||
fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
|
||||
// 检查 thinking 是否启用
|
||||
let thinking_enabled = body
|
||||
.get("thinking")
|
||||
.and_then(|t| t.get("type"))
|
||||
.and_then(|t| t.as_str())
|
||||
== Some("enabled");
|
||||
|
||||
if !thinking_enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 找到最后一条 assistant 消息
|
||||
let last_assistant = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"));
|
||||
|
||||
let last_assistant_content = match last_assistant
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_array())
|
||||
{
|
||||
Some(c) if !c.is_empty() => c,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// 检查首块是否为 thinking/redacted_thinking
|
||||
let first_block_type = last_assistant_content
|
||||
.first()
|
||||
.and_then(|b| b.get("type"))
|
||||
.and_then(|t| t.as_str());
|
||||
|
||||
let missing_thinking_prefix =
|
||||
first_block_type != Some("thinking") && first_block_type != Some("redacted_thinking");
|
||||
|
||||
if !missing_thinking_prefix {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否存在 tool_use
|
||||
last_assistant_content
|
||||
.iter()
|
||||
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn enabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn master_disabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: false,
|
||||
request_thinking_signature: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== should_rectify_thinking_signature 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_signature() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("messages.1.content.0: Invalid `signature` in `thinking` block"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_signature_no_backticks() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("Messages.1.Content.0: invalid signature in thinking block"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_signature_nested_json() {
|
||||
// 测试嵌套 JSON 格式的错误消息(第三方渠道常见格式)
|
||||
let nested_error = r#"{"error":{"message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_xxx\"}"}}"#;
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some(nested_error),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_thinking_expected() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `tool_use`."),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_must_start_with_thinking() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("a final `assistant` message must start with a thinking block"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_trigger_for_unrelated_error() {
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("Request timeout"),
|
||||
&enabled_config()
|
||||
));
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("Connection refused"),
|
||||
&enabled_config()
|
||||
));
|
||||
assert!(!should_rectify_thinking_signature(None, &enabled_config()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_signature_field_required() {
|
||||
// 场景4: signature 字段缺失
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("***.***.***.***.***.signature: Field required"),
|
||||
&enabled_config()
|
||||
));
|
||||
// 嵌套 JSON 格式
|
||||
let nested_error = r#"{"error":{"type":"<nil>","message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.***.***.***.***.signature: Field required\"},\"request_id\":\"req_xxx\"}"}}"#;
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some(nested_error),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_config() {
|
||||
// 即使错误匹配,配置关闭时也不触发
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("Invalid `signature` in `thinking` block"),
|
||||
&disabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_master_disabled() {
|
||||
// 总开关关闭时,即使子开关开启也不触发
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("Invalid `signature` in `thinking` block"),
|
||||
&master_disabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== rectify_anthropic_request 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_rectify_removes_thinking_blocks() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "thinking", "thinking": "t", "signature": "sig" },
|
||||
{ "type": "text", "text": "hello", "signature": "sig_text" },
|
||||
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {}, "signature": "sig_tool" },
|
||||
{ "type": "redacted_thinking", "data": "r", "signature": "sig_redacted" }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.removed_thinking_blocks, 1);
|
||||
assert_eq!(result.removed_redacted_thinking_blocks, 1);
|
||||
assert_eq!(result.removed_signature_fields, 2);
|
||||
|
||||
let content = body["messages"][0]["content"].as_array().unwrap();
|
||||
assert_eq!(content.len(), 2);
|
||||
assert_eq!(content[0]["type"], "text");
|
||||
assert!(content[0].get("signature").is_none());
|
||||
assert_eq!(content[1]["type"], "tool_use");
|
||||
assert!(content[1].get("signature").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_removes_top_level_thinking() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 1024 },
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {} }
|
||||
]
|
||||
}, {
|
||||
"role": "user",
|
||||
"content": [{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "ok" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert!(body.get("thinking").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_no_change_when_no_issues() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{ "type": "text", "text": "hello" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(result.removed_thinking_blocks, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_no_messages() {
|
||||
let mut body = json!({ "model": "claude-test" });
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
assert!(!result.applied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_preserves_thinking_when_prefix_exists() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled" },
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "thinking", "thinking": "some thought" },
|
||||
{ "type": "tool_use", "id": "toolu_1", "name": "Test", "input": {} }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
// thinking block 被移除,但顶层 thinking 不应被移除(因为原本有 thinking 前缀)
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.removed_thinking_blocks, 1);
|
||||
// 注意:由于 thinking block 被移除后,首块变成了 tool_use,
|
||||
// 此时会触发删除顶层 thinking 的逻辑
|
||||
// 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking
|
||||
}
|
||||
}
|
||||
@@ -191,3 +191,67 @@ pub struct AppProxyConfig {
|
||||
/// 计算错误率的最小请求数
|
||||
pub circuit_min_requests: u32,
|
||||
}
|
||||
|
||||
/// 整流器配置
|
||||
///
|
||||
/// 存储在 settings 表中
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RectifierConfig {
|
||||
/// 总开关:是否启用整流器
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// 请求整流:启用 thinking 签名整流器
|
||||
///
|
||||
/// 处理错误:Invalid 'signature' in 'thinking' block
|
||||
#[serde(default = "default_true")]
|
||||
pub request_thinking_signature: bool,
|
||||
}
|
||||
|
||||
impl Default for RectifierConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_default_enabled() {
|
||||
// 验证 RectifierConfig::default() 返回全启用状态
|
||||
// 防止回归:#[derive(Default)] 会使 bool 默认为 false
|
||||
let config = RectifierConfig::default();
|
||||
assert!(config.enabled, "整流器总开关默认应为 true");
|
||||
assert!(
|
||||
config.request_thinking_signature,
|
||||
"thinking 签名整流器默认应为 true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_default() {
|
||||
// 验证反序列化缺字段时使用 default_true
|
||||
let json = "{}";
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(config.enabled);
|
||||
assert!(config.request_thinking_signature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_explicit_false() {
|
||||
// 验证显式设置 false 时正确反序列化
|
||||
let json = r#"{"enabled": false, "requestThinkingSignature": false}"#;
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(!config.enabled);
|
||||
assert!(!config.request_thinking_signature);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,10 @@ impl ConfigService {
|
||||
AppType::Codex => Self::sync_codex_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)?,
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode, no live sync needed
|
||||
// OpenCode providers are managed directly in the config file
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -37,6 +37,9 @@ impl McpService {
|
||||
if prev_apps.gemini && !server.apps.gemini {
|
||||
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
|
||||
}
|
||||
if prev_apps.opencode && !server.apps.opencode {
|
||||
Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?;
|
||||
}
|
||||
|
||||
// 同步到各个启用的应用
|
||||
Self::sync_server_to_apps(state, &server)?;
|
||||
@@ -113,6 +116,13 @@ impl McpService {
|
||||
AppType::Gemini => {
|
||||
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
mcp::sync_single_server_to_opencode(
|
||||
&Default::default(),
|
||||
&server.id,
|
||||
&server.server,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -135,6 +145,9 @@ impl McpService {
|
||||
AppType::Claude => mcp::remove_server_from_claude(id)?,
|
||||
AppType::Codex => mcp::remove_server_from_codex(id)?,
|
||||
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
|
||||
AppType::OpenCode => {
|
||||
mcp::remove_server_from_opencode(id)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -311,4 +324,42 @@ impl McpService {
|
||||
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// 从 OpenCode 导入 MCP(v3.9.2+ 新增)
|
||||
pub fn import_from_opencode(state: &AppState) -> Result<usize, AppError> {
|
||||
// 创建临时 MultiAppConfig 用于导入
|
||||
let mut temp_config = crate::app_config::MultiAppConfig::default();
|
||||
|
||||
// 调用原有的导入逻辑(从 mcp/opencode.rs)
|
||||
let count = crate::mcp::import_from_opencode(&mut temp_config)?;
|
||||
|
||||
let mut new_count = 0;
|
||||
|
||||
// 如果有导入的服务器,保存到数据库
|
||||
if count > 0 {
|
||||
if let Some(servers) = &temp_config.mcp.servers {
|
||||
let mut existing = state.db.get_all_mcp_servers()?;
|
||||
for server in servers.values() {
|
||||
// 已存在:仅启用 OpenCode,不覆盖其他字段(与导入模块语义保持一致)
|
||||
let to_save = if let Some(existing_server) = existing.get(&server.id) {
|
||||
let mut merged = existing_server.clone();
|
||||
merged.apps.opencode = true;
|
||||
merged
|
||||
} else {
|
||||
// 真正的新服务器
|
||||
new_count += 1;
|
||||
server.clone()
|
||||
};
|
||||
|
||||
state.db.save_mcp_server(&to_save)?;
|
||||
existing.insert(to_save.id.clone(), to_save.clone());
|
||||
|
||||
// 同步到对应应用 live 配置
|
||||
Self::sync_server_to_apps(state, &to_save)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(new_count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,64 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
// Delegate to write_gemini_live which handles env file writing correctly
|
||||
write_gemini_live(provider)?;
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode - write provider to config
|
||||
use crate::opencode_config;
|
||||
use crate::provider::OpenCodeProviderConfig;
|
||||
|
||||
// Defensive check: if settings_config is a full config structure, extract provider fragment
|
||||
let config_to_write = if let Some(obj) = provider.settings_config.as_object() {
|
||||
// Detect full config structure (has $schema or top-level provider field)
|
||||
if obj.contains_key("$schema") || obj.contains_key("provider") {
|
||||
log::warn!(
|
||||
"OpenCode provider '{}' has full config structure in settings_config, attempting to extract fragment",
|
||||
provider.id
|
||||
);
|
||||
// Try to extract from provider.{id}
|
||||
obj.get("provider")
|
||||
.and_then(|p| p.get(&provider.id))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| provider.settings_config.clone())
|
||||
} else {
|
||||
provider.settings_config.clone()
|
||||
}
|
||||
} else {
|
||||
provider.settings_config.clone()
|
||||
};
|
||||
|
||||
// Convert settings_config to OpenCodeProviderConfig
|
||||
let opencode_config_result =
|
||||
serde_json::from_value::<OpenCodeProviderConfig>(config_to_write.clone());
|
||||
|
||||
match opencode_config_result {
|
||||
Ok(config) => {
|
||||
opencode_config::set_typed_provider(&provider.id, &config)?;
|
||||
log::info!("OpenCode provider '{}' written to live config", provider.id);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to parse OpenCode provider config for '{}': {}",
|
||||
provider.id,
|
||||
e
|
||||
);
|
||||
// Only write if config looks like a valid provider fragment
|
||||
if config_to_write.get("npm").is_some()
|
||||
|| config_to_write.get("options").is_some()
|
||||
{
|
||||
opencode_config::set_provider(&provider.id, config_to_write)?;
|
||||
log::info!(
|
||||
"OpenCode provider '{}' written as raw JSON to live config",
|
||||
provider.id
|
||||
);
|
||||
} else {
|
||||
log::error!(
|
||||
"OpenCode provider '{}' has invalid config structure, skipping write",
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -152,7 +210,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
// Skill sync
|
||||
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
|
||||
log::warn!("同步 Skill 到 {:?} 失败: {}", app_type, e);
|
||||
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
|
||||
// Continue syncing other apps, don't abort
|
||||
}
|
||||
}
|
||||
@@ -220,6 +278,21 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||||
"config": config_obj
|
||||
}))
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
|
||||
|
||||
let config_path = get_opencode_config_path();
|
||||
if !config_path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"opencode.config.missing",
|
||||
"OpenCode 配置文件不存在",
|
||||
"OpenCode configuration file not found",
|
||||
));
|
||||
}
|
||||
|
||||
let config = read_opencode_config()?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +368,24 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
"config": config_obj
|
||||
})
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode - import from live is not the same pattern
|
||||
// For now, return an empty config structure
|
||||
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
|
||||
|
||||
let config_path = get_opencode_config_path();
|
||||
if !config_path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"opencode.live.missing",
|
||||
"OpenCode 配置文件不存在",
|
||||
"OpenCode configuration file is missing",
|
||||
));
|
||||
}
|
||||
|
||||
// For OpenCode, we return the full config - but note that OpenCode
|
||||
// uses additive mode, so importing defaults works differently
|
||||
read_opencode_config()?
|
||||
}
|
||||
};
|
||||
|
||||
let mut provider = Provider::with_id(
|
||||
@@ -399,3 +490,84 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove an OpenCode provider from the live configuration
|
||||
///
|
||||
/// This is specific to OpenCode's additive mode - removing a provider
|
||||
/// from the opencode.json file.
|
||||
pub(crate) fn remove_opencode_provider_from_live(provider_id: &str) -> Result<(), AppError> {
|
||||
use crate::opencode_config;
|
||||
|
||||
// Check if OpenCode config directory exists
|
||||
if !opencode_config::get_opencode_dir().exists() {
|
||||
log::debug!(
|
||||
"OpenCode config directory doesn't exist, skipping removal of '{}'",
|
||||
provider_id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
opencode_config::remove_provider(provider_id)?;
|
||||
log::info!(
|
||||
"OpenCode provider '{}' removed from live config",
|
||||
provider_id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import all providers from OpenCode live config to database
|
||||
///
|
||||
/// This imports existing providers from ~/.config/opencode/opencode.json
|
||||
/// into the CC Switch database. Each provider found will be added to the
|
||||
/// database with is_current set to false.
|
||||
pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, AppError> {
|
||||
use crate::opencode_config;
|
||||
|
||||
let providers = opencode_config::get_typed_providers()?;
|
||||
if providers.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut imported = 0;
|
||||
let existing = state.db.get_all_providers("opencode")?;
|
||||
|
||||
for (id, config) in providers {
|
||||
// Skip if already exists in database
|
||||
if existing.contains_key(&id) {
|
||||
log::debug!(
|
||||
"OpenCode provider '{}' already exists in database, skipping",
|
||||
id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert to Value for settings_config
|
||||
let settings_config = match serde_json::to_value(&config) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to serialize OpenCode provider '{}': {}", id, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Create provider
|
||||
let provider = Provider::with_id(
|
||||
id.clone(),
|
||||
config.name.clone().unwrap_or_else(|| id.clone()),
|
||||
settings_config,
|
||||
None,
|
||||
);
|
||||
|
||||
// Save to database
|
||||
if let Err(e) = state.db.save_provider("opencode", &provider) {
|
||||
log::warn!("Failed to import OpenCode provider '{}': {}", id, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
imported += 1;
|
||||
log::info!("Imported OpenCode provider '{}' from live config", id);
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
@@ -20,13 +20,16 @@ use crate::settings::CustomEndpoint;
|
||||
use crate::store::AppState;
|
||||
|
||||
// Re-export sub-module functions for external access
|
||||
pub use live::{import_default_config, read_live_settings, sync_current_to_live};
|
||||
pub use live::{
|
||||
import_default_config, import_opencode_providers_from_live, read_live_settings,
|
||||
sync_current_to_live,
|
||||
};
|
||||
|
||||
// Internal re-exports (pub(crate))
|
||||
pub(crate) use live::write_live_snapshot;
|
||||
|
||||
// Internal re-exports
|
||||
use live::write_gemini_live;
|
||||
use live::{remove_opencode_provider_from_live, write_gemini_live};
|
||||
use usage::validate_usage_script;
|
||||
|
||||
/// Provider business logic service
|
||||
@@ -137,7 +140,13 @@ impl ProviderService {
|
||||
/// 使用有效的当前供应商 ID(验证过存在性)。
|
||||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||||
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
|
||||
///
|
||||
/// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。
|
||||
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
|
||||
// OpenCode uses additive mode - no "current" provider concept
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
return Ok(String::new());
|
||||
}
|
||||
crate::settings::get_effective_current_provider(&state.db, &app_type)
|
||||
.map(|opt| opt.unwrap_or_default())
|
||||
}
|
||||
@@ -152,7 +161,13 @@ impl ProviderService {
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
|
||||
// Check if sync is needed (if this is current provider, or no current provider)
|
||||
// OpenCode uses additive mode - always write to live config
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// For other apps: Check if sync is needed (if this is current provider, or no current provider)
|
||||
let current = state.db.get_current_provider(app_type.as_str())?;
|
||||
if current.is_none() {
|
||||
// No current provider, set as current and sync
|
||||
@@ -176,14 +191,20 @@ impl ProviderService {
|
||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||
Self::validate_provider_settings(&app_type, &provider)?;
|
||||
|
||||
// Check if this is current provider (use effective current, not just DB)
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
|
||||
// OpenCode uses additive mode - always update in live config
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// For other apps: Check if this is current provider (use effective current, not just DB)
|
||||
let effective_current =
|
||||
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||||
let is_current = effective_current.as_deref() == Some(provider.id.as_str());
|
||||
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
|
||||
if is_current {
|
||||
// 如果代理接管模式处于激活状态,并且代理服务正在运行:
|
||||
// - 不写 Live 配置(否则会破坏接管)
|
||||
@@ -216,8 +237,18 @@ impl ProviderService {
|
||||
/// Delete a provider
|
||||
///
|
||||
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
|
||||
/// 对于 OpenCode(累加模式),可以随时删除任意供应商,同时从 live 配置中移除。
|
||||
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
// Check both local settings and database
|
||||
// OpenCode uses additive mode - no current provider concept
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
// Remove from database
|
||||
state.db.delete_provider(app_type.as_str(), id)?;
|
||||
// Also remove from live config
|
||||
remove_opencode_provider_from_live(id)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// For other apps: Check both local settings and database
|
||||
let local_current = crate::settings::get_current_provider(&app_type);
|
||||
let db_current = state.db.get_current_provider(app_type.as_str())?;
|
||||
|
||||
@@ -230,6 +261,27 @@ impl ProviderService {
|
||||
state.db.delete_provider(app_type.as_str(), id)
|
||||
}
|
||||
|
||||
/// Remove provider from live config only (for additive mode apps like OpenCode)
|
||||
///
|
||||
/// Does NOT delete from database - provider remains in the list.
|
||||
/// This is used when user wants to "remove" a provider from active config
|
||||
/// but keep it available for future use.
|
||||
pub fn remove_from_live_config(app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
match app_type {
|
||||
AppType::OpenCode => {
|
||||
remove_opencode_provider_from_live(id)?;
|
||||
}
|
||||
// Future: add other additive mode apps here
|
||||
_ => {
|
||||
return Err(AppError::Message(format!(
|
||||
"App {} does not support remove from live config",
|
||||
app_type.as_str()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Switch to a provider
|
||||
///
|
||||
/// Switch flow:
|
||||
@@ -326,22 +378,29 @@ impl ProviderService {
|
||||
|
||||
if let Some(current_id) = current_id {
|
||||
if current_id != id {
|
||||
// Only backfill when switching to a different provider
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||
current_provider.settings_config = live_config;
|
||||
// Ignore backfill failure, don't affect switch flow
|
||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
||||
// OpenCode uses additive mode - all providers coexist in the same file,
|
||||
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
|
||||
if !matches!(app_type, AppType::OpenCode) {
|
||||
// Only backfill when switching to a different provider
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||
current_provider.settings_config = live_config;
|
||||
// Ignore backfill failure, don't affect switch flow
|
||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update local settings (device-level, takes priority)
|
||||
crate::settings::set_current_provider(&app_type, Some(id))?;
|
||||
// OpenCode uses additive mode - skip setting is_current (no such concept)
|
||||
if !matches!(app_type, AppType::OpenCode) {
|
||||
// Update local settings (device-level, takes priority)
|
||||
crate::settings::set_current_provider(&app_type, Some(id))?;
|
||||
|
||||
// Update database is_current (as default for new devices)
|
||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||
// Update database is_current (as default for new devices)
|
||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||
}
|
||||
|
||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
@@ -380,6 +439,7 @@ impl ProviderService {
|
||||
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
|
||||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +452,7 @@ impl ProviderService {
|
||||
AppType::Claude => Self::extract_claude_common_config(settings_config),
|
||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,6 +586,29 @@ impl ProviderService {
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Extract common config for OpenCode (JSON format)
|
||||
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
// OpenCode uses a different config structure with npm, options, models
|
||||
// For common config, we exclude provider-specific fields like apiKey
|
||||
let mut config = settings.clone();
|
||||
|
||||
// Remove provider-specific fields
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
|
||||
options.remove("apiKey");
|
||||
options.remove("baseURL");
|
||||
}
|
||||
// Keep npm and models as they might be common
|
||||
}
|
||||
|
||||
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
|
||||
return Ok("{}".to_string());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Import default configuration from live files (re-export)
|
||||
///
|
||||
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
||||
@@ -615,6 +699,7 @@ impl ProviderService {
|
||||
base_url: Option<&str>,
|
||||
access_token: Option<&str>,
|
||||
user_id: Option<&str>,
|
||||
template_type: Option<&str>,
|
||||
) -> Result<UsageResult, AppError> {
|
||||
usage::test_usage_script(
|
||||
state,
|
||||
@@ -626,6 +711,7 @@ impl ProviderService {
|
||||
base_url,
|
||||
access_token,
|
||||
user_id,
|
||||
template_type,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -689,6 +775,17 @@ impl ProviderService {
|
||||
use crate::gemini_config::validate_gemini_settings;
|
||||
validate_gemini_settings(&provider.settings_config)?
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses a different config structure: { npm, options, models }
|
||||
// Basic validation - must be an object
|
||||
if !provider.settings_config.is_object() {
|
||||
return Err(AppError::localized(
|
||||
"provider.opencode.settings.not_object",
|
||||
"OpenCode 配置必须是 JSON 对象",
|
||||
"OpenCode configuration must be a JSON object",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and clean UsageScript configuration (common for all app types)
|
||||
@@ -826,6 +923,40 @@ impl ProviderService {
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses options.apiKey and options.baseURL
|
||||
let options = provider
|
||||
.settings_config
|
||||
.get("options")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.opencode.options.missing",
|
||||
"配置格式错误: 缺少 options",
|
||||
"Invalid configuration: missing options section",
|
||||
)
|
||||
})?;
|
||||
|
||||
let api_key = options
|
||||
.get("apiKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.opencode.api_key.missing",
|
||||
"缺少 API Key",
|
||||
"API key is missing",
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let base_url = options
|
||||
.get("baseURL")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pub(crate) async fn execute_and_format_usage_result(
|
||||
timeout: u64,
|
||||
access_token: Option<&str>,
|
||||
user_id: Option<&str>,
|
||||
template_type: Option<&str>,
|
||||
) -> Result<UsageResult, AppError> {
|
||||
match usage_script::execute_usage_script(
|
||||
script_code,
|
||||
@@ -25,6 +26,7 @@ pub(crate) async fn execute_and_format_usage_result(
|
||||
timeout,
|
||||
access_token,
|
||||
user_id,
|
||||
template_type,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -113,7 +115,7 @@ pub async fn query_usage(
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
) -> Result<UsageResult, AppError> {
|
||||
let (script_code, timeout, api_key, base_url, access_token, user_id) = {
|
||||
let (script_code, timeout, api_key, base_url, access_token, user_id, template_type) = {
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
let provider = providers.get(provider_id).ok_or_else(|| {
|
||||
AppError::localized(
|
||||
@@ -164,6 +166,7 @@ pub async fn query_usage(
|
||||
base_url,
|
||||
usage_script.access_token.clone(),
|
||||
usage_script.user_id.clone(),
|
||||
usage_script.template_type.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -174,6 +177,7 @@ pub async fn query_usage(
|
||||
timeout,
|
||||
access_token.as_deref(),
|
||||
user_id.as_deref(),
|
||||
template_type.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -190,6 +194,7 @@ pub async fn test_usage_script(
|
||||
base_url: Option<&str>,
|
||||
access_token: Option<&str>,
|
||||
user_id: Option<&str>,
|
||||
template_type: Option<&str>,
|
||||
) -> Result<UsageResult, AppError> {
|
||||
// Use provided credential parameters directly for testing
|
||||
execute_and_format_usage_result(
|
||||
@@ -199,6 +204,7 @@ pub async fn test_usage_script(
|
||||
timeout,
|
||||
access_token,
|
||||
user_id,
|
||||
template_type,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -368,6 +368,10 @@ impl ProxyService {
|
||||
AppType::Claude => self.read_claude_live()?,
|
||||
AppType::Codex => self.read_codex_live()?,
|
||||
AppType::Gemini => self.read_gemini_live()?,
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
self.sync_live_config_to_provider(app_type, &live_config)
|
||||
@@ -581,6 +585,9 @@ impl ProxyService {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -759,6 +766,10 @@ impl ProxyService {
|
||||
AppType::Claude => ("claude", self.read_claude_live()?),
|
||||
AppType::Codex => ("codex", self.read_codex_live()?),
|
||||
AppType::Gemini => ("gemini", self.read_gemini_live()?),
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let json_str = serde_json::to_string(&config)
|
||||
@@ -967,6 +978,10 @@ impl ProxyService {
|
||||
self.write_gemini_live(&live_config)?;
|
||||
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1050,6 +1065,9 @@ impl ProxyService {
|
||||
let _ = self.write_gemini_live(&live_config);
|
||||
}
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1082,6 +1100,9 @@ impl ProxyService {
|
||||
log::info!("Gemini Live 配置已恢复");
|
||||
}
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1161,6 +1182,10 @@ impl ProxyService {
|
||||
AppType::Claude => self.write_claude_live(config),
|
||||
AppType::Codex => self.write_codex_live(config),
|
||||
AppType::Gemini => self.write_gemini_live(config),
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features
|
||||
Err("OpenCode 不支持代理功能".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1178,6 +1203,10 @@ impl ProxyService {
|
||||
Ok(config) => Self::is_gemini_live_taken_over(&config),
|
||||
Err(_) => false,
|
||||
},
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy takeover
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1217,6 +1246,10 @@ impl ProxyService {
|
||||
AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),
|
||||
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
|
||||
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
@@ -143,9 +142,7 @@ pub struct SkillMetadata {
|
||||
|
||||
// ========== SkillService ==========
|
||||
|
||||
pub struct SkillService {
|
||||
http_client: Client,
|
||||
}
|
||||
pub struct SkillService;
|
||||
|
||||
impl Default for SkillService {
|
||||
fn default() -> Self {
|
||||
@@ -155,13 +152,7 @@ impl Default for SkillService {
|
||||
|
||||
impl SkillService {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
http_client: Client::builder()
|
||||
.user_agent("cc-switch")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
Self
|
||||
}
|
||||
|
||||
// ========== 路径管理 ==========
|
||||
@@ -192,6 +183,11 @@ impl SkillService {
|
||||
return Ok(custom.join("skills"));
|
||||
}
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
if let Some(custom) = crate::settings::get_opencode_override_dir() {
|
||||
return Ok(custom.join("skills"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认路径:回退到用户主目录下的标准位置
|
||||
@@ -205,6 +201,7 @@ impl SkillService {
|
||||
AppType::Claude => home.join(".claude").join("skills"),
|
||||
AppType::Codex => home.join(".codex").join("skills"),
|
||||
AppType::Gemini => home.join(".gemini").join("skills"),
|
||||
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -326,7 +323,7 @@ impl SkillService {
|
||||
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
|
||||
|
||||
// 从所有应用目录删除
|
||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] {
|
||||
let _ = Self::remove_from_app(&skill.directory, &app);
|
||||
}
|
||||
|
||||
@@ -385,7 +382,7 @@ impl SkillService {
|
||||
|
||||
let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
|
||||
|
||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] {
|
||||
let app_dir = match Self::get_app_skills_dir(&app) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
@@ -434,6 +431,7 @@ impl SkillService {
|
||||
AppType::Claude => "claude",
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::OpenCode => "opencode",
|
||||
};
|
||||
|
||||
unmanaged
|
||||
@@ -466,7 +464,7 @@ impl SkillService {
|
||||
let mut source_path: Option<PathBuf> = None;
|
||||
let mut found_in: Vec<String> = Vec::new();
|
||||
|
||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] {
|
||||
if let Ok(app_dir) = Self::get_app_skills_dir(&app) {
|
||||
let skill_path = app_dir.join(&dir_name);
|
||||
if skill_path.exists() {
|
||||
@@ -477,6 +475,7 @@ impl SkillService {
|
||||
AppType::Claude => "claude",
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::OpenCode => "opencode",
|
||||
};
|
||||
found_in.push(app_str.to_string());
|
||||
}
|
||||
@@ -515,6 +514,7 @@ impl SkillService {
|
||||
"claude" => apps.claude = true,
|
||||
"codex" => apps.codex = true,
|
||||
"gemini" => apps.gemini = true,
|
||||
"opencode" => apps.opencode = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -863,7 +863,8 @@ impl SkillService {
|
||||
|
||||
/// 下载并解压 ZIP
|
||||
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
|
||||
let response = self.http_client.get(url).send().await?;
|
||||
let client = crate::proxy::http_client::get();
|
||||
let response = client.get(url).send().await?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16().to_string();
|
||||
return Err(anyhow::anyhow!(format_skill_error(
|
||||
@@ -984,7 +985,7 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
||||
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
|
||||
|
||||
// 扫描各应用目录
|
||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] {
|
||||
let app_dir = match SkillService::get_app_skills_dir(&app) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use futures::future::join_all;
|
||||
use reqwest::{Client, Url};
|
||||
use serde::Serialize;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@@ -65,17 +65,21 @@ impl SpeedtestService {
|
||||
}
|
||||
|
||||
let timeout = Self::sanitize_timeout(timeout_secs);
|
||||
let client = Self::build_client(timeout)?;
|
||||
let (client, request_timeout) = Self::build_client(timeout)?;
|
||||
|
||||
let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
|
||||
let _ = client.get(parsed_url.clone()).send().await;
|
||||
let _ = client
|
||||
.get(parsed_url.clone())
|
||||
.timeout(request_timeout)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
// 第二次请求开始计时,并将其作为结果返回。
|
||||
let start = Instant::now();
|
||||
let latency = match client.get(parsed_url).send().await {
|
||||
let latency = match client.get(parsed_url).timeout(request_timeout).send().await {
|
||||
Ok(resp) => EndpointLatency {
|
||||
url: trimmed,
|
||||
latency: Some(start.elapsed().as_millis()),
|
||||
@@ -112,19 +116,11 @@ impl SpeedtestService {
|
||||
Ok(results.into_iter().flatten().collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.user_agent("cc-switch-speedtest/1.0")
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
AppError::localized(
|
||||
"speedtest.client_create_failed",
|
||||
format!("创建 HTTP 客户端失败: {e}"),
|
||||
format!("Failed to create HTTP client: {e}"),
|
||||
)
|
||||
})
|
||||
fn build_client(timeout_secs: u64) -> Result<(Client, std::time::Duration), AppError> {
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
// 返回 timeout Duration 供请求级别使用
|
||||
let timeout = std::time::Duration::from_secs(timeout_secs);
|
||||
Ok((crate::proxy::http_client::get(), timeout))
|
||||
}
|
||||
|
||||
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
|
||||
|
||||
@@ -7,7 +7,7 @@ use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
@@ -36,6 +36,13 @@ pub struct StreamCheckConfig {
|
||||
pub codex_model: String,
|
||||
/// Gemini 测试模型
|
||||
pub gemini_model: String,
|
||||
/// 检查提示词
|
||||
#[serde(default = "default_test_prompt")]
|
||||
pub test_prompt: String,
|
||||
}
|
||||
|
||||
fn default_test_prompt() -> String {
|
||||
"Who are you?".to_string()
|
||||
}
|
||||
|
||||
impl Default for StreamCheckConfig {
|
||||
@@ -47,6 +54,7 @@ impl Default for StreamCheckConfig {
|
||||
claude_model: "claude-haiku-4-5-20251001".to_string(),
|
||||
codex_model: "gpt-5.1-codex@low".to_string(),
|
||||
gemini_model: "gemini-3-pro-preview".to_string(),
|
||||
test_prompt: default_test_prompt(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,7 +118,7 @@ impl StreamCheckService {
|
||||
Ok(last_result.unwrap_or_else(|| StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message: "检查失败".to_string(),
|
||||
message: "Check failed".to_string(),
|
||||
response_time_ms: None,
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
@@ -130,29 +138,60 @@ impl StreamCheckService {
|
||||
|
||||
let base_url = adapter
|
||||
.extract_base_url(provider)
|
||||
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
|
||||
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?;
|
||||
|
||||
let auth = adapter
|
||||
.extract_auth(provider)
|
||||
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
|
||||
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(config.timeout_secs))
|
||||
.user_agent("cc-switch/1.0")
|
||||
.build()
|
||||
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
let client = crate::proxy::http_client::get();
|
||||
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
|
||||
|
||||
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
||||
let test_prompt = &config.test_prompt;
|
||||
|
||||
let result = match app_type {
|
||||
AppType::Claude => {
|
||||
Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await
|
||||
Self::check_claude_stream(
|
||||
&client,
|
||||
&base_url,
|
||||
&auth,
|
||||
&model_to_test,
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AppType::Codex => {
|
||||
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await
|
||||
Self::check_codex_stream(
|
||||
&client,
|
||||
&base_url,
|
||||
&auth,
|
||||
&model_to_test,
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AppType::Gemini => {
|
||||
Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await
|
||||
Self::check_gemini_stream(
|
||||
&client,
|
||||
&base_url,
|
||||
&auth,
|
||||
&model_to_test,
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support stream check yet
|
||||
return Err(AppError::localized(
|
||||
"opencode_no_stream_check",
|
||||
"OpenCode 暂不支持健康检查",
|
||||
"OpenCode does not support health check yet",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -166,7 +205,7 @@ impl StreamCheckService {
|
||||
Ok(StreamCheckResult {
|
||||
status: health_status,
|
||||
success: true,
|
||||
message: "检查成功".to_string(),
|
||||
message: "Check succeeded".to_string(),
|
||||
response_time_ms: Some(response_time),
|
||||
http_status: Some(status_code),
|
||||
model_used: model,
|
||||
@@ -188,31 +227,69 @@ impl StreamCheckService {
|
||||
}
|
||||
|
||||
/// Claude 流式检查
|
||||
///
|
||||
/// 严格按照 Claude CLI 真实请求格式构建请求
|
||||
async fn check_claude_stream(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
auth: &AuthInfo,
|
||||
model: &str,
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
// URL 必须包含 ?beta=true 参数(某些中转服务依赖此参数验证请求来源)
|
||||
let url = if base.ends_with("/v1") {
|
||||
format!("{base}/messages")
|
||||
format!("{base}/messages?beta=true")
|
||||
} else {
|
||||
format!("{base}/v1/messages")
|
||||
format!("{base}/v1/messages?beta=true")
|
||||
};
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"max_tokens": 1,
|
||||
"messages": [{ "role": "user", "content": "hi" }],
|
||||
"messages": [{ "role": "user", "content": test_prompt }],
|
||||
"stream": true
|
||||
});
|
||||
|
||||
// 获取本地系统信息
|
||||
let os_name = Self::get_os_name();
|
||||
let arch_name = Self::get_arch_name();
|
||||
|
||||
// 严格按照 Claude CLI 请求格式设置 headers
|
||||
let response = client
|
||||
.post(&url)
|
||||
// 认证 headers(双重认证)
|
||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("x-api-key", &auth.api_key)
|
||||
// Anthropic 必需 headers
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("Content-Type", "application/json")
|
||||
.header(
|
||||
"anthropic-beta",
|
||||
"claude-code-20250219,interleaved-thinking-2025-05-14",
|
||||
)
|
||||
.header("anthropic-dangerous-direct-browser-access", "true")
|
||||
// 内容类型 headers
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "application/json")
|
||||
.header("accept-encoding", "identity")
|
||||
.header("accept-language", "*")
|
||||
// 客户端标识 headers
|
||||
.header("user-agent", "claude-cli/2.1.2 (external, cli)")
|
||||
.header("x-app", "cli")
|
||||
// x-stainless SDK headers(动态获取本地系统信息)
|
||||
.header("x-stainless-lang", "js")
|
||||
.header("x-stainless-package-version", "0.70.0")
|
||||
.header("x-stainless-os", os_name)
|
||||
.header("x-stainless-arch", arch_name)
|
||||
.header("x-stainless-runtime", "node")
|
||||
.header("x-stainless-runtime-version", "v22.20.0")
|
||||
.header("x-stainless-retry-count", "0")
|
||||
.header("x-stainless-timeout", "600")
|
||||
// 其他 headers
|
||||
.header("sec-fetch-mode", "cors")
|
||||
.header("connection", "keep-alive")
|
||||
.timeout(timeout)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
@@ -230,51 +307,64 @@ impl StreamCheckService {
|
||||
if let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(_) => Ok((status, model.to_string())),
|
||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
||||
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
||||
}
|
||||
} else {
|
||||
Err(AppError::Message("未收到响应数据".to_string()))
|
||||
Err(AppError::Message("No response data received".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex 流式检查
|
||||
///
|
||||
/// 严格按照 Codex CLI 真实请求格式构建请求 (Responses API)
|
||||
async fn check_codex_stream(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
auth: &AuthInfo,
|
||||
model: &str,
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
// Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API)
|
||||
let url = if base.ends_with("/v1") {
|
||||
format!("{base}/chat/completions")
|
||||
format!("{base}/responses")
|
||||
} else {
|
||||
format!("{base}/v1/chat/completions")
|
||||
format!("{base}/v1/responses")
|
||||
};
|
||||
|
||||
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
|
||||
|
||||
// 获取本地系统信息
|
||||
let os_name = Self::get_os_name();
|
||||
let arch_name = Self::get_arch_name();
|
||||
|
||||
// Responses API 请求体格式 (input 必须是数组)
|
||||
let mut body = json!({
|
||||
"model": actual_model,
|
||||
"messages": [
|
||||
{ "role": "system", "content": "" },
|
||||
{ "role": "assistant", "content": "" },
|
||||
{ "role": "user", "content": "hi" }
|
||||
],
|
||||
"max_tokens": 1,
|
||||
"temperature": 0,
|
||||
"input": [{ "role": "user", "content": test_prompt }],
|
||||
"stream": true
|
||||
});
|
||||
|
||||
// 如果是推理模型,添加 reasoning_effort
|
||||
if let Some(effort) = reasoning_effort {
|
||||
body["reasoning_effort"] = json!(effort);
|
||||
body["reasoning"] = json!({ "effort": effort });
|
||||
}
|
||||
|
||||
// 严格按照 Codex CLI 请求格式设置 headers
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
.header("accept-encoding", "identity")
|
||||
.header(
|
||||
"user-agent",
|
||||
format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"),
|
||||
)
|
||||
.header("originator", "codex_cli_rs")
|
||||
.timeout(timeout)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
@@ -291,10 +381,10 @@ impl StreamCheckService {
|
||||
if let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(_) => Ok((status, model.to_string())),
|
||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
||||
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
||||
}
|
||||
} else {
|
||||
Err(AppError::Message("未收到响应数据".to_string()))
|
||||
Err(AppError::Message("No response data received".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,13 +394,15 @@ impl StreamCheckService {
|
||||
base_url: &str,
|
||||
auth: &AuthInfo,
|
||||
model: &str,
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let url = format!("{base}/v1/chat/completions");
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"messages": [{ "role": "user", "content": "hi" }],
|
||||
"messages": [{ "role": "user", "content": test_prompt }],
|
||||
"max_tokens": 1,
|
||||
"temperature": 0,
|
||||
"stream": true
|
||||
@@ -320,6 +412,7 @@ impl StreamCheckService {
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(timeout)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
@@ -336,10 +429,10 @@ impl StreamCheckService {
|
||||
if let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(_) => Ok((status, model.to_string())),
|
||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
||||
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
||||
}
|
||||
} else {
|
||||
Err(AppError::Message("未收到响应数据".to_string()))
|
||||
Err(AppError::Message("No response data received".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,7 +447,6 @@ impl StreamCheckService {
|
||||
/// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||
/// 返回 (实际模型名, Option<推理等级>)
|
||||
fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
|
||||
// 查找 @ 或 # 分隔符
|
||||
if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
|
||||
let actual_model = model[..pos].to_string();
|
||||
let effort = model[pos + 1..].to_string();
|
||||
@@ -367,17 +459,14 @@ impl StreamCheckService {
|
||||
|
||||
fn should_retry(msg: &str) -> bool {
|
||||
let lower = msg.to_lowercase();
|
||||
lower.contains("timeout")
|
||||
|| lower.contains("abort")
|
||||
|| lower.contains("中断")
|
||||
|| lower.contains("超时")
|
||||
lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out")
|
||||
}
|
||||
|
||||
fn map_request_error(e: reqwest::Error) -> AppError {
|
||||
if e.is_timeout() {
|
||||
AppError::Message("请求超时".to_string())
|
||||
AppError::Message("Request timeout".to_string())
|
||||
} else if e.is_connect() {
|
||||
AppError::Message(format!("连接失败: {e}"))
|
||||
AppError::Message(format!("Connection failed: {e}"))
|
||||
} else {
|
||||
AppError::Message(e.to_string())
|
||||
}
|
||||
@@ -396,9 +485,24 @@ impl StreamCheckService {
|
||||
}
|
||||
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
|
||||
.unwrap_or_else(|| config.gemini_model.clone()),
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses models map in settings_config
|
||||
// Try to extract first model from the models object
|
||||
Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_opencode_model(provider: &Provider) -> Option<String> {
|
||||
let models = provider
|
||||
.settings_config
|
||||
.get("models")
|
||||
.and_then(|m| m.as_object())?;
|
||||
|
||||
// Return the first model ID from the models map
|
||||
models.keys().next().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
|
||||
provider
|
||||
.settings_config
|
||||
@@ -424,6 +528,26 @@ impl StreamCheckService {
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// 获取操作系统名称(映射为 Claude CLI 使用的格式)
|
||||
fn get_os_name() -> &'static str {
|
||||
match std::env::consts::OS {
|
||||
"macos" => "MacOS",
|
||||
"linux" => "Linux",
|
||||
"windows" => "Windows",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 CPU 架构名称(映射为 Claude CLI 使用的格式)
|
||||
fn get_arch_name() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"aarch64" => "arm64",
|
||||
"x86_64" => "x86_64",
|
||||
"x86" => "x86",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -448,9 +572,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_should_retry() {
|
||||
assert!(StreamCheckService::should_retry("请求超时"));
|
||||
assert!(StreamCheckService::should_retry("request timeout"));
|
||||
assert!(!StreamCheckService::should_retry("API Key 无效"));
|
||||
assert!(StreamCheckService::should_retry("Request timeout"));
|
||||
assert!(StreamCheckService::should_retry("request timed out"));
|
||||
assert!(StreamCheckService::should_retry("connection abort"));
|
||||
assert!(!StreamCheckService::should_retry("API Key invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -478,4 +603,33 @@ mod tests {
|
||||
assert_eq!(model, "gpt-4o-mini");
|
||||
assert_eq!(effort, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_os_name() {
|
||||
let os_name = StreamCheckService::get_os_name();
|
||||
// 确保返回非空字符串
|
||||
assert!(!os_name.is_empty());
|
||||
// 在 macOS 上应该返回 "MacOS"
|
||||
#[cfg(target_os = "macos")]
|
||||
assert_eq!(os_name, "MacOS");
|
||||
// 在 Linux 上应该返回 "Linux"
|
||||
#[cfg(target_os = "linux")]
|
||||
assert_eq!(os_name, "Linux");
|
||||
// 在 Windows 上应该返回 "Windows"
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(os_name, "Windows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_arch_name() {
|
||||
let arch_name = StreamCheckService::get_arch_name();
|
||||
// 确保返回非空字符串
|
||||
assert!(!arch_name.is_empty());
|
||||
// 在 ARM64 上应该返回 "arm64"
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
assert_eq!(arch_name, "arm64");
|
||||
// 在 x86_64 上应该返回 "x86_64"
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
assert_eq!(arch_name, "x86_64");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ pub struct AppSettings {
|
||||
pub codex_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gemini_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode_config_dir: Option<String>,
|
||||
|
||||
// ===== 当前供应商 ID(设备级)=====
|
||||
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current)
|
||||
@@ -58,6 +60,9 @@ pub struct AppSettings {
|
||||
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_gemini: Option<String>,
|
||||
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_opencode: Option<String>,
|
||||
}
|
||||
|
||||
fn default_show_in_tray() -> bool {
|
||||
@@ -84,9 +89,11 @@ impl Default for AppSettings {
|
||||
claude_config_dir: None,
|
||||
codex_config_dir: None,
|
||||
gemini_config_dir: None,
|
||||
opencode_config_dir: None,
|
||||
current_provider_claude: None,
|
||||
current_provider_codex: None,
|
||||
current_provider_gemini: None,
|
||||
current_provider_opencode: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +126,13 @@ impl AppSettings {
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
self.opencode_config_dir = self
|
||||
.opencode_config_dir
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
self.language = self
|
||||
.language
|
||||
.as_ref()
|
||||
@@ -251,6 +265,14 @@ pub fn get_gemini_override_dir() -> Option<PathBuf> {
|
||||
.map(|p| resolve_override_path(p))
|
||||
}
|
||||
|
||||
pub fn get_opencode_override_dir() -> Option<PathBuf> {
|
||||
let settings = settings_store().read().ok()?;
|
||||
settings
|
||||
.opencode_config_dir
|
||||
.as_ref()
|
||||
.map(|p| resolve_override_path(p))
|
||||
}
|
||||
|
||||
// ===== 当前供应商管理函数 =====
|
||||
|
||||
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
|
||||
@@ -263,6 +285,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
|
||||
AppType::Claude => settings.current_provider_claude.clone(),
|
||||
AppType::Codex => settings.current_provider_codex.clone(),
|
||||
AppType::Gemini => settings.current_provider_gemini.clone(),
|
||||
AppType::OpenCode => settings.current_provider_opencode.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +300,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
|
||||
AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()),
|
||||
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
|
||||
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
|
||||
AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()),
|
||||
}
|
||||
|
||||
update_settings(settings)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use reqwest::Client;
|
||||
use rquickjs::{Context, Function, Runtime};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use url::{Host, Url};
|
||||
|
||||
use crate::error::AppError;
|
||||
@@ -15,13 +13,21 @@ pub async fn execute_usage_script(
|
||||
timeout_secs: u64,
|
||||
access_token: Option<&str>,
|
||||
user_id: Option<&str>,
|
||||
template_type: Option<&str>,
|
||||
) -> Result<Value, AppError> {
|
||||
// 检测是否为自定义模板模式
|
||||
// 优先使用前端传递的 template_type
|
||||
let is_custom_template = template_type.map(|t| t == "custom").unwrap_or(false);
|
||||
|
||||
// 1. 替换模板变量,避免泄露敏感信息
|
||||
let script_with_vars =
|
||||
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
|
||||
|
||||
// 2. 验证 base_url 的安全性
|
||||
validate_base_url(base_url)?;
|
||||
// 2. 验证 base_url 的安全性(仅当提供了 base_url 时)
|
||||
// 自定义模板模式下,用户可能不使用模板变量,而是直接在脚本中写完整 URL
|
||||
if !base_url.is_empty() {
|
||||
validate_base_url(base_url)?;
|
||||
}
|
||||
|
||||
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
||||
let request_config = {
|
||||
@@ -99,7 +105,8 @@ pub async fn execute_usage_script(
|
||||
})?;
|
||||
|
||||
// 5. 验证请求 URL 是否安全(防止 SSRF)
|
||||
validate_request_url(&request.url, base_url)?;
|
||||
// 如果提供了 base_url,则验证同源;否则只做基本安全检查
|
||||
validate_request_url(&request.url, base_url, is_custom_template)?;
|
||||
|
||||
// 6. 发送 HTTP 请求
|
||||
let response_data = send_http_request(&request, timeout_secs).await?;
|
||||
@@ -215,18 +222,10 @@ struct RequestConfig {
|
||||
|
||||
/// 发送 HTTP 请求
|
||||
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {
|
||||
// 约束超时范围,防止异常配置导致长时间阻塞
|
||||
let timeout = timeout_secs.clamp(2, 30);
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(timeout))
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.client_create_failed",
|
||||
format!("创建客户端失败: {e}"),
|
||||
format!("Failed to create client: {e}"),
|
||||
)
|
||||
})?;
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
let client = crate::proxy::http_client::get();
|
||||
// 约束超时范围,防止异常配置导致长时间阻塞(最小 2 秒,最大 30 秒)
|
||||
let request_timeout = std::time::Duration::from_secs(timeout_secs.clamp(2, 30));
|
||||
|
||||
// 严格校验 HTTP 方法,非法值不回退为 GET
|
||||
let method: reqwest::Method = config.method.parse().map_err(|_| {
|
||||
@@ -237,7 +236,9 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut req = client.request(method.clone(), &config.url);
|
||||
let mut req = client
|
||||
.request(method.clone(), &config.url)
|
||||
.timeout(request_timeout);
|
||||
|
||||
// 添加请求头
|
||||
for (k, v) in &config.headers {
|
||||
@@ -480,7 +481,11 @@ fn validate_base_url(base_url: &str) -> Result<(), AppError> {
|
||||
}
|
||||
|
||||
/// 验证请求 URL 是否安全(防止 SSRF)
|
||||
fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppError> {
|
||||
fn validate_request_url(
|
||||
request_url: &str,
|
||||
base_url: &str,
|
||||
is_custom_template: bool,
|
||||
) -> Result<(), AppError> {
|
||||
// 解析请求 URL
|
||||
let parsed_request = Url::parse(request_url).map_err(|e| {
|
||||
AppError::localized(
|
||||
@@ -490,19 +495,11 @@ fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppErro
|
||||
)
|
||||
})?;
|
||||
|
||||
// 解析 base URL
|
||||
let parsed_base = Url::parse(base_url).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.base_url_invalid",
|
||||
format!("无效的 base_url: {e}"),
|
||||
format!("Invalid base_url: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let is_request_loopback = is_loopback_host(&parsed_request);
|
||||
|
||||
// 必须使用 HTTPS(允许 localhost 用于开发)
|
||||
if parsed_request.scheme() != "https" && !is_request_loopback {
|
||||
// 自定义模板模式下,允许用户自行决定是否使用 HTTP(用户需自行承担安全风险)
|
||||
if !is_custom_template && parsed_request.scheme() != "https" && !is_request_loopback {
|
||||
return Err(AppError::localized(
|
||||
"usage_script.request_https_required",
|
||||
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
|
||||
@@ -510,60 +507,85 @@ fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppErro
|
||||
));
|
||||
}
|
||||
|
||||
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
|
||||
if parsed_request.host_str() != parsed_base.host_str() {
|
||||
return Err(AppError::localized(
|
||||
"usage_script.request_host_mismatch",
|
||||
format!(
|
||||
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
|
||||
parsed_request.host_str().unwrap_or("unknown"),
|
||||
parsed_base.host_str().unwrap_or("unknown")
|
||||
),
|
||||
format!(
|
||||
"Request host {} must match base_url host {} (same-origin required)",
|
||||
parsed_request.host_str().unwrap_or("unknown"),
|
||||
parsed_base.host_str().unwrap_or("unknown")
|
||||
),
|
||||
));
|
||||
}
|
||||
// 如果提供了 base_url(非空),则进行同源检查
|
||||
// 🔧 自定义模板模式下,用户可以自由访问任意 HTTPS 域名,跳过同源检查
|
||||
if !base_url.is_empty() && !is_custom_template {
|
||||
// 解析 base URL
|
||||
let parsed_base = Url::parse(base_url).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.base_url_invalid",
|
||||
format!("无效的 base_url: {e}"),
|
||||
format!("Invalid base_url: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 检查端口是否匹配(考虑默认端口)
|
||||
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443)
|
||||
match (
|
||||
parsed_request.port_or_known_default(),
|
||||
parsed_base.port_or_known_default(),
|
||||
) {
|
||||
(Some(request_port), Some(base_port)) if request_port == base_port => {
|
||||
// 端口匹配,继续执行
|
||||
}
|
||||
(Some(request_port), Some(base_port)) => {
|
||||
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
|
||||
if parsed_request.host_str() != parsed_base.host_str() {
|
||||
return Err(AppError::localized(
|
||||
"usage_script.request_port_mismatch",
|
||||
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
|
||||
format!("Request port {request_port} must match base_url port {base_port}"),
|
||||
"usage_script.request_host_mismatch",
|
||||
format!(
|
||||
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
|
||||
parsed_request.host_str().unwrap_or("unknown"),
|
||||
parsed_base.host_str().unwrap_or("unknown")
|
||||
),
|
||||
format!(
|
||||
"Request host {} must match base_url host {} (same-origin required)",
|
||||
parsed_request.host_str().unwrap_or("unknown"),
|
||||
parsed_base.host_str().unwrap_or("unknown")
|
||||
),
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
|
||||
return Err(AppError::localized(
|
||||
"usage_script.request_port_unknown",
|
||||
"无法确定端口号",
|
||||
"Unable to determine port number",
|
||||
));
|
||||
|
||||
// 检查端口是否匹配(考虑默认端口)
|
||||
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443)
|
||||
match (
|
||||
parsed_request.port_or_known_default(),
|
||||
parsed_base.port_or_known_default(),
|
||||
) {
|
||||
(Some(request_port), Some(base_port)) if request_port == base_port => {
|
||||
// 端口匹配,继续执行
|
||||
}
|
||||
(Some(request_port), Some(base_port)) => {
|
||||
return Err(AppError::localized(
|
||||
"usage_script.request_port_mismatch",
|
||||
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
|
||||
format!("Request port {request_port} must match base_url port {base_port}"),
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
|
||||
return Err(AppError::localized(
|
||||
"usage_script.request_port_unknown",
|
||||
"无法确定端口号",
|
||||
"Unable to determine port number",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
|
||||
if let Some(host) = parsed_request.host_str() {
|
||||
let base_host = parsed_base.host_str().unwrap_or("");
|
||||
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
|
||||
if let Some(host) = parsed_request.host_str() {
|
||||
let base_host = parsed_base.host_str().unwrap_or("");
|
||||
|
||||
// 如果 base_url 不是私有地址,则禁止访问私有IP
|
||||
if !is_private_ip(base_host) && is_private_ip(host) {
|
||||
return Err(AppError::localized(
|
||||
"usage_script.private_ip_blocked",
|
||||
"禁止访问私有 IP 地址",
|
||||
"Access to private IP addresses is blocked",
|
||||
));
|
||||
// 如果 base_url 不是私有地址,则禁止访问私有IP
|
||||
if !is_private_ip(base_host) && is_private_ip(host) {
|
||||
return Err(AppError::localized(
|
||||
"usage_script.private_ip_blocked",
|
||||
"禁止访问私有 IP 地址",
|
||||
"Access to private IP addresses is blocked",
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 自定义模板模式:没有 base_url,需要额外的安全检查
|
||||
// 禁止访问私有 IP 地址(SSRF 防护)
|
||||
if let Some(host) = parsed_request.host_str() {
|
||||
if is_private_ip(host) && !is_request_loopback {
|
||||
return Err(AppError::localized(
|
||||
"usage_script.private_ip_blocked",
|
||||
"禁止访问私有 IP 地址(localhost 除外)",
|
||||
"Access to private IP addresses is blocked (localhost allowed)",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,7 +873,7 @@ mod tests {
|
||||
];
|
||||
|
||||
for (base_url, request_url, should_match) in test_cases {
|
||||
let result = validate_request_url(request_url, base_url);
|
||||
let result = validate_request_url(request_url, base_url, false);
|
||||
|
||||
if should_match {
|
||||
assert!(
|
||||
|
||||
@@ -553,6 +553,7 @@ command = "echo"
|
||||
claude: false,
|
||||
codex: false, // 初始未启用
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
@@ -680,6 +681,7 @@ fn import_from_claude_merges_into_config() {
|
||||
claude: false, // 初始未启用
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
|
||||
@@ -214,6 +214,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
|
||||
claude: false,
|
||||
codex: false, // 初始未启用
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
@@ -277,6 +278,7 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
@@ -320,6 +322,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
@@ -352,6 +355,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
@@ -483,6 +487,7 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
@@ -536,6 +541,7 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
|
||||
@@ -74,6 +74,7 @@ command = "say"
|
||||
claude: false,
|
||||
codex: true, // 启用 Codex
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
|
||||
@@ -88,6 +88,7 @@ command = "say"
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
|
||||
+107
-21
@@ -77,7 +77,11 @@ function App() {
|
||||
|
||||
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
||||
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
|
||||
// Confirm action state: 'remove' = remove from live config, 'delete' = delete from database
|
||||
const [confirmAction, setConfirmAction] = useState<{
|
||||
provider: Provider;
|
||||
action: "remove" | "delete";
|
||||
} | null>(null);
|
||||
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
||||
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
||||
|
||||
@@ -322,11 +326,46 @@ function App() {
|
||||
setEditingProvider(null);
|
||||
};
|
||||
|
||||
// 确认删除供应商
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!confirmDelete) return;
|
||||
await deleteProvider(confirmDelete.id);
|
||||
setConfirmDelete(null);
|
||||
// 确认删除/移除供应商
|
||||
const handleConfirmAction = async () => {
|
||||
if (!confirmAction) return;
|
||||
const { provider, action } = confirmAction;
|
||||
|
||||
if (action === "remove") {
|
||||
// Remove from live config only (for additive mode apps like OpenCode)
|
||||
// Does NOT delete from database - provider remains in the list
|
||||
await providersApi.removeFromLiveConfig(provider.id, activeApp);
|
||||
// Invalidate queries to refresh the isInConfig state
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["opencodeLiveProviderIds"],
|
||||
});
|
||||
toast.success(
|
||||
t("notifications.removeFromConfigSuccess", {
|
||||
defaultValue: "已从配置移除",
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
} else {
|
||||
// Delete from database
|
||||
await deleteProvider(provider.id);
|
||||
}
|
||||
setConfirmAction(null);
|
||||
};
|
||||
|
||||
// Generate a unique provider key for OpenCode duplication
|
||||
const generateUniqueOpencodeKey = (originalKey: string, existingKeys: string[]): string => {
|
||||
const baseKey = `${originalKey}-copy`;
|
||||
|
||||
if (!existingKeys.includes(baseKey)) {
|
||||
return baseKey;
|
||||
}
|
||||
|
||||
// If -copy already exists, try -copy-2, -copy-3, ...
|
||||
let counter = 2;
|
||||
while (existingKeys.includes(`${baseKey}-${counter}`)) {
|
||||
counter++;
|
||||
}
|
||||
return `${baseKey}-${counter}`;
|
||||
};
|
||||
|
||||
// 复制供应商
|
||||
@@ -335,7 +374,7 @@ function App() {
|
||||
const newSortIndex =
|
||||
provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined;
|
||||
|
||||
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> = {
|
||||
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> & { providerKey?: string } = {
|
||||
name: `${provider.name} copy`,
|
||||
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
|
||||
websiteUrl: provider.websiteUrl,
|
||||
@@ -348,6 +387,12 @@ function App() {
|
||||
iconColor: provider.iconColor,
|
||||
};
|
||||
|
||||
// OpenCode: generate unique provider key (used as ID)
|
||||
if (activeApp === "opencode") {
|
||||
const existingKeys = Object.keys(providers);
|
||||
duplicatedProvider.providerKey = generateUniqueOpencodeKey(provider.id, existingKeys);
|
||||
}
|
||||
|
||||
// 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1
|
||||
if (provider.sortIndex !== undefined) {
|
||||
const updates = Object.values(providers)
|
||||
@@ -382,6 +427,26 @@ function App() {
|
||||
await addProvider(duplicatedProvider);
|
||||
};
|
||||
|
||||
// 打开提供商终端
|
||||
const handleOpenTerminal = async (provider: Provider) => {
|
||||
try {
|
||||
await providersApi.openTerminal(provider.id, activeApp);
|
||||
toast.success(
|
||||
t("provider.terminalOpened", {
|
||||
defaultValue: "终端已打开",
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[App] Failed to open terminal", error);
|
||||
const errorMessage = extractErrorMessage(error);
|
||||
toast.error(
|
||||
t("provider.terminalOpenFailed", {
|
||||
defaultValue: "打开终端失败",
|
||||
}) + (errorMessage ? `: ${errorMessage}` : ""),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导入配置成功后刷新
|
||||
const handleImportSuccess = async () => {
|
||||
try {
|
||||
@@ -434,7 +499,7 @@ function App() {
|
||||
/>
|
||||
);
|
||||
case "skillsDiscovery":
|
||||
return <SkillsPage ref={skillsPageRef} initialApp={activeApp} />;
|
||||
return <SkillsPage ref={skillsPageRef} initialApp={activeApp === "opencode" ? "claude" : activeApp} />;
|
||||
case "mcp":
|
||||
return (
|
||||
<UnifiedMcpPanel
|
||||
@@ -448,13 +513,13 @@ function App() {
|
||||
);
|
||||
case "universal":
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] px-5 pt-4">
|
||||
<div className="px-6 pt-4">
|
||||
<UniversalProviderPanel />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
||||
<AnimatePresence mode="wait">
|
||||
@@ -478,10 +543,21 @@ function App() {
|
||||
activeProviderId={activeProviderId}
|
||||
onSwitch={switchProvider}
|
||||
onEdit={setEditingProvider}
|
||||
onDelete={setConfirmDelete}
|
||||
onDelete={(provider) =>
|
||||
setConfirmAction({ provider, action: "delete" })
|
||||
}
|
||||
onRemoveFromConfig={
|
||||
activeApp === "opencode"
|
||||
? (provider) =>
|
||||
setConfirmAction({ provider, action: "remove" })
|
||||
: undefined
|
||||
}
|
||||
onDuplicate={handleDuplicateProvider}
|
||||
onConfigureUsage={setUsageProvider}
|
||||
onOpenWebsite={handleOpenWebsite}
|
||||
onOpenTerminal={
|
||||
activeApp === "claude" ? handleOpenTerminal : undefined
|
||||
}
|
||||
onCreate={() => setIsAddOpen(true)}
|
||||
/>
|
||||
</motion.div>
|
||||
@@ -557,7 +633,7 @@ function App() {
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="mx-auto flex h-full max-w-[56rem] flex-wrap items-center justify-between gap-2 px-6"
|
||||
className="flex h-full items-center justify-between gap-2 px-6"
|
||||
data-tauri-drag-region
|
||||
style={{ WebkitAppRegion: "drag" } as any}
|
||||
>
|
||||
@@ -717,7 +793,9 @@ function App() {
|
||||
)}
|
||||
{currentView === "providers" && (
|
||||
<>
|
||||
<ProxyToggle activeApp={activeApp} />
|
||||
{activeApp !== "opencode" && (
|
||||
<ProxyToggle activeApp={activeApp} />
|
||||
)}
|
||||
|
||||
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
||||
|
||||
@@ -822,17 +900,25 @@ function App() {
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={Boolean(confirmDelete)}
|
||||
title={t("confirm.deleteProvider")}
|
||||
isOpen={Boolean(confirmAction)}
|
||||
title={
|
||||
confirmAction?.action === "remove"
|
||||
? t("confirm.removeProvider")
|
||||
: t("confirm.deleteProvider")
|
||||
}
|
||||
message={
|
||||
confirmDelete
|
||||
? t("confirm.deleteProviderMessage", {
|
||||
name: confirmDelete.name,
|
||||
})
|
||||
confirmAction
|
||||
? confirmAction.action === "remove"
|
||||
? t("confirm.removeProviderMessage", {
|
||||
name: confirmAction.provider.name,
|
||||
})
|
||||
: t("confirm.deleteProviderMessage", {
|
||||
name: confirmAction.provider.name,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
onConfirm={() => void handleConfirmDelete()}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
onConfirm={() => void handleConfirmAction()}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
|
||||
<DeepLinkImportDialog />
|
||||
|
||||
@@ -16,11 +16,13 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
||||
claude: "claude",
|
||||
codex: "openai",
|
||||
gemini: "gemini",
|
||||
opencode: "opencode",
|
||||
};
|
||||
const appDisplayName: Record<AppId, string> = {
|
||||
claude: "Claude",
|
||||
codex: "Codex",
|
||||
gemini: "Gemini",
|
||||
opencode: "OpenCode",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -90,6 +92,28 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
||||
/>
|
||||
<span>{appDisplayName.gemini}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSwitch("opencode")}
|
||||
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
activeApp === "opencode"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderIcon
|
||||
icon={appIconName.opencode}
|
||||
name={appDisplayName.opencode}
|
||||
size={iconSize}
|
||||
className={
|
||||
activeApp === "opencode"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground group-hover:text-foreground transition-colors"
|
||||
}
|
||||
/>
|
||||
<span>{appDisplayName.opencode}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -389,12 +389,27 @@ export function DeepLinkImportDialog() {
|
||||
</div>
|
||||
|
||||
{/* API Endpoint */}
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
<div className="grid grid-cols-3 items-start gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground pt-0.5">
|
||||
{t("deeplink.endpoint")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm break-all">
|
||||
{request.endpoint}
|
||||
<div className="col-span-2 text-sm break-all space-y-1">
|
||||
{request.endpoint?.split(",").map((ep, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={
|
||||
idx === 0 ? "font-medium" : "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{idx === 0 ? "🔹 " : "└ "}
|
||||
{ep.trim()}
|
||||
{idx === 0 && request.endpoint?.includes(",") && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({t("deeplink.primaryEndpoint")})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ interface UsageFooterProps {
|
||||
appId: AppId;
|
||||
usageEnabled: boolean; // 是否启用了用量查询
|
||||
isCurrent: boolean; // 是否为当前激活的供应商
|
||||
isInConfig?: boolean; // OpenCode: 是否已添加到配置
|
||||
inline?: boolean; // 是否内联显示(在按钮左侧)
|
||||
}
|
||||
|
||||
@@ -20,12 +21,15 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
||||
appId,
|
||||
usageEnabled,
|
||||
isCurrent,
|
||||
isInConfig = false,
|
||||
inline = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 统一的用量查询(自动查询仅对当前激活的供应商启用)
|
||||
const autoQueryInterval = isCurrent
|
||||
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent
|
||||
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
|
||||
const autoQueryInterval = shouldAutoQuery
|
||||
? provider.meta?.usage_script?.autoQueryInterval || 0
|
||||
: 0;
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import React, { useState } from "react";
|
||||
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Provider, UsageScript, UsageData } from "@/types";
|
||||
import { usageApi, type AppId } from "@/lib/api";
|
||||
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
|
||||
import JsonEditor from "./JsonEditor";
|
||||
import * as prettier from "prettier/standalone";
|
||||
import * as parserBabel from "prettier/parser-babel";
|
||||
@@ -109,19 +111,67 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 生成带国际化的预设模板
|
||||
const PRESET_TEMPLATES = generatePresetTemplates(t);
|
||||
|
||||
const [script, setScript] = useState<UsageScript>(() => {
|
||||
return (
|
||||
provider.meta?.usage_script || {
|
||||
enabled: false,
|
||||
language: "javascript",
|
||||
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
|
||||
timeout: 10,
|
||||
// 从 provider 的 settingsConfig 中提取 API Key 和 Base URL
|
||||
const getProviderCredentials = (): {
|
||||
apiKey: string | undefined;
|
||||
baseUrl: string | undefined;
|
||||
} => {
|
||||
try {
|
||||
const config = provider.settingsConfig;
|
||||
if (!config) return { apiKey: undefined, baseUrl: undefined };
|
||||
|
||||
// 处理不同应用的配置格式
|
||||
if (appId === "claude") {
|
||||
// Claude: { env: { ANTHROPIC_AUTH_TOKEN | ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL } }
|
||||
const env = (config as any).env || {};
|
||||
return {
|
||||
apiKey: env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY,
|
||||
baseUrl: env.ANTHROPIC_BASE_URL,
|
||||
};
|
||||
} else if (appId === "codex") {
|
||||
// Codex: { auth: { OPENAI_API_KEY }, config: TOML string with base_url }
|
||||
const auth = (config as any).auth || {};
|
||||
const configToml = (config as any).config || "";
|
||||
return {
|
||||
apiKey: auth.OPENAI_API_KEY,
|
||||
baseUrl: extractCodexBaseUrl(configToml),
|
||||
};
|
||||
} else if (appId === "gemini") {
|
||||
// Gemini: { env: { GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL } }
|
||||
const env = (config as any).env || {};
|
||||
return {
|
||||
apiKey: env.GEMINI_API_KEY,
|
||||
baseUrl: env.GOOGLE_GEMINI_BASE_URL,
|
||||
};
|
||||
}
|
||||
);
|
||||
return { apiKey: undefined, baseUrl: undefined };
|
||||
} catch (error) {
|
||||
console.error("Failed to extract provider credentials:", error);
|
||||
return { apiKey: undefined, baseUrl: undefined };
|
||||
}
|
||||
};
|
||||
|
||||
const providerCredentials = getProviderCredentials();
|
||||
|
||||
const [script, setScript] = useState<UsageScript>(() => {
|
||||
const savedScript = provider.meta?.usage_script;
|
||||
const defaultScript = {
|
||||
enabled: false,
|
||||
language: "javascript" as const,
|
||||
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
|
||||
timeout: 10,
|
||||
};
|
||||
|
||||
if (!savedScript) {
|
||||
return defaultScript;
|
||||
}
|
||||
|
||||
return savedScript;
|
||||
});
|
||||
|
||||
const [testing, setTesting] = useState(false);
|
||||
@@ -176,6 +226,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
|
||||
() => {
|
||||
const existingScript = provider.meta?.usage_script;
|
||||
// 优先使用保存的 templateType
|
||||
if (existingScript?.templateType) {
|
||||
return existingScript.templateType;
|
||||
}
|
||||
// 向后兼容:根据字段推断模板类型
|
||||
// 检测 NEW_API 模板(有 accessToken 或 userId)
|
||||
if (existingScript?.accessToken || existingScript?.userId) {
|
||||
return TEMPLATE_KEYS.NEW_API;
|
||||
@@ -201,7 +256,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
|
||||
return;
|
||||
}
|
||||
onSave(script);
|
||||
// 保存时记录当前选择的模板类型
|
||||
const scriptWithTemplate = {
|
||||
...script,
|
||||
templateType: selectedTemplate as
|
||||
| "custom"
|
||||
| "general"
|
||||
| "newapi"
|
||||
| undefined,
|
||||
};
|
||||
onSave(scriptWithTemplate);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -217,6 +281,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
script.baseUrl,
|
||||
script.accessToken,
|
||||
script.userId,
|
||||
selectedTemplate as "custom" | "general" | "newapi" | undefined,
|
||||
);
|
||||
if (result.success && result.data && result.data.length > 0) {
|
||||
const summary = result.data
|
||||
@@ -229,6 +294,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
duration: 3000,
|
||||
closeButton: true,
|
||||
});
|
||||
|
||||
// 🔧 测试成功后,更新主界面列表的用量查询缓存
|
||||
queryClient.setQueryData(["usage", provider.id, appId], result);
|
||||
} else {
|
||||
toast.error(
|
||||
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
|
||||
@@ -278,9 +346,13 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
const preset = PRESET_TEMPLATES[presetName];
|
||||
if (preset) {
|
||||
if (presetName === TEMPLATE_KEYS.CUSTOM) {
|
||||
// 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换
|
||||
// 这样可以避免同源检查导致的问题
|
||||
// 如果用户想使用变量,需要手动在配置中设置 baseUrl/apiKey
|
||||
setScript({
|
||||
...script,
|
||||
code: preset,
|
||||
// 清除凭证,用户可选择手动输入或保持空
|
||||
apiKey: undefined,
|
||||
baseUrl: undefined,
|
||||
accessToken: undefined,
|
||||
@@ -401,6 +473,74 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 自定义模式:变量提示和具体值 */}
|
||||
{selectedTemplate === TEMPLATE_KEYS.CUSTOM && (
|
||||
<div className="space-y-2 border-t border-white/10 pt-3">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{t("usageScript.supportedVariables")}
|
||||
</h4>
|
||||
<div className="space-y-1 text-xs">
|
||||
{/* baseUrl */}
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
|
||||
{"{{baseUrl}}"}
|
||||
</code>
|
||||
<span className="text-muted-foreground/50">=</span>
|
||||
{providerCredentials.baseUrl ? (
|
||||
<code className="text-foreground/70 break-all font-mono">
|
||||
{providerCredentials.baseUrl}
|
||||
</code>
|
||||
) : (
|
||||
<span className="text-muted-foreground/50 italic">
|
||||
{t("common.notSet") || "未设置"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* apiKey */}
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
|
||||
{"{{apiKey}}"}
|
||||
</code>
|
||||
<span className="text-muted-foreground/50">=</span>
|
||||
{providerCredentials.apiKey ? (
|
||||
<>
|
||||
{showApiKey ? (
|
||||
<code className="text-foreground/70 break-all font-mono">
|
||||
{providerCredentials.apiKey}
|
||||
</code>
|
||||
) : (
|
||||
<code className="text-foreground/70 font-mono">
|
||||
••••••••
|
||||
</code>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors ml-1"
|
||||
aria-label={
|
||||
showApiKey
|
||||
? t("apiKeyInput.hide")
|
||||
: t("apiKeyInput.show")
|
||||
}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff size={12} />
|
||||
) : (
|
||||
<Eye size={12} />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground/50 italic">
|
||||
{t("common.notSet") || "未设置"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 凭证配置 */}
|
||||
{shouldShowCredentialsConfig && (
|
||||
<div className="space-y-4">
|
||||
@@ -601,11 +741,13 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
value={script.autoIntervalMinutes ?? 0}
|
||||
value={
|
||||
script.autoQueryInterval ?? script.autoIntervalMinutes ?? 0
|
||||
}
|
||||
onChange={(e) =>
|
||||
setScript({
|
||||
...script,
|
||||
autoIntervalMinutes: validateAndClampInterval(
|
||||
autoQueryInterval: validateAndClampInterval(
|
||||
e.target.value,
|
||||
),
|
||||
})
|
||||
@@ -613,7 +755,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
onBlur={(e) =>
|
||||
setScript({
|
||||
...script,
|
||||
autoIntervalMinutes: validateAndClampInterval(
|
||||
autoQueryInterval: validateAndClampInterval(
|
||||
e.target.value,
|
||||
),
|
||||
})
|
||||
|
||||
@@ -72,7 +72,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="mx-auto max-w-[56rem] px-6 w-full flex items-center gap-4"
|
||||
className="px-6 w-full flex items-center gap-4"
|
||||
data-tauri-drag-region
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
@@ -94,7 +94,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto scroll-overlay">
|
||||
<div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
|
||||
<div className="px-6 py-6 space-y-6 w-full">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +105,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
||||
className="flex-shrink-0 py-4 border-t border-border-default"
|
||||
style={{ backgroundColor: "hsl(var(--background))" }}
|
||||
>
|
||||
<div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
|
||||
<div className="px-6 flex items-center justify-end gap-3">
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,6 +65,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
claude: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
opencode: boolean;
|
||||
}>(() => {
|
||||
if (initialData?.apps) {
|
||||
return { ...initialData.apps };
|
||||
@@ -73,6 +74,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
claude: defaultEnabledApps.includes("claude"),
|
||||
codex: defaultEnabledApps.includes("codex"),
|
||||
gemini: defaultEnabledApps.includes("gemini"),
|
||||
opencode: defaultEnabledApps.includes("opencode"),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -59,11 +59,12 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
|
||||
// Count enabled servers per app
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = { claude: 0, codex: 0, gemini: 0 };
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
|
||||
serverEntries.forEach(([_, server]) => {
|
||||
if (server.apps.claude) counts.claude++;
|
||||
if (server.apps.codex) counts.codex++;
|
||||
if (server.apps.gemini) counts.gemini++;
|
||||
if (server.apps.opencode) counts.opencode++;
|
||||
});
|
||||
return counts;
|
||||
}, [serverEntries]);
|
||||
@@ -141,14 +142,15 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
{/* Info Section */}
|
||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{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}
|
||||
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini} ·{" "}
|
||||
{t("mcp.unifiedPanel.apps.opencode")}: {enabledCounts.opencode}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -337,6 +339,22 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-opencode`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.opencode")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${id}-opencode`}
|
||||
checked={server.apps.opencode}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(id, "opencode", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
|
||||
@@ -34,6 +34,7 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
|
||||
claude: "CLAUDE.md",
|
||||
codex: "AGENTS.md",
|
||||
gemini: "GEMINI.md",
|
||||
opencode: "AGENTS.md",
|
||||
};
|
||||
const filename = filenameMap[appId];
|
||||
const [name, setName] = useState("");
|
||||
|
||||
@@ -28,6 +28,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
claude: "CLAUDE.md",
|
||||
codex: "AGENTS.md",
|
||||
gemini: "GEMINI.md",
|
||||
opencode: "AGENTS.md",
|
||||
};
|
||||
const filename = filenameMap[appId];
|
||||
const [name, setName] = useState("");
|
||||
|
||||
@@ -96,7 +96,7 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
||||
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] px-6">
|
||||
<div className="flex flex-col h-[calc(100vh-8rem)] px-6">
|
||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("prompts.count", { count: promptEntries.length })} ·{" "}
|
||||
|
||||
@@ -17,13 +17,14 @@ import { UniversalProviderPanel } from "@/components/universal";
|
||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||
// Note: opencodeProviderPresets is loaded via ProviderForm, not needed here
|
||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||
|
||||
interface AddProviderDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
appId: AppId;
|
||||
onSubmit: (provider: Omit<Provider, "id">) => Promise<void> | void;
|
||||
onSubmit: (provider: Omit<Provider, "id"> & { providerKey?: string }) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function AddProviderDialog({
|
||||
@@ -33,6 +34,8 @@ export function AddProviderDialog({
|
||||
onSubmit,
|
||||
}: AddProviderDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// OpenCode doesn't support universal providers
|
||||
const showUniversalTab = appId !== "opencode";
|
||||
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
||||
"app-specific",
|
||||
);
|
||||
@@ -82,7 +85,7 @@ export function AddProviderDialog({
|
||||
>;
|
||||
|
||||
// 构造基础提交数据
|
||||
const providerData: Omit<Provider, "id"> = {
|
||||
const providerData: Omit<Provider, "id"> & { providerKey?: string } = {
|
||||
name: values.name.trim(),
|
||||
notes: values.notes?.trim() || undefined,
|
||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||
@@ -93,6 +96,11 @@ export function AddProviderDialog({
|
||||
...(values.meta ? { meta: values.meta } : {}),
|
||||
};
|
||||
|
||||
// OpenCode: pass providerKey for ID generation
|
||||
if (appId === "opencode" && values.providerKey) {
|
||||
providerData.providerKey = values.providerKey;
|
||||
}
|
||||
|
||||
const hasCustomEndpoints =
|
||||
providerData.meta?.custom_endpoints &&
|
||||
Object.keys(providerData.meta.custom_endpoints).length > 0;
|
||||
@@ -153,6 +161,7 @@ export function AddProviderDialog({
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: OpenCode doesn't use endpointCandidates - it handles endpoints internally
|
||||
}
|
||||
|
||||
if (appId === "claude") {
|
||||
@@ -175,6 +184,12 @@ export function AddProviderDialog({
|
||||
if (env?.GOOGLE_GEMINI_BASE_URL) {
|
||||
addUrl(env.GOOGLE_GEMINI_BASE_URL);
|
||||
}
|
||||
} else if (appId === "opencode") {
|
||||
// OpenCode uses options.baseURL
|
||||
const options = parsedConfig.options as Record<string, any> | undefined;
|
||||
if (options?.baseURL) {
|
||||
addUrl(options.baseURL);
|
||||
}
|
||||
}
|
||||
|
||||
const urls = Array.from(urlSet);
|
||||
@@ -204,7 +219,7 @@ export function AddProviderDialog({
|
||||
|
||||
// 动态 footer:根据当前 Tab 显示不同按钮
|
||||
const footer =
|
||||
activeTab === "app-specific" ? (
|
||||
!showUniversalTab || activeTab === "app-specific" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -248,41 +263,54 @@ export function AddProviderDialog({
|
||||
onClose={() => onOpenChange(false)}
|
||||
footer={footer}
|
||||
>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="app-specific">
|
||||
{t(`apps.${appId}`)} {t("provider.tabProvider")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="universal">
|
||||
{t("provider.tabUniversal")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{showUniversalTab ? (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="app-specific">
|
||||
{t(`apps.${appId}`)} {t("provider.tabProvider")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="universal">
|
||||
{t("provider.tabUniversal")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="app-specific" className="mt-0">
|
||||
<ProviderForm
|
||||
appId={appId}
|
||||
submitLabel={t("common.add")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
showButtons={false}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="app-specific" className="mt-0">
|
||||
<ProviderForm
|
||||
appId={appId}
|
||||
submitLabel={t("common.add")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
showButtons={false}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="universal" className="mt-0">
|
||||
<UniversalProviderPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="universal" className="mt-0">
|
||||
<UniversalProviderPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
// OpenCode: directly show form without tabs
|
||||
<ProviderForm
|
||||
appId={appId}
|
||||
submitLabel={t("common.add")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
showButtons={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Universal Provider Form Modal */}
|
||||
<UniversalProviderFormModal
|
||||
isOpen={universalFormOpen}
|
||||
onClose={handleUniversalFormClose}
|
||||
onSave={handleUniversalProviderSave}
|
||||
initialPreset={selectedUniversalPreset}
|
||||
/>
|
||||
{showUniversalTab && (
|
||||
<UniversalProviderFormModal
|
||||
isOpen={universalFormOpen}
|
||||
onClose={handleUniversalFormClose}
|
||||
onSave={handleUniversalProviderSave}
|
||||
initialPreset={selectedUniversalPreset}
|
||||
/>
|
||||
)}
|
||||
</FullScreenPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,17 @@ export function EditProviderDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// OpenCode uses additive mode - each provider's config is stored independently in DB
|
||||
// Reading live config would return the full opencode.json (with $schema, provider, mcp etc.)
|
||||
// instead of just the provider fragment, causing incorrect nested structure on save
|
||||
if (appId === "opencode") {
|
||||
if (!cancelled) {
|
||||
setLiveSettings(null);
|
||||
setHasLoadedLive(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentId = await providersApi.getCurrent(appId);
|
||||
if (currentId && provider.id === currentId) {
|
||||
|
||||
@@ -4,17 +4,23 @@ import {
|
||||
Copy,
|
||||
Edit,
|
||||
Loader2,
|
||||
Minus,
|
||||
Play,
|
||||
Plus,
|
||||
Terminal,
|
||||
TestTube2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AppId } from "@/lib/api";
|
||||
|
||||
interface ProviderActionsProps {
|
||||
appId?: AppId;
|
||||
isCurrent: boolean;
|
||||
/** OpenCode: 是否已添加到配置 */
|
||||
isInConfig?: boolean;
|
||||
isTesting?: boolean;
|
||||
isProxyTakeover?: boolean;
|
||||
onSwitch: () => void;
|
||||
@@ -23,6 +29,9 @@ interface ProviderActionsProps {
|
||||
onTest?: () => void;
|
||||
onConfigureUsage: () => void;
|
||||
onDelete: () => void;
|
||||
/** OpenCode: remove from live config (not delete from database) */
|
||||
onRemoveFromConfig?: () => void;
|
||||
onOpenTerminal?: () => void;
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled?: boolean;
|
||||
isInFailoverQueue?: boolean;
|
||||
@@ -30,7 +39,9 @@ interface ProviderActionsProps {
|
||||
}
|
||||
|
||||
export function ProviderActions({
|
||||
appId,
|
||||
isCurrent,
|
||||
isInConfig = false,
|
||||
isTesting,
|
||||
isProxyTakeover = false,
|
||||
onSwitch,
|
||||
@@ -39,6 +50,8 @@ export function ProviderActions({
|
||||
onTest,
|
||||
onConfigureUsage,
|
||||
onDelete,
|
||||
onRemoveFromConfig,
|
||||
onOpenTerminal,
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled = false,
|
||||
isInFailoverQueue = false,
|
||||
@@ -47,12 +60,27 @@ export function ProviderActions({
|
||||
const { t } = useTranslation();
|
||||
const iconButtonClass = "h-8 w-8 p-1";
|
||||
|
||||
// 故障转移模式下的按钮逻辑
|
||||
const isFailoverMode = isAutoFailoverEnabled && onToggleFailover;
|
||||
// OpenCode 使用累加模式
|
||||
const isOpenCodeMode = appId === "opencode";
|
||||
|
||||
// 故障转移模式下的按钮逻辑(OpenCode 不支持故障转移)
|
||||
const isFailoverMode = !isOpenCodeMode && isAutoFailoverEnabled && onToggleFailover;
|
||||
|
||||
// 处理主按钮点击
|
||||
const handleMainButtonClick = () => {
|
||||
if (isFailoverMode) {
|
||||
if (isOpenCodeMode) {
|
||||
// OpenCode 模式:切换配置状态(添加/移除)
|
||||
if (isInConfig) {
|
||||
// Use onRemoveFromConfig if available, otherwise fall back to onDelete
|
||||
if (onRemoveFromConfig) {
|
||||
onRemoveFromConfig();
|
||||
} else {
|
||||
onDelete();
|
||||
}
|
||||
} else {
|
||||
onSwitch(); // 添加到配置
|
||||
}
|
||||
} else if (isFailoverMode) {
|
||||
// 故障转移模式:切换队列状态
|
||||
onToggleFailover(!isInFailoverQueue);
|
||||
} else {
|
||||
@@ -63,8 +91,30 @@ export function ProviderActions({
|
||||
|
||||
// 主按钮的状态和样式
|
||||
const getMainButtonState = () => {
|
||||
// OpenCode 累加模式
|
||||
if (isOpenCodeMode) {
|
||||
if (isInConfig) {
|
||||
return {
|
||||
disabled: false,
|
||||
variant: "secondary" as const,
|
||||
className:
|
||||
"bg-orange-100 text-orange-600 hover:bg-orange-200 dark:bg-orange-900/50 dark:text-orange-400 dark:hover:bg-orange-900/70",
|
||||
icon: <Minus className="h-4 w-4" />,
|
||||
text: t("provider.removeFromConfig", { defaultValue: "移除" }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
disabled: false,
|
||||
variant: "default" as const,
|
||||
className:
|
||||
"bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
|
||||
icon: <Plus className="h-4 w-4" />,
|
||||
text: t("provider.addToConfig", { defaultValue: "添加" }),
|
||||
};
|
||||
}
|
||||
|
||||
// 故障转移模式
|
||||
if (isFailoverMode) {
|
||||
// 故障转移模式
|
||||
if (isInFailoverQueue) {
|
||||
return {
|
||||
disabled: false,
|
||||
@@ -110,6 +160,9 @@ export function ProviderActions({
|
||||
|
||||
const buttonState = getMainButtonState();
|
||||
|
||||
// OpenCode 模式下删除按钮始终可用(主按钮"移除"是从 live 配置移除,删除是从数据库删除)
|
||||
const canDelete = isOpenCodeMode ? true : !isCurrent;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
@@ -171,15 +224,30 @@ export function ProviderActions({
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{onOpenTerminal && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onOpenTerminal}
|
||||
title={t("provider.openTerminal", "打开终端")}
|
||||
className={cn(
|
||||
iconButtonClass,
|
||||
"hover:text-emerald-600 dark:hover:text-emerald-400",
|
||||
)}
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={isCurrent ? undefined : onDelete}
|
||||
onClick={canDelete ? onDelete : undefined}
|
||||
title={t("common.delete")}
|
||||
className={cn(
|
||||
iconButtonClass,
|
||||
!isCurrent && "hover:text-red-500 dark:hover:text-red-400",
|
||||
isCurrent && "opacity-40 cursor-not-allowed text-muted-foreground",
|
||||
canDelete && "hover:text-red-500 dark:hover:text-red-400",
|
||||
!canDelete && "opacity-40 cursor-not-allowed text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { useMemo, useState, useEffect, useRef } from "react";
|
||||
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
@@ -26,13 +26,17 @@ interface ProviderCardProps {
|
||||
provider: Provider;
|
||||
isCurrent: boolean;
|
||||
appId: AppId;
|
||||
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
|
||||
onSwitch: (provider: Provider) => void;
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (provider: Provider) => void;
|
||||
/** OpenCode: remove from live config (not delete from database) */
|
||||
onRemoveFromConfig?: (provider: Provider) => void;
|
||||
onConfigureUsage: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
onTest?: (provider: Provider) => void;
|
||||
onOpenTerminal?: (provider: Provider) => void;
|
||||
isTesting?: boolean;
|
||||
isProxyRunning: boolean;
|
||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
||||
@@ -84,13 +88,16 @@ export function ProviderCard({
|
||||
provider,
|
||||
isCurrent,
|
||||
appId,
|
||||
isInConfig = true,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRemoveFromConfig,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
onDuplicate,
|
||||
onTest,
|
||||
onOpenTerminal,
|
||||
isTesting,
|
||||
isProxyRunning,
|
||||
isProxyTakeover = false,
|
||||
@@ -132,7 +139,9 @@ export function ProviderCard({
|
||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||
|
||||
// 获取用量数据以判断是否有多套餐
|
||||
const autoQueryInterval = isCurrent
|
||||
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent
|
||||
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
|
||||
const autoQueryInterval = shouldAutoQuery
|
||||
? provider.meta?.usage_script?.autoQueryInterval || 0
|
||||
: 0;
|
||||
|
||||
@@ -147,6 +156,10 @@ export function ProviderCard({
|
||||
// 多套餐默认展开
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// 操作按钮容器 ref,用于动态计算宽度
|
||||
const actionsRef = useRef<HTMLDivElement>(null);
|
||||
const [actionsWidth, setActionsWidth] = useState(0);
|
||||
|
||||
// 当检测到多套餐时自动展开
|
||||
useEffect(() => {
|
||||
if (hasMultiplePlans) {
|
||||
@@ -154,6 +167,20 @@ export function ProviderCard({
|
||||
}
|
||||
}, [hasMultiplePlans]);
|
||||
|
||||
// 动态获取操作按钮宽度
|
||||
useEffect(() => {
|
||||
if (actionsRef.current) {
|
||||
const updateWidth = () => {
|
||||
const width = actionsRef.current?.offsetWidth || 0;
|
||||
setActionsWidth(width);
|
||||
};
|
||||
updateWidth();
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener("resize", updateWidth);
|
||||
return () => window.removeEventListener("resize", updateWidth);
|
||||
}
|
||||
}, [onTest, onOpenTerminal]); // 按钮数量可能变化时重新计算
|
||||
|
||||
const handleOpenWebsite = () => {
|
||||
if (!isClickableUrl) {
|
||||
return;
|
||||
@@ -162,12 +189,16 @@ export function ProviderCard({
|
||||
};
|
||||
|
||||
// 判断是否是"当前使用中"的供应商
|
||||
// - OpenCode(累加模式):不存在"当前"概念,始终返回 false
|
||||
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||||
// - 代理接管模式(非故障转移):isCurrent
|
||||
// - 普通模式:isCurrent
|
||||
const isActiveProvider = isAutoFailoverEnabled
|
||||
? activeProviderId === provider.id
|
||||
: isCurrent;
|
||||
const isActiveProvider =
|
||||
appId === "opencode"
|
||||
? false
|
||||
: isAutoFailoverEnabled
|
||||
? activeProviderId === provider.id
|
||||
: isCurrent;
|
||||
|
||||
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
|
||||
const shouldUseGreen = isProxyTakeover && isActiveProvider;
|
||||
@@ -279,10 +310,17 @@ export function ProviderCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center ml-auto min-w-0">
|
||||
<div
|
||||
className="relative flex items-center ml-auto min-w-0 gap-3"
|
||||
style={
|
||||
{
|
||||
"--actions-width": `${actionsWidth || 320}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
|
||||
<div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[14.5rem] group-focus-within:-translate-x-[14.5rem] sm:group-hover:-translate-x-[16rem] sm:group-focus-within:-translate-x-[16rem]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="ml-auto">
|
||||
<div className="flex items-center gap-1 transition-transform duration-200 group-hover:-translate-x-[var(--actions-width)] group-focus-within:-translate-x-[var(--actions-width)]">
|
||||
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
|
||||
{hasMultiplePlans ? (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
@@ -300,6 +338,7 @@ export function ProviderCard({
|
||||
appId={appId}
|
||||
usageEnabled={usageEnabled}
|
||||
isCurrent={isCurrent}
|
||||
isInConfig={isInConfig}
|
||||
inline={true}
|
||||
/>
|
||||
)}
|
||||
@@ -327,10 +366,15 @@ export function ProviderCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入 */}
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
|
||||
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */}
|
||||
<div
|
||||
ref={actionsRef}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
|
||||
>
|
||||
<ProviderActions
|
||||
appId={appId}
|
||||
isCurrent={isCurrent}
|
||||
isInConfig={isInConfig}
|
||||
isTesting={isTesting}
|
||||
isProxyTakeover={isProxyTakeover}
|
||||
onSwitch={() => onSwitch(provider)}
|
||||
@@ -339,6 +383,14 @@ export function ProviderCard({
|
||||
onTest={onTest ? () => onTest(provider) : undefined}
|
||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||
onDelete={() => onDelete(provider)}
|
||||
onRemoveFromConfig={
|
||||
onRemoveFromConfig
|
||||
? () => onRemoveFromConfig(provider)
|
||||
: undefined
|
||||
}
|
||||
onOpenTerminal={
|
||||
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
|
||||
}
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||
isInFailoverQueue={isInFailoverQueue}
|
||||
@@ -357,6 +409,7 @@ export function ProviderCard({
|
||||
appId={appId}
|
||||
usageEnabled={usageEnabled}
|
||||
isCurrent={isCurrent}
|
||||
isInConfig={isInConfig}
|
||||
inline={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Provider } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { providersApi } from "@/lib/api/providers";
|
||||
import { useDragSort } from "@/hooks/useDragSort";
|
||||
import { useStreamCheck } from "@/hooks/useStreamCheck";
|
||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||
@@ -38,9 +40,12 @@ interface ProviderListProps {
|
||||
onSwitch: (provider: Provider) => void;
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (provider: Provider) => void;
|
||||
/** OpenCode: remove from live config (not delete from database) */
|
||||
onRemoveFromConfig?: (provider: Provider) => void;
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
onConfigureUsage?: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
onOpenTerminal?: (provider: Provider) => void;
|
||||
onCreate?: () => void;
|
||||
isLoading?: boolean;
|
||||
isProxyRunning?: boolean; // 代理服务运行状态
|
||||
@@ -55,9 +60,11 @@ export function ProviderList({
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRemoveFromConfig,
|
||||
onDuplicate,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
onOpenTerminal,
|
||||
onCreate,
|
||||
isLoading = false,
|
||||
isProxyRunning = false,
|
||||
@@ -70,6 +77,22 @@ export function ProviderList({
|
||||
appId,
|
||||
);
|
||||
|
||||
// OpenCode: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig
|
||||
const { data: opencodeLiveIds } = useQuery({
|
||||
queryKey: ["opencodeLiveProviderIds"],
|
||||
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
|
||||
enabled: appId === "opencode",
|
||||
});
|
||||
|
||||
// OpenCode: 判断供应商是否已添加到 opencode.json
|
||||
const isProviderInConfig = useCallback(
|
||||
(providerId: string): boolean => {
|
||||
if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true
|
||||
return opencodeLiveIds?.includes(providerId) ?? false;
|
||||
},
|
||||
[appId, opencodeLiveIds],
|
||||
);
|
||||
|
||||
// 流式健康检查
|
||||
const { checkProvider, isChecking } = useStreamCheck(appId);
|
||||
|
||||
@@ -197,13 +220,16 @@ export function ProviderList({
|
||||
provider={provider}
|
||||
isCurrent={provider.id === currentProviderId}
|
||||
appId={appId}
|
||||
isInConfig={isProviderInConfig(provider.id)}
|
||||
onSwitch={onSwitch}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onRemoveFromConfig={onRemoveFromConfig}
|
||||
onDuplicate={onDuplicate}
|
||||
onConfigureUsage={onConfigureUsage}
|
||||
onOpenWebsite={onOpenWebsite}
|
||||
onTest={handleTest}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
onTest={appId !== "opencode" ? handleTest : undefined}
|
||||
isTesting={isChecking(provider.id)}
|
||||
isProxyRunning={isProxyRunning}
|
||||
isProxyTakeover={isProxyTakeover}
|
||||
@@ -305,13 +331,17 @@ interface SortableProviderCardProps {
|
||||
provider: Provider;
|
||||
isCurrent: boolean;
|
||||
appId: AppId;
|
||||
isInConfig: boolean;
|
||||
onSwitch: (provider: Provider) => void;
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (provider: Provider) => void;
|
||||
/** OpenCode: remove from live config (not delete from database) */
|
||||
onRemoveFromConfig?: (provider: Provider) => void;
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
onConfigureUsage?: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
onTest: (provider: Provider) => void;
|
||||
onOpenTerminal?: (provider: Provider) => void;
|
||||
onTest?: (provider: Provider) => void;
|
||||
isTesting: boolean;
|
||||
isProxyRunning: boolean;
|
||||
isProxyTakeover: boolean;
|
||||
@@ -327,12 +357,15 @@ function SortableProviderCard({
|
||||
provider,
|
||||
isCurrent,
|
||||
appId,
|
||||
isInConfig,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRemoveFromConfig,
|
||||
onDuplicate,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
onOpenTerminal,
|
||||
onTest,
|
||||
isTesting,
|
||||
isProxyRunning,
|
||||
@@ -363,14 +396,17 @@ function SortableProviderCard({
|
||||
provider={provider}
|
||||
isCurrent={isCurrent}
|
||||
appId={appId}
|
||||
isInConfig={isInConfig}
|
||||
onSwitch={onSwitch}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onRemoveFromConfig={onRemoveFromConfig}
|
||||
onDuplicate={onDuplicate}
|
||||
onConfigureUsage={
|
||||
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
||||
}
|
||||
onOpenWebsite={onOpenWebsite}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
onTest={onTest}
|
||||
isTesting={isTesting}
|
||||
isProxyRunning={isProxyRunning}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
@@ -24,9 +25,11 @@ import type { ProviderFormData } from "@/lib/schemas/provider";
|
||||
|
||||
interface BasicFormFieldsProps {
|
||||
form: UseFormReturn<ProviderFormData>;
|
||||
/** Slot to render content between icon and name fields */
|
||||
beforeNameSlot?: ReactNode;
|
||||
}
|
||||
|
||||
export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
||||
export function BasicFormFields({ form, beforeNameSlot }: BasicFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [iconDialogOpen, setIconDialogOpen] = useState(false);
|
||||
|
||||
@@ -78,7 +81,7 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-shrink-0 py-4 border-b border-border-default bg-muted/40">
|
||||
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
|
||||
<div className="px-6 flex items-center gap-4">
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
@@ -92,7 +95,7 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-2 mx-auto max-w-[56rem] px-6 py-6 w-full">
|
||||
<div className="space-y-2 px-6 py-6 w-full">
|
||||
<IconPicker
|
||||
value={currentIcon}
|
||||
onValueChange={handleIconSelect}
|
||||
@@ -112,6 +115,9 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Slot for additional fields between icon and name */}
|
||||
{beforeNameSlot}
|
||||
|
||||
{/* 基础信息 - 网格布局 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
|
||||
@@ -36,6 +36,8 @@ interface ClaudeFormFieldsProps {
|
||||
isEndpointModalOpen: boolean;
|
||||
onEndpointModalToggle: (open: boolean) => void;
|
||||
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
||||
autoSelect: boolean;
|
||||
onAutoSelectChange: (checked: boolean) => void;
|
||||
|
||||
// Model Selector
|
||||
shouldShowModelSelector: boolean;
|
||||
@@ -83,6 +85,8 @@ export function ClaudeFormFields({
|
||||
isEndpointModalOpen,
|
||||
onEndpointModalToggle,
|
||||
onCustomEndpointsChange,
|
||||
autoSelect,
|
||||
onAutoSelectChange,
|
||||
shouldShowModelSelector,
|
||||
claudeModel,
|
||||
reasoningModel,
|
||||
@@ -170,6 +174,8 @@ export function ClaudeFormFields({
|
||||
initialEndpoints={speedTestEndpoints}
|
||||
visible={isEndpointModalOpen}
|
||||
onClose={() => onEndpointModalToggle(false)}
|
||||
autoSelect={autoSelect}
|
||||
onAutoSelectChange={onAutoSelectChange}
|
||||
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,8 @@ interface CodexFormFieldsProps {
|
||||
isEndpointModalOpen: boolean;
|
||||
onEndpointModalToggle: (open: boolean) => void;
|
||||
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
||||
autoSelect: boolean;
|
||||
onAutoSelectChange: (checked: boolean) => void;
|
||||
|
||||
// Model Name
|
||||
shouldShowModelField?: boolean;
|
||||
@@ -50,6 +52,8 @@ export function CodexFormFields({
|
||||
isEndpointModalOpen,
|
||||
onEndpointModalToggle,
|
||||
onCustomEndpointsChange,
|
||||
autoSelect,
|
||||
onAutoSelectChange,
|
||||
shouldShowModelField = true,
|
||||
modelName = "",
|
||||
onModelNameChange,
|
||||
@@ -130,6 +134,8 @@ export function CodexFormFields({
|
||||
initialEndpoints={speedTestEndpoints}
|
||||
visible={isEndpointModalOpen}
|
||||
onClose={() => onEndpointModalToggle(false)}
|
||||
autoSelect={autoSelect}
|
||||
onAutoSelectChange={onAutoSelectChange}
|
||||
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -9,11 +9,12 @@ import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import type { CustomEndpoint, EndpointCandidate } from "@/types";
|
||||
|
||||
// 端点测速超时配置(秒)
|
||||
const ENDPOINT_TIMEOUT_SECS = {
|
||||
const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
|
||||
codex: 12,
|
||||
claude: 8,
|
||||
gemini: 8, // 新增 gemini
|
||||
} as const;
|
||||
gemini: 8,
|
||||
opencode: 8,
|
||||
};
|
||||
|
||||
interface TestResult {
|
||||
url: string;
|
||||
@@ -30,6 +31,8 @@ interface EndpointSpeedTestProps {
|
||||
initialEndpoints: EndpointCandidate[];
|
||||
visible?: boolean;
|
||||
onClose: () => void;
|
||||
autoSelect: boolean;
|
||||
onAutoSelectChange: (checked: boolean) => void;
|
||||
// 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目)
|
||||
// 编辑模式:不使用此回调,端点直接保存到后端
|
||||
onCustomEndpointsChange?: (urls: string[]) => void;
|
||||
@@ -85,6 +88,8 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
||||
initialEndpoints,
|
||||
visible = true,
|
||||
onClose,
|
||||
autoSelect,
|
||||
onAutoSelectChange,
|
||||
onCustomEndpointsChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -93,7 +98,6 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
||||
);
|
||||
const [customUrl, setCustomUrl] = useState("");
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [autoSelect, setAutoSelect] = useState(true);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -488,7 +492,9 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoSelect}
|
||||
onChange={(event) => setAutoSelect(event.target.checked)}
|
||||
onChange={(event) => {
|
||||
onAutoSelectChange(event.target.checked);
|
||||
}}
|
||||
className="h-3.5 w-3.5 rounded border-border-default bg-background text-primary focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
{t("endpointTest.autoSelect")}
|
||||
|
||||
@@ -29,6 +29,8 @@ interface GeminiFormFieldsProps {
|
||||
isEndpointModalOpen: boolean;
|
||||
onEndpointModalToggle: (open: boolean) => void;
|
||||
onCustomEndpointsChange: (endpoints: string[]) => void;
|
||||
autoSelect: boolean;
|
||||
onAutoSelectChange: (checked: boolean) => void;
|
||||
|
||||
// Model
|
||||
shouldShowModelField: boolean;
|
||||
@@ -55,6 +57,8 @@ export function GeminiFormFields({
|
||||
isEndpointModalOpen,
|
||||
onEndpointModalToggle,
|
||||
onCustomEndpointsChange,
|
||||
autoSelect,
|
||||
onAutoSelectChange,
|
||||
shouldShowModelField,
|
||||
model,
|
||||
onModelChange,
|
||||
@@ -142,6 +146,8 @@ export function GeminiFormFields({
|
||||
initialEndpoints={speedTestEndpoints}
|
||||
visible={isEndpointModalOpen}
|
||||
onClose={() => onEndpointModalToggle(false)}
|
||||
autoSelect={autoSelect}
|
||||
onAutoSelectChange={onAutoSelectChange}
|
||||
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, Trash2, ChevronRight } from "lucide-react";
|
||||
import { ApiKeySection } from "./shared";
|
||||
import { opencodeNpmPackages } from "@/config/opencodeProviderPresets";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProviderCategory, OpenCodeModel } from "@/types";
|
||||
|
||||
/**
|
||||
* Model ID input with local state to prevent focus loss.
|
||||
* The key prop issue: when Model ID changes, React sees it as a new element
|
||||
* and unmounts/remounts the input, losing focus. Using local state + onBlur
|
||||
* keeps the key stable during editing.
|
||||
*/
|
||||
function ModelIdInput({
|
||||
modelId,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
modelId: string;
|
||||
onChange: (newId: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [localValue, setLocalValue] = useState(modelId);
|
||||
|
||||
// Sync when external modelId changes (e.g., undo operation)
|
||||
useEffect(() => {
|
||||
setLocalValue(modelId);
|
||||
}, [modelId]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={localValue}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (localValue !== modelId && localValue.trim()) {
|
||||
onChange(localValue);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="flex-1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra option key input with local state to prevent focus loss.
|
||||
* Same pattern as ModelIdInput - use local state during editing,
|
||||
* only commit changes on blur.
|
||||
*/
|
||||
function ExtraOptionKeyInput({
|
||||
optionKey,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
optionKey: string;
|
||||
onChange: (newKey: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
// For new options with placeholder keys like "option-123", show empty string
|
||||
const displayValue = optionKey.startsWith("option-") ? "" : optionKey;
|
||||
const [localValue, setLocalValue] = useState(displayValue);
|
||||
|
||||
// Sync when external key changes
|
||||
useEffect(() => {
|
||||
setLocalValue(optionKey.startsWith("option-") ? "" : optionKey);
|
||||
}, [optionKey]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={localValue}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
const trimmed = localValue.trim();
|
||||
if (trimmed && trimmed !== optionKey) {
|
||||
onChange(trimmed);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="flex-1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Model option key input with local state to prevent focus loss.
|
||||
* Reuses the same pattern as ExtraOptionKeyInput.
|
||||
*/
|
||||
function ModelOptionKeyInput({
|
||||
optionKey,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
optionKey: string;
|
||||
onChange: (newKey: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const displayValue = optionKey.startsWith("option-") ? "" : optionKey;
|
||||
const [localValue, setLocalValue] = useState(displayValue);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(optionKey.startsWith("option-") ? "" : optionKey);
|
||||
}, [optionKey]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={localValue}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
const trimmed = localValue.trim();
|
||||
if (trimmed && trimmed !== optionKey) {
|
||||
onChange(trimmed);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="flex-1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface OpenCodeFormFieldsProps {
|
||||
// NPM Package
|
||||
npm: string;
|
||||
onNpmChange: (value: string) => void;
|
||||
|
||||
// API Key
|
||||
apiKey: string;
|
||||
onApiKeyChange: (value: string) => void;
|
||||
category?: ProviderCategory;
|
||||
shouldShowApiKeyLink: boolean;
|
||||
websiteUrl: string;
|
||||
|
||||
// Base URL
|
||||
baseUrl: string;
|
||||
onBaseUrlChange: (value: string) => void;
|
||||
|
||||
// Models
|
||||
models: Record<string, OpenCodeModel>;
|
||||
onModelsChange: (models: Record<string, OpenCodeModel>) => void;
|
||||
|
||||
// Extra Options
|
||||
extraOptions: Record<string, string>;
|
||||
onExtraOptionsChange: (options: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
export function OpenCodeFormFields({
|
||||
npm,
|
||||
onNpmChange,
|
||||
apiKey,
|
||||
onApiKeyChange,
|
||||
category,
|
||||
shouldShowApiKeyLink,
|
||||
websiteUrl,
|
||||
baseUrl,
|
||||
onBaseUrlChange,
|
||||
models,
|
||||
onModelsChange,
|
||||
extraOptions,
|
||||
onExtraOptionsChange,
|
||||
}: OpenCodeFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Track which models have expanded options panel
|
||||
const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());
|
||||
|
||||
// Toggle model expand state
|
||||
const toggleModelExpand = (key: string) => {
|
||||
setExpandedModels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Add a new model entry
|
||||
const handleAddModel = () => {
|
||||
const newKey = `model-${Date.now()}`;
|
||||
onModelsChange({
|
||||
...models,
|
||||
[newKey]: { name: "" },
|
||||
});
|
||||
};
|
||||
|
||||
// Remove a model entry
|
||||
const handleRemoveModel = (key: string) => {
|
||||
const newModels = { ...models };
|
||||
delete newModels[key];
|
||||
onModelsChange(newModels);
|
||||
// Also remove from expanded set
|
||||
setExpandedModels((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Update model ID (key)
|
||||
const handleModelIdChange = (oldKey: string, newKey: string) => {
|
||||
if (oldKey === newKey || !newKey.trim()) return;
|
||||
const newModels: Record<string, OpenCodeModel> = {};
|
||||
for (const [k, v] of Object.entries(models)) {
|
||||
if (k === oldKey) {
|
||||
newModels[newKey] = v;
|
||||
} else {
|
||||
newModels[k] = v;
|
||||
}
|
||||
}
|
||||
onModelsChange(newModels);
|
||||
// Update expanded set if this model was expanded
|
||||
if (expandedModels.has(oldKey)) {
|
||||
setExpandedModels((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(oldKey);
|
||||
next.add(newKey);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Update model name
|
||||
const handleModelNameChange = (key: string, name: string) => {
|
||||
onModelsChange({
|
||||
...models,
|
||||
[key]: { ...models[key], name },
|
||||
});
|
||||
};
|
||||
|
||||
// Model options handlers
|
||||
const handleAddModelOption = (modelKey: string) => {
|
||||
const model = models[modelKey];
|
||||
const newOptionKey = `option-${Date.now()}`;
|
||||
onModelsChange({
|
||||
...models,
|
||||
[modelKey]: {
|
||||
...model,
|
||||
options: { ...model.options, [newOptionKey]: "" },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveModelOption = (modelKey: string, optionKey: string) => {
|
||||
const model = models[modelKey];
|
||||
const newOptions = { ...model.options };
|
||||
delete newOptions[optionKey];
|
||||
onModelsChange({
|
||||
...models,
|
||||
[modelKey]: {
|
||||
...model,
|
||||
options: Object.keys(newOptions).length > 0 ? newOptions : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleModelOptionKeyChange = (
|
||||
modelKey: string,
|
||||
oldKey: string,
|
||||
newKey: string
|
||||
) => {
|
||||
if (!newKey.trim() || oldKey === newKey) return;
|
||||
const model = models[modelKey];
|
||||
const newOptions: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(model.options || {})) {
|
||||
if (k === oldKey) newOptions[newKey] = v;
|
||||
else newOptions[k] = v;
|
||||
}
|
||||
onModelsChange({
|
||||
...models,
|
||||
[modelKey]: { ...model, options: newOptions },
|
||||
});
|
||||
};
|
||||
|
||||
const handleModelOptionValueChange = (
|
||||
modelKey: string,
|
||||
optionKey: string,
|
||||
value: string
|
||||
) => {
|
||||
const model = models[modelKey];
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
} catch {
|
||||
parsedValue = value;
|
||||
}
|
||||
onModelsChange({
|
||||
...models,
|
||||
[modelKey]: {
|
||||
...model,
|
||||
options: { ...model.options, [optionKey]: parsedValue },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Extra Options handlers
|
||||
const handleAddExtraOption = () => {
|
||||
const newKey = `option-${Date.now()}`;
|
||||
onExtraOptionsChange({
|
||||
...extraOptions,
|
||||
[newKey]: "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveExtraOption = (key: string) => {
|
||||
const newOptions = { ...extraOptions };
|
||||
delete newOptions[key];
|
||||
onExtraOptionsChange(newOptions);
|
||||
};
|
||||
|
||||
const handleExtraOptionKeyChange = (oldKey: string, newKey: string) => {
|
||||
if (oldKey === newKey) return;
|
||||
const newOptions: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(extraOptions)) {
|
||||
if (k === oldKey) {
|
||||
newOptions[newKey.trim() || oldKey] = v;
|
||||
} else {
|
||||
newOptions[k] = v;
|
||||
}
|
||||
}
|
||||
onExtraOptionsChange(newOptions);
|
||||
};
|
||||
|
||||
const handleExtraOptionValueChange = (key: string, value: string) => {
|
||||
onExtraOptionsChange({
|
||||
...extraOptions,
|
||||
[key]: value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* NPM Package Selector */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="opencode-npm">
|
||||
{t("opencode.npmPackage", {
|
||||
defaultValue: "接口格式",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Select value={npm} onValueChange={onNpmChange}>
|
||||
<SelectTrigger id="opencode-npm">
|
||||
<SelectValue
|
||||
placeholder={t("opencode.selectPackage", {
|
||||
defaultValue: "Select a package",
|
||||
})}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{opencodeNpmPackages.map((pkg) => (
|
||||
<SelectItem key={pkg.value} value={pkg.value}>
|
||||
{pkg.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("opencode.npmPackageHint", {
|
||||
defaultValue:
|
||||
"Select the AI SDK package that matches your provider.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<ApiKeySection
|
||||
value={apiKey}
|
||||
onChange={onApiKeyChange}
|
||||
category={category}
|
||||
shouldShowLink={shouldShowApiKeyLink}
|
||||
websiteUrl={websiteUrl}
|
||||
/>
|
||||
|
||||
{/* Base URL */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="opencode-baseurl">
|
||||
{t("opencode.baseUrl", { defaultValue: "Base URL" })}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="opencode-baseurl"
|
||||
value={baseUrl}
|
||||
onChange={(e) => onBaseUrlChange(e.target.value)}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("opencode.baseUrlHint", {
|
||||
defaultValue:
|
||||
"The base URL for the API endpoint. Leave empty to use the default endpoint for official SDKs.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Extra Options Editor */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>
|
||||
{t("opencode.extraOptions", { defaultValue: "额外选项" })}
|
||||
</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddExtraOption}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("opencode.addExtraOption", { defaultValue: "添加" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{Object.keys(extraOptions).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
{t("opencode.noExtraOptions", {
|
||||
defaultValue: "暂无额外选项",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 mb-1">
|
||||
<span className="flex-1">
|
||||
{t("opencode.extraOptionKey", { defaultValue: "键名" })}
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t("opencode.extraOptionValue", { defaultValue: "值" })}
|
||||
</span>
|
||||
<span className="w-9" />
|
||||
</div>
|
||||
{Object.entries(extraOptions).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<ExtraOptionKeyInput
|
||||
optionKey={key}
|
||||
onChange={(newKey) => handleExtraOptionKeyChange(key, newKey)}
|
||||
placeholder={t("opencode.extraOptionKeyPlaceholder", {
|
||||
defaultValue: "timeout",
|
||||
})}
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => handleExtraOptionValueChange(key, e.target.value)}
|
||||
placeholder={t("opencode.extraOptionValuePlaceholder", {
|
||||
defaultValue: "600000",
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveExtraOption(key)}
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("opencode.extraOptionsHint", {
|
||||
defaultValue:
|
||||
"配置额外的 SDK 选项,如 timeout、setCacheKey 等。值会自动解析类型(数字、布尔值等)。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Models Editor */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>
|
||||
{t("opencode.models", { defaultValue: "Models" })}
|
||||
</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddModel}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("opencode.addModel", { defaultValue: "Add" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{Object.keys(models).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
{t("opencode.noModels", {
|
||||
defaultValue: "No models configured. Click Add to add a model.",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 mb-1">
|
||||
<span className="w-9" />
|
||||
<span className="flex-1">
|
||||
{t("opencode.modelId", { defaultValue: "模型 ID" })}
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t("opencode.modelName", { defaultValue: "显示名称" })}
|
||||
</span>
|
||||
<span className="w-9" />
|
||||
</div>
|
||||
{Object.entries(models).map(([key, model]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
{/* Model row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggleModelExpand(key)}
|
||||
className="h-9 w-9 shrink-0"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform",
|
||||
expandedModels.has(key) && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<ModelIdInput
|
||||
modelId={key}
|
||||
onChange={(newId) => handleModelIdChange(key, newId)}
|
||||
placeholder={t("opencode.modelId", {
|
||||
defaultValue: "Model ID",
|
||||
})}
|
||||
/>
|
||||
<Input
|
||||
value={model.name}
|
||||
onChange={(e) => handleModelNameChange(key, e.target.value)}
|
||||
placeholder={t("opencode.modelName", {
|
||||
defaultValue: "Display Name",
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveModel(key)}
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Expanded model options */}
|
||||
{expandedModels.has(key) && (
|
||||
<div className="ml-9 pl-4 border-l-2 border-muted space-y-2">
|
||||
{Object.keys(model.options || {}).length === 0 ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground py-1">
|
||||
{t("opencode.noModelOptions", {
|
||||
defaultValue: "模型选项,点击 + 添加",
|
||||
})}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleAddModelOption(key)}
|
||||
className="h-6 px-2 gap-1"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{Object.entries(model.options || {}).map(
|
||||
([optKey, optValue]) => (
|
||||
<div key={optKey} className="flex items-center gap-2">
|
||||
<ModelOptionKeyInput
|
||||
optionKey={optKey}
|
||||
onChange={(newKey) =>
|
||||
handleModelOptionKeyChange(key, optKey, newKey)
|
||||
}
|
||||
placeholder={t(
|
||||
"opencode.modelOptionKeyPlaceholder",
|
||||
{
|
||||
defaultValue: "provider",
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
value={
|
||||
typeof optValue === "string"
|
||||
? optValue
|
||||
: JSON.stringify(optValue)
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleModelOptionValueChange(
|
||||
key,
|
||||
optKey,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder={t(
|
||||
"opencode.modelOptionValuePlaceholder",
|
||||
{
|
||||
defaultValue: '{"order": ["baseten"]}',
|
||||
}
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleRemoveModelOption(key, optKey)
|
||||
}
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleAddModelOption(key)}
|
||||
className="h-6 px-2 gap-1"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("opencode.modelsHint", {
|
||||
defaultValue:
|
||||
"Configure available models. Model ID is the API identifier, Display Name is shown in the UI.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import type { ProviderCategory, ProviderMeta } from "@/types";
|
||||
@@ -20,6 +21,12 @@ import {
|
||||
geminiProviderPresets,
|
||||
type GeminiProviderPreset,
|
||||
} from "@/config/geminiProviderPresets";
|
||||
import {
|
||||
opencodeProviderPresets,
|
||||
type OpenCodeProviderPreset,
|
||||
} from "@/config/opencodeProviderPresets";
|
||||
import { OpenCodeFormFields } from "./OpenCodeFormFields";
|
||||
import type { OpenCodeModel } from "@/types";
|
||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||
@@ -27,6 +34,8 @@ import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
||||
import CodexConfigEditor from "./CodexConfigEditor";
|
||||
import { CommonConfigEditor } from "./CommonConfigEditor";
|
||||
import GeminiConfigEditor from "./GeminiConfigEditor";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ProviderPresetSelector } from "./ProviderPresetSelector";
|
||||
import { BasicFormFields } from "./BasicFormFields";
|
||||
import { ClaudeFormFields } from "./ClaudeFormFields";
|
||||
@@ -47,6 +56,7 @@ import {
|
||||
useGeminiConfigState,
|
||||
useGeminiCommonConfig,
|
||||
} from "./hooks";
|
||||
import { useProvidersQuery } from "@/lib/query/queries";
|
||||
|
||||
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
|
||||
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
||||
@@ -62,9 +72,22 @@ const GEMINI_DEFAULT_CONFIG = JSON.stringify(
|
||||
2,
|
||||
);
|
||||
|
||||
const OPENCODE_DEFAULT_CONFIG = JSON.stringify(
|
||||
{
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
type PresetEntry = {
|
||||
id: string;
|
||||
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset;
|
||||
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset | OpenCodeProviderPreset;
|
||||
};
|
||||
|
||||
interface ProviderFormProps {
|
||||
@@ -124,6 +147,9 @@ export function ProviderForm({
|
||||
return [];
|
||||
},
|
||||
);
|
||||
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
|
||||
() => initialData?.meta?.endpointAutoSelect ?? true,
|
||||
);
|
||||
|
||||
// 使用 category hook
|
||||
const { category } = useProviderCategory({
|
||||
@@ -141,6 +167,7 @@ export function ProviderForm({
|
||||
if (!initialData) {
|
||||
setDraftCustomEndpoints([]);
|
||||
}
|
||||
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
|
||||
}, [appId, initialData]);
|
||||
|
||||
const defaultValues: ProviderFormData = useMemo(
|
||||
@@ -154,7 +181,9 @@ export function ProviderForm({
|
||||
? CODEX_DEFAULT_CONFIG
|
||||
: appId === "gemini"
|
||||
? GEMINI_DEFAULT_CONFIG
|
||||
: CLAUDE_DEFAULT_CONFIG,
|
||||
: appId === "opencode"
|
||||
? OPENCODE_DEFAULT_CONFIG
|
||||
: CLAUDE_DEFAULT_CONFIG,
|
||||
icon: initialData?.icon ?? "",
|
||||
iconColor: initialData?.iconColor ?? "",
|
||||
}),
|
||||
@@ -167,7 +196,7 @@ export function ProviderForm({
|
||||
mode: "onSubmit",
|
||||
});
|
||||
|
||||
const settingsConfigValue = form.watch("settingsConfig");
|
||||
const settingsConfigValue = form.getValues("settingsConfig");
|
||||
|
||||
// 使用 API Key hook
|
||||
const {
|
||||
@@ -175,7 +204,7 @@ export function ProviderForm({
|
||||
handleApiKeyChange,
|
||||
showApiKey: shouldShowApiKey,
|
||||
} = useApiKeyState({
|
||||
initialConfig: form.watch("settingsConfig"),
|
||||
initialConfig: form.getValues("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
selectedPresetId,
|
||||
category,
|
||||
@@ -186,7 +215,7 @@ export function ProviderForm({
|
||||
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
|
||||
appType: appId,
|
||||
category,
|
||||
settingsConfig: form.watch("settingsConfig"),
|
||||
settingsConfig: form.getValues("settingsConfig"),
|
||||
codexConfig: "",
|
||||
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
onCodexConfigChange: () => {
|
||||
@@ -203,7 +232,7 @@ export function ProviderForm({
|
||||
defaultOpusModel,
|
||||
handleModelChange,
|
||||
} = useModelState({
|
||||
settingsConfig: form.watch("settingsConfig"),
|
||||
settingsConfig: form.getValues("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
});
|
||||
|
||||
@@ -324,6 +353,11 @@ export function ProviderForm({
|
||||
id: `gemini-${index}`,
|
||||
preset,
|
||||
}));
|
||||
} else if (appId === "opencode") {
|
||||
return opencodeProviderPresets.map<PresetEntry>((preset, index) => ({
|
||||
id: `opencode-${index}`,
|
||||
preset,
|
||||
}));
|
||||
}
|
||||
return providerPresets.map<PresetEntry>((preset, index) => ({
|
||||
id: `claude-${index}`,
|
||||
@@ -341,7 +375,7 @@ export function ProviderForm({
|
||||
} = useTemplateValues({
|
||||
selectedPresetId: appId === "claude" ? selectedPresetId : null,
|
||||
presetEntries: appId === "claude" ? presetEntries : [],
|
||||
settingsConfig: form.watch("settingsConfig"),
|
||||
settingsConfig: form.getValues("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
});
|
||||
|
||||
@@ -355,10 +389,11 @@ export function ProviderForm({
|
||||
isExtracting: isClaudeExtracting,
|
||||
handleExtract: handleClaudeExtract,
|
||||
} = useCommonConfigSnippet({
|
||||
settingsConfig: form.watch("settingsConfig"),
|
||||
settingsConfig: form.getValues("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
initialData: appId === "claude" ? initialData : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
enabled: appId === "claude",
|
||||
});
|
||||
|
||||
// 使用 Codex 通用配置片段 hook (仅 Codex 模式)
|
||||
@@ -404,7 +439,7 @@ export function ProviderForm({
|
||||
originalHandleGeminiApiKeyChange(key);
|
||||
// 同步更新 settingsConfig
|
||||
try {
|
||||
const config = JSON.parse(form.watch("settingsConfig") || "{}");
|
||||
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
|
||||
if (!config.env) config.env = {};
|
||||
config.env.GEMINI_API_KEY = key.trim();
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
@@ -420,7 +455,7 @@ export function ProviderForm({
|
||||
originalHandleGeminiBaseUrlChange(url);
|
||||
// 同步更新 settingsConfig
|
||||
try {
|
||||
const config = JSON.parse(form.watch("settingsConfig") || "{}");
|
||||
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
|
||||
if (!config.env) config.env = {};
|
||||
config.env.GOOGLE_GEMINI_BASE_URL = url.trim().replace(/\/+$/, "");
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
@@ -436,7 +471,7 @@ export function ProviderForm({
|
||||
originalHandleGeminiModelChange(model);
|
||||
// 同步更新 settingsConfig
|
||||
try {
|
||||
const config = JSON.parse(form.watch("settingsConfig") || "{}");
|
||||
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
|
||||
if (!config.env) config.env = {};
|
||||
config.env.GEMINI_MODEL = model.trim();
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
@@ -465,6 +500,180 @@ export function ProviderForm({
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
});
|
||||
|
||||
// OpenCode: query existing providers for duplicate key checking
|
||||
const { data: opencodeProvidersData } = useProvidersQuery("opencode");
|
||||
const existingOpencodeKeys = useMemo(() => {
|
||||
if (!opencodeProvidersData?.providers) return [];
|
||||
// Exclude current provider ID when in edit mode
|
||||
return Object.keys(opencodeProvidersData.providers).filter(
|
||||
(k) => k !== providerId
|
||||
);
|
||||
}, [opencodeProvidersData?.providers, providerId]);
|
||||
|
||||
// OpenCode Provider Key state
|
||||
const [opencodeProviderKey, setOpencodeProviderKey] = useState<string>(() => {
|
||||
if (appId !== "opencode") return "";
|
||||
// In edit mode, use the existing provider ID as the key
|
||||
return providerId || "";
|
||||
});
|
||||
|
||||
// OpenCode 配置状态
|
||||
const [opencodeNpm, setOpencodeNpm] = useState<string>(() => {
|
||||
if (appId !== "opencode") return "@ai-sdk/openai-compatible";
|
||||
try {
|
||||
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
|
||||
return config.npm || "@ai-sdk/openai-compatible";
|
||||
} catch {
|
||||
return "@ai-sdk/openai-compatible";
|
||||
}
|
||||
});
|
||||
|
||||
const [opencodeApiKey, setOpencodeApiKey] = useState<string>(() => {
|
||||
if (appId !== "opencode") return "";
|
||||
try {
|
||||
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
|
||||
return config.options?.apiKey || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const [opencodeBaseUrl, setOpencodeBaseUrl] = useState<string>(() => {
|
||||
if (appId !== "opencode") return "";
|
||||
try {
|
||||
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
|
||||
return config.options?.baseURL || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const [opencodeModels, setOpencodeModels] = useState<Record<string, OpenCodeModel>>(() => {
|
||||
if (appId !== "opencode") return {};
|
||||
try {
|
||||
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
|
||||
return config.models || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
// OpenCode extra options state (e.g., timeout, setCacheKey)
|
||||
const [opencodeExtraOptions, setOpencodeExtraOptions] = useState<Record<string, string>>(() => {
|
||||
if (appId !== "opencode") return {};
|
||||
try {
|
||||
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
|
||||
const options = config.options || {};
|
||||
const extra: Record<string, string> = {};
|
||||
const knownKeys = ["baseURL", "apiKey", "headers"];
|
||||
for (const [k, v] of Object.entries(options)) {
|
||||
if (!knownKeys.includes(k)) {
|
||||
// Convert value to string for display
|
||||
extra[k] = typeof v === "string" ? v : JSON.stringify(v);
|
||||
}
|
||||
}
|
||||
return extra;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
// OpenCode handlers - sync state to form
|
||||
const handleOpencodeNpmChange = useCallback(
|
||||
(npm: string) => {
|
||||
setOpencodeNpm(npm);
|
||||
try {
|
||||
const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
|
||||
config.npm = npm;
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const handleOpencodeApiKeyChange = useCallback(
|
||||
(apiKey: string) => {
|
||||
setOpencodeApiKey(apiKey);
|
||||
try {
|
||||
const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
|
||||
if (!config.options) config.options = {};
|
||||
config.options.apiKey = apiKey;
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const handleOpencodeBaseUrlChange = useCallback(
|
||||
(baseUrl: string) => {
|
||||
setOpencodeBaseUrl(baseUrl);
|
||||
try {
|
||||
const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
|
||||
if (!config.options) config.options = {};
|
||||
config.options.baseURL = baseUrl.trim().replace(/\/+$/, "");
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const handleOpencodeModelsChange = useCallback(
|
||||
(models: Record<string, OpenCodeModel>) => {
|
||||
setOpencodeModels(models);
|
||||
try {
|
||||
const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
|
||||
config.models = models;
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const handleOpencodeExtraOptionsChange = useCallback(
|
||||
(options: Record<string, string>) => {
|
||||
setOpencodeExtraOptions(options);
|
||||
try {
|
||||
const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
|
||||
if (!config.options) config.options = {};
|
||||
|
||||
// Remove old extra options (keep only known keys)
|
||||
const knownKeys = ["baseURL", "apiKey", "headers"];
|
||||
for (const k of Object.keys(config.options)) {
|
||||
if (!knownKeys.includes(k)) {
|
||||
delete config.options[k];
|
||||
}
|
||||
}
|
||||
|
||||
// Add new extra options (auto-parse value types)
|
||||
for (const [k, v] of Object.entries(options)) {
|
||||
const trimmedKey = k.trim();
|
||||
if (trimmedKey && !trimmedKey.startsWith("option-")) {
|
||||
try {
|
||||
// Try to parse as JSON (number, boolean, object, array)
|
||||
config.options[trimmedKey] = JSON.parse(v);
|
||||
} catch {
|
||||
// If parsing fails, keep as string
|
||||
config.options[trimmedKey] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
const handleSubmit = (values: ProviderFormData) => {
|
||||
@@ -492,6 +701,23 @@ export function ProviderForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// OpenCode: validate provider key
|
||||
if (appId === "opencode") {
|
||||
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
if (!opencodeProviderKey.trim()) {
|
||||
toast.error(t("opencode.providerKeyRequired"));
|
||||
return;
|
||||
}
|
||||
if (!keyPattern.test(opencodeProviderKey)) {
|
||||
toast.error(t("opencode.providerKeyInvalid"));
|
||||
return;
|
||||
}
|
||||
if (!isEditMode && existingOpencodeKeys.includes(opencodeProviderKey)) {
|
||||
toast.error(t("opencode.providerKeyDuplicate"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 非官方供应商必填校验:端点和 API Key
|
||||
if (category !== "official") {
|
||||
if (appId === "claude") {
|
||||
@@ -589,6 +815,11 @@ export function ProviderForm({
|
||||
settingsConfig,
|
||||
};
|
||||
|
||||
// OpenCode: pass provider key for ID generation
|
||||
if (appId === "opencode") {
|
||||
payload.providerKey = opencodeProviderKey;
|
||||
}
|
||||
|
||||
if (activePreset) {
|
||||
payload.presetId = activePreset.id;
|
||||
if (activePreset.category) {
|
||||
@@ -647,6 +878,13 @@ export function ProviderForm({
|
||||
}
|
||||
}
|
||||
|
||||
const baseMeta: ProviderMeta | undefined =
|
||||
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
|
||||
payload.meta = {
|
||||
...(baseMeta ?? {}),
|
||||
endpointAutoSelect,
|
||||
};
|
||||
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
@@ -737,6 +975,15 @@ export function ProviderForm({
|
||||
if (appId === "gemini") {
|
||||
resetGeminiConfig({}, {});
|
||||
}
|
||||
// OpenCode 自定义模式:重置为空配置
|
||||
if (appId === "opencode") {
|
||||
setOpencodeProviderKey("");
|
||||
setOpencodeNpm("@ai-sdk/openai-compatible");
|
||||
setOpencodeBaseUrl("");
|
||||
setOpencodeApiKey("");
|
||||
setOpencodeModels({});
|
||||
setOpencodeExtraOptions({});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -790,6 +1037,42 @@ export function ProviderForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// OpenCode preset handling
|
||||
if (appId === "opencode") {
|
||||
const preset = entry.preset as OpenCodeProviderPreset;
|
||||
const config = preset.settingsConfig;
|
||||
|
||||
// Clear provider key (user must enter their own unique key)
|
||||
setOpencodeProviderKey("");
|
||||
|
||||
// Update OpenCode-specific states
|
||||
setOpencodeNpm(config.npm || "@ai-sdk/openai-compatible");
|
||||
setOpencodeBaseUrl(config.options?.baseURL || "");
|
||||
setOpencodeApiKey(config.options?.apiKey || "");
|
||||
setOpencodeModels(config.models || {});
|
||||
|
||||
// Extract extra options from preset
|
||||
const options = config.options || {};
|
||||
const extra: Record<string, string> = {};
|
||||
const knownKeys = ["baseURL", "apiKey", "headers"];
|
||||
for (const [k, v] of Object.entries(options)) {
|
||||
if (!knownKeys.includes(k)) {
|
||||
extra[k] = typeof v === "string" ? v : JSON.stringify(v);
|
||||
}
|
||||
}
|
||||
setOpencodeExtraOptions(extra);
|
||||
|
||||
// Update form fields
|
||||
form.reset({
|
||||
name: preset.name,
|
||||
websiteUrl: preset.websiteUrl ?? "",
|
||||
settingsConfig: JSON.stringify(config, null, 2),
|
||||
icon: preset.icon ?? "",
|
||||
iconColor: preset.iconColor ?? "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = entry.preset as ProviderPreset;
|
||||
const config = applyTemplateValues(
|
||||
preset.settingsConfig,
|
||||
@@ -827,14 +1110,55 @@ export function ProviderForm({
|
||||
)}
|
||||
|
||||
{/* 基础字段 */}
|
||||
<BasicFormFields form={form} />
|
||||
<BasicFormFields
|
||||
form={form}
|
||||
beforeNameSlot={
|
||||
appId === "opencode" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="opencode-key">
|
||||
{t("opencode.providerKey")}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="opencode-key"
|
||||
value={opencodeProviderKey}
|
||||
onChange={(e) => setOpencodeProviderKey(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))}
|
||||
placeholder={t("opencode.providerKeyPlaceholder")}
|
||||
disabled={isEditMode}
|
||||
className={
|
||||
(existingOpencodeKeys.includes(opencodeProviderKey) && !isEditMode) ||
|
||||
(opencodeProviderKey.trim() !== "" && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey))
|
||||
? "border-destructive"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{existingOpencodeKeys.includes(opencodeProviderKey) && !isEditMode && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("opencode.providerKeyDuplicate")}
|
||||
</p>
|
||||
)}
|
||||
{opencodeProviderKey.trim() !== "" && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey) && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("opencode.providerKeyInvalid")}
|
||||
</p>
|
||||
)}
|
||||
{!(existingOpencodeKeys.includes(opencodeProviderKey) && !isEditMode) &&
|
||||
(opencodeProviderKey.trim() === "" || /^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey)) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("opencode.providerKeyHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Claude 专属字段 */}
|
||||
{appId === "claude" && (
|
||||
<ClaudeFormFields
|
||||
providerId={providerId}
|
||||
shouldShowApiKey={shouldShowApiKey(
|
||||
form.watch("settingsConfig"),
|
||||
form.getValues("settingsConfig"),
|
||||
isEditMode,
|
||||
)}
|
||||
apiKey={apiKey}
|
||||
@@ -856,6 +1180,8 @@ export function ProviderForm({
|
||||
onCustomEndpointsChange={
|
||||
isEditMode ? undefined : setDraftCustomEndpoints
|
||||
}
|
||||
autoSelect={endpointAutoSelect}
|
||||
onAutoSelectChange={setEndpointAutoSelect}
|
||||
shouldShowModelSelector={category !== "official"}
|
||||
claudeModel={claudeModel}
|
||||
reasoningModel={reasoningModel}
|
||||
@@ -889,6 +1215,8 @@ export function ProviderForm({
|
||||
onCustomEndpointsChange={
|
||||
isEditMode ? undefined : setDraftCustomEndpoints
|
||||
}
|
||||
autoSelect={endpointAutoSelect}
|
||||
onAutoSelectChange={setEndpointAutoSelect}
|
||||
shouldShowModelField={category !== "official"}
|
||||
modelName={codexModelName}
|
||||
onModelNameChange={handleCodexModelNameChange}
|
||||
@@ -901,7 +1229,7 @@ export function ProviderForm({
|
||||
<GeminiFormFields
|
||||
providerId={providerId}
|
||||
shouldShowApiKey={shouldShowApiKey(
|
||||
form.watch("settingsConfig"),
|
||||
form.getValues("settingsConfig"),
|
||||
isEditMode,
|
||||
)}
|
||||
apiKey={geminiApiKey}
|
||||
@@ -917,6 +1245,8 @@ export function ProviderForm({
|
||||
isEndpointModalOpen={isEndpointModalOpen}
|
||||
onEndpointModalToggle={setIsEndpointModalOpen}
|
||||
onCustomEndpointsChange={setDraftCustomEndpoints}
|
||||
autoSelect={endpointAutoSelect}
|
||||
onAutoSelectChange={setEndpointAutoSelect}
|
||||
shouldShowModelField={true}
|
||||
model={geminiModel}
|
||||
onModelChange={handleGeminiModelChange}
|
||||
@@ -924,6 +1254,25 @@ export function ProviderForm({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* OpenCode 专属字段 */}
|
||||
{appId === "opencode" && (
|
||||
<OpenCodeFormFields
|
||||
npm={opencodeNpm}
|
||||
onNpmChange={handleOpencodeNpmChange}
|
||||
apiKey={opencodeApiKey}
|
||||
onApiKeyChange={handleOpencodeApiKeyChange}
|
||||
category={category}
|
||||
shouldShowApiKeyLink={false}
|
||||
websiteUrl=""
|
||||
baseUrl={opencodeBaseUrl}
|
||||
onBaseUrlChange={handleOpencodeBaseUrlChange}
|
||||
models={opencodeModels}
|
||||
onModelsChange={handleOpencodeModelsChange}
|
||||
extraOptions={opencodeExtraOptions}
|
||||
onExtraOptionsChange={handleOpencodeExtraOptionsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */}
|
||||
{appId === "codex" ? (
|
||||
<>
|
||||
@@ -983,10 +1332,40 @@ export function ProviderForm({
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : appId === "opencode" ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settingsConfig">{t("provider.configJson")}</Label>
|
||||
<JsonEditor
|
||||
value={form.getValues("settingsConfig")}
|
||||
onChange={(config) => form.setValue("settingsConfig", config)}
|
||||
placeholder={`{
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"options": {
|
||||
"baseURL": "https://your-api-endpoint.com",
|
||||
"apiKey": "your-api-key-here"
|
||||
},
|
||||
"models": {}
|
||||
}`}
|
||||
rows={14}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsConfig"
|
||||
render={() => (
|
||||
<FormItem className="space-y-0">
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CommonConfigEditor
|
||||
value={form.watch("settingsConfig")}
|
||||
value={form.getValues("settingsConfig")}
|
||||
onChange={(value) => form.setValue("settingsConfig", value)}
|
||||
useCommonConfig={useCommonConfig}
|
||||
onCommonConfigToggle={handleCommonConfigToggle}
|
||||
@@ -1030,4 +1409,5 @@ export type ProviderFormValues = ProviderFormData & {
|
||||
presetCategory?: ProviderCategory;
|
||||
isPartner?: boolean;
|
||||
meta?: ProviderMeta;
|
||||
providerKey?: string; // OpenCode: user-defined provider key
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import type { ProviderCategory } from "@/types";
|
||||
|
||||
interface UseBaseUrlStateProps {
|
||||
appType: "claude" | "codex" | "gemini";
|
||||
appType: "claude" | "codex" | "gemini" | "opencode";
|
||||
category: ProviderCategory | undefined;
|
||||
settingsConfig: string;
|
||||
codexConfig?: string;
|
||||
|
||||
@@ -19,6 +19,8 @@ interface UseCommonConfigSnippetProps {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
selectedPresetId?: string;
|
||||
/** When false, the hook skips all logic and returns disabled state. Default: true */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,6 +32,7 @@ export function useCommonConfigSnippet({
|
||||
onConfigChange,
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
enabled = true,
|
||||
}: UseCommonConfigSnippetProps) {
|
||||
const { t } = useTranslation();
|
||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||
@@ -47,11 +50,16 @@ export function useCommonConfigSnippet({
|
||||
|
||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
hasInitializedNewMode.current = false;
|
||||
}, [selectedPresetId]);
|
||||
}, [selectedPresetId, enabled]);
|
||||
|
||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
let mounted = true;
|
||||
|
||||
const loadSnippet = async () => {
|
||||
@@ -100,10 +108,11 @@ export function useCommonConfigSnippet({
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
|
||||
// 初始化时检查通用配置片段(编辑模式)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (initialData && !isLoading) {
|
||||
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
|
||||
const hasCommon = hasCommonConfigSnippet(
|
||||
@@ -112,10 +121,11 @@ export function useCommonConfigSnippet({
|
||||
);
|
||||
setUseCommonConfig(hasCommon);
|
||||
}
|
||||
}, [initialData, commonConfigSnippet, isLoading]);
|
||||
}, [enabled, initialData, commonConfigSnippet, isLoading]);
|
||||
|
||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// 仅新建模式、加载完成、尚未初始化过
|
||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
||||
hasInitializedNewMode.current = true;
|
||||
@@ -145,6 +155,7 @@ export function useCommonConfigSnippet({
|
||||
}
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
initialData,
|
||||
commonConfigSnippet,
|
||||
isLoading,
|
||||
@@ -259,6 +270,7 @@ export function useCommonConfigSnippet({
|
||||
|
||||
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (isUpdatingFromCommonConfig.current || isLoading) {
|
||||
return;
|
||||
}
|
||||
@@ -267,7 +279,7 @@ export function useCommonConfigSnippet({
|
||||
commonConfigSnippet,
|
||||
);
|
||||
setUseCommonConfig(hasCommon);
|
||||
}, [settingsConfig, commonConfigSnippet, isLoading]);
|
||||
}, [enabled, settingsConfig, commonConfigSnippet, isLoading]);
|
||||
|
||||
// 从编辑器当前内容提取通用配置片段
|
||||
const handleExtract = useCallback(async () => {
|
||||
|
||||
@@ -106,13 +106,11 @@ export function ProxyPanel() {
|
||||
// 校验地址格式(简单的 IP 地址或 localhost 校验)
|
||||
const addressTrimmed = listenAddress.trim();
|
||||
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
// 规范化 localhost 为 127.0.0.1
|
||||
const normalizedAddress =
|
||||
addressTrimmed === "localhost" ? "127.0.0.1" : addressTrimmed;
|
||||
const isValidAddress =
|
||||
normalizedAddress === "0.0.0.0" ||
|
||||
(ipv4Regex.test(normalizedAddress) &&
|
||||
normalizedAddress.split(".").every((n) => {
|
||||
addressTrimmed === "localhost" ||
|
||||
addressTrimmed === "0.0.0.0" ||
|
||||
(ipv4Regex.test(addressTrimmed) &&
|
||||
addressTrimmed.split(".").every((n) => {
|
||||
const num = parseInt(n);
|
||||
return num >= 0 && num <= 255;
|
||||
}));
|
||||
@@ -148,11 +146,9 @@ export function ProxyPanel() {
|
||||
try {
|
||||
await updateGlobalConfig.mutateAsync({
|
||||
...globalConfig,
|
||||
listenAddress: normalizedAddress,
|
||||
listenAddress: addressTrimmed,
|
||||
listenPort: port,
|
||||
});
|
||||
// 同步更新本地状态为规范化后的值
|
||||
setListenAddress(normalizedAddress);
|
||||
toast.success(
|
||||
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
|
||||
{ closeButton: true },
|
||||
|
||||
@@ -37,7 +37,9 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
|
||||
? "Claude"
|
||||
: activeApp === "codex"
|
||||
? "Codex"
|
||||
: "Gemini";
|
||||
: activeApp === "gemini"
|
||||
? "Gemini"
|
||||
: "OpenCode";
|
||||
|
||||
const tooltipText = takeoverEnabled
|
||||
? isRunning
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 全局出站代理设置组件
|
||||
*
|
||||
* 提供配置全局代理的输入界面,支持用户名密码认证。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, TestTube2, Search, Eye, EyeOff, X } from "lucide-react";
|
||||
import {
|
||||
useGlobalProxyUrl,
|
||||
useSetGlobalProxyUrl,
|
||||
useTestProxy,
|
||||
useScanProxies,
|
||||
type DetectedProxy,
|
||||
} from "@/hooks/useGlobalProxy";
|
||||
|
||||
/** 从完整 URL 提取认证信息 */
|
||||
function extractAuth(url: string): {
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
} {
|
||||
if (!url.trim()) return { baseUrl: "", username: "", password: "" };
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const username = decodeURIComponent(parsed.username || "");
|
||||
const password = decodeURIComponent(parsed.password || "");
|
||||
// 移除认证信息,获取基础 URL
|
||||
parsed.username = "";
|
||||
parsed.password = "";
|
||||
return { baseUrl: parsed.toString(), username, password };
|
||||
} catch {
|
||||
return { baseUrl: url, username: "", password: "" };
|
||||
}
|
||||
}
|
||||
|
||||
/** 将认证信息合并到 URL */
|
||||
function mergeAuth(
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): string {
|
||||
if (!baseUrl.trim()) return "";
|
||||
if (!username.trim()) return baseUrl;
|
||||
|
||||
try {
|
||||
const parsed = new URL(baseUrl);
|
||||
// URL 对象的 username/password setter 会自动进行 percent-encoding
|
||||
// 不要使用 encodeURIComponent,否则会导致双重编码
|
||||
parsed.username = username.trim();
|
||||
if (password) {
|
||||
parsed.password = password;
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
// URL 解析失败,尝试手动插入(此时需要手动编码)
|
||||
const match = baseUrl.match(/^(\w+:\/\/)(.+)$/);
|
||||
if (match) {
|
||||
const auth = password
|
||||
? `${encodeURIComponent(username.trim())}:${encodeURIComponent(password)}@`
|
||||
: `${encodeURIComponent(username.trim())}@`;
|
||||
return `${match[1]}${auth}${match[2]}`;
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export function GlobalProxySettings() {
|
||||
const { t } = useTranslation();
|
||||
const { data: savedUrl, isLoading } = useGlobalProxyUrl();
|
||||
const setMutation = useSetGlobalProxyUrl();
|
||||
const testMutation = useTestProxy();
|
||||
const scanMutation = useScanProxies();
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [detected, setDetected] = useState<DetectedProxy[]>([]);
|
||||
|
||||
// 计算完整 URL(含认证信息)
|
||||
const fullUrl = useMemo(
|
||||
() => mergeAuth(url, username, password),
|
||||
[url, username, password],
|
||||
);
|
||||
|
||||
// 同步远程配置
|
||||
useEffect(() => {
|
||||
if (savedUrl !== undefined) {
|
||||
const { baseUrl, username: u, password: p } = extractAuth(savedUrl || "");
|
||||
setUrl(baseUrl);
|
||||
setUsername(u);
|
||||
setPassword(p);
|
||||
setDirty(false);
|
||||
}
|
||||
}, [savedUrl]);
|
||||
|
||||
const handleSave = async () => {
|
||||
await setMutation.mutateAsync(fullUrl);
|
||||
setDirty(false);
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (fullUrl) {
|
||||
await testMutation.mutateAsync(fullUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScan = async () => {
|
||||
const result = await scanMutation.mutateAsync();
|
||||
setDetected(result);
|
||||
};
|
||||
|
||||
const handleSelect = (proxyUrl: string) => {
|
||||
const { baseUrl, username: u, password: p } = extractAuth(proxyUrl);
|
||||
setUrl(baseUrl);
|
||||
setUsername(u);
|
||||
setPassword(p);
|
||||
setDirty(true);
|
||||
setDetected([]);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setUrl("");
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && dirty && !setMutation.isPending) {
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
// 只在首次加载且无数据时显示加载状态
|
||||
if (isLoading && savedUrl === undefined) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 描述 */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.globalProxy.hint")}
|
||||
</p>
|
||||
|
||||
{/* 代理地址输入框和按钮 */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="http://127.0.0.1:7890 / socks5://127.0.0.1:1080"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="font-mono text-sm flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={scanMutation.isPending}
|
||||
onClick={handleScan}
|
||||
title={t("settings.globalProxy.scan")}
|
||||
>
|
||||
{scanMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={!fullUrl || testMutation.isPending}
|
||||
onClick={handleTest}
|
||||
title={t("settings.globalProxy.test")}
|
||||
>
|
||||
{testMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<TestTube2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={!url && !username && !password}
|
||||
onClick={handleClear}
|
||||
title={t("settings.globalProxy.clear")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || setMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
{setMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 认证信息:用户名 + 密码(可选) */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t("settings.globalProxy.username")}
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="font-mono text-sm flex-1"
|
||||
/>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t("settings.globalProxy.password")}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="font-mono text-sm pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 扫描结果 */}
|
||||
{detected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{detected.map((p) => (
|
||||
<Button
|
||||
key={p.url}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleSelect(p.url)}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{p.url}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { settingsApi, type RectifierConfig } from "@/lib/api/settings";
|
||||
|
||||
export function RectifierConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [config, setConfig] = useState<RectifierConfig>({
|
||||
enabled: true,
|
||||
requestThinkingSignature: true,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
settingsApi
|
||||
.getRectifierConfig()
|
||||
.then(setConfig)
|
||||
.catch((e) => console.error("Failed to load rectifier config:", e))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleChange = async (updates: Partial<RectifierConfig>) => {
|
||||
const newConfig = { ...config, ...updates };
|
||||
setConfig(newConfig);
|
||||
try {
|
||||
await settingsApi.setRectifierConfig(newConfig);
|
||||
} catch (e) {
|
||||
console.error("Failed to save rectifier config:", e);
|
||||
toast.error(String(e));
|
||||
setConfig(config);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t("settings.advanced.rectifier.enabled")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.advanced.rectifier.enabledDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
onCheckedChange={(checked) => handleChange({ enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{t("settings.advanced.rectifier.requestGroup")}
|
||||
</h4>
|
||||
<div className="flex items-center justify-between pl-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t("settings.advanced.rectifier.thinkingSignature")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.advanced.rectifier.thinkingSignatureDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.requestThinkingSignature}
|
||||
disabled={!config.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({ requestThinkingSignature: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
Database,
|
||||
Server,
|
||||
ChevronDown,
|
||||
Zap,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { toast } from "sonner";
|
||||
@@ -34,12 +36,14 @@ import { WindowSettings } from "@/components/settings/WindowSettings";
|
||||
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
||||
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
||||
import { AboutSection } from "@/components/settings/AboutSection";
|
||||
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
|
||||
import { ProxyPanel } from "@/components/proxy";
|
||||
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
||||
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
||||
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
|
||||
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
|
||||
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
||||
import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useImportExport } from "@/hooks/useImportExport";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -198,7 +202,7 @@ export function SettingsPage({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
|
||||
<div className="flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
|
||||
{isBusy ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
@@ -495,6 +499,28 @@ export function SettingsPage({
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="globalProxy"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="h-5 w-5 text-cyan-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.globalProxy.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.globalProxy.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<GlobalProxySettings />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="data"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
@@ -526,6 +552,28 @@ export function SettingsPage({
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="rectifier"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="h-5 w-5 text-purple-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.rectifier.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.rectifier.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<RectifierConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="pt-4">
|
||||
|
||||
@@ -193,7 +193,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
}, [skills, searchQuery, filterStatus]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden bg-background/50">
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden bg-background/50">
|
||||
{/* 技能网格(可滚动详情区域) */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden animate-fade-in">
|
||||
<div className="py-4">
|
||||
|
||||
@@ -52,12 +52,13 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
|
||||
// Count enabled skills per app
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = { claude: 0, codex: 0, gemini: 0 };
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
|
||||
if (!skills) return counts;
|
||||
skills.forEach((skill) => {
|
||||
if (skill.apps.claude) counts.claude++;
|
||||
if (skill.apps.codex) counts.codex++;
|
||||
if (skill.apps.gemini) counts.gemini++;
|
||||
if (skill.apps.opencode) counts.opencode++;
|
||||
});
|
||||
return counts;
|
||||
}, [skills]);
|
||||
@@ -132,14 +133,15 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
{/* Info Section */}
|
||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("skills.installed", { count: skills?.length || 0 })} ·{" "}
|
||||
{t("skills.apps.claude")}: {enabledCounts.claude} ·{" "}
|
||||
{t("skills.apps.codex")}: {enabledCounts.codex} ·{" "}
|
||||
{t("skills.apps.gemini")}: {enabledCounts.gemini}
|
||||
{t("skills.apps.gemini")}: {enabledCounts.gemini} ·{" "}
|
||||
{t("skills.apps.opencode")}: {enabledCounts.opencode}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -308,6 +310,22 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${skill.id}-opencode`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("skills.apps.opencode")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${skill.id}-opencode`}
|
||||
checked={skill.apps.opencode}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(skill.id, "opencode", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:删除按钮 */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
@@ -37,7 +37,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-border-default bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"relative z-[100] min-w-[8rem] overflow-hidden rounded-md border border-border-default bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
@@ -87,12 +87,6 @@ const SelectItem = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Save, Loader2 } from "lucide-react";
|
||||
@@ -25,6 +26,7 @@ export function ModelTestConfigPanel() {
|
||||
claudeModel: "claude-haiku-4-5-20251001",
|
||||
codexModel: "gpt-5.1-codex@low",
|
||||
geminiModel: "gemini-3-pro-preview",
|
||||
testPrompt: "Who are you?",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,6 +45,7 @@ export function ModelTestConfigPanel() {
|
||||
claudeModel: data.claudeModel,
|
||||
codexModel: data.codexModel,
|
||||
geminiModel: data.geminiModel,
|
||||
testPrompt: data.testPrompt || "Who are you?",
|
||||
});
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
@@ -66,6 +69,7 @@ export function ModelTestConfigPanel() {
|
||||
claudeModel: config.claudeModel,
|
||||
codexModel: config.codexModel,
|
||||
geminiModel: config.geminiModel,
|
||||
testPrompt: config.testPrompt || "Who are you?",
|
||||
};
|
||||
await saveStreamCheckConfig(parsed);
|
||||
toast.success(t("streamCheck.configSaved"), {
|
||||
@@ -189,6 +193,21 @@ export function ModelTestConfigPanel() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 检查提示词配置 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="testPrompt">{t("streamCheck.testPrompt")}</Label>
|
||||
<Textarea
|
||||
id="testPrompt"
|
||||
value={config.testPrompt}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, testPrompt: e.target.value })
|
||||
}
|
||||
placeholder="Who are you?"
|
||||
rows={2}
|
||||
className="min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -202,6 +202,7 @@ export function PricingConfigPanel() {
|
||||
|
||||
{editingModel && (
|
||||
<PricingEditModal
|
||||
open={!!editingModel}
|
||||
model={editingModel}
|
||||
isNew={isAddingNew}
|
||||
onClose={() => {
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Save, Plus } from "lucide-react";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -15,12 +10,14 @@ import { useUpdateModelPricing } from "@/lib/query/usage";
|
||||
import type { ModelPricing } from "@/types/usage";
|
||||
|
||||
interface PricingEditModalProps {
|
||||
open: boolean;
|
||||
model: ModelPricing;
|
||||
isNew?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PricingEditModal({
|
||||
open,
|
||||
model,
|
||||
isNew = false,
|
||||
onClose,
|
||||
@@ -86,139 +83,142 @@ export function PricingEditModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isNew
|
||||
? t("usage.addPricing", "新增定价")
|
||||
: `${t("usage.editPricing", "编辑定价")} - ${model.modelId}`}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{isNew && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modelId">{t("usage.modelId", "模型 ID")}</Label>
|
||||
<Input
|
||||
id="modelId"
|
||||
value={formData.modelId}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, modelId: e.target.value })
|
||||
}
|
||||
placeholder={t("usage.modelIdPlaceholder", {
|
||||
defaultValue: "例如: claude-3-5-sonnet-20241022",
|
||||
})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<FullScreenPanel
|
||||
isOpen={open}
|
||||
title={
|
||||
isNew
|
||||
? t("usage.addPricing", "新增定价")
|
||||
: `${t("usage.editPricing", "编辑定价")} - ${model.modelId}`
|
||||
}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<Button
|
||||
type="submit"
|
||||
form="pricing-form"
|
||||
disabled={updatePricing.isPending}
|
||||
>
|
||||
{isNew ? (
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
|
||||
{updatePricing.isPending
|
||||
? t("common.saving", "保存中...")
|
||||
: isNew
|
||||
? t("common.add", "新增")
|
||||
: t("common.save", "保存")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<form id="pricing-form" onSubmit={handleSubmit} className="space-y-6">
|
||||
{isNew && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">
|
||||
{t("usage.displayName", "显示名称")}
|
||||
</Label>
|
||||
<Label htmlFor="modelId">{t("usage.modelId", "模型 ID")}</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={formData.displayName}
|
||||
id="modelId"
|
||||
value={formData.modelId}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, displayName: e.target.value })
|
||||
setFormData({ ...formData, modelId: e.target.value })
|
||||
}
|
||||
placeholder={t("usage.displayNamePlaceholder", {
|
||||
defaultValue: "例如: Claude 3.5 Sonnet",
|
||||
placeholder={t("usage.modelIdPlaceholder", {
|
||||
defaultValue: "例如: claude-3-5-sonnet-20241022",
|
||||
})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inputCost">
|
||||
{t("usage.inputCostPerMillion", "输入成本 (每百万 tokens, USD)")}
|
||||
</Label>
|
||||
<Input
|
||||
id="inputCost"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.inputCost}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, inputCost: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">
|
||||
{t("usage.displayName", "显示名称")}
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={formData.displayName}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, displayName: e.target.value })
|
||||
}
|
||||
placeholder={t("usage.displayNamePlaceholder", {
|
||||
defaultValue: "例如: Claude 3.5 Sonnet",
|
||||
})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="outputCost">
|
||||
{t("usage.outputCostPerMillion", "输出成本 (每百万 tokens, USD)")}
|
||||
</Label>
|
||||
<Input
|
||||
id="outputCost"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.outputCost}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, outputCost: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inputCost">
|
||||
{t("usage.inputCostPerMillion", "输入成本 (每百万 tokens, USD)")}
|
||||
</Label>
|
||||
<Input
|
||||
id="inputCost"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.inputCost}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, inputCost: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cacheReadCost">
|
||||
{t(
|
||||
"usage.cacheReadCostPerMillion",
|
||||
"缓存读取成本 (每百万 tokens, USD)",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="cacheReadCost"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.cacheReadCost}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, cacheReadCost: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="outputCost">
|
||||
{t("usage.outputCostPerMillion", "输出成本 (每百万 tokens, USD)")}
|
||||
</Label>
|
||||
<Input
|
||||
id="outputCost"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.outputCost}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, outputCost: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cacheCreationCost">
|
||||
{t(
|
||||
"usage.cacheCreationCostPerMillion",
|
||||
"缓存写入成本 (每百万 tokens, USD)",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="cacheCreationCost"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.cacheCreationCost}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, cacheCreationCost: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cacheReadCost">
|
||||
{t(
|
||||
"usage.cacheReadCostPerMillion",
|
||||
"缓存读取成本 (每百万 tokens, USD)",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="cacheReadCost"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.cacheReadCost}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, cacheReadCost: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel", "取消")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={updatePricing.isPending}>
|
||||
{updatePricing.isPending
|
||||
? t("common.saving", "保存中...")
|
||||
: isNew
|
||||
? t("common.add", "新增")
|
||||
: t("common.save", "保存")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cacheCreationCost">
|
||||
{t(
|
||||
"usage.cacheCreationCostPerMillion",
|
||||
"缓存写入成本 (每百万 tokens, USD)",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="cacheCreationCost"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.cacheCreationCost}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, cacheCreationCost: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</FullScreenPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
/**
|
||||
* OpenCode 预设供应商配置模板
|
||||
* OpenCode 使用 AI SDK npm 包,配置结构与其他应用不同
|
||||
*/
|
||||
import type { ProviderCategory, OpenCodeProviderConfig } from "../types";
|
||||
import type { PresetTheme, TemplateValueConfig } from "./claudeProviderPresets";
|
||||
|
||||
export interface OpenCodeProviderPreset {
|
||||
name: string;
|
||||
websiteUrl: string;
|
||||
apiKeyUrl?: string;
|
||||
/** OpenCode settings_config 结构 */
|
||||
settingsConfig: OpenCodeProviderConfig;
|
||||
isOfficial?: boolean;
|
||||
isPartner?: boolean;
|
||||
partnerPromotionKey?: string;
|
||||
category?: ProviderCategory;
|
||||
/** 模板变量定义 */
|
||||
templateValues?: Record<string, TemplateValueConfig>;
|
||||
/** 视觉主题配置 */
|
||||
theme?: PresetTheme;
|
||||
/** 图标名称 */
|
||||
icon?: string;
|
||||
/** 图标颜色 */
|
||||
iconColor?: string;
|
||||
/** 标记为自定义模板(用于 UI 区分) */
|
||||
isCustomTemplate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenCode npm 包选项(AI SDK 生态)
|
||||
*/
|
||||
export const opencodeNpmPackages = [
|
||||
{ value: "@ai-sdk/openai", label: "OpenAI" },
|
||||
{ value: "@ai-sdk/openai-compatible", label: "OpenAI Compatible" },
|
||||
{ value: "@ai-sdk/anthropic", label: "Anthropic" },
|
||||
{ value: "@ai-sdk/google", label: "Google (Gemini)" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* OpenCode 供应商预设列表
|
||||
*/
|
||||
export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
// ========== 国产官方 ==========
|
||||
{
|
||||
name: "DeepSeek",
|
||||
websiteUrl: "https://platform.deepseek.com",
|
||||
apiKeyUrl: "https://platform.deepseek.com/api_keys",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
baseURL: "https://api.deepseek.com/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"deepseek-chat": { name: "DeepSeek V3.2" },
|
||||
"deepseek-reasoner": { name: "DeepSeek R1" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "deepseek",
|
||||
iconColor: "#1E88E5",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "sk-...",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Zhipu GLM",
|
||||
websiteUrl: "https://open.bigmodel.cn",
|
||||
apiKeyUrl: "https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Zhipu GLM",
|
||||
options: {
|
||||
baseURL: "https://open.bigmodel.cn/api/paas/v4",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"glm-4.7": { name: "GLM-4.7" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "zhipu",
|
||||
icon: "zhipu",
|
||||
iconColor: "#0F62FE",
|
||||
templateValues: {
|
||||
baseURL: {
|
||||
label: "Base URL",
|
||||
placeholder: "https://open.bigmodel.cn/api/paas/v4",
|
||||
defaultValue: "https://open.bigmodel.cn/api/paas/v4",
|
||||
editorValue: "",
|
||||
},
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Z.ai GLM",
|
||||
websiteUrl: "https://z.ai",
|
||||
apiKeyUrl: "https://z.ai/subscribe?ic=8JVLJQFSKB",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Z.ai GLM",
|
||||
options: {
|
||||
baseURL: "https://api.z.ai/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"glm-4.7": { name: "GLM-4.7" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "zhipu",
|
||||
icon: "zhipu",
|
||||
iconColor: "#0F62FE",
|
||||
templateValues: {
|
||||
baseURL: {
|
||||
label: "Base URL",
|
||||
placeholder: "https://api.z.ai/v1",
|
||||
defaultValue: "https://api.z.ai/v1",
|
||||
editorValue: "",
|
||||
},
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Qwen Coder",
|
||||
websiteUrl: "https://bailian.console.aliyun.com",
|
||||
apiKeyUrl: "https://bailian.console.aliyun.com/#/api-key",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Qwen Coder",
|
||||
options: {
|
||||
baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"qwen3-max": { name: "Qwen3 Max" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "qwen",
|
||||
iconColor: "#FF6A00",
|
||||
templateValues: {
|
||||
baseURL: {
|
||||
label: "Base URL",
|
||||
placeholder: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
defaultValue: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
editorValue: "",
|
||||
},
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "sk-...",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Kimi k2",
|
||||
websiteUrl: "https://platform.moonshot.cn/console",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Kimi k2",
|
||||
options: {
|
||||
baseURL: "https://api.moonshot.cn/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"kimi-k2-thinking": { name: "Kimi K2 Thinking" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "kimi",
|
||||
iconColor: "#6366F1",
|
||||
templateValues: {
|
||||
baseURL: {
|
||||
label: "Base URL",
|
||||
placeholder: "https://api.moonshot.cn/v1",
|
||||
defaultValue: "https://api.moonshot.cn/v1",
|
||||
editorValue: "",
|
||||
},
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "sk-...",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
websiteUrl: "https://www.kimi.com/coding/docs/",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Kimi For Coding",
|
||||
options: {
|
||||
baseURL: "https://api.kimi.com/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"kimi-for-coding": { name: "Kimi For Coding" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "kimi",
|
||||
iconColor: "#6366F1",
|
||||
templateValues: {
|
||||
baseURL: {
|
||||
label: "Base URL",
|
||||
placeholder: "https://api.kimi.com/v1",
|
||||
defaultValue: "https://api.kimi.com/v1",
|
||||
editorValue: "",
|
||||
},
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "sk-...",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ModelScope",
|
||||
websiteUrl: "https://modelscope.cn",
|
||||
apiKeyUrl: "https://modelscope.cn/my/myaccesstoken",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "ModelScope",
|
||||
options: {
|
||||
baseURL: "https://api-inference.modelscope.cn/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"ZhipuAI/GLM-4.7": { name: "GLM-4.7" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
icon: "modelscope",
|
||||
iconColor: "#624AFF",
|
||||
templateValues: {
|
||||
baseURL: {
|
||||
label: "Base URL",
|
||||
placeholder: "https://api-inference.modelscope.cn/v1",
|
||||
defaultValue: "https://api-inference.modelscope.cn/v1",
|
||||
editorValue: "",
|
||||
},
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "KAT-Coder",
|
||||
websiteUrl: "https://console.streamlake.ai",
|
||||
apiKeyUrl: "https://console.streamlake.ai/console/api-key",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "KAT-Coder",
|
||||
options: {
|
||||
baseURL:
|
||||
"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"KAT-Coder-Pro": { name: "KAT-Coder Pro" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
templateValues: {
|
||||
baseURL: {
|
||||
label: "Base URL",
|
||||
placeholder:
|
||||
"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai",
|
||||
defaultValue:
|
||||
"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai",
|
||||
editorValue: "",
|
||||
},
|
||||
ENDPOINT_ID: {
|
||||
label: "Vanchin Endpoint ID",
|
||||
placeholder: "ep-xxx-xxx",
|
||||
defaultValue: "",
|
||||
editorValue: "",
|
||||
},
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Longcat",
|
||||
websiteUrl: "https://longcat.chat/platform",
|
||||
apiKeyUrl: "https://longcat.chat/platform/api_keys",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Longcat",
|
||||
options: {
|
||||
baseURL: "https://api.longcat.chat/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"LongCat-Flash-Chat": { name: "LongCat Flash Chat" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "longcat",
|
||||
iconColor: "#29E154",
|
||||
templateValues: {
|
||||
baseURL: {
|
||||
label: "Base URL",
|
||||
placeholder: "https://api.longcat.chat/v1",
|
||||
defaultValue: "https://api.longcat.chat/v1",
|
||||
editorValue: "",
|
||||
},
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MiniMax",
|
||||
websiteUrl: "https://platform.minimaxi.com",
|
||||
apiKeyUrl: "https://platform.minimaxi.com/subscribe/coding-plan",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "MiniMax",
|
||||
options: {
|
||||
baseURL: "https://api.minimaxi.com/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"MiniMax-M2.1": { name: "MiniMax M2.1" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "minimax_cn",
|
||||
theme: {
|
||||
backgroundColor: "#f64551",
|
||||
textColor: "#FFFFFF",
|
||||
},
|
||||
icon: "minimax",
|
||||
iconColor: "#FF6B6B",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MiniMax en",
|
||||
websiteUrl: "https://platform.minimax.io",
|
||||
apiKeyUrl: "https://platform.minimax.io/subscribe/coding-plan",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "MiniMax en",
|
||||
options: {
|
||||
baseURL: "https://api.minimax.io/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"MiniMax-M2.1": { name: "MiniMax M2.1" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "minimax_en",
|
||||
theme: {
|
||||
backgroundColor: "#f64551",
|
||||
textColor: "#FFFFFF",
|
||||
},
|
||||
icon: "minimax",
|
||||
iconColor: "#FF6B6B",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
websiteUrl: "https://www.volcengine.com/product/doubao",
|
||||
apiKeyUrl: "https://www.volcengine.com/product/doubao",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "DouBaoSeed",
|
||||
options: {
|
||||
baseURL: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"doubao-seed-code-preview-latest": { name: "Doubao Seed Code Preview" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "doubao",
|
||||
iconColor: "#3370FF",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BaiLing",
|
||||
websiteUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/get_started",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "BaiLing",
|
||||
options: {
|
||||
baseURL: "https://api.tbox.cn/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"Ling-1T": { name: "Ling 1T" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Xiaomi MiMo",
|
||||
websiteUrl: "https://platform.xiaomimimo.com",
|
||||
apiKeyUrl: "https://platform.xiaomimimo.com/#/console/api-keys",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Xiaomi MiMo",
|
||||
options: {
|
||||
baseURL: "https://api.xiaomimimo.com/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"mimo-v2-flash": { name: "MiMo V2 Flash" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "xiaomimimo",
|
||||
iconColor: "#000000",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ========== 聚合网站 ==========
|
||||
{
|
||||
name: "AiHubMix",
|
||||
websiteUrl: "https://aihubmix.com",
|
||||
apiKeyUrl: "https://aihubmix.com",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "AiHubMix",
|
||||
options: {
|
||||
baseURL: "https://aihubmix.com/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
icon: "aihubmix",
|
||||
iconColor: "#006FFB",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DMXAPI",
|
||||
websiteUrl: "https://www.dmxapi.cn",
|
||||
apiKeyUrl: "https://www.dmxapi.cn",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "DMXAPI",
|
||||
options: {
|
||||
baseURL: "https://www.dmxapi.cn/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "dmxapi",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OpenRouter",
|
||||
websiteUrl: "https://openrouter.ai",
|
||||
apiKeyUrl: "https://openrouter.ai/keys",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "OpenRouter",
|
||||
options: {
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4.5": { name: "Claude Sonnet 4.5" },
|
||||
"anthropic/claude-opus-4.5": { name: "Claude Opus 4.5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
icon: "openrouter",
|
||||
iconColor: "#6566F1",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "sk-or-...",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ========== 第三方合作伙伴 ==========
|
||||
{
|
||||
name: "PackyCode",
|
||||
websiteUrl: "https://www.packyapi.com",
|
||||
apiKeyUrl: "https://www.packyapi.com/register?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "PackyCode",
|
||||
options: {
|
||||
baseURL: "https://www.packyapi.com/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "packycode",
|
||||
icon: "packycode",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Cubence",
|
||||
websiteUrl: "https://cubence.com",
|
||||
apiKeyUrl: "https://cubence.com/signup?code=CCSWITCH&source=ccs",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "Cubence",
|
||||
options: {
|
||||
baseURL: "https://api.cubence.com/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "cubence",
|
||||
icon: "cubence",
|
||||
iconColor: "#000000",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AIGoCode",
|
||||
websiteUrl: "https://aigocode.com",
|
||||
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "AIGoCode",
|
||||
options: {
|
||||
baseURL: "https://api.aigocode.com/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aigocode",
|
||||
icon: "aigocode",
|
||||
iconColor: "#5B7FFF",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ========== 自定义模板 ==========
|
||||
{
|
||||
name: "OpenAI Compatible",
|
||||
websiteUrl: "",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {},
|
||||
},
|
||||
category: "custom",
|
||||
isCustomTemplate: true,
|
||||
icon: "generic",
|
||||
iconColor: "#6B7280",
|
||||
templateValues: {
|
||||
baseURL: {
|
||||
label: "Base URL",
|
||||
placeholder: "https://api.example.com/v1",
|
||||
editorValue: "",
|
||||
},
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 全局出站代理 React Hooks
|
||||
*
|
||||
* 提供获取、设置和测试全局代理的 React Query hooks。
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getGlobalProxyUrl,
|
||||
setGlobalProxyUrl,
|
||||
testProxyUrl,
|
||||
getUpstreamProxyStatus,
|
||||
scanLocalProxies,
|
||||
type ProxyTestResult,
|
||||
type UpstreamProxyStatus,
|
||||
type DetectedProxy,
|
||||
} from "@/lib/api/globalProxy";
|
||||
|
||||
/**
|
||||
* 获取全局代理 URL
|
||||
*/
|
||||
export function useGlobalProxyUrl() {
|
||||
return useQuery({
|
||||
queryKey: ["globalProxyUrl"],
|
||||
queryFn: getGlobalProxyUrl,
|
||||
staleTime: 30 * 1000, // 30秒内不重新获取,避免展开时闪烁
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置全局代理 URL
|
||||
*/
|
||||
export function useSetGlobalProxyUrl() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: setGlobalProxyUrl,
|
||||
onSuccess: () => {
|
||||
toast.success(t("settings.globalProxy.saved"));
|
||||
queryClient.invalidateQueries({ queryKey: ["globalProxyUrl"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["upstreamProxyStatus"] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: "Unknown error";
|
||||
toast.error(t("settings.globalProxy.saveFailed", { error: message }));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试代理连接
|
||||
*/
|
||||
export function useTestProxy() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: testProxyUrl,
|
||||
onSuccess: (result: ProxyTestResult) => {
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
t("settings.globalProxy.testSuccess", { latency: result.latencyMs }),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t("settings.globalProxy.testFailed", { error: result.error }),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前出站代理状态
|
||||
*/
|
||||
export function useUpstreamProxyStatus() {
|
||||
return useQuery<UpstreamProxyStatus>({
|
||||
queryKey: ["upstreamProxyStatus"],
|
||||
queryFn: getUpstreamProxyStatus,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描本地代理
|
||||
*/
|
||||
export function useScanProxies() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: scanLocalProxies,
|
||||
onError: (error: Error) => {
|
||||
toast.error(
|
||||
t("settings.globalProxy.scanFailed", { error: error.message }),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type { DetectedProxy };
|
||||
@@ -54,7 +54,7 @@ export function useProviderActions(activeApp: AppId) {
|
||||
|
||||
// 添加供应商
|
||||
const addProvider = useCallback(
|
||||
async (provider: Omit<Provider, "id">) => {
|
||||
async (provider: Omit<Provider, "id"> & { providerKey?: string }) => {
|
||||
await addProviderMutation.mutateAsync(provider);
|
||||
},
|
||||
[addProviderMutation],
|
||||
@@ -115,6 +115,11 @@ export function useProviderActions(activeApp: AppId) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["providers", activeApp],
|
||||
});
|
||||
// 🔧 保存用量脚本后,也应该失效该 provider 的用量查询缓存
|
||||
// 这样主页列表会使用新配置重新查询,而不是使用测试时的缓存
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["usage", provider.id, activeApp],
|
||||
});
|
||||
toast.success(
|
||||
t("provider.usageSaved", {
|
||||
defaultValue: "用量查询配置已保存",
|
||||
|
||||
@@ -101,7 +101,9 @@ export function useProxyStatus() {
|
||||
? "Claude"
|
||||
: variables.appType === "codex"
|
||||
? "Codex"
|
||||
: "Gemini";
|
||||
: variables.appType === "gemini"
|
||||
? "Gemini"
|
||||
: "OpenCode";
|
||||
|
||||
toast.success(
|
||||
variables.enabled
|
||||
|
||||
@@ -84,6 +84,10 @@
|
||||
"addClaudeProvider": "Add Claude Code Provider",
|
||||
"addCodexProvider": "Add Codex Provider",
|
||||
"addGeminiProvider": "Add Gemini Provider",
|
||||
"addOpenCodeProvider": "Add OpenCode Provider",
|
||||
"addToConfig": "Add",
|
||||
"removeFromConfig": "Remove",
|
||||
"inConfig": "Added",
|
||||
"addProviderHint": "Fill in the information to quickly switch providers in the list.",
|
||||
"editClaudeProvider": "Edit Claude Code Provider",
|
||||
"editCodexProvider": "Edit Codex Provider",
|
||||
@@ -133,6 +137,8 @@
|
||||
"providerSaved": "Provider configuration saved",
|
||||
"providerDeleted": "Provider deleted successfully",
|
||||
"switchSuccess": "Switch successful!",
|
||||
"addToConfigSuccess": "Added to config",
|
||||
"removeFromConfigSuccess": "Removed from config",
|
||||
"switchFailedTitle": "Switch failed",
|
||||
"switchFailed": "Switch failed: {{error}}",
|
||||
"autoImported": "Default provider created from existing configuration",
|
||||
@@ -153,7 +159,9 @@
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "Delete Provider",
|
||||
"deleteProviderMessage": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone."
|
||||
"deleteProviderMessage": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone.",
|
||||
"removeProvider": "Remove Provider",
|
||||
"removeProviderMessage": "Are you sure you want to remove provider \"{{name}}\" from the configuration?\n\nAfter removal, this provider will no longer be active, but the configuration data will be retained in CC Switch. You can re-add it at any time."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -183,9 +191,23 @@
|
||||
"title": "Cost Pricing",
|
||||
"description": "Manage token pricing rules for each model"
|
||||
},
|
||||
"globalProxy": {
|
||||
"title": "Global Outbound Proxy",
|
||||
"description": "Configure proxy for CC Switch to access external APIs"
|
||||
},
|
||||
"data": {
|
||||
"title": "Data Management",
|
||||
"description": "Import/export configurations and backup/restore"
|
||||
},
|
||||
"rectifier": {
|
||||
"title": "Rectifier",
|
||||
"description": "Automatically fix API request compatibility issues",
|
||||
"enabled": "Enable Rectifier",
|
||||
"enabledDescription": "Master switch, all rectification features will be disabled when turned off",
|
||||
"requestGroup": "Request Rectification",
|
||||
"responseGroup": "Response Rectification",
|
||||
"thinkingSignature": "Thinking Signature Rectification",
|
||||
"thinkingSignatureDescription": "Automatically fix Claude API errors caused by thinking signature validation failures"
|
||||
}
|
||||
},
|
||||
"language": "Language",
|
||||
@@ -271,12 +293,27 @@
|
||||
"restartLater": "Restart Later",
|
||||
"restartFailed": "Application restart failed, please manually close and reopen.",
|
||||
"devModeRestartHint": "Dev Mode: Automatic restart not supported, please manually restart the application.",
|
||||
"saving": "Saving..."
|
||||
"saving": "Saving...",
|
||||
"globalProxy": {
|
||||
"label": "Global Proxy",
|
||||
"hint": "Proxy all requests (API, Skills download, etc.). Leave empty for direct connection.",
|
||||
"username": "Username (optional)",
|
||||
"password": "Password (optional)",
|
||||
"test": "Test Connection",
|
||||
"scan": "Scan Local Proxies",
|
||||
"clear": "Clear",
|
||||
"scanFailed": "Scan failed: {{error}}",
|
||||
"saved": "Proxy settings saved",
|
||||
"saveFailed": "Save failed: {{error}}",
|
||||
"testSuccess": "Connected! Latency {{latency}}ms",
|
||||
"testFailed": "Connection failed: {{error}}"
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"claude": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
},
|
||||
"console": {
|
||||
"providerSwitchReceived": "Received provider switch event:",
|
||||
@@ -427,6 +464,36 @@
|
||||
"configMergeFailed": "Config merge failed: {{error}}",
|
||||
"configReplaceFailed": "Config replace failed: {{error}}"
|
||||
},
|
||||
"opencode": {
|
||||
"npmPackage": "API Format",
|
||||
"selectPackage": "Select API format",
|
||||
"npmPackageHint": "Select the API format for the AI service",
|
||||
"baseUrl": "Base URL",
|
||||
"baseUrlHint": "Custom API endpoint URL",
|
||||
"models": "Models",
|
||||
"modelsHint": "Configure available models and their display names",
|
||||
"addModel": "Add Model",
|
||||
"modelId": "Model ID",
|
||||
"modelName": "Display Name",
|
||||
"noModels": "No models configured",
|
||||
"providerKey": "Provider Key",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
|
||||
"providerKeyRequired": "Provider key is required",
|
||||
"providerKeyDuplicate": "This key is already in use",
|
||||
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
|
||||
"extraOptions": "Extra Options",
|
||||
"extraOptionsHint": "Configure extra SDK options like timeout, setCacheKey, etc. Values are auto-parsed to appropriate types (number, boolean, etc.).",
|
||||
"addExtraOption": "Add",
|
||||
"extraOptionKey": "Key",
|
||||
"extraOptionValue": "Value",
|
||||
"extraOptionKeyPlaceholder": "timeout",
|
||||
"extraOptionValuePlaceholder": "600000",
|
||||
"noExtraOptions": "No extra options configured",
|
||||
"noModelOptions": "Model options, click + to add",
|
||||
"modelOptionKeyPlaceholder": "provider",
|
||||
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "Provider Preset",
|
||||
"custom": "Custom Configuration",
|
||||
@@ -556,6 +623,7 @@
|
||||
"testFailed": "Test failed",
|
||||
"formatSuccess": "Format successful",
|
||||
"formatFailed": "Format failed",
|
||||
"supportedVariables": "Supported Variables",
|
||||
"variablesHint": "Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object",
|
||||
"scriptConfig": "Request configuration",
|
||||
"extractorCode": "Extractor code",
|
||||
@@ -605,7 +673,8 @@
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
}
|
||||
},
|
||||
"userLevelPath": "User-level MCP path",
|
||||
@@ -913,7 +982,8 @@
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
}
|
||||
},
|
||||
"deeplink": {
|
||||
@@ -958,6 +1028,7 @@
|
||||
"configDetails": "Config Details",
|
||||
"configUrl": "Config File URL",
|
||||
"configMergeError": "Failed to merge configuration file",
|
||||
"primaryEndpoint": "Primary",
|
||||
"mcp": {
|
||||
"title": "Batch Import MCP Servers",
|
||||
"targetApps": "Target Apps",
|
||||
@@ -1173,7 +1244,8 @@
|
||||
"checkParams": "Check Parameters",
|
||||
"timeout": "Timeout (seconds)",
|
||||
"maxRetries": "Max Retries",
|
||||
"degradedThreshold": "Degraded Threshold (ms)"
|
||||
"degradedThreshold": "Degraded Threshold (ms)",
|
||||
"testPrompt": "Test Prompt"
|
||||
},
|
||||
"proxyConfig": {
|
||||
"proxyEnabled": "Proxy Enabled",
|
||||
|
||||
@@ -84,6 +84,10 @@
|
||||
"addClaudeProvider": "Claude Code プロバイダーを追加",
|
||||
"addCodexProvider": "Codex プロバイダーを追加",
|
||||
"addGeminiProvider": "Gemini プロバイダーを追加",
|
||||
"addOpenCodeProvider": "OpenCode プロバイダーを追加",
|
||||
"addToConfig": "追加",
|
||||
"removeFromConfig": "削除",
|
||||
"inConfig": "追加済み",
|
||||
"addProviderHint": "一覧にすばやく切り替えられるよう、ここに情報を入力してください。",
|
||||
"editClaudeProvider": "Claude Code プロバイダーを編集",
|
||||
"editCodexProvider": "Codex プロバイダーを編集",
|
||||
@@ -133,6 +137,8 @@
|
||||
"providerSaved": "プロバイダー設定を保存しました",
|
||||
"providerDeleted": "プロバイダーを削除しました",
|
||||
"switchSuccess": "切り替え成功!",
|
||||
"addToConfigSuccess": "設定に追加しました",
|
||||
"removeFromConfigSuccess": "設定から削除しました",
|
||||
"switchFailedTitle": "切り替えに失敗しました",
|
||||
"switchFailed": "切り替えに失敗しました: {{error}}",
|
||||
"autoImported": "既存設定からデフォルトプロバイダーを自動作成しました",
|
||||
@@ -153,7 +159,9 @@
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "プロバイダーを削除",
|
||||
"deleteProviderMessage": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。"
|
||||
"deleteProviderMessage": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"removeProvider": "プロバイダーを解除",
|
||||
"removeProviderMessage": "プロバイダー「{{name}}」を設定から解除してもよろしいですか?\n\n解除後、このプロバイダーは無効になりますが、設定データは CC Switch に保持されます。いつでも再追加できます。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
@@ -183,9 +191,23 @@
|
||||
"title": "コスト計算",
|
||||
"description": "各モデルのトークン料金ルールを管理"
|
||||
},
|
||||
"globalProxy": {
|
||||
"title": "グローバル送信プロキシ",
|
||||
"description": "CC Switch が外部 API にアクセスする際のプロキシを設定"
|
||||
},
|
||||
"data": {
|
||||
"title": "データ管理",
|
||||
"description": "設定のインポート/エクスポートとバックアップ/復元"
|
||||
},
|
||||
"rectifier": {
|
||||
"title": "整流器",
|
||||
"description": "API リクエストの互換性問題を自動修正",
|
||||
"enabled": "整流器を有効化",
|
||||
"enabledDescription": "マスタースイッチ、オフにするとすべての整流機能が無効になります",
|
||||
"requestGroup": "リクエスト整流",
|
||||
"responseGroup": "レスポンス整流",
|
||||
"thinkingSignature": "Thinking 署名整流",
|
||||
"thinkingSignatureDescription": "Claude API の thinking 署名検証エラーを自動修正"
|
||||
}
|
||||
},
|
||||
"language": "言語",
|
||||
@@ -271,12 +293,27 @@
|
||||
"restartLater": "後で再起動",
|
||||
"restartFailed": "アプリの再起動に失敗しました。手動で閉じて再度開いてください。",
|
||||
"devModeRestartHint": "開発モードでは自動再起動をサポートしていません。手動で再起動してください。",
|
||||
"saving": "保存中..."
|
||||
"saving": "保存中...",
|
||||
"globalProxy": {
|
||||
"label": "グローバルプロキシ",
|
||||
"hint": "すべてのリクエスト(API、Skills ダウンロードなど)をプロキシ経由で送信します。空欄で直接接続。",
|
||||
"username": "ユーザー名(任意)",
|
||||
"password": "パスワード(任意)",
|
||||
"test": "接続テスト",
|
||||
"scan": "ローカルプロキシをスキャン",
|
||||
"clear": "クリア",
|
||||
"scanFailed": "スキャンに失敗しました: {{error}}",
|
||||
"saved": "プロキシ設定を保存しました",
|
||||
"saveFailed": "保存に失敗しました: {{error}}",
|
||||
"testSuccess": "接続成功!遅延 {{latency}}ms",
|
||||
"testFailed": "接続に失敗しました: {{error}}"
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"claude": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
},
|
||||
"console": {
|
||||
"providerSwitchReceived": "プロバイダー切り替えイベントを受信:",
|
||||
@@ -427,6 +464,36 @@
|
||||
"configMergeFailed": "設定のマージに失敗しました: {{error}}",
|
||||
"configReplaceFailed": "設定の置換に失敗しました: {{error}}"
|
||||
},
|
||||
"opencode": {
|
||||
"npmPackage": "API フォーマット",
|
||||
"selectPackage": "API フォーマットを選択",
|
||||
"npmPackageHint": "AI サービスの API フォーマットを選択",
|
||||
"baseUrl": "Base URL",
|
||||
"baseUrlHint": "カスタム API エンドポイント URL",
|
||||
"models": "モデル設定",
|
||||
"modelsHint": "利用可能なモデルとその表示名を設定",
|
||||
"addModel": "モデルを追加",
|
||||
"modelId": "モデル ID",
|
||||
"modelName": "表示名",
|
||||
"noModels": "モデルが設定されていません",
|
||||
"providerKey": "プロバイダーキー",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。",
|
||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。",
|
||||
"extraOptions": "追加オプション",
|
||||
"extraOptionsHint": "timeout、setCacheKey などの SDK オプションを設定。値は自動的に適切な型(数値、真偽値など)に変換されます。",
|
||||
"addExtraOption": "追加",
|
||||
"extraOptionKey": "キー名",
|
||||
"extraOptionValue": "値",
|
||||
"extraOptionKeyPlaceholder": "timeout",
|
||||
"extraOptionValuePlaceholder": "600000",
|
||||
"noExtraOptions": "追加オプションはありません",
|
||||
"noModelOptions": "モデルオプション、+ をクリックして追加",
|
||||
"modelOptionKeyPlaceholder": "provider",
|
||||
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "プロバイダータイプ",
|
||||
"custom": "カスタム設定",
|
||||
@@ -556,6 +623,7 @@
|
||||
"testFailed": "テストに失敗しました",
|
||||
"formatSuccess": "整形に成功しました",
|
||||
"formatFailed": "整形に失敗しました",
|
||||
"supportedVariables": "使用可能な変数",
|
||||
"variablesHint": "使用可能な変数: {{apiKey}}, {{baseUrl}} | extractor 関数には API 応答の JSON オブジェクトが渡されます",
|
||||
"scriptConfig": "リクエスト設定",
|
||||
"extractorCode": "抽出コード",
|
||||
@@ -605,7 +673,8 @@
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
}
|
||||
},
|
||||
"userLevelPath": "ユーザーレベルの MCP パス",
|
||||
@@ -913,7 +982,8 @@
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
}
|
||||
},
|
||||
"deeplink": {
|
||||
@@ -958,6 +1028,7 @@
|
||||
"configDetails": "設定の詳細",
|
||||
"configUrl": "設定ファイル URL",
|
||||
"configMergeError": "設定ファイルのマージに失敗しました",
|
||||
"primaryEndpoint": "メイン",
|
||||
"mcp": {
|
||||
"title": "MCP サーバーを一括インポート",
|
||||
"targetApps": "ターゲットアプリ",
|
||||
@@ -1167,7 +1238,8 @@
|
||||
"checkParams": "チェックパラメーター",
|
||||
"timeout": "タイムアウト(秒)",
|
||||
"maxRetries": "最大リトライ回数",
|
||||
"degradedThreshold": "劣化しきい値(ミリ秒)"
|
||||
"degradedThreshold": "劣化しきい値(ミリ秒)",
|
||||
"testPrompt": "テストプロンプト"
|
||||
},
|
||||
"proxyConfig": {
|
||||
"proxyEnabled": "プロキシ有効",
|
||||
|
||||
@@ -84,6 +84,10 @@
|
||||
"addClaudeProvider": "添加 Claude Code 供应商",
|
||||
"addCodexProvider": "添加 Codex 供应商",
|
||||
"addGeminiProvider": "添加 Gemini 供应商",
|
||||
"addOpenCodeProvider": "添加 OpenCode 供应商",
|
||||
"addToConfig": "添加",
|
||||
"removeFromConfig": "移除",
|
||||
"inConfig": "已添加",
|
||||
"addProviderHint": "填写信息后即可在列表中快速切换供应商。",
|
||||
"editClaudeProvider": "编辑 Claude Code 供应商",
|
||||
"editCodexProvider": "编辑 Codex 供应商",
|
||||
@@ -133,6 +137,8 @@
|
||||
"providerSaved": "供应商配置已保存",
|
||||
"providerDeleted": "供应商删除成功",
|
||||
"switchSuccess": "切换成功!",
|
||||
"addToConfigSuccess": "已添加到配置",
|
||||
"removeFromConfigSuccess": "已从配置移除",
|
||||
"switchFailedTitle": "切换失败",
|
||||
"switchFailed": "切换失败:{{error}}",
|
||||
"autoImported": "已从现有配置创建默认供应商",
|
||||
@@ -153,7 +159,9 @@
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "删除供应商",
|
||||
"deleteProviderMessage": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。"
|
||||
"deleteProviderMessage": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。",
|
||||
"removeProvider": "移除供应商",
|
||||
"removeProviderMessage": "确定要从配置中移除供应商 \"{{name}}\" 吗?\n\n移除后该供应商将不再生效,但配置数据会保留在 CC Switch 中,您可以随时重新添加。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
@@ -183,9 +191,23 @@
|
||||
"title": "成本定价",
|
||||
"description": "管理各模型 Token 计费规则"
|
||||
},
|
||||
"globalProxy": {
|
||||
"title": "全局出站代理",
|
||||
"description": "配置 CC Switch 访问外部 API 时使用的代理"
|
||||
},
|
||||
"data": {
|
||||
"title": "数据管理",
|
||||
"description": "导入导出配置与备份恢复"
|
||||
},
|
||||
"rectifier": {
|
||||
"title": "整流器",
|
||||
"description": "自动修复 API 请求中的兼容性问题",
|
||||
"enabled": "启用整流器",
|
||||
"enabledDescription": "总开关,关闭后所有整流功能将被禁用",
|
||||
"requestGroup": "请求整流",
|
||||
"responseGroup": "响应整流",
|
||||
"thinkingSignature": "Thinking 签名整流",
|
||||
"thinkingSignatureDescription": "自动修复 Claude API 中因 thinking 签名校验失败导致的请求错误"
|
||||
}
|
||||
},
|
||||
"language": "界面语言",
|
||||
@@ -271,12 +293,27 @@
|
||||
"restartLater": "稍后重启",
|
||||
"restartFailed": "应用重启失败,请手动关闭后重新打开。",
|
||||
"devModeRestartHint": "开发模式下不支持自动重启,请手动重新启动应用。",
|
||||
"saving": "正在保存..."
|
||||
"saving": "正在保存...",
|
||||
"globalProxy": {
|
||||
"label": "全局代理",
|
||||
"hint": "代理所有请求(API、Skills 下载等)。留空表示直连。",
|
||||
"username": "用户名(可选)",
|
||||
"password": "密码(可选)",
|
||||
"test": "测试连接",
|
||||
"scan": "扫描本地代理",
|
||||
"clear": "清除",
|
||||
"scanFailed": "扫描失败:{{error}}",
|
||||
"saved": "代理设置已保存",
|
||||
"saveFailed": "保存失败:{{error}}",
|
||||
"testSuccess": "连接成功!延迟 {{latency}}ms",
|
||||
"testFailed": "连接失败:{{error}}"
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"claude": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
},
|
||||
"console": {
|
||||
"providerSwitchReceived": "收到供应商切换事件:",
|
||||
@@ -427,6 +464,36 @@
|
||||
"configMergeFailed": "配置合并失败: {{error}}",
|
||||
"configReplaceFailed": "配置替换失败: {{error}}"
|
||||
},
|
||||
"opencode": {
|
||||
"npmPackage": "接口格式",
|
||||
"selectPackage": "选择接口格式",
|
||||
"npmPackageHint": "选择 AI 服务的 API 接口格式",
|
||||
"baseUrl": "Base URL",
|
||||
"baseUrlHint": "自定义 API 端点地址",
|
||||
"models": "模型配置",
|
||||
"modelsHint": "配置可用的模型及其显示名称",
|
||||
"addModel": "添加模型",
|
||||
"modelId": "模型 ID",
|
||||
"modelName": "显示名称",
|
||||
"noModels": "暂无模型配置",
|
||||
"providerKey": "供应商标识",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
||||
"providerKeyRequired": "请填写供应商标识",
|
||||
"providerKeyDuplicate": "此标识已被使用,请更换",
|
||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
||||
"extraOptions": "额外选项",
|
||||
"extraOptionsHint": "配置额外的 SDK 选项,如 timeout、setCacheKey 等。值会自动解析类型(数字、布尔值等)。",
|
||||
"addExtraOption": "添加",
|
||||
"extraOptionKey": "键名",
|
||||
"extraOptionValue": "值",
|
||||
"extraOptionKeyPlaceholder": "timeout",
|
||||
"extraOptionValuePlaceholder": "600000",
|
||||
"noExtraOptions": "暂无额外选项",
|
||||
"noModelOptions": "模型选项,点击 + 添加",
|
||||
"modelOptionKeyPlaceholder": "provider",
|
||||
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "预设供应商",
|
||||
"custom": "自定义配置",
|
||||
@@ -556,6 +623,7 @@
|
||||
"testFailed": "测试失败",
|
||||
"formatSuccess": "格式化成功",
|
||||
"formatFailed": "格式化失败",
|
||||
"supportedVariables": "支持的变量",
|
||||
"variablesHint": "支持变量: {{apiKey}}, {{baseUrl}} | extractor 函数接收 API 响应的 JSON 对象",
|
||||
"scriptConfig": "请求配置",
|
||||
"extractorCode": "提取器代码",
|
||||
@@ -605,7 +673,8 @@
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
}
|
||||
},
|
||||
"userLevelPath": "用户级 MCP 配置路径",
|
||||
@@ -913,7 +982,8 @@
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
}
|
||||
},
|
||||
"deeplink": {
|
||||
@@ -958,6 +1028,7 @@
|
||||
"configDetails": "配置详情",
|
||||
"configUrl": "配置文件 URL",
|
||||
"configMergeError": "合并配置文件失败",
|
||||
"primaryEndpoint": "主",
|
||||
"mcp": {
|
||||
"title": "批量导入 MCP Servers",
|
||||
"targetApps": "目标应用",
|
||||
@@ -1173,7 +1244,8 @@
|
||||
"checkParams": "检查参数",
|
||||
"timeout": "超时时间(秒)",
|
||||
"maxRetries": "最大重试次数",
|
||||
"degradedThreshold": "降级阈值(毫秒)"
|
||||
"degradedThreshold": "降级阈值(毫秒)",
|
||||
"testPrompt": "检查提示词"
|
||||
},
|
||||
"proxyConfig": {
|
||||
"proxyEnabled": "代理总开关",
|
||||
|
||||
@@ -53,6 +53,7 @@ export const icons: Record<string, string> = {
|
||||
longcat: `<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LongCat</title><path clip-rule="evenodd" d="M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z" fill="#29E154" fill-rule="evenodd"></path><path d="M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z"></path></svg>`,
|
||||
modelscope: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelScope</title><path d="M0 7.967h2.667v2.667H0zM8 10.633h2.667V13.3H8z" fill="#36CED0"></path><path d="M0 10.633h2.667V13.3H0zM2.667 13.3h2.666v2.667H8v2.666H2.667V13.3zM2.667 5.3H8v2.667H5.333v2.666H2.667V5.3zM10.667 13.3h2.667v2.667h-2.667z" fill="#624AFF"></path><path d="M24 7.967h-2.667v2.667H24zM16 10.633h-2.667V13.3H16z" fill="#36CED0"></path><path d="M24 10.633h-2.667V13.3H24zM21.333 13.3h-2.666v2.667H16v2.666h5.333V13.3zM21.333 5.3H16v2.667h2.667v2.666h2.666V5.3z" fill="#624AFF"></path></svg>`,
|
||||
aihubmix: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AiHubMix</title><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" fill="#006FFB"></path><path clip-rule="evenodd" d="M11.24 8.393c.095-.644.302-1.47.624-2.48L12 5.496l.136.417c.322 1.01.53 1.836.624 2.48.071.472.071 1.072 0 1.8-.072.731-.072 1.336 0 1.814.106.7.426 1.281.96 1.744a2.795 2.795 0 001.89.708 2.78 2.78 0 002.034-.84c.56-.559.842-1.234.848-2.024.003-.7.075-1.472.216-2.316.069-.422.14-.775.21-1.06l.095-.384.168.356a7.862 7.862 0 01.76 3.244v.16a7.84 7.84 0 01-.624 3.089 7.952 7.952 0 01-4.228 4.228 7.841 7.841 0 01-3.089.623 7.84 7.84 0 01-3.089-.623 7.952 7.952 0 01-4.228-4.228 7.84 7.84 0 01-.623-3.09v-.159a7.862 7.862 0 01.759-3.244l.169-.356.093.385c.072.284.143.637.211 1.059.141.844.213 1.616.216 2.316.006.79.29 1.465.848 2.024.563.56 1.241.84 2.035.84.715 0 1.345-.236 1.889-.708a2.79 2.79 0 00.96-1.744c.073-.478.073-1.083 0-1.814-.071-.728-.071-1.328 0-1.8zm.76 9.694c1.097 0 2.125-.26 3.085-.778a6.379 6.379 0 001.77-1.399c.063-.07-.01-.178-.101-.153-.37.1-.75.15-1.144.15a4.236 4.236 0 01-2.18-.59 4.253 4.253 0 01-1.35-1.233.099.099 0 00-.16 0 4.253 4.253 0 01-1.35 1.232 4.236 4.236 0 01-2.18.591c-.393 0-.774-.05-1.143-.15-.091-.025-.165.083-.102.153a6.38 6.38 0 001.77 1.399c.96.518 1.988.778 3.085.778z" fill="#fff" fill-rule="evenodd"></path></svg>`,
|
||||
opencode: `<svg height="1em" width="1em" style="flex:none;line-height:1" viewBox="0 0 240 300" xmlns="http://www.w3.org/2000/svg"><title>OpenCode</title><g clip-path="url(#clip0_1401_86274)"><mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300"><path d="M240 0H0V300H240V0Z" fill="white"/></mask><g mask="url(#mask0_1401_86274)"><path d="M180 240H60V120H180V240Z" fill="#CFCECD"/><path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E"/></g></g><defs><clipPath id="clip0_1401_86274"><rect width="240" height="300" fill="white"/></clipPath></defs></svg>`,
|
||||
};
|
||||
|
||||
export const iconList = Object.keys(icons);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg width='240' height='300' viewBox='0 0 240 300' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_1401_86274)'><mask id='mask0_1401_86274' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='240' height='300'><path d='M240 0H0V300H240V0Z' fill='white'/></mask><g mask='url(#mask0_1401_86274)'><path d='M180 240H60V120H180V240Z' fill='#CFCECD'/><path d='M180 60H60V240H180V60ZM240 300H0V0H240V300Z' fill='#211E1E'/></g></g><defs><clipPath id='clip0_1401_86274'><rect width='240' height='300' fill='white'/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 577 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user