mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 23:56:02 +08:00
Compare commits
44 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 |
@@ -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>` - 自定义配置目录
|
||||||
Generated
-1
@@ -727,7 +727,6 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"serial_test",
|
"serial_test",
|
||||||
"sha2",
|
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-deep-link",
|
"tauri-plugin-deep-link",
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
|||||||
indexmap = { version = "2", features = ["serde"] }
|
indexmap = { version = "2", features = ["serde"] }
|
||||||
rust_decimal = "1.33"
|
rust_decimal = "1.33"
|
||||||
uuid = { version = "1.11", features = ["v4"] }
|
uuid = { version = "1.11", features = ["v4"] }
|
||||||
sha2 = "0.10"
|
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ pub struct McpApps {
|
|||||||
pub codex: bool,
|
pub codex: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gemini: bool,
|
pub gemini: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub opencode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl McpApps {
|
impl McpApps {
|
||||||
@@ -22,6 +24,7 @@ impl McpApps {
|
|||||||
AppType::Claude => self.claude,
|
AppType::Claude => self.claude,
|
||||||
AppType::Codex => self.codex,
|
AppType::Codex => self.codex,
|
||||||
AppType::Gemini => self.gemini,
|
AppType::Gemini => self.gemini,
|
||||||
|
AppType::OpenCode => self.opencode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ impl McpApps {
|
|||||||
AppType::Claude => self.claude = enabled,
|
AppType::Claude => self.claude = enabled,
|
||||||
AppType::Codex => self.codex = enabled,
|
AppType::Codex => self.codex = enabled,
|
||||||
AppType::Gemini => self.gemini = enabled,
|
AppType::Gemini => self.gemini = enabled,
|
||||||
|
AppType::OpenCode => self.opencode = enabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,12 +50,15 @@ impl McpApps {
|
|||||||
if self.gemini {
|
if self.gemini {
|
||||||
apps.push(AppType::Gemini);
|
apps.push(AppType::Gemini);
|
||||||
}
|
}
|
||||||
|
if self.opencode {
|
||||||
|
apps.push(AppType::OpenCode);
|
||||||
|
}
|
||||||
apps
|
apps
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查是否所有应用都未启用
|
/// 检查是否所有应用都未启用
|
||||||
pub fn is_empty(&self) -> bool {
|
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,
|
pub codex: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gemini: bool,
|
pub gemini: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub opencode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SkillApps {
|
impl SkillApps {
|
||||||
@@ -73,6 +82,7 @@ impl SkillApps {
|
|||||||
AppType::Claude => self.claude,
|
AppType::Claude => self.claude,
|
||||||
AppType::Codex => self.codex,
|
AppType::Codex => self.codex,
|
||||||
AppType::Gemini => self.gemini,
|
AppType::Gemini => self.gemini,
|
||||||
|
AppType::OpenCode => self.opencode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +92,7 @@ impl SkillApps {
|
|||||||
AppType::Claude => self.claude = enabled,
|
AppType::Claude => self.claude = enabled,
|
||||||
AppType::Codex => self.codex = enabled,
|
AppType::Codex => self.codex = enabled,
|
||||||
AppType::Gemini => self.gemini = enabled,
|
AppType::Gemini => self.gemini = enabled,
|
||||||
|
AppType::OpenCode => self.opencode = enabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,12 +108,15 @@ impl SkillApps {
|
|||||||
if self.gemini {
|
if self.gemini {
|
||||||
apps.push(AppType::Gemini);
|
apps.push(AppType::Gemini);
|
||||||
}
|
}
|
||||||
|
if self.opencode {
|
||||||
|
apps.push(AppType::OpenCode);
|
||||||
|
}
|
||||||
apps
|
apps
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查是否所有应用都未启用
|
/// 检查是否所有应用都未启用
|
||||||
pub fn is_empty(&self) -> bool {
|
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,
|
pub codex: McpConfig,
|
||||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
pub gemini: McpConfig,
|
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 {
|
impl Default for McpRoot {
|
||||||
@@ -216,6 +233,7 @@ impl Default for McpRoot {
|
|||||||
claude: McpConfig::default(),
|
claude: McpConfig::default(),
|
||||||
codex: McpConfig::default(),
|
codex: McpConfig::default(),
|
||||||
gemini: McpConfig::default(),
|
gemini: McpConfig::default(),
|
||||||
|
opencode: McpConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,6 +254,8 @@ pub struct PromptRoot {
|
|||||||
pub codex: PromptConfig,
|
pub codex: PromptConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gemini: PromptConfig,
|
pub gemini: PromptConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub opencode: PromptConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||||
@@ -249,7 +269,8 @@ use crate::provider::ProviderManager;
|
|||||||
pub enum AppType {
|
pub enum AppType {
|
||||||
Claude,
|
Claude,
|
||||||
Codex,
|
Codex,
|
||||||
Gemini, // 新增
|
Gemini,
|
||||||
|
OpenCode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppType {
|
impl AppType {
|
||||||
@@ -257,7 +278,8 @@ impl AppType {
|
|||||||
match self {
|
match self {
|
||||||
AppType::Claude => "claude",
|
AppType::Claude => "claude",
|
||||||
AppType::Codex => "codex",
|
AppType::Codex => "codex",
|
||||||
AppType::Gemini => "gemini", // 新增
|
AppType::Gemini => "gemini",
|
||||||
|
AppType::OpenCode => "opencode",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,11 +292,12 @@ impl FromStr for AppType {
|
|||||||
match normalized.as_str() {
|
match normalized.as_str() {
|
||||||
"claude" => Ok(AppType::Claude),
|
"claude" => Ok(AppType::Claude),
|
||||||
"codex" => Ok(AppType::Codex),
|
"codex" => Ok(AppType::Codex),
|
||||||
"gemini" => Ok(AppType::Gemini), // 新增
|
"gemini" => Ok(AppType::Gemini),
|
||||||
|
"opencode" => Ok(AppType::OpenCode),
|
||||||
other => Err(AppError::localized(
|
other => Err(AppError::localized(
|
||||||
"unsupported_app",
|
"unsupported_app",
|
||||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
|
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode。"),
|
||||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
|
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")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub gemini: Option<String>,
|
pub gemini: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub opencode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonConfigSnippets {
|
impl CommonConfigSnippets {
|
||||||
@@ -300,6 +326,7 @@ impl CommonConfigSnippets {
|
|||||||
AppType::Claude => self.claude.as_ref(),
|
AppType::Claude => self.claude.as_ref(),
|
||||||
AppType::Codex => self.codex.as_ref(),
|
AppType::Codex => self.codex.as_ref(),
|
||||||
AppType::Gemini => self.gemini.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::Claude => self.claude = snippet,
|
||||||
AppType::Codex => self.codex = snippet,
|
AppType::Codex => self.codex = snippet,
|
||||||
AppType::Gemini => self.gemini = snippet,
|
AppType::Gemini => self.gemini = snippet,
|
||||||
|
AppType::OpenCode => self.opencode = snippet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,7 +375,8 @@ impl Default for MultiAppConfig {
|
|||||||
let mut apps = HashMap::new();
|
let mut apps = HashMap::new();
|
||||||
apps.insert("claude".to_string(), ProviderManager::default());
|
apps.insert("claude".to_string(), ProviderManager::default());
|
||||||
apps.insert("codex".to_string(), ProviderManager::default());
|
apps.insert("codex".to_string(), ProviderManager::default());
|
||||||
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
|
apps.insert("gemini".to_string(), ProviderManager::default());
|
||||||
|
apps.insert("opencode".to_string(), ProviderManager::default());
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
version: 2,
|
version: 2,
|
||||||
@@ -506,6 +535,7 @@ impl MultiAppConfig {
|
|||||||
AppType::Claude => &self.mcp.claude,
|
AppType::Claude => &self.mcp.claude,
|
||||||
AppType::Codex => &self.mcp.codex,
|
AppType::Codex => &self.mcp.codex,
|
||||||
AppType::Gemini => &self.mcp.gemini,
|
AppType::Gemini => &self.mcp.gemini,
|
||||||
|
AppType::OpenCode => &self.mcp.opencode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +545,7 @@ impl MultiAppConfig {
|
|||||||
AppType::Claude => &mut self.mcp.claude,
|
AppType::Claude => &mut self.mcp.claude,
|
||||||
AppType::Codex => &mut self.mcp.codex,
|
AppType::Codex => &mut self.mcp.codex,
|
||||||
AppType::Gemini => &mut self.mcp.gemini,
|
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::Claude)?;
|
||||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
|
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::Gemini)?;
|
||||||
|
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
|
||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
@@ -547,6 +579,7 @@ impl MultiAppConfig {
|
|||||||
if !self.prompts.claude.prompts.is_empty()
|
if !self.prompts.claude.prompts.is_empty()
|
||||||
|| !self.prompts.codex.prompts.is_empty()
|
|| !self.prompts.codex.prompts.is_empty()
|
||||||
|| !self.prompts.gemini.prompts.is_empty()
|
|| !self.prompts.gemini.prompts.is_empty()
|
||||||
|
|| !self.prompts.opencode.prompts.is_empty()
|
||||||
{
|
{
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
@@ -554,7 +587,12 @@ impl MultiAppConfig {
|
|||||||
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
|
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
|
||||||
|
|
||||||
let mut imported = false;
|
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)? {
|
if Self::auto_import_prompt_if_exists(self, app)? {
|
||||||
imported = true;
|
imported = true;
|
||||||
@@ -623,6 +661,7 @@ impl MultiAppConfig {
|
|||||||
AppType::Claude => &mut config.prompts.claude.prompts,
|
AppType::Claude => &mut config.prompts.claude.prompts,
|
||||||
AppType::Codex => &mut config.prompts.codex.prompts,
|
AppType::Codex => &mut config.prompts.codex.prompts,
|
||||||
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||||
|
AppType::OpenCode => &mut config.prompts.opencode.prompts,
|
||||||
};
|
};
|
||||||
|
|
||||||
prompts.insert(id, prompt);
|
prompts.insert(id, prompt);
|
||||||
@@ -656,6 +695,7 @@ impl MultiAppConfig {
|
|||||||
AppType::Claude => &self.mcp.claude.servers,
|
AppType::Claude => &self.mcp.claude.servers,
|
||||||
AppType::Codex => &self.mcp.codex.servers,
|
AppType::Codex => &self.mcp.codex.servers,
|
||||||
AppType::Gemini => &self.mcp.gemini.servers,
|
AppType::Gemini => &self.mcp.gemini.servers,
|
||||||
|
AppType::OpenCode => &self.mcp.opencode.servers,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (id, entry) in old_servers {
|
for (id, entry) in old_servers {
|
||||||
|
|||||||
@@ -51,6 +51,15 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
|||||||
|
|
||||||
Ok(ConfigStatus { exists, path })
|
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::Claude => config::get_claude_config_dir(),
|
||||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||||
|
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(dir.to_string_lossy().to_string())
|
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::Claude => config::get_claude_config_dir(),
|
||||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||||
|
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if !config_dir.exists() {
|
if !config_dir.exists() {
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ pub async fn upsert_mcp_server_in_config(
|
|||||||
new_server.apps.claude = true;
|
new_server.apps.claude = true;
|
||||||
new_server.apps.codex = true;
|
new_server.apps.codex = true;
|
||||||
new_server.apps.gemini = true;
|
new_server.apps.gemini = true;
|
||||||
|
new_server.apps.opencode = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
McpService::upsert_server(&state, new_server)
|
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_claude(&state).unwrap_or(0);
|
||||||
total += McpService::import_from_codex(&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_gemini(&state).unwrap_or(0);
|
||||||
|
total += McpService::import_from_opencode(&state).unwrap_or(0);
|
||||||
Ok(total)
|
Ok(total)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ mod proxy;
|
|||||||
mod settings;
|
mod settings;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
mod stream_check;
|
mod stream_check;
|
||||||
mod template;
|
|
||||||
mod usage;
|
mod usage;
|
||||||
|
|
||||||
pub use config::*;
|
pub use config::*;
|
||||||
@@ -33,5 +32,4 @@ pub use proxy::*;
|
|||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use skill::*;
|
pub use skill::*;
|
||||||
pub use stream_check::*;
|
pub use stream_check::*;
|
||||||
pub use template::*;
|
|
||||||
pub use usage::*;
|
pub use usage::*;
|
||||||
|
|||||||
@@ -60,6 +60,16 @@ pub fn delete_provider(
|
|||||||
.map_err(|e| e.to_string())
|
.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> {
|
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||||
ProviderService::switch(state, app_type, id)
|
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)] baseUrl: Option<String>,
|
||||||
#[allow(non_snake_case)] accessToken: Option<String>,
|
#[allow(non_snake_case)] accessToken: Option<String>,
|
||||||
#[allow(non_snake_case)] userId: Option<String>,
|
#[allow(non_snake_case)] userId: Option<String>,
|
||||||
|
#[allow(non_snake_case)] templateType: Option<String>,
|
||||||
) -> Result<crate::provider::UsageResult, String> {
|
) -> Result<crate::provider::UsageResult, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
ProviderService::test_usage_script(
|
ProviderService::test_usage_script(
|
||||||
@@ -145,6 +156,7 @@ pub async fn testUsageScript(
|
|||||||
baseUrl.as_deref(),
|
baseUrl.as_deref(),
|
||||||
accessToken.as_deref(),
|
accessToken.as_deref(),
|
||||||
userId.as_deref(),
|
userId.as_deref(),
|
||||||
|
templateType.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
@@ -323,3 +335,27 @@ pub fn sync_universal_provider(
|
|||||||
|
|
||||||
Ok(result)
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ fn parse_app_type(app: &str) -> Result<AppType, String> {
|
|||||||
"claude" => Ok(AppType::Claude),
|
"claude" => Ok(AppType::Claude),
|
||||||
"codex" => Ok(AppType::Codex),
|
"codex" => Ok(AppType::Codex),
|
||||||
"gemini" => Ok(AppType::Gemini),
|
"gemini" => Ok(AppType::Gemini),
|
||||||
|
"opencode" => Ok(AppType::OpenCode),
|
||||||
_ => Err(format!("不支持的 app 类型: {app}")),
|
_ => Err(format!("不支持的 app 类型: {app}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,278 +0,0 @@
|
|||||||
use tauri::State;
|
|
||||||
|
|
||||||
use crate::database::lock_conn;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::services::{
|
|
||||||
BatchInstallResult, ComponentDetail, InstalledComponent, PaginatedResult, TemplateComponent,
|
|
||||||
TemplateRepo, TemplateService,
|
|
||||||
};
|
|
||||||
use crate::store::AppState;
|
|
||||||
|
|
||||||
/// 刷新模板索引
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn refresh_template_index(state: State<'_, AppState>) -> Result<(), String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
// 使用 spawn_blocking 在后台线程中执行数据库操作
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.refresh_index(&conn)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取模板组件列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn list_template_components(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
component_type: Option<String>,
|
|
||||||
category: Option<String>,
|
|
||||||
search: Option<String>,
|
|
||||||
page: u32,
|
|
||||||
page_size: u32,
|
|
||||||
app_type: Option<String>,
|
|
||||||
) -> Result<PaginatedResult<TemplateComponent>, AppError> {
|
|
||||||
let (mut components, total) = state.db.list_components(
|
|
||||||
component_type.as_deref(),
|
|
||||||
category.as_deref(),
|
|
||||||
search.as_deref(),
|
|
||||||
page,
|
|
||||||
page_size,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 填充 installed 字段
|
|
||||||
if let Some(app) = &app_type {
|
|
||||||
let installed_ids = state.db.get_installed_component_ids(app)?;
|
|
||||||
for component in &mut components {
|
|
||||||
if let Some(id) = component.id {
|
|
||||||
component.installed = installed_ids.contains(&id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(PaginatedResult {
|
|
||||||
items: components,
|
|
||||||
total: total as i64,
|
|
||||||
page,
|
|
||||||
page_size,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取组件详情
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_template_component(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: i64,
|
|
||||||
) -> Result<ComponentDetail, String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
let detail = tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.get_component(&conn, id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
|
||||||
|
|
||||||
Ok(detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装组件
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn install_template_component(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: i64,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.install_component(&conn, id, &app_type)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 卸载组件
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn uninstall_template_component(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: i64,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let service = TemplateService::new().map_err(|e| AppError::Config(e.to_string()))?;
|
|
||||||
let conn = lock_conn!(state.db.conn);
|
|
||||||
|
|
||||||
service
|
|
||||||
.uninstall_component(&conn, id, &app_type)
|
|
||||||
.map_err(|e| AppError::Config(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 批量安装组件
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn batch_install_template_components(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
ids: Vec<i64>,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<BatchInstallResult, String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
let result = tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.batch_install(&conn, ids, &app_type)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取模板仓库列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn list_template_repos(state: State<'_, AppState>) -> Result<Vec<TemplateRepo>, AppError> {
|
|
||||||
state.db.list_repos()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加模板仓库
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn add_template_repo(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
owner: String,
|
|
||||||
name: String,
|
|
||||||
branch: String,
|
|
||||||
) -> Result<i64, AppError> {
|
|
||||||
let repo = TemplateRepo::new(owner, name, branch);
|
|
||||||
state.db.insert_repo(&repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除模板仓库
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn remove_template_repo(state: State<'_, AppState>, id: i64) -> Result<(), AppError> {
|
|
||||||
state.db.delete_repo(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换仓库启用状态
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn toggle_template_repo(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: i64,
|
|
||||||
enabled: bool,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
state.db.toggle_repo_enabled(id, enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取组件分类列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn list_template_categories(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
component_type: Option<String>,
|
|
||||||
) -> Result<Vec<String>, AppError> {
|
|
||||||
let conn = lock_conn!(state.db.conn);
|
|
||||||
|
|
||||||
// 构建查询语句
|
|
||||||
let sql = if let Some(ct) = component_type {
|
|
||||||
format!(
|
|
||||||
"SELECT DISTINCT category FROM template_components WHERE component_type = '{ct}' AND category IS NOT NULL ORDER BY category"
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
"SELECT DISTINCT category FROM template_components WHERE category IS NOT NULL ORDER BY category".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
|
||||||
let categories = stmt
|
|
||||||
.query_map([], |row| row.get::<_, String>(0))?
|
|
||||||
.collect::<Result<Vec<String>, _>>()?;
|
|
||||||
|
|
||||||
Ok(categories)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取已安装组件列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn list_installed_components(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app_type: Option<String>,
|
|
||||||
component_type: Option<String>,
|
|
||||||
) -> Result<Vec<InstalledComponent>, AppError> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.list_installed_components(app_type.as_deref(), component_type.as_deref())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 预览组件内容
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn preview_component_content(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: i64,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.preview_content(&conn, id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))?
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取市场组合列表
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn list_marketplace_bundles(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<Vec<crate::services::MarketplaceBundle>, String> {
|
|
||||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let conn = lock_conn!(db.conn);
|
|
||||||
let rt = tokio::runtime::Handle::current();
|
|
||||||
rt.block_on(async {
|
|
||||||
service
|
|
||||||
.fetch_marketplace_bundles(&conn)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("任务执行失败: {e}"))?
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ impl Database {
|
|||||||
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let mut stmt = conn.prepare(
|
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
|
FROM mcp_servers
|
||||||
ORDER BY name ASC, id ASC"
|
ORDER BY name ASC, id ASC"
|
||||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -30,6 +30,7 @@ impl Database {
|
|||||||
let enabled_claude: bool = row.get(7)?;
|
let enabled_claude: bool = row.get(7)?;
|
||||||
let enabled_codex: bool = row.get(8)?;
|
let enabled_codex: bool = row.get(8)?;
|
||||||
let enabled_gemini: bool = row.get(9)?;
|
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 server = serde_json::from_str(&server_config_str).unwrap_or_default();
|
||||||
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
|
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||||
@@ -44,6 +45,7 @@ impl Database {
|
|||||||
claude: enabled_claude,
|
claude: enabled_claude,
|
||||||
codex: enabled_codex,
|
codex: enabled_codex,
|
||||||
gemini: enabled_gemini,
|
gemini: enabled_gemini,
|
||||||
|
opencode: enabled_opencode,
|
||||||
},
|
},
|
||||||
description,
|
description,
|
||||||
homepage,
|
homepage,
|
||||||
@@ -68,8 +70,8 @@ impl Database {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO mcp_servers (
|
"INSERT OR REPLACE INTO mcp_servers (
|
||||||
id, name, server_config, description, homepage, docs, tags,
|
id, name, server_config, description, homepage, docs, tags,
|
||||||
enabled_claude, enabled_codex, enabled_gemini
|
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||||
params![
|
params![
|
||||||
server.id,
|
server.id,
|
||||||
server.name,
|
server.name,
|
||||||
@@ -84,6 +86,7 @@ impl Database {
|
|||||||
server.apps.claude,
|
server.apps.claude,
|
||||||
server.apps.codex,
|
server.apps.codex,
|
||||||
server.apps.gemini,
|
server.apps.gemini,
|
||||||
|
server.apps.opencode,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ pub mod proxy;
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod stream_check;
|
pub mod stream_check;
|
||||||
pub mod template;
|
|
||||||
pub mod universal_providers;
|
pub mod universal_providers;
|
||||||
|
|
||||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
|
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
|
||||||
//!
|
//!
|
||||||
//! v3.10.0+ 统一管理架构:
|
//! v3.10.0+ 统一管理架构:
|
||||||
//! - Skills 使用统一的 id 主键,支持三应用启用标志
|
//! - Skills 使用统一的 id 主键,支持四应用启用标志
|
||||||
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
|
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
|
||||||
|
|
||||||
use crate::app_config::{InstalledSkill, SkillApps};
|
use crate::app_config::{InstalledSkill, SkillApps};
|
||||||
@@ -22,7 +22,7 @@ impl Database {
|
|||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
"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",
|
FROM skills ORDER BY name ASC",
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -42,8 +42,9 @@ impl Database {
|
|||||||
claude: row.get(8)?,
|
claude: row.get(8)?,
|
||||||
codex: row.get(9)?,
|
codex: row.get(9)?,
|
||||||
gemini: row.get(10)?,
|
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()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -62,7 +63,7 @@ impl Database {
|
|||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
"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",
|
FROM skills WHERE id = ?1",
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -81,8 +82,9 @@ impl Database {
|
|||||||
claude: row.get(8)?,
|
claude: row.get(8)?,
|
||||||
codex: row.get(9)?,
|
codex: row.get(9)?,
|
||||||
gemini: row.get(10)?,
|
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(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO skills
|
"INSERT OR REPLACE INTO skills
|
||||||
(id, name, description, directory, repo_owner, repo_name, repo_branch,
|
(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)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||||
params![
|
params![
|
||||||
skill.id,
|
skill.id,
|
||||||
skill.name,
|
skill.name,
|
||||||
@@ -113,6 +115,7 @@ impl Database {
|
|||||||
skill.apps.claude,
|
skill.apps.claude,
|
||||||
skill.apps.codex,
|
skill.apps.codex,
|
||||||
skill.apps.gemini,
|
skill.apps.gemini,
|
||||||
|
skill.apps.opencode,
|
||||||
skill.installed_at,
|
skill.installed_at,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -142,8 +145,8 @@ impl Database {
|
|||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let affected = conn
|
let affected = conn
|
||||||
.execute(
|
.execute(
|
||||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3 WHERE id = ?4",
|
"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, id],
|
params![apps.claude, apps.codex, apps.gemini, apps.opencode, id],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
Ok(affected > 0)
|
Ok(affected > 0)
|
||||||
|
|||||||
@@ -1,595 +0,0 @@
|
|||||||
//! Template 数据访问对象
|
|
||||||
//!
|
|
||||||
//! 提供 Template Repos、Template Components 和 Installed Components 的 CRUD 操作。
|
|
||||||
|
|
||||||
use crate::database::{lock_conn, Database};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::services::template::{
|
|
||||||
ComponentType, InstalledComponent, TemplateComponent, TemplateRepo,
|
|
||||||
};
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use rusqlite::{params, OptionalExtension};
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
// ==================== TemplateRepo 相关 ====================
|
|
||||||
|
|
||||||
/// 插入模板仓库
|
|
||||||
pub fn insert_repo(&self, repo: &TemplateRepo) -> Result<i64, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO template_repos (owner, name, branch, enabled, created_at, updated_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
params![repo.owner, repo.name, repo.branch, repo.enabled, now, now],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("插入模板仓库失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取单个模板仓库
|
|
||||||
pub fn get_repo(&self, id: i64) -> Result<Option<TemplateRepo>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
|
||||||
FROM template_repos
|
|
||||||
WHERE id = ?1",
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询模板仓库失败: {e}")))?;
|
|
||||||
|
|
||||||
let repo = stmt
|
|
||||||
.query_row(params![id], |row| {
|
|
||||||
Ok(TemplateRepo {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
owner: row.get(1)?,
|
|
||||||
name: row.get(2)?,
|
|
||||||
branch: row.get(3)?,
|
|
||||||
enabled: row.get(4)?,
|
|
||||||
created_at: row
|
|
||||||
.get::<_, String>(5)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
|
||||||
.map(|dt| dt.with_timezone(&Utc)),
|
|
||||||
updated_at: row
|
|
||||||
.get::<_, String>(6)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
|
||||||
.map(|dt| dt.with_timezone(&Utc)),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.map_err(|e| AppError::Database(format!("查询模板仓库失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取所有模板仓库
|
|
||||||
pub fn list_repos(&self) -> Result<Vec<TemplateRepo>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
|
||||||
FROM template_repos
|
|
||||||
ORDER BY created_at DESC",
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询模板仓库列表失败: {e}")))?;
|
|
||||||
|
|
||||||
let repo_iter = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
Ok(TemplateRepo {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
owner: row.get(1)?,
|
|
||||||
name: row.get(2)?,
|
|
||||||
branch: row.get(3)?,
|
|
||||||
enabled: row.get(4)?,
|
|
||||||
created_at: row
|
|
||||||
.get::<_, String>(5)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
|
||||||
.map(|dt| dt.with_timezone(&Utc)),
|
|
||||||
updated_at: row
|
|
||||||
.get::<_, String>(6)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
|
||||||
.map(|dt| dt.with_timezone(&Utc)),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(format!("查询模板仓库列表失败: {e}")))?;
|
|
||||||
|
|
||||||
let mut repos = Vec::new();
|
|
||||||
for repo_res in repo_iter {
|
|
||||||
repos.push(repo_res.map_err(|e| AppError::Database(format!("解析模板仓库失败: {e}")))?);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(repos)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新模板仓库
|
|
||||||
pub fn update_repo(&self, repo: &TemplateRepo) -> Result<(), AppError> {
|
|
||||||
let repo_id = repo
|
|
||||||
.id
|
|
||||||
.ok_or_else(|| AppError::Database("仓库 ID 不能为空".to_string()))?;
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE template_repos
|
|
||||||
SET owner = ?1, name = ?2, branch = ?3, enabled = ?4, updated_at = ?5
|
|
||||||
WHERE id = ?6",
|
|
||||||
params![
|
|
||||||
repo.owner,
|
|
||||||
repo.name,
|
|
||||||
repo.branch,
|
|
||||||
repo.enabled,
|
|
||||||
now,
|
|
||||||
repo_id
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("更新模板仓库失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除模板仓库
|
|
||||||
pub fn delete_repo(&self, id: i64) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute("DELETE FROM template_repos WHERE id = ?1", params![id])
|
|
||||||
.map_err(|e| AppError::Database(format!("删除模板仓库失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换仓库启用状态
|
|
||||||
pub fn toggle_repo_enabled(&self, id: i64, enabled: bool) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE template_repos SET enabled = ?1, updated_at = ?2 WHERE id = ?3",
|
|
||||||
params![enabled, now, id],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("切换仓库启用状态失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== TemplateComponent 相关 ====================
|
|
||||||
|
|
||||||
/// 插入模板组件
|
|
||||||
pub fn insert_component(&self, component: &TemplateComponent) -> Result<i64, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO template_components
|
|
||||||
(repo_id, component_type, category, name, path, description, content_hash, created_at, updated_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
|
||||||
params![
|
|
||||||
component.repo_id,
|
|
||||||
component.component_type.as_str(),
|
|
||||||
component.category,
|
|
||||||
component.name,
|
|
||||||
component.path,
|
|
||||||
component.description,
|
|
||||||
component.content_hash,
|
|
||||||
now,
|
|
||||||
now
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("插入模板组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取单个模板组件
|
|
||||||
pub fn get_component(&self, id: i64) -> Result<Option<TemplateComponent>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
WHERE id = ?1",
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询模板组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let component = stmt
|
|
||||||
.query_row(params![id], |row| {
|
|
||||||
let component_type_str: String = row.get(2)?;
|
|
||||||
let component_type = ComponentType::from_str(&component_type_str)
|
|
||||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type,
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false, // 需要单独查询
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.map_err(|e| AppError::Database(format!("查询模板组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(component)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取组件列表(支持过滤和分页)
|
|
||||||
pub fn list_components(
|
|
||||||
&self,
|
|
||||||
component_type: Option<&str>,
|
|
||||||
category: Option<&str>,
|
|
||||||
search: Option<&str>,
|
|
||||||
page: u32,
|
|
||||||
page_size: u32,
|
|
||||||
) -> Result<(Vec<TemplateComponent>, u32), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
// 构建 WHERE 子句
|
|
||||||
let mut where_clauses = Vec::new();
|
|
||||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
|
||||||
|
|
||||||
if let Some(ct) = component_type {
|
|
||||||
where_clauses.push("component_type = ?");
|
|
||||||
params_vec.push(Box::new(ct.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(cat) = category {
|
|
||||||
where_clauses.push("category = ?");
|
|
||||||
params_vec.push(Box::new(cat.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(s) = search {
|
|
||||||
where_clauses.push("(name LIKE ? OR description LIKE ?)");
|
|
||||||
let pattern = format!("%{s}%");
|
|
||||||
params_vec.push(Box::new(pattern.clone()));
|
|
||||||
params_vec.push(Box::new(pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
let where_sql = if where_clauses.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("WHERE {}", where_clauses.join(" AND "))
|
|
||||||
};
|
|
||||||
|
|
||||||
// 查询总数
|
|
||||||
let count_sql = format!("SELECT COUNT(*) FROM template_components {where_sql}");
|
|
||||||
|
|
||||||
let total: u32 = {
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(&count_sql)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备统计组件数量失败: {e}")))?;
|
|
||||||
|
|
||||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
|
||||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
stmt.query_row(¶ms_refs[..], |row| row.get(0))
|
|
||||||
.map_err(|e| AppError::Database(format!("统计组件数量失败: {e}")))?
|
|
||||||
};
|
|
||||||
|
|
||||||
// 查询数据
|
|
||||||
let offset = (page.saturating_sub(1)) * page_size;
|
|
||||||
let query_sql = format!(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
{where_sql}
|
|
||||||
ORDER BY name ASC
|
|
||||||
LIMIT ? OFFSET ?"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(&query_sql)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询组件列表失败: {e}")))?;
|
|
||||||
|
|
||||||
params_vec.push(Box::new(page_size));
|
|
||||||
params_vec.push(Box::new(offset));
|
|
||||||
|
|
||||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
|
||||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
let component_iter = stmt
|
|
||||||
.query_map(¶ms_refs[..], |row| {
|
|
||||||
let component_type_str: String = row.get(2)?;
|
|
||||||
let component_type = ComponentType::from_str(&component_type_str)
|
|
||||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type,
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false, // 需要单独查询
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(format!("查询组件列表失败: {e}")))?;
|
|
||||||
|
|
||||||
let mut components = Vec::new();
|
|
||||||
for component_res in component_iter {
|
|
||||||
components
|
|
||||||
.push(component_res.map_err(|e| AppError::Database(format!("解析组件失败: {e}")))?);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((components, total))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除仓库的所有组件
|
|
||||||
pub fn delete_components_by_repo(&self, repo_id: i64) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM template_components WHERE repo_id = ?1",
|
|
||||||
params![repo_id],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("删除仓库组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Upsert 模板组件(根据 repo_id + component_type + path 判断是否已存在)
|
|
||||||
pub fn upsert_component(&self, component: &TemplateComponent) -> Result<i64, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO template_components
|
|
||||||
(repo_id, component_type, category, name, path, description, content_hash, created_at, updated_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
||||||
ON CONFLICT(repo_id, component_type, path) DO UPDATE SET
|
|
||||||
category = excluded.category,
|
|
||||||
name = excluded.name,
|
|
||||||
description = excluded.description,
|
|
||||||
content_hash = excluded.content_hash,
|
|
||||||
updated_at = excluded.updated_at",
|
|
||||||
params![
|
|
||||||
component.repo_id,
|
|
||||||
component.component_type.as_str(),
|
|
||||||
component.category,
|
|
||||||
component.name,
|
|
||||||
component.path,
|
|
||||||
component.description,
|
|
||||||
component.content_hash,
|
|
||||||
now,
|
|
||||||
now
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Upsert 模板组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== InstalledComponent 相关 ====================
|
|
||||||
|
|
||||||
/// 插入已安装组件
|
|
||||||
pub fn insert_installed(&self, installed: &InstalledComponent) -> Result<i64, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let installed_at = installed.installed_at.to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO installed_components
|
|
||||||
(component_id, component_type, name, path, app_type, installed_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
params![
|
|
||||||
installed.component_id,
|
|
||||||
installed.component_type.as_str(),
|
|
||||||
installed.name,
|
|
||||||
installed.path,
|
|
||||||
installed.app_type,
|
|
||||||
installed_at
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("插入已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除已安装组件
|
|
||||||
pub fn delete_installed(
|
|
||||||
&self,
|
|
||||||
component_type: &str,
|
|
||||||
path: &str,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM installed_components
|
|
||||||
WHERE component_type = ?1 AND path = ?2 AND app_type = ?3",
|
|
||||||
params![component_type, path, app_type],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("删除已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取已安装组件列表
|
|
||||||
pub fn list_installed(
|
|
||||||
&self,
|
|
||||||
app_type: Option<&str>,
|
|
||||||
) -> Result<Vec<InstalledComponent>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::ToSql>>) = if let Some(at) = app_type
|
|
||||||
{
|
|
||||||
(
|
|
||||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
|
||||||
FROM installed_components
|
|
||||||
WHERE app_type = ?
|
|
||||||
ORDER BY installed_at DESC"
|
|
||||||
.to_string(),
|
|
||||||
vec![Box::new(at.to_string())],
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
|
||||||
FROM installed_components
|
|
||||||
ORDER BY installed_at DESC"
|
|
||||||
.to_string(),
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(&sql)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
|
||||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
let installed_iter = stmt
|
|
||||||
.query_map(¶ms_refs[..], |row| {
|
|
||||||
let component_type_str: String = row.get(2)?;
|
|
||||||
let component_type = ComponentType::from_str(&component_type_str)
|
|
||||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
let installed_at_str: String = row.get(6)?;
|
|
||||||
let installed_at = DateTime::parse_from_rfc3339(&installed_at_str)
|
|
||||||
.map(|dt| dt.with_timezone(&Utc))
|
|
||||||
.map_err(|_| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
Ok(InstalledComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
component_id: row.get(1)?,
|
|
||||||
component_type,
|
|
||||||
name: row.get(3)?,
|
|
||||||
path: row.get(4)?,
|
|
||||||
app_type: row.get(5)?,
|
|
||||||
installed_at,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let mut installed = Vec::new();
|
|
||||||
for installed_res in installed_iter {
|
|
||||||
installed.push(
|
|
||||||
installed_res
|
|
||||||
.map_err(|e| AppError::Database(format!("解析已安装组件失败: {e}")))?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(installed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取已安装组件列表(支持 app_type 和 component_type 过滤)
|
|
||||||
pub fn list_installed_components(
|
|
||||||
&self,
|
|
||||||
app_type: Option<&str>,
|
|
||||||
component_type: Option<&str>,
|
|
||||||
) -> Result<Vec<InstalledComponent>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
// 构建 WHERE 子句
|
|
||||||
let mut where_clauses = Vec::new();
|
|
||||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
|
||||||
|
|
||||||
if let Some(at) = app_type {
|
|
||||||
where_clauses.push("app_type = ?");
|
|
||||||
params_vec.push(Box::new(at.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ct) = component_type {
|
|
||||||
where_clauses.push("component_type = ?");
|
|
||||||
params_vec.push(Box::new(ct.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let where_sql = if where_clauses.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("WHERE {}", where_clauses.join(" AND "))
|
|
||||||
};
|
|
||||||
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
|
||||||
FROM installed_components
|
|
||||||
{where_sql}
|
|
||||||
ORDER BY installed_at DESC"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(&sql)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
|
||||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
let installed_iter = stmt
|
|
||||||
.query_map(¶ms_refs[..], |row| {
|
|
||||||
let component_type_str: String = row.get(2)?;
|
|
||||||
let component_type = ComponentType::from_str(&component_type_str)
|
|
||||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
let installed_at_str: String = row.get(6)?;
|
|
||||||
let installed_at = DateTime::parse_from_rfc3339(&installed_at_str)
|
|
||||||
.map(|dt| dt.with_timezone(&Utc))
|
|
||||||
.map_err(|_| rusqlite::Error::InvalidQuery)?;
|
|
||||||
|
|
||||||
Ok(InstalledComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
component_id: row.get(1)?,
|
|
||||||
component_type,
|
|
||||||
name: row.get(3)?,
|
|
||||||
path: row.get(4)?,
|
|
||||||
app_type: row.get(5)?,
|
|
||||||
installed_at,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let mut installed = Vec::new();
|
|
||||||
for installed_res in installed_iter {
|
|
||||||
installed.push(
|
|
||||||
installed_res
|
|
||||||
.map_err(|e| AppError::Database(format!("解析已安装组件失败: {e}")))?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(installed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查组件是否已安装
|
|
||||||
pub fn is_installed(
|
|
||||||
&self,
|
|
||||||
component_type: &str,
|
|
||||||
path: &str,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<bool, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM installed_components
|
|
||||||
WHERE component_type = ?1 AND path = ?2 AND app_type = ?3",
|
|
||||||
params![component_type, path, app_type],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("检查组件安装状态失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(count > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取指定应用已安装的组件 ID 列表
|
|
||||||
pub fn get_installed_component_ids(&self, app_type: &str) -> Result<Vec<i64>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT component_id FROM installed_components WHERE app_type = ?1 AND component_id IS NOT NULL",
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
|
|
||||||
|
|
||||||
let ids: Vec<i64> = stmt
|
|
||||||
.query_map(params![app_type], |row| row.get(0))
|
|
||||||
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?
|
|
||||||
.filter_map(|r| r.ok())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(ids)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
|
|||||||
|
|
||||||
/// 当前 Schema 版本号
|
/// 当前 Schema 版本号
|
||||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||||
pub(crate) const SCHEMA_VERSION: i32 = 3;
|
pub(crate) const SCHEMA_VERSION: i32 = 4;
|
||||||
|
|
||||||
/// 安全地序列化 JSON,避免 unwrap panic
|
/// 安全地序列化 JSON,避免 unwrap panic
|
||||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
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,
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
|
||||||
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
|
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_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_claude BOOLEAN NOT NULL DEFAULT 0,
|
||||||
enabled_codex 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,
|
||||||
installed_at INTEGER NOT NULL DEFAULT 0
|
installed_at INTEGER NOT NULL DEFAULT 0
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
@@ -302,90 +303,6 @@ impl Database {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 15. Template Repos 表 (模板仓库)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS template_repos (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
owner TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
branch TEXT NOT NULL DEFAULT 'main',
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(owner, name)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 插入默认模板仓库
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR IGNORE INTO template_repos (owner, name, branch, enabled)
|
|
||||||
VALUES ('yovinchen', 'claude-code-templates', 'main', 1)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 16. Template Components 表 (模板组件)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS template_components (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
repo_id INTEGER NOT NULL,
|
|
||||||
component_type TEXT NOT NULL,
|
|
||||||
category TEXT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
content_hash TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (repo_id) REFERENCES template_repos(id) ON DELETE CASCADE,
|
|
||||||
UNIQUE(repo_id, component_type, path)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 为 template_components 创建索引
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_template_components_type
|
|
||||||
ON template_components(component_type)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_template_components_category
|
|
||||||
ON template_components(category)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 17. Installed Components 表 (已安装组件)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS installed_components (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
component_id INTEGER,
|
|
||||||
component_type TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (component_id) REFERENCES template_components(id) ON DELETE SET NULL,
|
|
||||||
UNIQUE(component_type, path, app_type)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 为 installed_components 创建索引
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_installed_components_app
|
|
||||||
ON installed_components(app_type)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,12 +347,11 @@ impl Database {
|
|||||||
Self::migrate_v2_to_v3(conn)?;
|
Self::migrate_v2_to_v3(conn)?;
|
||||||
Self::set_user_version(conn, 3)?;
|
Self::set_user_version(conn, 3)?;
|
||||||
}
|
}
|
||||||
// v3 -> v4: Claude Code Templates 市场功能(暂未启用)
|
3 => {
|
||||||
// 3 => {
|
log::info!("迁移数据库从 v3 到 v4(OpenCode 支持)");
|
||||||
// log::info!("迁移数据库从 v3 到 v4(Claude Code Templates 市场功能)");
|
Self::migrate_v3_to_v4(conn)?;
|
||||||
// Self::migrate_v3_to_v4(conn)?;
|
Self::set_user_version(conn, 4)?;
|
||||||
// Self::set_user_version(conn, 4)?;
|
}
|
||||||
// }
|
|
||||||
_ => {
|
_ => {
|
||||||
return Err(AppError::Database(format!(
|
return Err(AppError::Database(format!(
|
||||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||||
@@ -876,97 +792,6 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// v3 -> v4 迁移:添加 Claude Code Templates 功能相关表
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> {
|
|
||||||
// 1. template_repos 表 - 存储模板仓库信息
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS template_repos (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
owner TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
branch TEXT NOT NULL DEFAULT 'main',
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(owner, name)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("创建 template_repos 表失败: {e}")))?;
|
|
||||||
|
|
||||||
// 2. template_components 表 - 存储从仓库中发现的模板组件
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS template_components (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
repo_id INTEGER NOT NULL,
|
|
||||||
component_type TEXT NOT NULL,
|
|
||||||
category TEXT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
content_hash TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (repo_id) REFERENCES template_repos(id) ON DELETE CASCADE,
|
|
||||||
UNIQUE(repo_id, component_type, path)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("创建 template_components 表失败: {e}")))?;
|
|
||||||
|
|
||||||
// 为 template_components 创建索引
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_template_components_type
|
|
||||||
ON template_components(component_type)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("创建 template_components 类型索引失败: {e}")))?;
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_template_components_category
|
|
||||||
ON template_components(category)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("创建 template_components 分类索引失败: {e}")))?;
|
|
||||||
|
|
||||||
// 3. installed_components 表 - 存储已安装的组件
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS installed_components (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
component_id INTEGER,
|
|
||||||
component_type TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (component_id) REFERENCES template_components(id) ON DELETE SET NULL,
|
|
||||||
UNIQUE(component_type, path, app_type)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("创建 installed_components 表失败: {e}")))?;
|
|
||||||
|
|
||||||
// 为 installed_components 创建索引
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_installed_components_app
|
|
||||||
ON installed_components(app_type)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("创建 installed_components 应用索引失败: {e}")))?;
|
|
||||||
|
|
||||||
// 4. 插入默认模板仓库
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR IGNORE INTO template_repos (owner, name, branch, enabled)
|
|
||||||
VALUES ('yovinchen', 'claude-code-templates', 'main', 1)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("插入默认模板仓库失败: {e}")))?;
|
|
||||||
|
|
||||||
log::info!("已创建 Claude Code Templates 相关表并插入默认仓库");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// v2 -> v3 迁移:Skills 统一管理架构
|
/// v2 -> v3 迁移:Skills 统一管理架构
|
||||||
///
|
///
|
||||||
/// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构,
|
/// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构,
|
||||||
@@ -1030,6 +855,30 @@ impl Database {
|
|||||||
Ok(())
|
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, display_name, input, output, cache_read, cache_creation)
|
||||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||||
|
|||||||
@@ -518,7 +518,8 @@ fn model_pricing_is_seeded_on_init() {
|
|||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
count > 0,
|
count > 0,
|
||||||
"模型定价数据应该在初始化时自动填充,实际数量: {count}"
|
"模型定价数据应该在初始化时自动填充,实际数量: {}",
|
||||||
|
count
|
||||||
);
|
);
|
||||||
|
|
||||||
// 验证包含 Claude 模型
|
// 验证包含 Claude 模型
|
||||||
@@ -531,7 +532,8 @@ fn model_pricing_is_seeded_on_init() {
|
|||||||
.expect("check claude");
|
.expect("check claude");
|
||||||
assert!(
|
assert!(
|
||||||
claude_count > 0,
|
claude_count > 0,
|
||||||
"应该包含 Claude 模型定价,实际数量: {claude_count}"
|
"应该包含 Claude 模型定价,实际数量: {}",
|
||||||
|
claude_count
|
||||||
);
|
);
|
||||||
|
|
||||||
// 验证包含 GPT 模型
|
// 验证包含 GPT 模型
|
||||||
@@ -544,7 +546,8 @@ fn model_pricing_is_seeded_on_init() {
|
|||||||
.expect("check gpt");
|
.expect("check gpt");
|
||||||
assert!(
|
assert!(
|
||||||
gpt_count > 0,
|
gpt_count > 0,
|
||||||
"应该包含 GPT 模型定价,实际数量: {gpt_count}"
|
"应该包含 GPT 模型定价,实际数量: {}",
|
||||||
|
gpt_count
|
||||||
);
|
);
|
||||||
|
|
||||||
// 验证包含 Gemini 模型
|
// 验证包含 Gemini 模型
|
||||||
@@ -557,90 +560,7 @@ fn model_pricing_is_seeded_on_init() {
|
|||||||
.expect("check gemini");
|
.expect("check gemini");
|
||||||
assert!(
|
assert!(
|
||||||
gemini_count > 0,
|
gemini_count > 0,
|
||||||
"应该包含 Gemini 模型定价,实际数量: {gemini_count}"
|
"应该包含 Gemini 模型定价,实际数量: {}",
|
||||||
|
gemini_count
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_v2_to_v3_migration_creates_template_tables() {
|
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
|
||||||
|
|
||||||
// 创建 v2 schema(即当前完整的 schema)
|
|
||||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
|
||||||
Database::set_user_version(&conn, 2).expect("set v2 version");
|
|
||||||
|
|
||||||
// 应用迁移到 v3
|
|
||||||
Database::apply_schema_migrations_on_conn(&conn).expect("migrate to v3");
|
|
||||||
|
|
||||||
// 验证版本号已更新
|
|
||||||
assert_eq!(
|
|
||||||
Database::get_user_version(&conn).expect("read version"),
|
|
||||||
3,
|
|
||||||
"版本应该更新为 3"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 验证 template_repos 表存在且包含默认仓库
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM template_repos", [], |row| row.get(0))
|
|
||||||
.expect("count template_repos");
|
|
||||||
assert_eq!(count, 1, "应该有 1 个默认模板仓库");
|
|
||||||
|
|
||||||
let (owner, name, branch): (String, String, String) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT owner, name, branch FROM template_repos WHERE id = 1",
|
|
||||||
[],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
||||||
)
|
|
||||||
.expect("read default repo");
|
|
||||||
assert_eq!(owner, "yovinchen", "默认仓库 owner 应该是 yovinchen");
|
|
||||||
assert_eq!(
|
|
||||||
name, "claude-code-templates",
|
|
||||||
"默认仓库 name 应该是 claude-code-templates"
|
|
||||||
);
|
|
||||||
assert_eq!(branch, "main", "默认仓库 branch 应该是 main");
|
|
||||||
|
|
||||||
// 验证 template_components 表存在
|
|
||||||
assert!(
|
|
||||||
Database::table_exists(&conn, "template_components").expect("check table"),
|
|
||||||
"template_components 表应该存在"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 验证 installed_components 表存在
|
|
||||||
assert!(
|
|
||||||
Database::table_exists(&conn, "installed_components").expect("check table"),
|
|
||||||
"installed_components 表应该存在"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 验证索引存在
|
|
||||||
let index_count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND (
|
|
||||||
name = 'idx_template_components_type' OR
|
|
||||||
name = 'idx_template_components_category' OR
|
|
||||||
name = 'idx_installed_components_app'
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.expect("count indexes");
|
|
||||||
assert_eq!(index_count, 3, "应该创建 3 个索引");
|
|
||||||
|
|
||||||
// 验证外键约束
|
|
||||||
let fk_count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM pragma_foreign_key_list('template_components')",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.expect("count fk");
|
|
||||||
assert_eq!(fk_count, 1, "template_components 应该有 1 个外键约束");
|
|
||||||
|
|
||||||
let fk_count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM pragma_foreign_key_list('installed_components')",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.expect("count fk");
|
|
||||||
assert_eq!(fk_count, 1, "installed_components 应该有 1 个外键约束");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
for app in apps_str.split(',') {
|
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,
|
"claude" => apps.claude = true,
|
||||||
"codex" => apps.codex = true,
|
"codex" => apps.codex = true,
|
||||||
"gemini" => apps.gemini = true,
|
"gemini" => apps.gemini = true,
|
||||||
|
"opencode" => apps.opencode = true,
|
||||||
other => {
|
other => {
|
||||||
return Err(AppError::InvalidInput(format!(
|
return Err(AppError::InvalidInput(format!(
|
||||||
"Invalid app in 'apps': {other}"
|
"Invalid app in 'apps': {other}"
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ pub(crate) fn build_provider_from_request(
|
|||||||
AppType::Claude => build_claude_settings(request),
|
AppType::Claude => build_claude_settings(request),
|
||||||
AppType::Codex => build_codex_settings(request),
|
AppType::Codex => build_codex_settings(request),
|
||||||
AppType::Gemini => build_gemini_settings(request),
|
AppType::Gemini => build_gemini_settings(request),
|
||||||
|
AppType::OpenCode => build_opencode_settings(request),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build usage script configuration if provided
|
// Build usage script configuration if provided
|
||||||
@@ -225,6 +226,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
|||||||
}),
|
}),
|
||||||
access_token: request.usage_access_token.clone(),
|
access_token: request.usage_access_token.clone(),
|
||||||
user_id: request.usage_user_id.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,
|
auto_query_interval: request.usage_auto_interval,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -362,6 +364,33 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
|||||||
json!({ "env": env })
|
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
|
// Config Merge Logic
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -365,7 +365,8 @@ fn test_parse_prompt_deeplink() {
|
|||||||
let content = "Hello World";
|
let content = "Hello World";
|
||||||
let content_b64 = BASE64_STANDARD.encode(content);
|
let content_b64 = BASE64_STANDARD.encode(content);
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"ccswitch://v1/import?resource=prompt&app=claude&name=test&content={content_b64}&description=desc&enabled=true"
|
"ccswitch://v1/import?resource=prompt&app=claude&name=test&content={}&description=desc&enabled=true",
|
||||||
|
content_b64
|
||||||
);
|
);
|
||||||
|
|
||||||
let request = parse_deeplink_url(&url).unwrap();
|
let request = parse_deeplink_url(&url).unwrap();
|
||||||
@@ -382,7 +383,8 @@ fn test_parse_mcp_deeplink() {
|
|||||||
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
|
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
|
||||||
let config_b64 = BASE64_STANDARD.encode(config);
|
let config_b64 = BASE64_STANDARD.encode(config);
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"ccswitch://v1/import?resource=mcp&apps=claude,codex&config={config_b64}&enabled=true"
|
"ccswitch://v1/import?resource=mcp&apps=claude,codex&config={}&enabled=true",
|
||||||
|
config_b64
|
||||||
);
|
);
|
||||||
|
|
||||||
let request = parse_deeplink_url(&url).unwrap();
|
let request = parse_deeplink_url(&url).unwrap();
|
||||||
|
|||||||
+24
-16
@@ -13,6 +13,7 @@ mod gemini_config;
|
|||||||
mod gemini_mcp;
|
mod gemini_mcp;
|
||||||
mod init_status;
|
mod init_status;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
|
mod opencode_config;
|
||||||
mod panic_hook;
|
mod panic_hook;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod prompt_files;
|
mod prompt_files;
|
||||||
@@ -482,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 服务器配置(表空时触发)
|
// 3. 导入 MCP 服务器配置(表空时触发)
|
||||||
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
|
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
|
||||||
log::info!("MCP table empty, importing from live configurations...");
|
log::info!("MCP table empty, importing from live configurations...");
|
||||||
@@ -509,6 +521,14 @@ pub fn run() {
|
|||||||
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
|
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
|
||||||
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
|
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. 导入提示词文件(表空时触发)
|
// 4. 导入提示词文件(表空时触发)
|
||||||
@@ -712,6 +732,7 @@ pub fn run() {
|
|||||||
commands::add_provider,
|
commands::add_provider,
|
||||||
commands::update_provider,
|
commands::update_provider,
|
||||||
commands::delete_provider,
|
commands::delete_provider,
|
||||||
|
commands::remove_provider_from_live_config,
|
||||||
commands::switch_provider,
|
commands::switch_provider,
|
||||||
commands::import_default_config,
|
commands::import_default_config,
|
||||||
commands::get_claude_config_status,
|
commands::get_claude_config_status,
|
||||||
@@ -874,28 +895,15 @@ pub fn run() {
|
|||||||
commands::upsert_universal_provider,
|
commands::upsert_universal_provider,
|
||||||
commands::delete_universal_provider,
|
commands::delete_universal_provider,
|
||||||
commands::sync_universal_provider,
|
commands::sync_universal_provider,
|
||||||
|
// OpenCode specific
|
||||||
|
commands::import_opencode_providers_from_live,
|
||||||
|
commands::get_opencode_live_provider_ids,
|
||||||
// Global upstream proxy
|
// Global upstream proxy
|
||||||
commands::get_global_proxy_url,
|
commands::get_global_proxy_url,
|
||||||
commands::set_global_proxy_url,
|
commands::set_global_proxy_url,
|
||||||
commands::test_proxy_url,
|
commands::test_proxy_url,
|
||||||
commands::get_upstream_proxy_status,
|
commands::get_upstream_proxy_status,
|
||||||
commands::scan_local_proxies,
|
commands::scan_local_proxies,
|
||||||
|
|
||||||
// Template management
|
|
||||||
commands::refresh_template_index,
|
|
||||||
commands::list_template_components,
|
|
||||||
commands::get_template_component,
|
|
||||||
commands::install_template_component,
|
|
||||||
commands::uninstall_template_component,
|
|
||||||
commands::batch_install_template_components,
|
|
||||||
commands::list_template_repos,
|
|
||||||
commands::add_template_repo,
|
|
||||||
commands::remove_template_repo,
|
|
||||||
commands::toggle_template_repo,
|
|
||||||
commands::list_template_categories,
|
|
||||||
commands::list_installed_components,
|
|
||||||
commands::preview_component_content,
|
|
||||||
commands::list_marketplace_bundles,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let app = builder
|
let app = builder
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
|
|||||||
claude: true,
|
claude: true,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: true,
|
codex: true,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: true,
|
gemini: true,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
//! - `claude` - Claude MCP 同步和导入
|
//! - `claude` - Claude MCP 同步和导入
|
||||||
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
|
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
|
||||||
//! - `gemini` - Gemini MCP 同步和导入
|
//! - `gemini` - Gemini MCP 同步和导入
|
||||||
|
//! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换)
|
||||||
|
|
||||||
mod claude;
|
mod claude;
|
||||||
mod codex;
|
mod codex;
|
||||||
mod gemini;
|
mod gemini;
|
||||||
|
mod opencode;
|
||||||
mod validation;
|
mod validation;
|
||||||
|
|
||||||
// 重新导出公共 API
|
// 重新导出公共 API
|
||||||
@@ -26,3 +28,6 @@ pub use gemini::{
|
|||||||
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
|
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
|
||||||
sync_single_server_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::config::get_claude_settings_path;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::gemini_config::get_gemini_dir;
|
use crate::gemini_config::get_gemini_dir;
|
||||||
|
use crate::opencode_config::get_opencode_dir;
|
||||||
|
|
||||||
/// 返回指定应用所使用的提示词文件路径。
|
/// 返回指定应用所使用的提示词文件路径。
|
||||||
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
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::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
|
||||||
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
|
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
|
||||||
AppType::Gemini => get_gemini_dir(),
|
AppType::Gemini => get_gemini_dir(),
|
||||||
|
AppType::OpenCode => get_opencode_dir(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let filename = match app {
|
let filename = match app {
|
||||||
AppType::Claude => "CLAUDE.md",
|
AppType::Claude => "CLAUDE.md",
|
||||||
AppType::Codex => "AGENTS.md",
|
AppType::Codex => "AGENTS.md",
|
||||||
AppType::Gemini => "GEMINI.md",
|
AppType::Gemini => "GEMINI.md",
|
||||||
|
AppType::OpenCode => "AGENTS.md",
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(base_dir.join(filename))
|
Ok(base_dir.join(filename))
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ pub struct UsageScript {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "userId")]
|
#[serde(rename = "userId")]
|
||||||
pub user_id: Option<String>,
|
pub user_id: Option<String>,
|
||||||
|
/// 模板类型(用于后端判断验证规则)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "templateType")]
|
||||||
|
pub template_type: Option<String>,
|
||||||
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
|
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "autoQueryInterval")]
|
#[serde(rename = "autoQueryInterval")]
|
||||||
@@ -458,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>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ impl ProviderType {
|
|||||||
}
|
}
|
||||||
ProviderType::Gemini
|
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::Claude => Box::new(ClaudeAdapter::new()),
|
||||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy, fallback to Codex adapter
|
||||||
|
Box::new(CodexAdapter::new())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ impl ConfigService {
|
|||||||
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
||||||
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
||||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
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(())
|
Ok(())
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ impl McpService {
|
|||||||
if prev_apps.gemini && !server.apps.gemini {
|
if prev_apps.gemini && !server.apps.gemini {
|
||||||
Self::remove_server_from_app(state, &server.id, &AppType::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)?;
|
Self::sync_server_to_apps(state, &server)?;
|
||||||
@@ -113,6 +116,13 @@ impl McpService {
|
|||||||
AppType::Gemini => {
|
AppType::Gemini => {
|
||||||
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -135,6 +145,9 @@ impl McpService {
|
|||||||
AppType::Claude => mcp::remove_server_from_claude(id)?,
|
AppType::Claude => mcp::remove_server_from_claude(id)?,
|
||||||
AppType::Codex => mcp::remove_server_from_codex(id)?,
|
AppType::Codex => mcp::remove_server_from_codex(id)?,
|
||||||
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
|
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
|
||||||
|
AppType::OpenCode => {
|
||||||
|
mcp::remove_server_from_opencode(id)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -311,4 +324,42 @@ impl McpService {
|
|||||||
|
|
||||||
Ok(new_count)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ pub mod proxy;
|
|||||||
pub mod skill;
|
pub mod skill;
|
||||||
pub mod speedtest;
|
pub mod speedtest;
|
||||||
pub mod stream_check;
|
pub mod stream_check;
|
||||||
pub mod template;
|
|
||||||
pub mod usage_stats;
|
pub mod usage_stats;
|
||||||
|
|
||||||
pub use config::ConfigService;
|
pub use config::ConfigService;
|
||||||
@@ -20,12 +19,6 @@ pub use proxy::ProxyService;
|
|||||||
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
||||||
pub use speedtest::{EndpointLatency, SpeedtestService};
|
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use template::{
|
|
||||||
BatchInstallResult, ComponentDetail, ComponentMetadata, ComponentType, InstalledComponent,
|
|
||||||
MarketplaceBundle, MarketplaceBundleItem, PaginatedResult, TemplateComponent, TemplateRepo,
|
|
||||||
TemplateService,
|
|
||||||
};
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use usage_stats::{
|
pub use usage_stats::{
|
||||||
DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,
|
DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,
|
||||||
RequestLogDetail, UsageSummary,
|
RequestLogDetail, UsageSummary,
|
||||||
|
|||||||
@@ -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
|
// Delegate to write_gemini_live which handles env file writing correctly
|
||||||
write_gemini_live(provider)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -220,6 +278,21 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
|||||||
"config": config_obj
|
"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
|
"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(
|
let mut provider = Provider::with_id(
|
||||||
@@ -399,3 +490,84 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
|||||||
|
|
||||||
Ok(())
|
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;
|
use crate::store::AppState;
|
||||||
|
|
||||||
// Re-export sub-module functions for external access
|
// 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))
|
// Internal re-exports (pub(crate))
|
||||||
pub(crate) use live::write_live_snapshot;
|
pub(crate) use live::write_live_snapshot;
|
||||||
|
|
||||||
// Internal re-exports
|
// Internal re-exports
|
||||||
use live::write_gemini_live;
|
use live::{remove_opencode_provider_from_live, write_gemini_live};
|
||||||
use usage::validate_usage_script;
|
use usage::validate_usage_script;
|
||||||
|
|
||||||
/// Provider business logic service
|
/// Provider business logic service
|
||||||
@@ -137,7 +140,13 @@ impl ProviderService {
|
|||||||
/// 使用有效的当前供应商 ID(验证过存在性)。
|
/// 使用有效的当前供应商 ID(验证过存在性)。
|
||||||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||||||
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
|
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
|
||||||
|
///
|
||||||
|
/// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。
|
||||||
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
|
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)
|
crate::settings::get_effective_current_provider(&state.db, &app_type)
|
||||||
.map(|opt| opt.unwrap_or_default())
|
.map(|opt| opt.unwrap_or_default())
|
||||||
}
|
}
|
||||||
@@ -152,7 +161,13 @@ impl ProviderService {
|
|||||||
// Save to database
|
// Save to database
|
||||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
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())?;
|
let current = state.db.get_current_provider(app_type.as_str())?;
|
||||||
if current.is_none() {
|
if current.is_none() {
|
||||||
// No current provider, set as current and sync
|
// No current provider, set as current and sync
|
||||||
@@ -176,14 +191,20 @@ impl ProviderService {
|
|||||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||||
Self::validate_provider_settings(&app_type, &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 =
|
let effective_current =
|
||||||
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||||||
let is_current = effective_current.as_deref() == Some(provider.id.as_str());
|
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 {
|
if is_current {
|
||||||
// 如果代理接管模式处于激活状态,并且代理服务正在运行:
|
// 如果代理接管模式处于激活状态,并且代理服务正在运行:
|
||||||
// - 不写 Live 配置(否则会破坏接管)
|
// - 不写 Live 配置(否则会破坏接管)
|
||||||
@@ -216,8 +237,18 @@ impl ProviderService {
|
|||||||
/// Delete a provider
|
/// Delete a provider
|
||||||
///
|
///
|
||||||
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
|
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
|
||||||
|
/// 对于 OpenCode(累加模式),可以随时删除任意供应商,同时从 live 配置中移除。
|
||||||
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
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 local_current = crate::settings::get_current_provider(&app_type);
|
||||||
let db_current = state.db.get_current_provider(app_type.as_str())?;
|
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)
|
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 to a provider
|
||||||
///
|
///
|
||||||
/// Switch flow:
|
/// Switch flow:
|
||||||
@@ -326,22 +378,29 @@ impl ProviderService {
|
|||||||
|
|
||||||
if let Some(current_id) = current_id {
|
if let Some(current_id) = current_id {
|
||||||
if current_id != id {
|
if current_id != id {
|
||||||
// Only backfill when switching to a different provider
|
// OpenCode uses additive mode - all providers coexist in the same file,
|
||||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
|
||||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
if !matches!(app_type, AppType::OpenCode) {
|
||||||
current_provider.settings_config = live_config;
|
// Only backfill when switching to a different provider
|
||||||
// Ignore backfill failure, don't affect switch flow
|
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
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)
|
// OpenCode uses additive mode - skip setting is_current (no such concept)
|
||||||
crate::settings::set_current_provider(&app_type, Some(id))?;
|
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)
|
// Update database is_current (as default for new devices)
|
||||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||||
|
}
|
||||||
|
|
||||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
||||||
write_live_snapshot(&app_type, provider)?;
|
write_live_snapshot(&app_type, provider)?;
|
||||||
@@ -380,6 +439,7 @@ impl ProviderService {
|
|||||||
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
|
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
|
||||||
AppType::Codex => Self::extract_codex_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::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::Claude => Self::extract_claude_common_config(settings_config),
|
||||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
||||||
AppType::Gemini => Self::extract_gemini_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}")))
|
.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)
|
/// Import default configuration from live files (re-export)
|
||||||
///
|
///
|
||||||
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
||||||
@@ -615,6 +699,7 @@ impl ProviderService {
|
|||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
usage::test_usage_script(
|
usage::test_usage_script(
|
||||||
state,
|
state,
|
||||||
@@ -626,6 +711,7 @@ impl ProviderService {
|
|||||||
base_url,
|
base_url,
|
||||||
access_token,
|
access_token,
|
||||||
user_id,
|
user_id,
|
||||||
|
template_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -689,6 +775,17 @@ impl ProviderService {
|
|||||||
use crate::gemini_config::validate_gemini_settings;
|
use crate::gemini_config::validate_gemini_settings;
|
||||||
validate_gemini_settings(&provider.settings_config)?
|
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)
|
// Validate and clean UsageScript configuration (common for all app types)
|
||||||
@@ -826,6 +923,40 @@ impl ProviderService {
|
|||||||
|
|
||||||
Ok((api_key, base_url))
|
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,
|
timeout: u64,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
match usage_script::execute_usage_script(
|
match usage_script::execute_usage_script(
|
||||||
script_code,
|
script_code,
|
||||||
@@ -25,6 +26,7 @@ pub(crate) async fn execute_and_format_usage_result(
|
|||||||
timeout,
|
timeout,
|
||||||
access_token,
|
access_token,
|
||||||
user_id,
|
user_id,
|
||||||
|
template_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -113,7 +115,7 @@ pub async fn query_usage(
|
|||||||
app_type: AppType,
|
app_type: AppType,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> 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 providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
let provider = providers.get(provider_id).ok_or_else(|| {
|
let provider = providers.get(provider_id).ok_or_else(|| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
@@ -164,6 +166,7 @@ pub async fn query_usage(
|
|||||||
base_url,
|
base_url,
|
||||||
usage_script.access_token.clone(),
|
usage_script.access_token.clone(),
|
||||||
usage_script.user_id.clone(),
|
usage_script.user_id.clone(),
|
||||||
|
usage_script.template_type.clone(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,6 +177,7 @@ pub async fn query_usage(
|
|||||||
timeout,
|
timeout,
|
||||||
access_token.as_deref(),
|
access_token.as_deref(),
|
||||||
user_id.as_deref(),
|
user_id.as_deref(),
|
||||||
|
template_type.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -190,6 +194,7 @@ pub async fn test_usage_script(
|
|||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
// Use provided credential parameters directly for testing
|
// Use provided credential parameters directly for testing
|
||||||
execute_and_format_usage_result(
|
execute_and_format_usage_result(
|
||||||
@@ -199,6 +204,7 @@ pub async fn test_usage_script(
|
|||||||
timeout,
|
timeout,
|
||||||
access_token,
|
access_token,
|
||||||
user_id,
|
user_id,
|
||||||
|
template_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -368,6 +368,10 @@ impl ProxyService {
|
|||||||
AppType::Claude => self.read_claude_live()?,
|
AppType::Claude => self.read_claude_live()?,
|
||||||
AppType::Codex => self.read_codex_live()?,
|
AppType::Codex => self.read_codex_live()?,
|
||||||
AppType::Gemini => self.read_gemini_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)
|
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(())
|
Ok(())
|
||||||
@@ -759,6 +766,10 @@ impl ProxyService {
|
|||||||
AppType::Claude => ("claude", self.read_claude_live()?),
|
AppType::Claude => ("claude", self.read_claude_live()?),
|
||||||
AppType::Codex => ("codex", self.read_codex_live()?),
|
AppType::Codex => ("codex", self.read_codex_live()?),
|
||||||
AppType::Gemini => ("gemini", self.read_gemini_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)
|
let json_str = serde_json::to_string(&config)
|
||||||
@@ -967,6 +978,10 @@ impl ProxyService {
|
|||||||
self.write_gemini_live(&live_config)?;
|
self.write_gemini_live(&live_config)?;
|
||||||
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
|
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features
|
||||||
|
return Err("OpenCode 不支持代理功能".to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1050,6 +1065,9 @@ impl ProxyService {
|
|||||||
let _ = self.write_gemini_live(&live_config);
|
let _ = self.write_gemini_live(&live_config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features, skip silently
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1082,6 +1100,9 @@ impl ProxyService {
|
|||||||
log::info!("Gemini Live 配置已恢复");
|
log::info!("Gemini Live 配置已恢复");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features, skip silently
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1161,6 +1182,10 @@ impl ProxyService {
|
|||||||
AppType::Claude => self.write_claude_live(config),
|
AppType::Claude => self.write_claude_live(config),
|
||||||
AppType::Codex => self.write_codex_live(config),
|
AppType::Codex => self.write_codex_live(config),
|
||||||
AppType::Gemini => self.write_gemini_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),
|
Ok(config) => Self::is_gemini_live_taken_over(&config),
|
||||||
Err(_) => false,
|
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::Claude => self.cleanup_claude_takeover_placeholders_in_live(),
|
||||||
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
|
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
|
||||||
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
|
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,11 @@ impl SkillService {
|
|||||||
return Ok(custom.join("skills"));
|
return Ok(custom.join("skills"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
if let Some(custom) = crate::settings::get_opencode_override_dir() {
|
||||||
|
return Ok(custom.join("skills"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认路径:回退到用户主目录下的标准位置
|
// 默认路径:回退到用户主目录下的标准位置
|
||||||
@@ -196,6 +201,7 @@ impl SkillService {
|
|||||||
AppType::Claude => home.join(".claude").join("skills"),
|
AppType::Claude => home.join(".claude").join("skills"),
|
||||||
AppType::Codex => home.join(".codex").join("skills"),
|
AppType::Codex => home.join(".codex").join("skills"),
|
||||||
AppType::Gemini => home.join(".gemini").join("skills"),
|
AppType::Gemini => home.join(".gemini").join("skills"),
|
||||||
|
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +323,7 @@ impl SkillService {
|
|||||||
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
|
.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);
|
let _ = Self::remove_from_app(&skill.directory, &app);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,7 +382,7 @@ impl SkillService {
|
|||||||
|
|
||||||
let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
|
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) {
|
let app_dir = match Self::get_app_skills_dir(&app) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
@@ -425,6 +431,7 @@ impl SkillService {
|
|||||||
AppType::Claude => "claude",
|
AppType::Claude => "claude",
|
||||||
AppType::Codex => "codex",
|
AppType::Codex => "codex",
|
||||||
AppType::Gemini => "gemini",
|
AppType::Gemini => "gemini",
|
||||||
|
AppType::OpenCode => "opencode",
|
||||||
};
|
};
|
||||||
|
|
||||||
unmanaged
|
unmanaged
|
||||||
@@ -457,7 +464,7 @@ impl SkillService {
|
|||||||
let mut source_path: Option<PathBuf> = None;
|
let mut source_path: Option<PathBuf> = None;
|
||||||
let mut found_in: Vec<String> = Vec::new();
|
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) {
|
if let Ok(app_dir) = Self::get_app_skills_dir(&app) {
|
||||||
let skill_path = app_dir.join(&dir_name);
|
let skill_path = app_dir.join(&dir_name);
|
||||||
if skill_path.exists() {
|
if skill_path.exists() {
|
||||||
@@ -468,6 +475,7 @@ impl SkillService {
|
|||||||
AppType::Claude => "claude",
|
AppType::Claude => "claude",
|
||||||
AppType::Codex => "codex",
|
AppType::Codex => "codex",
|
||||||
AppType::Gemini => "gemini",
|
AppType::Gemini => "gemini",
|
||||||
|
AppType::OpenCode => "opencode",
|
||||||
};
|
};
|
||||||
found_in.push(app_str.to_string());
|
found_in.push(app_str.to_string());
|
||||||
}
|
}
|
||||||
@@ -506,6 +514,7 @@ impl SkillService {
|
|||||||
"claude" => apps.claude = true,
|
"claude" => apps.claude = true,
|
||||||
"codex" => apps.codex = true,
|
"codex" => apps.codex = true,
|
||||||
"gemini" => apps.gemini = true,
|
"gemini" => apps.gemini = true,
|
||||||
|
"opencode" => apps.opencode = true,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -976,7 +985,7 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
|||||||
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
|
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) {
|
let app_dir = match SkillService::get_app_skills_dir(&app) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
|
|||||||
@@ -185,6 +185,14 @@ impl StreamCheckService {
|
|||||||
)
|
)
|
||||||
.await
|
.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",
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let response_time = start.elapsed().as_millis() as u64;
|
let response_time = start.elapsed().as_millis() as u64;
|
||||||
@@ -477,9 +485,24 @@ impl StreamCheckService {
|
|||||||
}
|
}
|
||||||
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
|
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
|
||||||
.unwrap_or_else(|| config.gemini_model.clone()),
|
.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> {
|
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
|
||||||
provider
|
provider
|
||||||
.settings_config
|
.settings_config
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
//! Claude 应用适配器
|
|
||||||
//!
|
|
||||||
//! 完整支持所有组件类型:
|
|
||||||
//! - Agent → `~/.claude/agents/{name}.md`
|
|
||||||
//! - Command → `~/.claude/commands/{name}.md`
|
|
||||||
//! - MCP → 合并到 `~/.claude.json` 的 mcpServers 字段
|
|
||||||
//! - Setting → 合并到 `~/.claude/settings.json` 的 permissions 字段
|
|
||||||
//! - Hook → 合并到 `~/.claude/settings.json` 的 hooks 字段
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use super::AppAdapter;
|
|
||||||
use crate::config::{atomic_write, get_claude_config_dir, get_claude_mcp_path};
|
|
||||||
|
|
||||||
/// Claude 应用适配器
|
|
||||||
pub struct ClaudeAdapter {
|
|
||||||
config_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClaudeAdapter {
|
|
||||||
/// 创建新的 Claude 适配器实例
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
config_dir: get_claude_config_dir(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取 JSON 配置文件
|
|
||||||
fn read_json_file(path: &PathBuf) -> Result<Value> {
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(serde_json::json!({}));
|
|
||||||
}
|
|
||||||
let content = fs::read_to_string(path)
|
|
||||||
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
|
|
||||||
let value: Value = serde_json::from_str(&content)
|
|
||||||
.with_context(|| format!("解析 JSON 失败: {}", path.display()))?;
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 写入 JSON 配置文件(原子写入)
|
|
||||||
fn write_json_file(path: &Path, value: &Value) -> Result<()> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)
|
|
||||||
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(value).context("序列化 JSON 失败")?;
|
|
||||||
|
|
||||||
atomic_write(path, json.as_bytes())
|
|
||||||
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 合并两个 JSON 对象(深度合并)
|
|
||||||
fn merge_json(base: &mut Value, overlay: &Value) {
|
|
||||||
if let (Some(base_obj), Some(overlay_obj)) = (base.as_object_mut(), overlay.as_object()) {
|
|
||||||
for (key, value) in overlay_obj {
|
|
||||||
if let Some(base_value) = base_obj.get_mut(key) {
|
|
||||||
// 如果两边都是对象,递归合并
|
|
||||||
if base_value.is_object() && value.is_object() {
|
|
||||||
Self::merge_json(base_value, value);
|
|
||||||
} else {
|
|
||||||
// 否则直接覆盖
|
|
||||||
*base_value = value.clone();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 键不存在,直接插入
|
|
||||||
base_obj.insert(key.clone(), value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Markdown 文件(通用)
|
|
||||||
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
let dir = self.config_dir.join(subdir);
|
|
||||||
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
|
|
||||||
|
|
||||||
let filename = if name.ends_with(".md") {
|
|
||||||
name.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{name}.md")
|
|
||||||
};
|
|
||||||
|
|
||||||
let file_path = dir.join(&filename);
|
|
||||||
|
|
||||||
atomic_write(&file_path, content.as_bytes())
|
|
||||||
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
|
|
||||||
|
|
||||||
log::info!("已安装 Claude {}: {}", subdir, file_path.display());
|
|
||||||
Ok(file_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Claude settings.json 路径
|
|
||||||
fn get_settings_path(&self) -> PathBuf {
|
|
||||||
crate::config::get_claude_settings_path()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppAdapter for ClaudeAdapter {
|
|
||||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "agents", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "commands", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
|
|
||||||
let mcp_path = get_claude_mcp_path();
|
|
||||||
|
|
||||||
// 读取现有 MCP 配置
|
|
||||||
let mut current = Self::read_json_file(&mcp_path)?;
|
|
||||||
|
|
||||||
// 确保 mcpServers 字段存在
|
|
||||||
if !current.is_object() {
|
|
||||||
current = serde_json::json!({});
|
|
||||||
}
|
|
||||||
if current.get("mcpServers").is_none() {
|
|
||||||
current["mcpServers"] = serde_json::json!({});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并新的 MCP 服务器配置
|
|
||||||
if let Some(mcp_servers) = current.get_mut("mcpServers") {
|
|
||||||
Self::merge_json(mcp_servers, mcp_config);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回配置文件
|
|
||||||
Self::write_json_file(&mcp_path, ¤t)?;
|
|
||||||
|
|
||||||
log::info!("已安装 Claude MCP 配置到: {}", mcp_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_setting(&self, setting_config: &Value) -> Result<()> {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
|
|
||||||
// 读取现有配置
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
// 确保 permissions 字段存在
|
|
||||||
if !current.is_object() {
|
|
||||||
current = serde_json::json!({});
|
|
||||||
}
|
|
||||||
if current.get("permissions").is_none() {
|
|
||||||
current["permissions"] = serde_json::json!({});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并新的 permissions 配置
|
|
||||||
if let Some(permissions) = current.get_mut("permissions") {
|
|
||||||
Self::merge_json(permissions, setting_config);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回配置文件
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
|
|
||||||
log::info!("已安装 Claude Setting 配置到: {}", settings_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_hook(&self, hook_config: &Value) -> Result<()> {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
|
|
||||||
// 读取现有配置
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
// 确保 hooks 字段存在
|
|
||||||
if !current.is_object() {
|
|
||||||
current = serde_json::json!({});
|
|
||||||
}
|
|
||||||
if current.get("hooks").is_none() {
|
|
||||||
current["hooks"] = serde_json::json!({});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并新的 hooks 配置
|
|
||||||
if let Some(hooks) = current.get_mut("hooks") {
|
|
||||||
Self::merge_json(hooks, hook_config);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回配置文件
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
|
|
||||||
log::info!("已安装 Claude Hook 配置到: {}", settings_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
|
|
||||||
match component_type.to_lowercase().as_str() {
|
|
||||||
"agent" => {
|
|
||||||
let path = self.config_dir.join("agents").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Claude Agent: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"command" => {
|
|
||||||
let path = self.config_dir.join("commands").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Claude Command: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"mcp" => {
|
|
||||||
let mcp_path = get_claude_mcp_path();
|
|
||||||
let mut current = Self::read_json_file(&mcp_path)?;
|
|
||||||
|
|
||||||
if let Some(mcp_servers) = current
|
|
||||||
.get_mut("mcpServers")
|
|
||||||
.and_then(|v| v.as_object_mut())
|
|
||||||
{
|
|
||||||
mcp_servers.remove(name);
|
|
||||||
Self::write_json_file(&mcp_path, ¤t)?;
|
|
||||||
log::info!("已卸载 Claude MCP 服务器: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"setting" => {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
if let Some(permissions) = current
|
|
||||||
.get_mut("permissions")
|
|
||||||
.and_then(|v| v.as_object_mut())
|
|
||||||
{
|
|
||||||
permissions.remove(name);
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
log::info!("已卸载 Claude Setting: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"hook" => {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
if let Some(hooks) = current.get_mut("hooks").and_then(|v| v.as_object_mut()) {
|
|
||||||
hooks.remove(name);
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
log::info!("已卸载 Claude Hook: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => anyhow::bail!("不支持的组件类型: {component_type}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_dir(&self) -> PathBuf {
|
|
||||||
self.config_dir.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_component_type(&self, component_type: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
component_type.to_lowercase().as_str(),
|
|
||||||
"agent" | "command" | "mcp" | "setting" | "hook"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ClaudeAdapter {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
//! Codex 应用适配器
|
|
||||||
//!
|
|
||||||
//! 部分支持:
|
|
||||||
//! - Agent → `~/.codex/agents/{name}.md`
|
|
||||||
//! - Command → `~/.codex/commands/{name}.md`
|
|
||||||
//! - MCP → 合并到 `~/.codex/config.toml` 的 [mcp_servers] 表
|
|
||||||
//! - Setting/Hook → 不支持(Codex 不支持这些功能)
|
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use super::AppAdapter;
|
|
||||||
use crate::codex_config::get_codex_config_dir;
|
|
||||||
use crate::config::{atomic_write, write_text_file};
|
|
||||||
|
|
||||||
/// Codex 应用适配器
|
|
||||||
pub struct CodexAdapter {
|
|
||||||
config_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CodexAdapter {
|
|
||||||
/// 创建新的 Codex 适配器实例
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
config_dir: get_codex_config_dir(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Markdown 文件(通用)
|
|
||||||
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
let dir = self.config_dir.join(subdir);
|
|
||||||
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
|
|
||||||
|
|
||||||
let filename = if name.ends_with(".md") {
|
|
||||||
name.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{name}.md")
|
|
||||||
};
|
|
||||||
|
|
||||||
let file_path = dir.join(&filename);
|
|
||||||
|
|
||||||
atomic_write(&file_path, content.as_bytes())
|
|
||||||
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
|
|
||||||
|
|
||||||
log::info!("已安装 Codex {}: {}", subdir, file_path.display());
|
|
||||||
Ok(file_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Codex config.toml 路径
|
|
||||||
fn get_config_toml_path(&self) -> PathBuf {
|
|
||||||
crate::codex_config::get_codex_config_path()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取 TOML 配置文件
|
|
||||||
fn read_toml_file(path: &PathBuf) -> Result<toml::Table> {
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(toml::Table::new());
|
|
||||||
}
|
|
||||||
let content = fs::read_to_string(path)
|
|
||||||
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
|
|
||||||
let table: toml::Table = toml::from_str(&content)
|
|
||||||
.with_context(|| format!("解析 TOML 失败: {}", path.display()))?;
|
|
||||||
Ok(table)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 写入 TOML 配置文件(原子写入)
|
|
||||||
fn write_toml_file(path: &Path, table: &toml::Table) -> Result<()> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)
|
|
||||||
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let toml_string = toml::to_string_pretty(table).context("序列化 TOML 失败")?;
|
|
||||||
|
|
||||||
write_text_file(path, &toml_string)
|
|
||||||
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将 JSON MCP 配置转换为 TOML 格式
|
|
||||||
fn json_mcp_to_toml(json_config: &Value) -> Result<toml::Table> {
|
|
||||||
let mut mcp_servers = toml::Table::new();
|
|
||||||
|
|
||||||
if let Some(obj) = json_config.as_object() {
|
|
||||||
for (server_id, server_spec) in obj {
|
|
||||||
let mut server_table = toml::Table::new();
|
|
||||||
|
|
||||||
if let Some(spec_obj) = server_spec.as_object() {
|
|
||||||
// type 字段(默认 stdio)
|
|
||||||
let server_type = spec_obj
|
|
||||||
.get("type")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("stdio");
|
|
||||||
server_table.insert(
|
|
||||||
"type".to_string(),
|
|
||||||
toml::Value::String(server_type.to_string()),
|
|
||||||
);
|
|
||||||
|
|
||||||
match server_type {
|
|
||||||
"stdio" => {
|
|
||||||
// command 字段(必需)
|
|
||||||
if let Some(cmd) = spec_obj.get("command").and_then(|v| v.as_str()) {
|
|
||||||
server_table.insert(
|
|
||||||
"command".to_string(),
|
|
||||||
toml::Value::String(cmd.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// args 字段(可选)
|
|
||||||
if let Some(args) = spec_obj.get("args").and_then(|v| v.as_array()) {
|
|
||||||
let toml_args: Vec<toml::Value> = args
|
|
||||||
.iter()
|
|
||||||
.filter_map(|v| v.as_str())
|
|
||||||
.map(|s| toml::Value::String(s.to_string()))
|
|
||||||
.collect();
|
|
||||||
if !toml_args.is_empty() {
|
|
||||||
server_table
|
|
||||||
.insert("args".to_string(), toml::Value::Array(toml_args));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// env 字段(可选)
|
|
||||||
if let Some(env) = spec_obj.get("env").and_then(|v| v.as_object()) {
|
|
||||||
let mut env_table = toml::Table::new();
|
|
||||||
for (key, value) in env {
|
|
||||||
if let Some(val_str) = value.as_str() {
|
|
||||||
env_table.insert(
|
|
||||||
key.clone(),
|
|
||||||
toml::Value::String(val_str.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !env_table.is_empty() {
|
|
||||||
server_table
|
|
||||||
.insert("env".to_string(), toml::Value::Table(env_table));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cwd 字段(可选)
|
|
||||||
if let Some(cwd) = spec_obj.get("cwd").and_then(|v| v.as_str()) {
|
|
||||||
server_table.insert(
|
|
||||||
"cwd".to_string(),
|
|
||||||
toml::Value::String(cwd.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"http" | "sse" => {
|
|
||||||
// url 字段(必需)
|
|
||||||
if let Some(url) = spec_obj.get("url").and_then(|v| v.as_str()) {
|
|
||||||
server_table.insert(
|
|
||||||
"url".to_string(),
|
|
||||||
toml::Value::String(url.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// http_headers 字段(可选)
|
|
||||||
if let Some(headers) =
|
|
||||||
spec_obj.get("http_headers").and_then(|v| v.as_object())
|
|
||||||
{
|
|
||||||
let mut headers_table = toml::Table::new();
|
|
||||||
for (key, value) in headers {
|
|
||||||
if let Some(val_str) = value.as_str() {
|
|
||||||
headers_table.insert(
|
|
||||||
key.clone(),
|
|
||||||
toml::Value::String(val_str.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !headers_table.is_empty() {
|
|
||||||
server_table.insert(
|
|
||||||
"http_headers".to_string(),
|
|
||||||
toml::Value::Table(headers_table),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mcp_servers.insert(server_id.clone(), toml::Value::Table(server_table));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(mcp_servers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppAdapter for CodexAdapter {
|
|
||||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "agents", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "commands", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
|
|
||||||
let config_path = self.get_config_toml_path();
|
|
||||||
|
|
||||||
// 读取现有 TOML 配置
|
|
||||||
let mut current = Self::read_toml_file(&config_path)?;
|
|
||||||
|
|
||||||
// 确保 mcp_servers 表存在
|
|
||||||
if !current.contains_key("mcp_servers") {
|
|
||||||
current.insert(
|
|
||||||
"mcp_servers".to_string(),
|
|
||||||
toml::Value::Table(toml::Table::new()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换 JSON MCP 配置到 TOML
|
|
||||||
let new_mcp_servers = Self::json_mcp_to_toml(mcp_config)?;
|
|
||||||
|
|
||||||
// 合并 MCP 服务器配置
|
|
||||||
if let Some(mcp_servers) = current
|
|
||||||
.get_mut("mcp_servers")
|
|
||||||
.and_then(|v| v.as_table_mut())
|
|
||||||
{
|
|
||||||
for (server_id, server_config) in new_mcp_servers {
|
|
||||||
mcp_servers.insert(server_id, server_config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回配置文件
|
|
||||||
Self::write_toml_file(&config_path, ¤t)?;
|
|
||||||
|
|
||||||
log::info!("已安装 Codex MCP 配置到: {}", config_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_setting(&self, _setting_config: &Value) -> Result<()> {
|
|
||||||
bail!("Codex 不支持 Setting 配置")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_hook(&self, _hook_config: &Value) -> Result<()> {
|
|
||||||
bail!("Codex 不支持 Hook 配置")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
|
|
||||||
match component_type.to_lowercase().as_str() {
|
|
||||||
"agent" => {
|
|
||||||
let path = self.config_dir.join("agents").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Codex Agent: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"command" => {
|
|
||||||
let path = self.config_dir.join("commands").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Codex Command: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"mcp" => {
|
|
||||||
let config_path = self.get_config_toml_path();
|
|
||||||
let mut current = Self::read_toml_file(&config_path)?;
|
|
||||||
|
|
||||||
if let Some(mcp_servers) = current
|
|
||||||
.get_mut("mcp_servers")
|
|
||||||
.and_then(|v| v.as_table_mut())
|
|
||||||
{
|
|
||||||
mcp_servers.remove(name);
|
|
||||||
Self::write_toml_file(&config_path, ¤t)?;
|
|
||||||
log::info!("已卸载 Codex MCP 服务器: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"setting" | "hook" => {
|
|
||||||
bail!("Codex 不支持 {component_type} 组件类型")
|
|
||||||
}
|
|
||||||
_ => bail!("不支持的组件类型: {component_type}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_dir(&self) -> PathBuf {
|
|
||||||
self.config_dir.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_component_type(&self, component_type: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
component_type.to_lowercase().as_str(),
|
|
||||||
"agent" | "command" | "mcp"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CodexAdapter {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
//! Gemini 应用适配器
|
|
||||||
//!
|
|
||||||
//! 部分支持:
|
|
||||||
//! - Agent → `~/.gemini/agents/{name}.md`
|
|
||||||
//! - Command → `~/.gemini/commands/{name}.md`
|
|
||||||
//! - MCP → 合并到 `~/.gemini/settings.json` 的 mcpServers 字段
|
|
||||||
//! - Setting/Hook → 不支持(Gemini 不支持这些功能)
|
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use super::AppAdapter;
|
|
||||||
use crate::config::atomic_write;
|
|
||||||
use crate::gemini_config::{get_gemini_dir, get_gemini_settings_path};
|
|
||||||
|
|
||||||
/// Gemini 应用适配器
|
|
||||||
pub struct GeminiAdapter {
|
|
||||||
config_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GeminiAdapter {
|
|
||||||
/// 创建新的 Gemini 适配器实例
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
config_dir: get_gemini_dir(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取 JSON 配置文件
|
|
||||||
fn read_json_file(path: &PathBuf) -> Result<Value> {
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(serde_json::json!({}));
|
|
||||||
}
|
|
||||||
let content = fs::read_to_string(path)
|
|
||||||
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
|
|
||||||
let value: Value = serde_json::from_str(&content)
|
|
||||||
.with_context(|| format!("解析 JSON 失败: {}", path.display()))?;
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 写入 JSON 配置文件(原子写入)
|
|
||||||
fn write_json_file(path: &Path, value: &Value) -> Result<()> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)
|
|
||||||
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(value).context("序列化 JSON 失败")?;
|
|
||||||
|
|
||||||
atomic_write(path, json.as_bytes())
|
|
||||||
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 合并两个 JSON 对象(深度合并)
|
|
||||||
fn merge_json(base: &mut Value, overlay: &Value) {
|
|
||||||
if let (Some(base_obj), Some(overlay_obj)) = (base.as_object_mut(), overlay.as_object()) {
|
|
||||||
for (key, value) in overlay_obj {
|
|
||||||
if let Some(base_value) = base_obj.get_mut(key) {
|
|
||||||
// 如果两边都是对象,递归合并
|
|
||||||
if base_value.is_object() && value.is_object() {
|
|
||||||
Self::merge_json(base_value, value);
|
|
||||||
} else {
|
|
||||||
// 否则直接覆盖
|
|
||||||
*base_value = value.clone();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 键不存在,直接插入
|
|
||||||
base_obj.insert(key.clone(), value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Markdown 文件(通用)
|
|
||||||
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
let dir = self.config_dir.join(subdir);
|
|
||||||
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
|
|
||||||
|
|
||||||
let filename = if name.ends_with(".md") {
|
|
||||||
name.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{name}.md")
|
|
||||||
};
|
|
||||||
|
|
||||||
let file_path = dir.join(&filename);
|
|
||||||
|
|
||||||
atomic_write(&file_path, content.as_bytes())
|
|
||||||
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
|
|
||||||
|
|
||||||
log::info!("已安装 Gemini {}: {}", subdir, file_path.display());
|
|
||||||
Ok(file_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Gemini settings.json 路径
|
|
||||||
fn get_settings_path(&self) -> PathBuf {
|
|
||||||
get_gemini_settings_path()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 转换 MCP 配置为 Gemini 格式
|
|
||||||
///
|
|
||||||
/// Gemini 使用特殊格式:
|
|
||||||
/// - HTTP 类型:使用 `httpUrl` 而不是 `url` + `type: "http"`
|
|
||||||
/// - SSE/stdio 类型:保持标准格式
|
|
||||||
fn transform_mcp_to_gemini(mcp_config: &Value) -> Result<Value> {
|
|
||||||
let mut transformed = mcp_config.clone();
|
|
||||||
|
|
||||||
if let Some(obj) = transformed.as_object_mut() {
|
|
||||||
for (_server_id, server_spec) in obj.iter_mut() {
|
|
||||||
if let Some(spec_obj) = server_spec.as_object_mut() {
|
|
||||||
// 检查是否为 HTTP 类型
|
|
||||||
let is_http = spec_obj
|
|
||||||
.get("type")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|t| t == "http")
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if is_http {
|
|
||||||
// 将 url 字段转换为 httpUrl
|
|
||||||
if let Some(url) = spec_obj.remove("url") {
|
|
||||||
spec_obj.insert("httpUrl".to_string(), url);
|
|
||||||
}
|
|
||||||
// 移除 type 字段(Gemini 不需要显式指定 type)
|
|
||||||
spec_obj.remove("type");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(transformed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppAdapter for GeminiAdapter {
|
|
||||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "agents", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
|
|
||||||
self.install_markdown_file(content, "commands", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
|
|
||||||
// 读取现有配置
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
// 确保 mcpServers 字段存在
|
|
||||||
if !current.is_object() {
|
|
||||||
current = serde_json::json!({});
|
|
||||||
}
|
|
||||||
if current.get("mcpServers").is_none() {
|
|
||||||
current["mcpServers"] = serde_json::json!({});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换 MCP 配置为 Gemini 格式
|
|
||||||
let transformed = Self::transform_mcp_to_gemini(mcp_config)?;
|
|
||||||
|
|
||||||
// 合并新的 MCP 服务器配置
|
|
||||||
if let Some(mcp_servers) = current.get_mut("mcpServers") {
|
|
||||||
Self::merge_json(mcp_servers, &transformed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回配置文件
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
|
|
||||||
log::info!("已安装 Gemini MCP 配置到: {}", settings_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_setting(&self, _setting_config: &Value) -> Result<()> {
|
|
||||||
bail!("Gemini 不支持 Setting 配置")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_hook(&self, _hook_config: &Value) -> Result<()> {
|
|
||||||
bail!("Gemini 不支持 Hook 配置")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
|
|
||||||
match component_type.to_lowercase().as_str() {
|
|
||||||
"agent" => {
|
|
||||||
let path = self.config_dir.join("agents").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Gemini Agent: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"command" => {
|
|
||||||
let path = self.config_dir.join("commands").join(format!("{name}.md"));
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
|
|
||||||
log::info!("已卸载 Gemini Command: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"mcp" => {
|
|
||||||
let settings_path = self.get_settings_path();
|
|
||||||
let mut current = Self::read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
if let Some(mcp_servers) = current
|
|
||||||
.get_mut("mcpServers")
|
|
||||||
.and_then(|v| v.as_object_mut())
|
|
||||||
{
|
|
||||||
mcp_servers.remove(name);
|
|
||||||
Self::write_json_file(&settings_path, ¤t)?;
|
|
||||||
log::info!("已卸载 Gemini MCP 服务器: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"setting" | "hook" => {
|
|
||||||
bail!("Gemini 不支持 {component_type} 组件类型")
|
|
||||||
}
|
|
||||||
_ => bail!("不支持的组件类型: {component_type}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_dir(&self) -> PathBuf {
|
|
||||||
self.config_dir.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_component_type(&self, component_type: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
component_type.to_lowercase().as_str(),
|
|
||||||
"agent" | "command" | "mcp"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for GeminiAdapter {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
//! 应用适配器模块
|
|
||||||
//!
|
|
||||||
//! 负责将 Template 组件安装到不同应用的配置目录中。
|
|
||||||
//! 每个应用有独立的适配器实现,处理各自的配置格式和目录结构。
|
|
||||||
|
|
||||||
mod claude;
|
|
||||||
mod codex;
|
|
||||||
mod gemini;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
pub use claude::ClaudeAdapter;
|
|
||||||
pub use codex::CodexAdapter;
|
|
||||||
pub use gemini::GeminiAdapter;
|
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
|
||||||
|
|
||||||
/// 应用适配器 trait
|
|
||||||
///
|
|
||||||
/// 定义了将 Template 组件安装到应用配置目录的统一接口。
|
|
||||||
/// 每个应用实现自己的适配器来处理特定的配置格式和目录结构。
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub trait AppAdapter: Send + Sync {
|
|
||||||
/// 安装 Agent 到应用配置目录
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `content`: Agent 内容(Markdown 格式)
|
|
||||||
/// - `name`: Agent 名称(用作文件名)
|
|
||||||
///
|
|
||||||
/// # 返回
|
|
||||||
/// 安装后的文件路径
|
|
||||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf>;
|
|
||||||
|
|
||||||
/// 安装 Command 到应用配置目录
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `content`: Command 内容(Markdown 格式)
|
|
||||||
/// - `name`: Command 名称(用作文件名)
|
|
||||||
///
|
|
||||||
/// # 返回
|
|
||||||
/// 安装后的文件路径
|
|
||||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf>;
|
|
||||||
|
|
||||||
/// 安装 MCP 服务器配置
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `mcp_config`: MCP 服务器配置(JSON 对象)
|
|
||||||
///
|
|
||||||
/// # 说明
|
|
||||||
/// 配置会合并到应用的 MCP 配置文件中,保留现有配置。
|
|
||||||
fn install_mcp(&self, mcp_config: &serde_json::Value) -> Result<()>;
|
|
||||||
|
|
||||||
/// 安装 Setting (permissions)
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `setting_config`: Setting 配置(JSON 对象)
|
|
||||||
///
|
|
||||||
/// # 说明
|
|
||||||
/// 仅 Claude 支持此功能,会合并到 settings.json 的 permissions 字段。
|
|
||||||
fn install_setting(&self, setting_config: &serde_json::Value) -> Result<()>;
|
|
||||||
|
|
||||||
/// 安装 Hook
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `hook_config`: Hook 配置(JSON 对象)
|
|
||||||
///
|
|
||||||
/// # 说明
|
|
||||||
/// 仅 Claude 支持此功能,会合并到 settings.json 的 hooks 字段。
|
|
||||||
fn install_hook(&self, hook_config: &serde_json::Value) -> Result<()>;
|
|
||||||
|
|
||||||
/// 卸载组件
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `component_type`: 组件类型(agent/command/mcp/setting/hook)
|
|
||||||
/// - `name`: 组件名称或 ID
|
|
||||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()>;
|
|
||||||
|
|
||||||
/// 获取配置目录路径
|
|
||||||
fn config_dir(&self) -> PathBuf;
|
|
||||||
|
|
||||||
/// 检查组件类型是否支持
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `component_type`: 组件类型字符串
|
|
||||||
///
|
|
||||||
/// # 返回
|
|
||||||
/// 如果应用支持该组件类型返回 true,否则返回 false
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn supports_component_type(&self, component_type: &str) -> bool;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 创建应用适配器工厂函数
|
|
||||||
///
|
|
||||||
/// # 参数
|
|
||||||
/// - `app_type`: 应用类型
|
|
||||||
///
|
|
||||||
/// # 返回
|
|
||||||
/// 对应应用的适配器实例
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn create_adapter(app_type: &AppType) -> Box<dyn AppAdapter> {
|
|
||||||
match app_type {
|
|
||||||
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
|
||||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
|
||||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,746 +0,0 @@
|
|||||||
use anyhow::{anyhow, Context, Result};
|
|
||||||
use rusqlite::{params, Connection};
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use tokio::time::timeout;
|
|
||||||
|
|
||||||
use super::{ComponentMetadata, ComponentType, TemplateComponent, TemplateRepo, TemplateService};
|
|
||||||
|
|
||||||
impl TemplateService {
|
|
||||||
/// 刷新所有启用仓库的组件索引
|
|
||||||
pub async fn refresh_index(&self, conn: &Connection) -> Result<()> {
|
|
||||||
// 获取所有启用的仓库
|
|
||||||
let repos = self.list_enabled_repos(conn)?;
|
|
||||||
|
|
||||||
if repos.is_empty() {
|
|
||||||
log::info!("没有启用的模板仓库");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("开始刷新 {} 个模板仓库", repos.len());
|
|
||||||
|
|
||||||
// 并行扫描所有仓库
|
|
||||||
let scan_tasks = repos.iter().map(|repo| self.scan_repo(repo));
|
|
||||||
let results: Vec<Result<Vec<TemplateComponent>>> =
|
|
||||||
futures::future::join_all(scan_tasks).await;
|
|
||||||
|
|
||||||
// 处理扫描结果
|
|
||||||
let mut total_components = 0;
|
|
||||||
for (repo, result) in repos.iter().zip(results.into_iter()) {
|
|
||||||
match result {
|
|
||||||
Ok(components) => {
|
|
||||||
log::info!(
|
|
||||||
"仓库 {}/{} 扫描到 {} 个组件",
|
|
||||||
repo.owner,
|
|
||||||
repo.name,
|
|
||||||
components.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// 保存到数据库
|
|
||||||
if let Err(e) = self.save_components(conn, &components) {
|
|
||||||
log::error!("保存组件到数据库失败: {e}");
|
|
||||||
} else {
|
|
||||||
total_components += components.len();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("扫描仓库 {}/{} 失败: {}", repo.owner, repo.name, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("刷新完成,共索引 {total_components} 个组件");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描单个仓库
|
|
||||||
pub async fn scan_repo(&self, repo: &TemplateRepo) -> Result<Vec<TemplateComponent>> {
|
|
||||||
log::info!("开始扫描仓库: {}/{}", repo.owner, repo.name);
|
|
||||||
|
|
||||||
// 下载仓库(增加超时控制)
|
|
||||||
let temp_dir = timeout(
|
|
||||||
std::time::Duration::from_secs(120),
|
|
||||||
self.download_repo(repo),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow!("下载仓库超时: {}/{}", repo.owner, repo.name))??;
|
|
||||||
|
|
||||||
let mut components = Vec::new();
|
|
||||||
|
|
||||||
// 扫描不同类型的组件
|
|
||||||
self.scan_agents(&temp_dir, repo, &mut components)?;
|
|
||||||
self.scan_commands(&temp_dir, repo, &mut components)?;
|
|
||||||
self.scan_mcps(&temp_dir, repo, &mut components)?;
|
|
||||||
self.scan_settings(&temp_dir, repo, &mut components)?;
|
|
||||||
self.scan_hooks(&temp_dir, repo, &mut components)?;
|
|
||||||
self.scan_skills(&temp_dir, repo, &mut components)?;
|
|
||||||
|
|
||||||
// 清理临时目录
|
|
||||||
let _ = fs::remove_dir_all(&temp_dir);
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"仓库 {}/{} 扫描完成,找到 {} 个组件",
|
|
||||||
repo.owner,
|
|
||||||
repo.name,
|
|
||||||
components.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(components)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 下载仓库 ZIP
|
|
||||||
async fn download_repo(&self, repo: &TemplateRepo) -> Result<PathBuf> {
|
|
||||||
let temp_dir = tempfile::tempdir().context("创建临时目录失败")?;
|
|
||||||
let temp_path = temp_dir.path().to_path_buf();
|
|
||||||
let _ = temp_dir.keep();
|
|
||||||
|
|
||||||
// 尝试多个分支
|
|
||||||
let branches = if repo.branch.is_empty() {
|
|
||||||
vec!["main", "master"]
|
|
||||||
} else {
|
|
||||||
vec![repo.branch.as_str(), "main", "master"]
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut last_error = None;
|
|
||||||
for branch in branches {
|
|
||||||
let url = format!(
|
|
||||||
"https://github.com/{}/{}/archive/refs/heads/{}.zip",
|
|
||||||
repo.owner, repo.name, branch
|
|
||||||
);
|
|
||||||
|
|
||||||
log::debug!("尝试下载: {url}");
|
|
||||||
match self.download_and_extract(&url, &temp_path).await {
|
|
||||||
Ok(_) => {
|
|
||||||
log::info!("成功下载仓库: {}/{} ({})", repo.owner, repo.name, branch);
|
|
||||||
return Ok(temp_path);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::debug!("下载分支 {branch} 失败: {e}");
|
|
||||||
last_error = Some(e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(last_error.unwrap_or_else(|| anyhow!("所有分支下载失败")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 下载并解压 ZIP
|
|
||||||
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
|
|
||||||
// 下载 ZIP
|
|
||||||
let response = self.client().get(url).send().await?;
|
|
||||||
if !response.status().is_success() {
|
|
||||||
anyhow::bail!("下载失败: HTTP {}", response.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
let bytes = response.bytes().await?;
|
|
||||||
|
|
||||||
// 解压
|
|
||||||
let cursor = std::io::Cursor::new(bytes);
|
|
||||||
let mut archive = zip::ZipArchive::new(cursor)?;
|
|
||||||
|
|
||||||
// 获取根目录名称
|
|
||||||
let root_name = if !archive.is_empty() {
|
|
||||||
let first_file = archive.by_index(0)?;
|
|
||||||
let name = first_file.name();
|
|
||||||
name.split('/').next().unwrap_or("").to_string()
|
|
||||||
} else {
|
|
||||||
return Err(anyhow!("空的压缩包"));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 解压所有文件
|
|
||||||
for i in 0..archive.len() {
|
|
||||||
let mut file = archive.by_index(i)?;
|
|
||||||
let file_path = file.name();
|
|
||||||
|
|
||||||
// 跳过根目录,直接提取内容
|
|
||||||
let relative_path =
|
|
||||||
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
|
|
||||||
stripped
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if relative_path.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let outpath = dest.join(relative_path);
|
|
||||||
|
|
||||||
if file.is_dir() {
|
|
||||||
fs::create_dir_all(&outpath)?;
|
|
||||||
} else {
|
|
||||||
if let Some(parent) = outpath.parent() {
|
|
||||||
fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
let mut outfile = fs::File::create(&outpath)?;
|
|
||||||
std::io::copy(&mut file, &mut outfile)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Agents
|
|
||||||
fn scan_agents(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
// 尝试多个可能的路径
|
|
||||||
let paths = [
|
|
||||||
base_dir.join("cli-tool").join("components").join("agents"),
|
|
||||||
base_dir.join("src").join("agents"),
|
|
||||||
base_dir.join("components").join("agents"),
|
|
||||||
];
|
|
||||||
for agents_dir in paths {
|
|
||||||
if agents_dir.exists() {
|
|
||||||
self.scan_markdown_components(
|
|
||||||
&agents_dir,
|
|
||||||
base_dir,
|
|
||||||
ComponentType::Agent,
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Commands
|
|
||||||
fn scan_commands(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let paths = [
|
|
||||||
base_dir
|
|
||||||
.join("cli-tool")
|
|
||||||
.join("components")
|
|
||||||
.join("commands"),
|
|
||||||
base_dir.join("src").join("commands"),
|
|
||||||
base_dir.join("components").join("commands"),
|
|
||||||
];
|
|
||||||
for commands_dir in paths {
|
|
||||||
if commands_dir.exists() {
|
|
||||||
self.scan_markdown_components(
|
|
||||||
&commands_dir,
|
|
||||||
base_dir,
|
|
||||||
ComponentType::Command,
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 MCPs
|
|
||||||
fn scan_mcps(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let paths = [
|
|
||||||
base_dir.join("cli-tool").join("components").join("mcps"),
|
|
||||||
base_dir.join("src").join("mcp"),
|
|
||||||
base_dir.join("components").join("mcps"),
|
|
||||||
];
|
|
||||||
for mcps_dir in paths {
|
|
||||||
if mcps_dir.exists() {
|
|
||||||
self.scan_json_components(
|
|
||||||
&mcps_dir,
|
|
||||||
base_dir,
|
|
||||||
ComponentType::Mcp,
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Settings
|
|
||||||
fn scan_settings(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let paths = [
|
|
||||||
base_dir
|
|
||||||
.join("cli-tool")
|
|
||||||
.join("components")
|
|
||||||
.join("settings"),
|
|
||||||
base_dir.join("src").join("settings"),
|
|
||||||
base_dir.join("components").join("settings"),
|
|
||||||
];
|
|
||||||
for settings_dir in paths {
|
|
||||||
if settings_dir.exists() {
|
|
||||||
self.scan_json_components(
|
|
||||||
&settings_dir,
|
|
||||||
base_dir,
|
|
||||||
ComponentType::Setting,
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Hooks
|
|
||||||
fn scan_hooks(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let paths = [
|
|
||||||
base_dir.join("cli-tool").join("components").join("hooks"),
|
|
||||||
base_dir.join("src").join("hooks"),
|
|
||||||
base_dir.join("components").join("hooks"),
|
|
||||||
];
|
|
||||||
for hooks_dir in paths {
|
|
||||||
if hooks_dir.exists() {
|
|
||||||
self.scan_json_components(
|
|
||||||
&hooks_dir,
|
|
||||||
base_dir,
|
|
||||||
ComponentType::Hook,
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Skills
|
|
||||||
fn scan_skills(
|
|
||||||
&self,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let paths = [
|
|
||||||
base_dir.join("cli-tool").join("components").join("skills"),
|
|
||||||
base_dir.join("src").join("skills"),
|
|
||||||
base_dir.join("components").join("skills"),
|
|
||||||
];
|
|
||||||
for skills_dir in paths {
|
|
||||||
if skills_dir.exists() {
|
|
||||||
self.scan_skills_recursive(&skills_dir, base_dir, repo, components)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 Markdown 组件(Agent/Command)
|
|
||||||
fn scan_markdown_components(
|
|
||||||
&self,
|
|
||||||
dir: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
component_type: ComponentType,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
for entry in fs::read_dir(dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
|
|
||||||
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
|
|
||||||
if let Ok(component) =
|
|
||||||
self.parse_markdown_component(&path, base_dir, component_type.clone(), repo)
|
|
||||||
{
|
|
||||||
components.push(component);
|
|
||||||
}
|
|
||||||
} else if path.is_dir() {
|
|
||||||
// 递归扫描子目录(用于分类)
|
|
||||||
self.scan_markdown_components(
|
|
||||||
&path,
|
|
||||||
base_dir,
|
|
||||||
component_type.clone(),
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描 JSON 组件(MCP/Setting/Hook)
|
|
||||||
fn scan_json_components(
|
|
||||||
&self,
|
|
||||||
dir: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
component_type: ComponentType,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
for entry in fs::read_dir(dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
|
|
||||||
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") {
|
|
||||||
if let Ok(component) =
|
|
||||||
self.parse_json_component(&path, base_dir, component_type.clone(), repo)
|
|
||||||
{
|
|
||||||
components.push(component);
|
|
||||||
}
|
|
||||||
} else if path.is_dir() {
|
|
||||||
// 递归扫描子目录(用于分类)
|
|
||||||
self.scan_json_components(
|
|
||||||
&path,
|
|
||||||
base_dir,
|
|
||||||
component_type.clone(),
|
|
||||||
repo,
|
|
||||||
components,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 递归扫描技能目录
|
|
||||||
fn scan_skills_recursive(
|
|
||||||
&self,
|
|
||||||
current_dir: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
components: &mut Vec<TemplateComponent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let skill_md = current_dir.join("SKILL.md");
|
|
||||||
|
|
||||||
if skill_md.exists() {
|
|
||||||
// 发现技能
|
|
||||||
if let Ok(component) = self.parse_skill_component(&skill_md, base_dir, repo) {
|
|
||||||
components.push(component);
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 继续递归扫描子目录
|
|
||||||
for entry in fs::read_dir(current_dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
self.scan_skills_recursive(&path, base_dir, repo, components)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 Markdown 组件元数据
|
|
||||||
fn parse_markdown_component(
|
|
||||||
&self,
|
|
||||||
path: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
component_type: ComponentType,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
) -> Result<TemplateComponent> {
|
|
||||||
let content = fs::read_to_string(path)?;
|
|
||||||
let meta = self.parse_component_metadata(&content)?;
|
|
||||||
|
|
||||||
let file_name = path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
|
|
||||||
// 提取分类(从目录结构)
|
|
||||||
let category = self.extract_category(path, &format!("src/{}", component_type.as_str()));
|
|
||||||
|
|
||||||
// 计算相对于仓库根目录的路径
|
|
||||||
let relative_path = path
|
|
||||||
.strip_prefix(base_dir)
|
|
||||||
.unwrap_or(path)
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: None,
|
|
||||||
repo_id: repo.id.unwrap_or(0),
|
|
||||||
component_type,
|
|
||||||
category,
|
|
||||||
name: meta.name.unwrap_or_else(|| file_name.to_string()),
|
|
||||||
path: relative_path,
|
|
||||||
description: meta.description,
|
|
||||||
content_hash: Some(Self::calculate_hash(&content)),
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 JSON 组件元数据
|
|
||||||
fn parse_json_component(
|
|
||||||
&self,
|
|
||||||
path: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
component_type: ComponentType,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
) -> Result<TemplateComponent> {
|
|
||||||
let content = fs::read_to_string(path)?;
|
|
||||||
let json: serde_json::Value = serde_json::from_str(&content)?;
|
|
||||||
|
|
||||||
let file_name = path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
|
|
||||||
let name = json
|
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or(file_name)
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let description = json
|
|
||||||
.get("description")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(String::from);
|
|
||||||
|
|
||||||
let category = self.extract_category(path, &format!("src/{}", component_type.as_str()));
|
|
||||||
|
|
||||||
// 计算相对于仓库根目录的路径
|
|
||||||
let relative_path = path
|
|
||||||
.strip_prefix(base_dir)
|
|
||||||
.unwrap_or(path)
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: None,
|
|
||||||
repo_id: repo.id.unwrap_or(0),
|
|
||||||
component_type,
|
|
||||||
category,
|
|
||||||
name,
|
|
||||||
path: relative_path,
|
|
||||||
description,
|
|
||||||
content_hash: Some(Self::calculate_hash(&content)),
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析技能组件
|
|
||||||
fn parse_skill_component(
|
|
||||||
&self,
|
|
||||||
skill_md: &Path,
|
|
||||||
base_dir: &Path,
|
|
||||||
repo: &TemplateRepo,
|
|
||||||
) -> Result<TemplateComponent> {
|
|
||||||
let content = fs::read_to_string(skill_md)?;
|
|
||||||
let meta = self.parse_component_metadata(&content)?;
|
|
||||||
|
|
||||||
let skill_dir = skill_md.parent().unwrap();
|
|
||||||
let directory = skill_dir
|
|
||||||
.strip_prefix(base_dir)
|
|
||||||
.unwrap_or(skill_dir)
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: None,
|
|
||||||
repo_id: repo.id.unwrap_or(0),
|
|
||||||
component_type: ComponentType::Skill,
|
|
||||||
category: None,
|
|
||||||
name: meta.name.unwrap_or_else(|| directory.clone()),
|
|
||||||
path: directory,
|
|
||||||
description: meta.description,
|
|
||||||
content_hash: Some(Self::calculate_hash(&content)),
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析组件元数据(从 front matter)
|
|
||||||
pub fn parse_component_metadata(&self, content: &str) -> Result<ComponentMetadata> {
|
|
||||||
// 移除 BOM
|
|
||||||
let content = content.trim_start_matches('\u{feff}');
|
|
||||||
|
|
||||||
// 提取 YAML front matter
|
|
||||||
let parts: Vec<&str> = content.splitn(3, "---").collect();
|
|
||||||
if parts.len() < 3 {
|
|
||||||
return Ok(ComponentMetadata {
|
|
||||||
name: None,
|
|
||||||
description: None,
|
|
||||||
tools: None,
|
|
||||||
model: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let front_matter = parts[1].trim();
|
|
||||||
let meta: ComponentMetadata =
|
|
||||||
serde_yaml::from_str(front_matter).unwrap_or(ComponentMetadata {
|
|
||||||
name: None,
|
|
||||||
description: None,
|
|
||||||
tools: None,
|
|
||||||
model: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 提取分类(从路径)
|
|
||||||
fn extract_category(&self, path: &Path, base: &str) -> Option<String> {
|
|
||||||
let path_str = path.to_string_lossy();
|
|
||||||
if let Some(pos) = path_str.find(base) {
|
|
||||||
let after_base = &path_str[pos + base.len()..];
|
|
||||||
let parts: Vec<&str> = after_base.split('/').filter(|s| !s.is_empty()).collect();
|
|
||||||
if parts.len() > 1 {
|
|
||||||
return Some(parts[0].to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 计算内容哈希
|
|
||||||
fn calculate_hash(content: &str) -> String {
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(content.as_bytes());
|
|
||||||
format!("{:x}", hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存组件到数据库
|
|
||||||
fn save_components(&self, conn: &Connection, components: &[TemplateComponent]) -> Result<()> {
|
|
||||||
for component in components {
|
|
||||||
// 检查是否已存在(通过 repo_id + component_type + path)
|
|
||||||
let existing: Option<i64> = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT id FROM template_components
|
|
||||||
WHERE repo_id = ?1 AND component_type = ?2 AND path = ?3",
|
|
||||||
params![
|
|
||||||
component.repo_id,
|
|
||||||
component.component_type.as_str(),
|
|
||||||
&component.path
|
|
||||||
],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
if let Some(id) = existing {
|
|
||||||
// 更新现有组件
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE template_components
|
|
||||||
SET category = ?1, name = ?2, description = ?3, content_hash = ?4, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?5",
|
|
||||||
params![
|
|
||||||
&component.category,
|
|
||||||
&component.name,
|
|
||||||
&component.description,
|
|
||||||
&component.content_hash,
|
|
||||||
id
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
} else {
|
|
||||||
// 插入新组件
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO template_components (repo_id, component_type, category, name, path, description, content_hash)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
||||||
params![
|
|
||||||
component.repo_id,
|
|
||||||
component.component_type.as_str(),
|
|
||||||
&component.category,
|
|
||||||
&component.name,
|
|
||||||
&component.path,
|
|
||||||
&component.description,
|
|
||||||
&component.content_hash
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 列出组件(支持过滤和分页)
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn list_components(
|
|
||||||
&self,
|
|
||||||
conn: &Connection,
|
|
||||||
component_type: Option<ComponentType>,
|
|
||||||
category: Option<String>,
|
|
||||||
search: Option<String>,
|
|
||||||
page: u32,
|
|
||||||
page_size: u32,
|
|
||||||
) -> Result<super::PaginatedResult<TemplateComponent>> {
|
|
||||||
let mut where_clauses = Vec::new();
|
|
||||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
|
||||||
|
|
||||||
if let Some(ct) = &component_type {
|
|
||||||
where_clauses.push("component_type = ?");
|
|
||||||
params.push(Box::new(ct.as_str().to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(cat) = &category {
|
|
||||||
where_clauses.push("category = ?");
|
|
||||||
params.push(Box::new(cat.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(s) = &search {
|
|
||||||
where_clauses.push("(name LIKE ? OR description LIKE ?)");
|
|
||||||
let search_pattern = format!("%{s}%");
|
|
||||||
params.push(Box::new(search_pattern.clone()));
|
|
||||||
params.push(Box::new(search_pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
let where_sql = if where_clauses.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("WHERE {}", where_clauses.join(" AND "))
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取总数
|
|
||||||
let count_sql = format!("SELECT COUNT(*) FROM template_components {where_sql}");
|
|
||||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
||||||
let total: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?;
|
|
||||||
|
|
||||||
// 获取分页数据
|
|
||||||
let offset = (page - 1) * page_size;
|
|
||||||
let query_sql = format!(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
{where_sql}
|
|
||||||
ORDER BY name
|
|
||||||
LIMIT ? OFFSET ?"
|
|
||||||
);
|
|
||||||
|
|
||||||
params.push(Box::new(page_size as i64));
|
|
||||||
params.push(Box::new(offset as i64));
|
|
||||||
|
|
||||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
let mut stmt = conn.prepare(&query_sql)?;
|
|
||||||
let components = stmt
|
|
||||||
.query_map(param_refs.as_slice(), |row| {
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
|
||||||
.unwrap_or(ComponentType::Agent),
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
})?
|
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
|
||||||
|
|
||||||
Ok(super::PaginatedResult {
|
|
||||||
items: components,
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
page_size,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,525 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use rusqlite::{params, Connection};
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
BatchInstallResult, ComponentDetail, ComponentType, InstalledComponent, TemplateComponent,
|
|
||||||
TemplateService,
|
|
||||||
};
|
|
||||||
|
|
||||||
impl TemplateService {
|
|
||||||
/// 获取组件详情(含完整内容)
|
|
||||||
pub async fn get_component(&self, conn: &Connection, id: i64) -> Result<ComponentDetail> {
|
|
||||||
// 查询组件基本信息
|
|
||||||
let component: TemplateComponent = conn.query_row(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
|row| {
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
|
||||||
.unwrap_or(ComponentType::Agent),
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 查询仓库信息
|
|
||||||
let (repo_owner, repo_name, branch): (String, String, String) = conn.query_row(
|
|
||||||
"SELECT owner, name, branch FROM template_repos WHERE id = ?1",
|
|
||||||
params![component.repo_id],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 构建 README URL
|
|
||||||
let readme_url = format!(
|
|
||||||
"https://github.com/{}/{}/tree/{}/{}",
|
|
||||||
repo_owner, repo_name, branch, component.path
|
|
||||||
);
|
|
||||||
|
|
||||||
// 下载并读取组件内容
|
|
||||||
let content = self
|
|
||||||
.download_component_content(&repo_owner, &repo_name, &branch, &component.path)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(ComponentDetail {
|
|
||||||
component,
|
|
||||||
content,
|
|
||||||
repo_owner,
|
|
||||||
repo_name,
|
|
||||||
repo_branch: branch,
|
|
||||||
readme_url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 下载组件内容
|
|
||||||
async fn download_component_content(
|
|
||||||
&self,
|
|
||||||
owner: &str,
|
|
||||||
name: &str,
|
|
||||||
branch: &str,
|
|
||||||
path: &str,
|
|
||||||
) -> Result<String> {
|
|
||||||
let url = format!("https://raw.githubusercontent.com/{owner}/{name}/{branch}/{path}");
|
|
||||||
|
|
||||||
let response = self.client().get(&url).send().await?;
|
|
||||||
if !response.status().is_success() {
|
|
||||||
anyhow::bail!("下载组件内容失败: HTTP {}", response.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = response.text().await?;
|
|
||||||
Ok(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装组件到指定应用
|
|
||||||
pub async fn install_component(
|
|
||||||
&self,
|
|
||||||
conn: &Connection,
|
|
||||||
id: i64,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
// 获取组件详情
|
|
||||||
let detail = self.get_component(conn, id).await?;
|
|
||||||
|
|
||||||
// 根据组件类型执行不同的安装逻辑
|
|
||||||
match detail.component.component_type {
|
|
||||||
ComponentType::Agent => {
|
|
||||||
self.install_agent(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
ComponentType::Command => {
|
|
||||||
self.install_command(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
ComponentType::Mcp => {
|
|
||||||
self.install_mcp(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
ComponentType::Setting => {
|
|
||||||
self.install_setting(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
ComponentType::Hook => {
|
|
||||||
self.install_hook(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
ComponentType::Skill => {
|
|
||||||
self.install_skill(&detail, app_type).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 记录安装状态
|
|
||||||
self.record_installation(conn, &detail.component, app_type)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Agent
|
|
||||||
async fn install_agent(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let agents_dir = config_dir.join("agents");
|
|
||||||
fs::create_dir_all(&agents_dir)?;
|
|
||||||
|
|
||||||
let file_name = format!("{}.md", detail.component.name);
|
|
||||||
let dest_path = agents_dir.join(&file_name);
|
|
||||||
|
|
||||||
fs::write(&dest_path, &detail.content)?;
|
|
||||||
log::info!("Agent 已安装: {}", dest_path.display());
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Command
|
|
||||||
async fn install_command(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let commands_dir = config_dir.join("commands");
|
|
||||||
fs::create_dir_all(&commands_dir)?;
|
|
||||||
|
|
||||||
let file_name = format!("{}.md", detail.component.name);
|
|
||||||
let dest_path = commands_dir.join(&file_name);
|
|
||||||
|
|
||||||
fs::write(&dest_path, &detail.content)?;
|
|
||||||
log::info!("Command 已安装: {}", dest_path.display());
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 MCP 服务器
|
|
||||||
/// MCP 配置保存为独立 JSON 文件到 mcps/ 目录,不会修改原有 .mcp.json
|
|
||||||
async fn install_mcp(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let mcps_dir = config_dir.join("mcps");
|
|
||||||
fs::create_dir_all(&mcps_dir)?;
|
|
||||||
|
|
||||||
// 保存为独立的 JSON 文件(保留原始格式,包含 mcpServers 结构)
|
|
||||||
let file_name = format!("{}.json", detail.component.name);
|
|
||||||
let dest_path = mcps_dir.join(&file_name);
|
|
||||||
|
|
||||||
fs::write(&dest_path, &detail.content)?;
|
|
||||||
log::info!(
|
|
||||||
"MCP 配置已保存: {} (可手动合并到 .mcp.json)",
|
|
||||||
dest_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Setting
|
|
||||||
/// Setting 配置保存为独立 JSON 文件到 settings/ 目录,不会修改原有 settings.json
|
|
||||||
/// 原始格式包含 permissions 等配置,可手动合并
|
|
||||||
async fn install_setting(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let settings_dir = config_dir.join("settings");
|
|
||||||
fs::create_dir_all(&settings_dir)?;
|
|
||||||
|
|
||||||
// 保存为独立的 JSON 文件(保留原始格式,包含 permissions 等结构)
|
|
||||||
let file_name = format!("{}.json", detail.component.name);
|
|
||||||
let dest_path = settings_dir.join(&file_name);
|
|
||||||
|
|
||||||
fs::write(&dest_path, &detail.content)?;
|
|
||||||
log::info!(
|
|
||||||
"Setting 配置已保存: {} (可手动合并到 settings.json)",
|
|
||||||
dest_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Hook
|
|
||||||
/// Hook 配置保存为独立 JSON 文件到 hooks/ 目录,不会修改原有 settings.json
|
|
||||||
/// 原始格式包含 hooks 对象(如 PostToolUse 等),可手动合并
|
|
||||||
async fn install_hook(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let hooks_dir = config_dir.join("hooks");
|
|
||||||
fs::create_dir_all(&hooks_dir)?;
|
|
||||||
|
|
||||||
// 保存为独立的 JSON 文件(保留原始格式,包含 hooks 结构)
|
|
||||||
let file_name = format!("{}.json", detail.component.name);
|
|
||||||
let dest_path = hooks_dir.join(&file_name);
|
|
||||||
|
|
||||||
fs::write(&dest_path, &detail.content)?;
|
|
||||||
log::info!(
|
|
||||||
"Hook 配置已保存: {} (可手动合并到 settings.json 的 hooks 字段)",
|
|
||||||
dest_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安装 Skill
|
|
||||||
/// Skill 是一个目录结构,包含 SKILL.md 和可能的子目录(如 reference/, scripts/)
|
|
||||||
/// 使用 GitHub API 递归下载整个目录
|
|
||||||
async fn install_skill(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let skills_dir = config_dir.join("skills");
|
|
||||||
fs::create_dir_all(&skills_dir)?;
|
|
||||||
|
|
||||||
let skill_dir = skills_dir.join(&detail.component.name);
|
|
||||||
fs::create_dir_all(&skill_dir)?;
|
|
||||||
|
|
||||||
// 首先保存 SKILL.md(已下载的内容)
|
|
||||||
let skill_md = skill_dir.join("SKILL.md");
|
|
||||||
fs::write(&skill_md, &detail.content)?;
|
|
||||||
|
|
||||||
// 尝试下载整个 skill 目录的其他文件
|
|
||||||
// 构建 GitHub API URL 来获取目录内容
|
|
||||||
let api_url = format!(
|
|
||||||
"https://api.github.com/repos/{}/{}/contents/{}",
|
|
||||||
detail.repo_owner,
|
|
||||||
detail.repo_name,
|
|
||||||
detail.component.path.trim_end_matches("/SKILL.md")
|
|
||||||
);
|
|
||||||
|
|
||||||
// 递归下载目录内容
|
|
||||||
if let Err(e) = self
|
|
||||||
.download_skill_directory(&api_url, &skill_dir, &detail.repo_branch)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
log::warn!("下载 Skill 附加文件失败: {e},仅安装 SKILL.md");
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Skill 已安装: {}", skill_dir.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 递归下载 Skill 目录内容
|
|
||||||
async fn download_skill_directory(
|
|
||||||
&self,
|
|
||||||
api_url: &str,
|
|
||||||
target_dir: &std::path::Path,
|
|
||||||
branch: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let response = self
|
|
||||||
.client()
|
|
||||||
.get(api_url)
|
|
||||||
.header("Accept", "application/vnd.github.v3+json")
|
|
||||||
.header("User-Agent", "cc-switch")
|
|
||||||
.query(&[("ref", branch)])
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
anyhow::bail!("GitHub API 请求失败: {}", response.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
let contents: Vec<serde_json::Value> = response.json().await?;
|
|
||||||
|
|
||||||
for item in contents {
|
|
||||||
let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
let item_name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
|
|
||||||
// 跳过 SKILL.md(已经下载)
|
|
||||||
if item_name == "SKILL.md" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if item_type == "file" {
|
|
||||||
// 下载文件
|
|
||||||
if let Some(download_url) = item.get("download_url").and_then(|v| v.as_str()) {
|
|
||||||
let file_response = self.client().get(download_url).send().await?;
|
|
||||||
if file_response.status().is_success() {
|
|
||||||
let content = file_response.text().await?;
|
|
||||||
let file_path = target_dir.join(item_name);
|
|
||||||
fs::write(&file_path, &content)?;
|
|
||||||
log::debug!("下载文件: {}", file_path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if item_type == "dir" {
|
|
||||||
// 递归下载子目录
|
|
||||||
if let Some(sub_url) = item.get("url").and_then(|v| v.as_str()) {
|
|
||||||
let sub_dir = target_dir.join(item_name);
|
|
||||||
fs::create_dir_all(&sub_dir)?;
|
|
||||||
// 递归调用,使用 Box::pin 处理异步递归
|
|
||||||
Box::pin(self.download_skill_directory(sub_url, &sub_dir, branch)).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 记录安装状态
|
|
||||||
fn record_installation(
|
|
||||||
&self,
|
|
||||||
conn: &Connection,
|
|
||||||
component: &TemplateComponent,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO installed_components (component_id, component_type, name, path, app_type)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
||||||
params![
|
|
||||||
component.id,
|
|
||||||
component.component_type.as_str(),
|
|
||||||
&component.name,
|
|
||||||
&component.path,
|
|
||||||
app_type
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 卸载组件
|
|
||||||
pub fn uninstall_component(&self, conn: &Connection, id: i64, app_type: &str) -> Result<()> {
|
|
||||||
// 查询组件信息
|
|
||||||
let component: TemplateComponent = conn.query_row(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
|row| {
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
|
||||||
.unwrap_or(ComponentType::Agent),
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 删除文件
|
|
||||||
match component.component_type {
|
|
||||||
ComponentType::Agent => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let file_path = config_dir
|
|
||||||
.join("agents")
|
|
||||||
.join(format!("{}.md", component.name));
|
|
||||||
if file_path.exists() {
|
|
||||||
fs::remove_file(&file_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ComponentType::Command => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let file_path = config_dir
|
|
||||||
.join("commands")
|
|
||||||
.join(format!("{}.md", component.name));
|
|
||||||
if file_path.exists() {
|
|
||||||
fs::remove_file(&file_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ComponentType::Skill => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let skill_dir = config_dir.join("skills").join(&component.name);
|
|
||||||
if skill_dir.exists() {
|
|
||||||
fs::remove_dir_all(&skill_dir)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ComponentType::Mcp => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let file_path = config_dir
|
|
||||||
.join("mcps")
|
|
||||||
.join(format!("{}.json", component.name));
|
|
||||||
if file_path.exists() {
|
|
||||||
fs::remove_file(&file_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ComponentType::Setting => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let file_path = config_dir
|
|
||||||
.join("settings")
|
|
||||||
.join(format!("{}.json", component.name));
|
|
||||||
if file_path.exists() {
|
|
||||||
fs::remove_file(&file_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ComponentType::Hook => {
|
|
||||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
|
||||||
let file_path = config_dir
|
|
||||||
.join("hooks")
|
|
||||||
.join(format!("{}.json", component.name));
|
|
||||||
if file_path.exists() {
|
|
||||||
fs::remove_file(&file_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除安装记录
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM installed_components
|
|
||||||
WHERE component_id = ?1 AND app_type = ?2",
|
|
||||||
params![id, app_type],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
log::info!("组件已卸载: {}", component.name);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 批量安装组件
|
|
||||||
pub async fn batch_install(
|
|
||||||
&self,
|
|
||||||
conn: &Connection,
|
|
||||||
ids: Vec<i64>,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<BatchInstallResult> {
|
|
||||||
let mut success = Vec::new();
|
|
||||||
let mut failed = Vec::new();
|
|
||||||
|
|
||||||
for id in ids {
|
|
||||||
match self.install_component(conn, id, app_type).await {
|
|
||||||
Ok(_) => success.push(id),
|
|
||||||
Err(e) => failed.push((id, e.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(BatchInstallResult { success, failed })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 列出已安装的组件
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn list_installed(
|
|
||||||
&self,
|
|
||||||
conn: &Connection,
|
|
||||||
app_type: Option<&str>,
|
|
||||||
) -> Result<Vec<InstalledComponent>> {
|
|
||||||
let (sql, params): (String, Vec<Box<dyn rusqlite::ToSql>>) = if let Some(at) = app_type {
|
|
||||||
(
|
|
||||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
|
||||||
FROM installed_components
|
|
||||||
WHERE app_type = ?
|
|
||||||
ORDER BY installed_at DESC"
|
|
||||||
.to_string(),
|
|
||||||
vec![Box::new(at.to_string())],
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
|
||||||
FROM installed_components
|
|
||||||
ORDER BY installed_at DESC"
|
|
||||||
.to_string(),
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
||||||
|
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
|
||||||
let components = stmt
|
|
||||||
.query_map(param_refs.as_slice(), |row| {
|
|
||||||
Ok(InstalledComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
component_id: row.get(1)?,
|
|
||||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
|
||||||
.unwrap_or(ComponentType::Agent),
|
|
||||||
name: row.get(3)?,
|
|
||||||
path: row.get(4)?,
|
|
||||||
app_type: row.get(5)?,
|
|
||||||
installed_at: row
|
|
||||||
.get::<_, String>(6)?
|
|
||||||
.parse()
|
|
||||||
.unwrap_or_else(|_| chrono::Utc::now()),
|
|
||||||
})
|
|
||||||
})?
|
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
|
||||||
|
|
||||||
Ok(components)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 预览组件内容(仅获取内容,不进行安装)
|
|
||||||
pub async fn preview_content(&self, conn: &Connection, id: i64) -> Result<String> {
|
|
||||||
// 查询组件基本信息
|
|
||||||
let component: TemplateComponent = conn.query_row(
|
|
||||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
|
||||||
FROM template_components
|
|
||||||
WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
|row| {
|
|
||||||
Ok(TemplateComponent {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
repo_id: row.get(1)?,
|
|
||||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
|
||||||
.unwrap_or(ComponentType::Agent),
|
|
||||||
category: row.get(3)?,
|
|
||||||
name: row.get(4)?,
|
|
||||||
path: row.get(5)?,
|
|
||||||
description: row.get(6)?,
|
|
||||||
content_hash: row.get(7)?,
|
|
||||||
installed: false,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 查询仓库信息
|
|
||||||
let (repo_owner, repo_name, branch): (String, String, String) = conn.query_row(
|
|
||||||
"SELECT owner, name, branch FROM template_repos WHERE id = ?1",
|
|
||||||
params![component.repo_id],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 下载并读取组件内容
|
|
||||||
let content = self
|
|
||||||
.download_component_content(&repo_owner, &repo_name, &branch, &component.path)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
use anyhow::{Context, Result};
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
pub mod adapters;
|
|
||||||
pub mod index;
|
|
||||||
pub mod installer;
|
|
||||||
pub mod repo;
|
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use adapters::{create_adapter, AppAdapter};
|
|
||||||
|
|
||||||
/// 组件类型枚举
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum ComponentType {
|
|
||||||
Agent,
|
|
||||||
Command,
|
|
||||||
Mcp,
|
|
||||||
Setting,
|
|
||||||
Hook,
|
|
||||||
Skill,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ComponentType {
|
|
||||||
pub fn as_str(&self) -> &str {
|
|
||||||
match self {
|
|
||||||
ComponentType::Agent => "agent",
|
|
||||||
ComponentType::Command => "command",
|
|
||||||
ComponentType::Mcp => "mcp",
|
|
||||||
ComponentType::Setting => "setting",
|
|
||||||
ComponentType::Hook => "hook",
|
|
||||||
ComponentType::Skill => "skill",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_str(s: &str) -> Option<Self> {
|
|
||||||
match s.to_lowercase().as_str() {
|
|
||||||
"agent" => Some(ComponentType::Agent),
|
|
||||||
"command" => Some(ComponentType::Command),
|
|
||||||
"mcp" => Some(ComponentType::Mcp),
|
|
||||||
"setting" => Some(ComponentType::Setting),
|
|
||||||
"hook" => Some(ComponentType::Hook),
|
|
||||||
"skill" => Some(ComponentType::Skill),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 模板仓库
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TemplateRepo {
|
|
||||||
pub id: Option<i64>,
|
|
||||||
pub owner: String,
|
|
||||||
pub name: String,
|
|
||||||
pub branch: String,
|
|
||||||
pub enabled: bool,
|
|
||||||
#[serde(rename = "createdAt")]
|
|
||||||
pub created_at: Option<DateTime<Utc>>,
|
|
||||||
#[serde(rename = "updatedAt")]
|
|
||||||
pub updated_at: Option<DateTime<Utc>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TemplateRepo {
|
|
||||||
pub fn new(owner: String, name: String, branch: String) -> Self {
|
|
||||||
Self {
|
|
||||||
id: None,
|
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
branch,
|
|
||||||
enabled: true,
|
|
||||||
created_at: None,
|
|
||||||
updated_at: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 模板组件
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TemplateComponent {
|
|
||||||
pub id: Option<i64>,
|
|
||||||
#[serde(rename = "repoId")]
|
|
||||||
pub repo_id: i64,
|
|
||||||
#[serde(rename = "componentType")]
|
|
||||||
pub component_type: ComponentType,
|
|
||||||
pub category: Option<String>,
|
|
||||||
pub name: String,
|
|
||||||
pub path: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
#[serde(rename = "contentHash")]
|
|
||||||
pub content_hash: Option<String>,
|
|
||||||
/// 是否已安装(前端展示用,需要在查询时填充)
|
|
||||||
pub installed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 组件详情(含完整内容)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ComponentDetail {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub component: TemplateComponent,
|
|
||||||
/// 完整文件内容
|
|
||||||
pub content: String,
|
|
||||||
/// 仓库所有者
|
|
||||||
#[serde(rename = "repoOwner")]
|
|
||||||
pub repo_owner: String,
|
|
||||||
/// 仓库名称
|
|
||||||
#[serde(rename = "repoName")]
|
|
||||||
pub repo_name: String,
|
|
||||||
/// 仓库分支
|
|
||||||
#[serde(rename = "repoBranch")]
|
|
||||||
pub repo_branch: String,
|
|
||||||
/// GitHub README URL
|
|
||||||
#[serde(rename = "readmeUrl")]
|
|
||||||
pub readme_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 组件元数据(从文件 front matter 解析)
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct ComponentMetadata {
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
/// Agent 专用 - 工具列表
|
|
||||||
pub tools: Option<String>,
|
|
||||||
/// Agent 专用 - 模型名称
|
|
||||||
pub model: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 分页结果
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PaginatedResult<T> {
|
|
||||||
pub items: Vec<T>,
|
|
||||||
pub total: i64,
|
|
||||||
pub page: u32,
|
|
||||||
#[serde(rename = "pageSize")]
|
|
||||||
pub page_size: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 批量安装结果
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct BatchInstallResult {
|
|
||||||
pub success: Vec<i64>,
|
|
||||||
pub failed: Vec<(i64, String)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 已安装组件
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct InstalledComponent {
|
|
||||||
pub id: Option<i64>,
|
|
||||||
#[serde(rename = "componentId")]
|
|
||||||
pub component_id: Option<i64>,
|
|
||||||
#[serde(rename = "componentType")]
|
|
||||||
pub component_type: ComponentType,
|
|
||||||
pub name: String,
|
|
||||||
pub path: String,
|
|
||||||
#[serde(rename = "appType")]
|
|
||||||
pub app_type: String,
|
|
||||||
#[serde(rename = "installedAt")]
|
|
||||||
pub installed_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 市场组合项(plugin 中的单个组件)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MarketplaceBundleItem {
|
|
||||||
pub name: String,
|
|
||||||
pub path: String,
|
|
||||||
#[serde(rename = "componentType")]
|
|
||||||
pub component_type: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 市场组合
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MarketplaceBundle {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub category: String,
|
|
||||||
pub components: Vec<MarketplaceBundleItem>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Template 服务
|
|
||||||
pub struct TemplateService {
|
|
||||||
http_client: Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TemplateService {
|
|
||||||
pub fn new() -> Result<Self> {
|
|
||||||
Ok(Self {
|
|
||||||
http_client: Client::builder()
|
|
||||||
.user_agent("cc-switch")
|
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.build()
|
|
||||||
.context("创建 HTTP 客户端失败")?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 HTTP 客户端
|
|
||||||
pub fn client(&self) -> &Client {
|
|
||||||
&self.http_client
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取应用配置目录
|
|
||||||
pub fn get_app_config_dir(app_type: &str) -> Result<PathBuf> {
|
|
||||||
let home = dirs::home_dir().context("无法获取用户主目录")?;
|
|
||||||
|
|
||||||
let dir = match app_type.to_lowercase().as_str() {
|
|
||||||
"claude" => {
|
|
||||||
// 检查是否有自定义 Claude 配置目录
|
|
||||||
if let Some(custom) = crate::settings::get_claude_override_dir() {
|
|
||||||
custom
|
|
||||||
} else {
|
|
||||||
home.join(".claude")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"codex" => {
|
|
||||||
// 检查是否有自定义 Codex 配置目录
|
|
||||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
|
||||||
custom
|
|
||||||
} else {
|
|
||||||
home.join(".codex")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"gemini" => {
|
|
||||||
// 检查是否有自定义 Gemini 配置目录
|
|
||||||
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
|
||||||
custom
|
|
||||||
} else {
|
|
||||||
home.join(".gemini")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => anyhow::bail!("不支持的应用类型: {app_type}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 components.json 获取市场组合
|
|
||||||
pub async fn fetch_marketplace_bundles(
|
|
||||||
&self,
|
|
||||||
conn: &rusqlite::Connection,
|
|
||||||
) -> Result<Vec<MarketplaceBundle>> {
|
|
||||||
// 获取启用的仓库
|
|
||||||
let repos = self.list_enabled_repos(conn)?;
|
|
||||||
if repos.is_empty() {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut bundles = Vec::new();
|
|
||||||
|
|
||||||
for repo in repos {
|
|
||||||
// 尝试多个可能的路径
|
|
||||||
let urls = [
|
|
||||||
format!(
|
|
||||||
"https://raw.githubusercontent.com/{}/{}/{}/components.json",
|
|
||||||
repo.owner, repo.name, repo.branch
|
|
||||||
),
|
|
||||||
format!(
|
|
||||||
"https://raw.githubusercontent.com/{}/{}/{}/docs/components.json",
|
|
||||||
repo.owner, repo.name, repo.branch
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for url in urls {
|
|
||||||
match self.http_client.get(&url).send().await {
|
|
||||||
Ok(resp) if resp.status().is_success() => {
|
|
||||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
|
||||||
// 解析 marketplace.plugins(完整插件包)
|
|
||||||
if let Some(marketplace) = json.get("marketplace") {
|
|
||||||
if let Some(plugins) = marketplace.get("plugins") {
|
|
||||||
if let Some(arr) = plugins.as_array() {
|
|
||||||
for plugin in arr {
|
|
||||||
let name = plugin
|
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let description = plugin
|
|
||||||
.get("description")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
// 提取各类型组件路径
|
|
||||||
let mut components = Vec::new();
|
|
||||||
let component_types = [
|
|
||||||
"agents", "commands", "mcps", "settings", "hooks",
|
|
||||||
"skills",
|
|
||||||
];
|
|
||||||
|
|
||||||
for comp_type in component_types {
|
|
||||||
if let Some(paths) =
|
|
||||||
plugin.get(comp_type).and_then(|v| v.as_array())
|
|
||||||
{
|
|
||||||
// 单数形式的类型名
|
|
||||||
let singular_type = match comp_type {
|
|
||||||
"agents" => "agent",
|
|
||||||
"commands" => "command",
|
|
||||||
"mcps" => "mcp",
|
|
||||||
"settings" => "setting",
|
|
||||||
"hooks" => "hook",
|
|
||||||
"skills" => "skill",
|
|
||||||
_ => comp_type,
|
|
||||||
};
|
|
||||||
|
|
||||||
for path_val in paths {
|
|
||||||
if let Some(path) = path_val.as_str() {
|
|
||||||
// 从路径提取组件名(文件名不含扩展名)
|
|
||||||
let comp_name =
|
|
||||||
std::path::Path::new(path)
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
components.push(
|
|
||||||
MarketplaceBundleItem {
|
|
||||||
name: comp_name,
|
|
||||||
path: path.to_string(),
|
|
||||||
component_type: singular_type
|
|
||||||
.to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !components.is_empty() {
|
|
||||||
bundles.push(MarketplaceBundle {
|
|
||||||
id: format!("{}-plugin-{}", repo.name, name),
|
|
||||||
name: name.to_string(),
|
|
||||||
description: description.to_string(),
|
|
||||||
category: "plugin".to_string(),
|
|
||||||
components,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break; // 成功获取后跳出 URL 循环
|
|
||||||
}
|
|
||||||
_ => continue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(bundles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for TemplateService {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new().expect("创建 TemplateService 失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
use anyhow::{Context, Result};
|
|
||||||
use rusqlite::{params, Connection};
|
|
||||||
|
|
||||||
use super::{TemplateRepo, TemplateService};
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl TemplateService {
|
|
||||||
/// 列出所有模板仓库
|
|
||||||
pub fn list_repos(&self, conn: &Connection) -> Result<Vec<TemplateRepo>> {
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
|
||||||
FROM template_repos
|
|
||||||
ORDER BY created_at DESC",
|
|
||||||
)
|
|
||||||
.context("准备查询模板仓库语句失败")?;
|
|
||||||
|
|
||||||
let repos = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
Ok(TemplateRepo {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
owner: row.get(1)?,
|
|
||||||
name: row.get(2)?,
|
|
||||||
branch: row.get(3)?,
|
|
||||||
enabled: row.get::<_, i64>(4)? != 0,
|
|
||||||
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
|
|
||||||
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.context("查询模板仓库失败")?
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.context("收集模板仓库结果失败")?;
|
|
||||||
|
|
||||||
Ok(repos)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加模板仓库
|
|
||||||
pub fn add_repo(&self, conn: &Connection, repo: TemplateRepo) -> Result<i64> {
|
|
||||||
// 检查是否已存在
|
|
||||||
let existing: Option<i64> = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT id FROM template_repos WHERE owner = ?1 AND name = ?2",
|
|
||||||
params![&repo.owner, &repo.name],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
if let Some(id) = existing {
|
|
||||||
// 更新已存在的仓库
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE template_repos
|
|
||||||
SET branch = ?1, enabled = ?2, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?3",
|
|
||||||
params![&repo.branch, repo.enabled as i64, id],
|
|
||||||
)
|
|
||||||
.context("更新模板仓库失败")?;
|
|
||||||
Ok(id)
|
|
||||||
} else {
|
|
||||||
// 插入新仓库
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO template_repos (owner, name, branch, enabled)
|
|
||||||
VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
params![&repo.owner, &repo.name, &repo.branch, repo.enabled as i64],
|
|
||||||
)
|
|
||||||
.context("插入模板仓库失败")?;
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除模板仓库
|
|
||||||
pub fn remove_repo(&self, conn: &Connection, id: i64) -> Result<()> {
|
|
||||||
let rows = conn
|
|
||||||
.execute("DELETE FROM template_repos WHERE id = ?1", params![id])
|
|
||||||
.context("删除模板仓库失败")?;
|
|
||||||
|
|
||||||
if rows == 0 {
|
|
||||||
anyhow::bail!("模板仓库不存在: id={id}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换仓库启用状态
|
|
||||||
pub fn toggle_repo_enabled(&self, conn: &Connection, id: i64) -> Result<bool> {
|
|
||||||
// 获取当前状态
|
|
||||||
let enabled: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT enabled FROM template_repos WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.context("查询仓库状态失败")?;
|
|
||||||
|
|
||||||
let new_enabled = enabled == 0;
|
|
||||||
|
|
||||||
// 更新状态
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE template_repos
|
|
||||||
SET enabled = ?1, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?2",
|
|
||||||
params![new_enabled as i64, id],
|
|
||||||
)
|
|
||||||
.context("更新仓库状态失败")?;
|
|
||||||
|
|
||||||
Ok(new_enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取单个仓库
|
|
||||||
pub fn get_repo(&self, conn: &Connection, id: i64) -> Result<TemplateRepo> {
|
|
||||||
conn.query_row(
|
|
||||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
|
||||||
FROM template_repos
|
|
||||||
WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
|row| {
|
|
||||||
Ok(TemplateRepo {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
owner: row.get(1)?,
|
|
||||||
name: row.get(2)?,
|
|
||||||
branch: row.get(3)?,
|
|
||||||
enabled: row.get::<_, i64>(4)? != 0,
|
|
||||||
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
|
|
||||||
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.context(format!("查询模板仓库失败: id={id}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取启用的仓库列表
|
|
||||||
pub fn list_enabled_repos(&self, conn: &Connection) -> Result<Vec<TemplateRepo>> {
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
|
||||||
FROM template_repos
|
|
||||||
WHERE enabled = 1
|
|
||||||
ORDER BY created_at DESC",
|
|
||||||
)
|
|
||||||
.context("准备查询启用仓库语句失败")?;
|
|
||||||
|
|
||||||
let repos = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
Ok(TemplateRepo {
|
|
||||||
id: Some(row.get(0)?),
|
|
||||||
owner: row.get(1)?,
|
|
||||||
name: row.get(2)?,
|
|
||||||
branch: row.get(3)?,
|
|
||||||
enabled: row.get::<_, i64>(4)? != 0,
|
|
||||||
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
|
|
||||||
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.context("查询启用仓库失败")?
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.context("收集启用仓库结果失败")?;
|
|
||||||
|
|
||||||
Ok(repos)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use rusqlite::Connection;
|
|
||||||
|
|
||||||
fn setup_db() -> Connection {
|
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS template_repos (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
owner TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
branch TEXT NOT NULL DEFAULT 'main',
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(owner, name)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_add_and_list_repos() {
|
|
||||||
let conn = setup_db();
|
|
||||||
let service = TemplateService::new().unwrap();
|
|
||||||
|
|
||||||
// 添加仓库
|
|
||||||
let repo = TemplateRepo::new(
|
|
||||||
"yovinchen".to_string(),
|
|
||||||
"claude-code-templates".to_string(),
|
|
||||||
"main".to_string(),
|
|
||||||
);
|
|
||||||
let id = service.add_repo(&conn, repo).unwrap();
|
|
||||||
assert!(id > 0);
|
|
||||||
|
|
||||||
// 列出仓库
|
|
||||||
let repos = service.list_repos(&conn).unwrap();
|
|
||||||
assert_eq!(repos.len(), 1);
|
|
||||||
assert_eq!(repos[0].owner, "yovinchen");
|
|
||||||
assert_eq!(repos[0].name, "claude-code-templates");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_toggle_repo_enabled() {
|
|
||||||
let conn = setup_db();
|
|
||||||
let service = TemplateService::new().unwrap();
|
|
||||||
|
|
||||||
// 添加仓库
|
|
||||||
let repo = TemplateRepo::new("test".to_string(), "repo".to_string(), "main".to_string());
|
|
||||||
let id = service.add_repo(&conn, repo).unwrap();
|
|
||||||
|
|
||||||
// 切换状态
|
|
||||||
let enabled = service.toggle_repo_enabled(&conn, id).unwrap();
|
|
||||||
assert!(!enabled);
|
|
||||||
|
|
||||||
let enabled = service.toggle_repo_enabled(&conn, id).unwrap();
|
|
||||||
assert!(enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_remove_repo() {
|
|
||||||
let conn = setup_db();
|
|
||||||
let service = TemplateService::new().unwrap();
|
|
||||||
|
|
||||||
// 添加仓库
|
|
||||||
let repo = TemplateRepo::new("test".to_string(), "repo".to_string(), "main".to_string());
|
|
||||||
let id = service.add_repo(&conn, repo).unwrap();
|
|
||||||
|
|
||||||
// 删除仓库
|
|
||||||
service.remove_repo(&conn, id).unwrap();
|
|
||||||
|
|
||||||
// 验证已删除
|
|
||||||
let repos = service.list_repos(&conn).unwrap();
|
|
||||||
assert_eq!(repos.len(), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -47,6 +47,8 @@ pub struct AppSettings {
|
|||||||
pub codex_config_dir: Option<String>,
|
pub codex_config_dir: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub gemini_config_dir: Option<String>,
|
pub gemini_config_dir: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub opencode_config_dir: Option<String>,
|
||||||
|
|
||||||
// ===== 当前供应商 ID(设备级)=====
|
// ===== 当前供应商 ID(设备级)=====
|
||||||
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current)
|
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current)
|
||||||
@@ -58,6 +60,9 @@ pub struct AppSettings {
|
|||||||
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current)
|
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current)
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub current_provider_gemini: Option<String>,
|
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 {
|
fn default_show_in_tray() -> bool {
|
||||||
@@ -84,9 +89,11 @@ impl Default for AppSettings {
|
|||||||
claude_config_dir: None,
|
claude_config_dir: None,
|
||||||
codex_config_dir: None,
|
codex_config_dir: None,
|
||||||
gemini_config_dir: None,
|
gemini_config_dir: None,
|
||||||
|
opencode_config_dir: None,
|
||||||
current_provider_claude: None,
|
current_provider_claude: None,
|
||||||
current_provider_codex: None,
|
current_provider_codex: None,
|
||||||
current_provider_gemini: None,
|
current_provider_gemini: None,
|
||||||
|
current_provider_opencode: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,6 +126,13 @@ impl AppSettings {
|
|||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(|s| s.to_string());
|
.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
|
self.language = self
|
||||||
.language
|
.language
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -251,6 +265,14 @@ pub fn get_gemini_override_dir() -> Option<PathBuf> {
|
|||||||
.map(|p| resolve_override_path(p))
|
.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 读取)
|
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
|
||||||
@@ -263,6 +285,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
|
|||||||
AppType::Claude => settings.current_provider_claude.clone(),
|
AppType::Claude => settings.current_provider_claude.clone(),
|
||||||
AppType::Codex => settings.current_provider_codex.clone(),
|
AppType::Codex => settings.current_provider_codex.clone(),
|
||||||
AppType::Gemini => settings.current_provider_gemini.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::Claude => settings.current_provider_claude = id.map(|s| s.to_string()),
|
||||||
AppType::Codex => settings.current_provider_codex = 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::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)
|
update_settings(settings)
|
||||||
|
|||||||
@@ -13,13 +13,21 @@ pub async fn execute_usage_script(
|
|||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<Value, AppError> {
|
) -> Result<Value, AppError> {
|
||||||
|
// 检测是否为自定义模板模式
|
||||||
|
// 优先使用前端传递的 template_type
|
||||||
|
let is_custom_template = template_type.map(|t| t == "custom").unwrap_or(false);
|
||||||
|
|
||||||
// 1. 替换模板变量,避免泄露敏感信息
|
// 1. 替换模板变量,避免泄露敏感信息
|
||||||
let script_with_vars =
|
let script_with_vars =
|
||||||
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
|
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
|
||||||
|
|
||||||
// 2. 验证 base_url 的安全性
|
// 2. 验证 base_url 的安全性(仅当提供了 base_url 时)
|
||||||
validate_base_url(base_url)?;
|
// 自定义模板模式下,用户可能不使用模板变量,而是直接在脚本中写完整 URL
|
||||||
|
if !base_url.is_empty() {
|
||||||
|
validate_base_url(base_url)?;
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
||||||
let request_config = {
|
let request_config = {
|
||||||
@@ -97,7 +105,8 @@ pub async fn execute_usage_script(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 5. 验证请求 URL 是否安全(防止 SSRF)
|
// 5. 验证请求 URL 是否安全(防止 SSRF)
|
||||||
validate_request_url(&request.url, base_url)?;
|
// 如果提供了 base_url,则验证同源;否则只做基本安全检查
|
||||||
|
validate_request_url(&request.url, base_url, is_custom_template)?;
|
||||||
|
|
||||||
// 6. 发送 HTTP 请求
|
// 6. 发送 HTTP 请求
|
||||||
let response_data = send_http_request(&request, timeout_secs).await?;
|
let response_data = send_http_request(&request, timeout_secs).await?;
|
||||||
@@ -472,7 +481,11 @@ fn validate_base_url(base_url: &str) -> Result<(), AppError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 验证请求 URL 是否安全(防止 SSRF)
|
/// 验证请求 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
|
// 解析请求 URL
|
||||||
let parsed_request = Url::parse(request_url).map_err(|e| {
|
let parsed_request = Url::parse(request_url).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
@@ -482,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);
|
let is_request_loopback = is_loopback_host(&parsed_request);
|
||||||
|
|
||||||
// 必须使用 HTTPS(允许 localhost 用于开发)
|
// 必须使用 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(
|
return Err(AppError::localized(
|
||||||
"usage_script.request_https_required",
|
"usage_script.request_https_required",
|
||||||
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
|
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
|
||||||
@@ -502,60 +507,85 @@ fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppErro
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
|
// 如果提供了 base_url(非空),则进行同源检查
|
||||||
if parsed_request.host_str() != parsed_base.host_str() {
|
// 🔧 自定义模板模式下,用户可以自由访问任意 HTTPS 域名,跳过同源检查
|
||||||
return Err(AppError::localized(
|
if !base_url.is_empty() && !is_custom_template {
|
||||||
"usage_script.request_host_mismatch",
|
// 解析 base URL
|
||||||
format!(
|
let parsed_base = Url::parse(base_url).map_err(|e| {
|
||||||
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
|
AppError::localized(
|
||||||
parsed_request.host_str().unwrap_or("unknown"),
|
"usage_script.base_url_invalid",
|
||||||
parsed_base.host_str().unwrap_or("unknown")
|
format!("无效的 base_url: {e}"),
|
||||||
),
|
format!("Invalid base_url: {e}"),
|
||||||
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 同源(相同域名和端口)
|
||||||
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443)
|
if parsed_request.host_str() != parsed_base.host_str() {
|
||||||
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(
|
return Err(AppError::localized(
|
||||||
"usage_script.request_port_mismatch",
|
"usage_script.request_host_mismatch",
|
||||||
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
|
format!(
|
||||||
format!("Request port {request_port} must match base_url port {base_port}"),
|
"请求域名 {} 与 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(
|
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443)
|
||||||
"usage_script.request_port_unknown",
|
match (
|
||||||
"无法确定端口号",
|
parsed_request.port_or_known_default(),
|
||||||
"Unable to determine port number",
|
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 本身就是私有地址,用于开发环境)
|
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
|
||||||
if let Some(host) = parsed_request.host_str() {
|
if let Some(host) = parsed_request.host_str() {
|
||||||
let base_host = parsed_base.host_str().unwrap_or("");
|
let base_host = parsed_base.host_str().unwrap_or("");
|
||||||
|
|
||||||
// 如果 base_url 不是私有地址,则禁止访问私有IP
|
// 如果 base_url 不是私有地址,则禁止访问私有IP
|
||||||
if !is_private_ip(base_host) && is_private_ip(host) {
|
if !is_private_ip(base_host) && is_private_ip(host) {
|
||||||
return Err(AppError::localized(
|
return Err(AppError::localized(
|
||||||
"usage_script.private_ip_blocked",
|
"usage_script.private_ip_blocked",
|
||||||
"禁止访问私有 IP 地址",
|
"禁止访问私有 IP 地址",
|
||||||
"Access to private IP addresses is blocked",
|
"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)",
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,7 +873,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (base_url, request_url, should_match) in test_cases {
|
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 {
|
if should_match {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -856,7 +886,9 @@ mod tests {
|
|||||||
} else {
|
} else {
|
||||||
assert!(
|
assert!(
|
||||||
result.is_err(),
|
result.is_err(),
|
||||||
"应该不匹配的URL被允许: base_url={base_url}, request_url={request_url}"
|
"应该不匹配的URL被允许: base_url={}, request_url={}",
|
||||||
|
base_url,
|
||||||
|
request_url
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -553,6 +553,7 @@ command = "echo"
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false, // 初始未启用
|
codex: false, // 初始未启用
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
@@ -680,6 +681,7 @@ fn import_from_claude_merges_into_config() {
|
|||||||
claude: false, // 初始未启用
|
claude: false, // 初始未启用
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false, // 初始未启用
|
codex: false, // 初始未启用
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
@@ -277,6 +278,7 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() {
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
@@ -320,6 +322,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
|
|||||||
claude: true,
|
claude: true,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
@@ -352,6 +355,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
@@ -483,6 +487,7 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
@@ -536,6 +541,7 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ command = "say"
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: true, // 启用 Codex
|
codex: true, // 启用 Codex
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ command = "say"
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: true,
|
codex: true,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
|
|||||||
+84
-36
@@ -13,7 +13,6 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
Server,
|
Server,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Package,
|
|
||||||
Search,
|
Search,
|
||||||
Download,
|
Download,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -49,7 +48,6 @@ import { SkillsPage } from "@/components/skills/SkillsPage";
|
|||||||
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
|
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
|
||||||
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||||
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
||||||
import { TemplatesPage } from "@/components/templates/TemplatesPage";
|
|
||||||
import { UniversalProviderPanel } from "@/components/universal";
|
import { UniversalProviderPanel } from "@/components/universal";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
@@ -60,7 +58,6 @@ type View =
|
|||||||
| "skills"
|
| "skills"
|
||||||
| "skillsDiscovery"
|
| "skillsDiscovery"
|
||||||
| "mcp"
|
| "mcp"
|
||||||
| "templates"
|
|
||||||
| "agents"
|
| "agents"
|
||||||
| "universal";
|
| "universal";
|
||||||
|
|
||||||
@@ -80,7 +77,11 @@ function App() {
|
|||||||
|
|
||||||
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
||||||
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
|
// 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 [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
||||||
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
||||||
|
|
||||||
@@ -325,11 +326,46 @@ function App() {
|
|||||||
setEditingProvider(null);
|
setEditingProvider(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 确认删除供应商
|
// 确认删除/移除供应商
|
||||||
const handleConfirmDelete = async () => {
|
const handleConfirmAction = async () => {
|
||||||
if (!confirmDelete) return;
|
if (!confirmAction) return;
|
||||||
await deleteProvider(confirmDelete.id);
|
const { provider, action } = confirmAction;
|
||||||
setConfirmDelete(null);
|
|
||||||
|
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}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 复制供应商
|
// 复制供应商
|
||||||
@@ -338,7 +374,7 @@ function App() {
|
|||||||
const newSortIndex =
|
const newSortIndex =
|
||||||
provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined;
|
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`,
|
name: `${provider.name} copy`,
|
||||||
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
|
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
|
||||||
websiteUrl: provider.websiteUrl,
|
websiteUrl: provider.websiteUrl,
|
||||||
@@ -351,6 +387,12 @@ function App() {
|
|||||||
iconColor: provider.iconColor,
|
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
|
// 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1
|
||||||
if (provider.sortIndex !== undefined) {
|
if (provider.sortIndex !== undefined) {
|
||||||
const updates = Object.values(providers)
|
const updates = Object.values(providers)
|
||||||
@@ -457,7 +499,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "skillsDiscovery":
|
case "skillsDiscovery":
|
||||||
return <SkillsPage ref={skillsPageRef} initialApp={activeApp} />;
|
return <SkillsPage ref={skillsPageRef} initialApp={activeApp === "opencode" ? "claude" : activeApp} />;
|
||||||
case "mcp":
|
case "mcp":
|
||||||
return (
|
return (
|
||||||
<UnifiedMcpPanel
|
<UnifiedMcpPanel
|
||||||
@@ -471,15 +513,13 @@ function App() {
|
|||||||
);
|
);
|
||||||
case "universal":
|
case "universal":
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[56rem] px-5 pt-4">
|
<div className="px-6 pt-4">
|
||||||
<UniversalProviderPanel />
|
<UniversalProviderPanel />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case "templates":
|
|
||||||
return <TemplatesPage activeApp={activeApp} />;
|
|
||||||
default:
|
default:
|
||||||
return (
|
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 与滚轮事件冲突 */}
|
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
@@ -503,7 +543,15 @@ function App() {
|
|||||||
activeProviderId={activeProviderId}
|
activeProviderId={activeProviderId}
|
||||||
onSwitch={switchProvider}
|
onSwitch={switchProvider}
|
||||||
onEdit={setEditingProvider}
|
onEdit={setEditingProvider}
|
||||||
onDelete={setConfirmDelete}
|
onDelete={(provider) =>
|
||||||
|
setConfirmAction({ provider, action: "delete" })
|
||||||
|
}
|
||||||
|
onRemoveFromConfig={
|
||||||
|
activeApp === "opencode"
|
||||||
|
? (provider) =>
|
||||||
|
setConfirmAction({ provider, action: "remove" })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onDuplicate={handleDuplicateProvider}
|
onDuplicate={handleDuplicateProvider}
|
||||||
onConfigureUsage={setUsageProvider}
|
onConfigureUsage={setUsageProvider}
|
||||||
onOpenWebsite={handleOpenWebsite}
|
onOpenWebsite={handleOpenWebsite}
|
||||||
@@ -585,7 +633,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<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
|
data-tauri-drag-region
|
||||||
style={{ WebkitAppRegion: "drag" } as any}
|
style={{ WebkitAppRegion: "drag" } as any}
|
||||||
>
|
>
|
||||||
@@ -617,7 +665,6 @@ function App() {
|
|||||||
{currentView === "skillsDiscovery" && t("skills.title")}
|
{currentView === "skillsDiscovery" && t("skills.title")}
|
||||||
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
|
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
|
||||||
{currentView === "agents" && t("agents.title")}
|
{currentView === "agents" && t("agents.title")}
|
||||||
{currentView === "templates" && t("templates.title")}
|
|
||||||
{currentView === "universal" &&
|
{currentView === "universal" &&
|
||||||
t("universalProvider.title", {
|
t("universalProvider.title", {
|
||||||
defaultValue: "统一供应商",
|
defaultValue: "统一供应商",
|
||||||
@@ -746,7 +793,9 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
{currentView === "providers" && (
|
{currentView === "providers" && (
|
||||||
<>
|
<>
|
||||||
<ProxyToggle activeApp={activeApp} />
|
{activeApp !== "opencode" && (
|
||||||
|
<ProxyToggle activeApp={activeApp} />
|
||||||
|
)}
|
||||||
|
|
||||||
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
||||||
|
|
||||||
@@ -796,15 +845,6 @@ function App() {
|
|||||||
>
|
>
|
||||||
<Server className="w-4 h-4" />
|
<Server className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentView("templates")}
|
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
|
||||||
title={t("templates.title")}
|
|
||||||
>
|
|
||||||
<Package className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -860,17 +900,25 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={Boolean(confirmDelete)}
|
isOpen={Boolean(confirmAction)}
|
||||||
title={t("confirm.deleteProvider")}
|
title={
|
||||||
|
confirmAction?.action === "remove"
|
||||||
|
? t("confirm.removeProvider")
|
||||||
|
: t("confirm.deleteProvider")
|
||||||
|
}
|
||||||
message={
|
message={
|
||||||
confirmDelete
|
confirmAction
|
||||||
? t("confirm.deleteProviderMessage", {
|
? confirmAction.action === "remove"
|
||||||
name: confirmDelete.name,
|
? t("confirm.removeProviderMessage", {
|
||||||
})
|
name: confirmAction.provider.name,
|
||||||
|
})
|
||||||
|
: t("confirm.deleteProviderMessage", {
|
||||||
|
name: confirmAction.provider.name,
|
||||||
|
})
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
onConfirm={() => void handleConfirmDelete()}
|
onConfirm={() => void handleConfirmAction()}
|
||||||
onCancel={() => setConfirmDelete(null)}
|
onCancel={() => setConfirmAction(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DeepLinkImportDialog />
|
<DeepLinkImportDialog />
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
claude: "claude",
|
claude: "claude",
|
||||||
codex: "openai",
|
codex: "openai",
|
||||||
gemini: "gemini",
|
gemini: "gemini",
|
||||||
|
opencode: "opencode",
|
||||||
};
|
};
|
||||||
const appDisplayName: Record<AppId, string> = {
|
const appDisplayName: Record<AppId, string> = {
|
||||||
claude: "Claude",
|
claude: "Claude",
|
||||||
codex: "Codex",
|
codex: "Codex",
|
||||||
gemini: "Gemini",
|
gemini: "Gemini",
|
||||||
|
opencode: "OpenCode",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -90,6 +92,28 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
/>
|
/>
|
||||||
<span>{appDisplayName.gemini}</span>
|
<span>{appDisplayName.gemini}</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface UsageFooterProps {
|
|||||||
appId: AppId;
|
appId: AppId;
|
||||||
usageEnabled: boolean; // 是否启用了用量查询
|
usageEnabled: boolean; // 是否启用了用量查询
|
||||||
isCurrent: boolean; // 是否为当前激活的供应商
|
isCurrent: boolean; // 是否为当前激活的供应商
|
||||||
|
isInConfig?: boolean; // OpenCode: 是否已添加到配置
|
||||||
inline?: boolean; // 是否内联显示(在按钮左侧)
|
inline?: boolean; // 是否内联显示(在按钮左侧)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,12 +21,15 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
appId,
|
appId,
|
||||||
usageEnabled,
|
usageEnabled,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
|
isInConfig = false,
|
||||||
inline = false,
|
inline = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// 统一的用量查询(自动查询仅对当前激活的供应商启用)
|
// 统一的用量查询(自动查询仅对当前激活的供应商启用)
|
||||||
const autoQueryInterval = isCurrent
|
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent
|
||||||
|
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
|
||||||
|
const autoQueryInterval = shouldAutoQuery
|
||||||
? provider.meta?.usage_script?.autoQueryInterval || 0
|
? provider.meta?.usage_script?.autoQueryInterval || 0
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import React, { useState } from "react";
|
|||||||
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
|
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Provider, UsageScript, UsageData } from "@/types";
|
import { Provider, UsageScript, UsageData } from "@/types";
|
||||||
import { usageApi, type AppId } from "@/lib/api";
|
import { usageApi, type AppId } from "@/lib/api";
|
||||||
|
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
|
||||||
import JsonEditor from "./JsonEditor";
|
import JsonEditor from "./JsonEditor";
|
||||||
import * as prettier from "prettier/standalone";
|
import * as prettier from "prettier/standalone";
|
||||||
import * as parserBabel from "prettier/parser-babel";
|
import * as parserBabel from "prettier/parser-babel";
|
||||||
@@ -109,19 +111,67 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
onSave,
|
onSave,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// 生成带国际化的预设模板
|
// 生成带国际化的预设模板
|
||||||
const PRESET_TEMPLATES = generatePresetTemplates(t);
|
const PRESET_TEMPLATES = generatePresetTemplates(t);
|
||||||
|
|
||||||
const [script, setScript] = useState<UsageScript>(() => {
|
// 从 provider 的 settingsConfig 中提取 API Key 和 Base URL
|
||||||
return (
|
const getProviderCredentials = (): {
|
||||||
provider.meta?.usage_script || {
|
apiKey: string | undefined;
|
||||||
enabled: false,
|
baseUrl: string | undefined;
|
||||||
language: "javascript",
|
} => {
|
||||||
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
|
try {
|
||||||
timeout: 10,
|
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);
|
const [testing, setTesting] = useState(false);
|
||||||
@@ -176,6 +226,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
|
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
|
||||||
() => {
|
() => {
|
||||||
const existingScript = provider.meta?.usage_script;
|
const existingScript = provider.meta?.usage_script;
|
||||||
|
// 优先使用保存的 templateType
|
||||||
|
if (existingScript?.templateType) {
|
||||||
|
return existingScript.templateType;
|
||||||
|
}
|
||||||
|
// 向后兼容:根据字段推断模板类型
|
||||||
// 检测 NEW_API 模板(有 accessToken 或 userId)
|
// 检测 NEW_API 模板(有 accessToken 或 userId)
|
||||||
if (existingScript?.accessToken || existingScript?.userId) {
|
if (existingScript?.accessToken || existingScript?.userId) {
|
||||||
return TEMPLATE_KEYS.NEW_API;
|
return TEMPLATE_KEYS.NEW_API;
|
||||||
@@ -201,7 +256,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
|
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSave(script);
|
// 保存时记录当前选择的模板类型
|
||||||
|
const scriptWithTemplate = {
|
||||||
|
...script,
|
||||||
|
templateType: selectedTemplate as
|
||||||
|
| "custom"
|
||||||
|
| "general"
|
||||||
|
| "newapi"
|
||||||
|
| undefined,
|
||||||
|
};
|
||||||
|
onSave(scriptWithTemplate);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -217,6 +281,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
script.baseUrl,
|
script.baseUrl,
|
||||||
script.accessToken,
|
script.accessToken,
|
||||||
script.userId,
|
script.userId,
|
||||||
|
selectedTemplate as "custom" | "general" | "newapi" | undefined,
|
||||||
);
|
);
|
||||||
if (result.success && result.data && result.data.length > 0) {
|
if (result.success && result.data && result.data.length > 0) {
|
||||||
const summary = result.data
|
const summary = result.data
|
||||||
@@ -229,6 +294,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
duration: 3000,
|
duration: 3000,
|
||||||
closeButton: true,
|
closeButton: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔧 测试成功后,更新主界面列表的用量查询缓存
|
||||||
|
queryClient.setQueryData(["usage", provider.id, appId], result);
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
|
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
|
||||||
@@ -278,9 +346,13 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
const preset = PRESET_TEMPLATES[presetName];
|
const preset = PRESET_TEMPLATES[presetName];
|
||||||
if (preset) {
|
if (preset) {
|
||||||
if (presetName === TEMPLATE_KEYS.CUSTOM) {
|
if (presetName === TEMPLATE_KEYS.CUSTOM) {
|
||||||
|
// 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换
|
||||||
|
// 这样可以避免同源检查导致的问题
|
||||||
|
// 如果用户想使用变量,需要手动在配置中设置 baseUrl/apiKey
|
||||||
setScript({
|
setScript({
|
||||||
...script,
|
...script,
|
||||||
code: preset,
|
code: preset,
|
||||||
|
// 清除凭证,用户可选择手动输入或保持空
|
||||||
apiKey: undefined,
|
apiKey: undefined,
|
||||||
baseUrl: undefined,
|
baseUrl: undefined,
|
||||||
accessToken: undefined,
|
accessToken: undefined,
|
||||||
@@ -401,6 +473,74 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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 && (
|
{shouldShowCredentialsConfig && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -601,11 +741,13 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={1440}
|
max={1440}
|
||||||
value={script.autoIntervalMinutes ?? 0}
|
value={
|
||||||
|
script.autoQueryInterval ?? script.autoIntervalMinutes ?? 0
|
||||||
|
}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setScript({
|
setScript({
|
||||||
...script,
|
...script,
|
||||||
autoIntervalMinutes: validateAndClampInterval(
|
autoQueryInterval: validateAndClampInterval(
|
||||||
e.target.value,
|
e.target.value,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -613,7 +755,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
onBlur={(e) =>
|
onBlur={(e) =>
|
||||||
setScript({
|
setScript({
|
||||||
...script,
|
...script,
|
||||||
autoIntervalMinutes: validateAndClampInterval(
|
autoQueryInterval: validateAndClampInterval(
|
||||||
e.target.value,
|
e.target.value,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<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
|
data-tauri-drag-region
|
||||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
@@ -94,7 +94,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto scroll-overlay">
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +105,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
|||||||
className="flex-shrink-0 py-4 border-t border-border-default"
|
className="flex-shrink-0 py-4 border-t border-border-default"
|
||||||
style={{ backgroundColor: "hsl(var(--background))" }}
|
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}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
claude: boolean;
|
claude: boolean;
|
||||||
codex: boolean;
|
codex: boolean;
|
||||||
gemini: boolean;
|
gemini: boolean;
|
||||||
|
opencode: boolean;
|
||||||
}>(() => {
|
}>(() => {
|
||||||
if (initialData?.apps) {
|
if (initialData?.apps) {
|
||||||
return { ...initialData.apps };
|
return { ...initialData.apps };
|
||||||
@@ -73,6 +74,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
claude: defaultEnabledApps.includes("claude"),
|
claude: defaultEnabledApps.includes("claude"),
|
||||||
codex: defaultEnabledApps.includes("codex"),
|
codex: defaultEnabledApps.includes("codex"),
|
||||||
gemini: defaultEnabledApps.includes("gemini"),
|
gemini: defaultEnabledApps.includes("gemini"),
|
||||||
|
opencode: defaultEnabledApps.includes("opencode"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -59,11 +59,12 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
|
|
||||||
// Count enabled servers per app
|
// Count enabled servers per app
|
||||||
const enabledCounts = useMemo(() => {
|
const enabledCounts = useMemo(() => {
|
||||||
const counts = { claude: 0, codex: 0, gemini: 0 };
|
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
|
||||||
serverEntries.forEach(([_, server]) => {
|
serverEntries.forEach(([_, server]) => {
|
||||||
if (server.apps.claude) counts.claude++;
|
if (server.apps.claude) counts.claude++;
|
||||||
if (server.apps.codex) counts.codex++;
|
if (server.apps.codex) counts.codex++;
|
||||||
if (server.apps.gemini) counts.gemini++;
|
if (server.apps.gemini) counts.gemini++;
|
||||||
|
if (server.apps.opencode) counts.opencode++;
|
||||||
});
|
});
|
||||||
return counts;
|
return counts;
|
||||||
}, [serverEntries]);
|
}, [serverEntries]);
|
||||||
@@ -141,14 +142,15 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Info Section */}
|
||||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 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">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
|
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
|
||||||
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
|
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
|
||||||
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -337,6 +339,22 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 右侧:操作按钮 */}
|
{/* 右侧:操作按钮 */}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
|
|||||||
claude: "CLAUDE.md",
|
claude: "CLAUDE.md",
|
||||||
codex: "AGENTS.md",
|
codex: "AGENTS.md",
|
||||||
gemini: "GEMINI.md",
|
gemini: "GEMINI.md",
|
||||||
|
opencode: "AGENTS.md",
|
||||||
};
|
};
|
||||||
const filename = filenameMap[appId];
|
const filename = filenameMap[appId];
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
|||||||
claude: "CLAUDE.md",
|
claude: "CLAUDE.md",
|
||||||
codex: "AGENTS.md",
|
codex: "AGENTS.md",
|
||||||
gemini: "GEMINI.md",
|
gemini: "GEMINI.md",
|
||||||
|
opencode: "AGENTS.md",
|
||||||
};
|
};
|
||||||
const filename = filenameMap[appId];
|
const filename = filenameMap[appId];
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
|||||||
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
|
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
|
||||||
|
|
||||||
return (
|
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="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t("prompts.count", { count: promptEntries.length })} ·{" "}
|
{t("prompts.count", { count: promptEntries.length })} ·{" "}
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ import { UniversalProviderPanel } from "@/components/universal";
|
|||||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||||
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||||
|
// Note: opencodeProviderPresets is loaded via ProviderForm, not needed here
|
||||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||||
|
|
||||||
interface AddProviderDialogProps {
|
interface AddProviderDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
onSubmit: (provider: Omit<Provider, "id">) => Promise<void> | void;
|
onSubmit: (provider: Omit<Provider, "id"> & { providerKey?: string }) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddProviderDialog({
|
export function AddProviderDialog({
|
||||||
@@ -33,6 +34,8 @@ export function AddProviderDialog({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
}: AddProviderDialogProps) {
|
}: AddProviderDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// OpenCode doesn't support universal providers
|
||||||
|
const showUniversalTab = appId !== "opencode";
|
||||||
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
||||||
"app-specific",
|
"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(),
|
name: values.name.trim(),
|
||||||
notes: values.notes?.trim() || undefined,
|
notes: values.notes?.trim() || undefined,
|
||||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||||
@@ -93,6 +96,11 @@ export function AddProviderDialog({
|
|||||||
...(values.meta ? { meta: values.meta } : {}),
|
...(values.meta ? { meta: values.meta } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// OpenCode: pass providerKey for ID generation
|
||||||
|
if (appId === "opencode" && values.providerKey) {
|
||||||
|
providerData.providerKey = values.providerKey;
|
||||||
|
}
|
||||||
|
|
||||||
const hasCustomEndpoints =
|
const hasCustomEndpoints =
|
||||||
providerData.meta?.custom_endpoints &&
|
providerData.meta?.custom_endpoints &&
|
||||||
Object.keys(providerData.meta.custom_endpoints).length > 0;
|
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") {
|
if (appId === "claude") {
|
||||||
@@ -175,6 +184,12 @@ export function AddProviderDialog({
|
|||||||
if (env?.GOOGLE_GEMINI_BASE_URL) {
|
if (env?.GOOGLE_GEMINI_BASE_URL) {
|
||||||
addUrl(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);
|
const urls = Array.from(urlSet);
|
||||||
@@ -204,7 +219,7 @@ export function AddProviderDialog({
|
|||||||
|
|
||||||
// 动态 footer:根据当前 Tab 显示不同按钮
|
// 动态 footer:根据当前 Tab 显示不同按钮
|
||||||
const footer =
|
const footer =
|
||||||
activeTab === "app-specific" ? (
|
!showUniversalTab || activeTab === "app-specific" ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -248,41 +263,54 @@ export function AddProviderDialog({
|
|||||||
onClose={() => onOpenChange(false)}
|
onClose={() => onOpenChange(false)}
|
||||||
footer={footer}
|
footer={footer}
|
||||||
>
|
>
|
||||||
<Tabs
|
{showUniversalTab ? (
|
||||||
value={activeTab}
|
<Tabs
|
||||||
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
|
value={activeTab}
|
||||||
>
|
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
|
||||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
>
|
||||||
<TabsTrigger value="app-specific">
|
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||||
{t(`apps.${appId}`)} {t("provider.tabProvider")}
|
<TabsTrigger value="app-specific">
|
||||||
</TabsTrigger>
|
{t(`apps.${appId}`)} {t("provider.tabProvider")}
|
||||||
<TabsTrigger value="universal">
|
</TabsTrigger>
|
||||||
{t("provider.tabUniversal")}
|
<TabsTrigger value="universal">
|
||||||
</TabsTrigger>
|
{t("provider.tabUniversal")}
|
||||||
</TabsList>
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="app-specific" className="mt-0">
|
<TabsContent value="app-specific" className="mt-0">
|
||||||
<ProviderForm
|
<ProviderForm
|
||||||
appId={appId}
|
appId={appId}
|
||||||
submitLabel={t("common.add")}
|
submitLabel={t("common.add")}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => onOpenChange(false)}
|
onCancel={() => onOpenChange(false)}
|
||||||
showButtons={false}
|
showButtons={false}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="universal" className="mt-0">
|
<TabsContent value="universal" className="mt-0">
|
||||||
<UniversalProviderPanel />
|
<UniversalProviderPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</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 */}
|
{/* Universal Provider Form Modal */}
|
||||||
<UniversalProviderFormModal
|
{showUniversalTab && (
|
||||||
isOpen={universalFormOpen}
|
<UniversalProviderFormModal
|
||||||
onClose={handleUniversalFormClose}
|
isOpen={universalFormOpen}
|
||||||
onSave={handleUniversalProviderSave}
|
onClose={handleUniversalFormClose}
|
||||||
initialPreset={selectedUniversalPreset}
|
onSave={handleUniversalProviderSave}
|
||||||
/>
|
initialPreset={selectedUniversalPreset}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</FullScreenPanel>
|
</FullScreenPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ export function EditProviderDialog({
|
|||||||
return;
|
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 {
|
try {
|
||||||
const currentId = await providersApi.getCurrent(appId);
|
const currentId = await providersApi.getCurrent(appId);
|
||||||
if (currentId && provider.id === currentId) {
|
if (currentId && provider.id === currentId) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Edit,
|
Edit,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Minus,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -13,9 +14,13 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { AppId } from "@/lib/api";
|
||||||
|
|
||||||
interface ProviderActionsProps {
|
interface ProviderActionsProps {
|
||||||
|
appId?: AppId;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
|
/** OpenCode: 是否已添加到配置 */
|
||||||
|
isInConfig?: boolean;
|
||||||
isTesting?: boolean;
|
isTesting?: boolean;
|
||||||
isProxyTakeover?: boolean;
|
isProxyTakeover?: boolean;
|
||||||
onSwitch: () => void;
|
onSwitch: () => void;
|
||||||
@@ -24,6 +29,8 @@ interface ProviderActionsProps {
|
|||||||
onTest?: () => void;
|
onTest?: () => void;
|
||||||
onConfigureUsage: () => void;
|
onConfigureUsage: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
/** OpenCode: remove from live config (not delete from database) */
|
||||||
|
onRemoveFromConfig?: () => void;
|
||||||
onOpenTerminal?: () => void;
|
onOpenTerminal?: () => void;
|
||||||
// 故障转移相关
|
// 故障转移相关
|
||||||
isAutoFailoverEnabled?: boolean;
|
isAutoFailoverEnabled?: boolean;
|
||||||
@@ -32,7 +39,9 @@ interface ProviderActionsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderActions({
|
export function ProviderActions({
|
||||||
|
appId,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
|
isInConfig = false,
|
||||||
isTesting,
|
isTesting,
|
||||||
isProxyTakeover = false,
|
isProxyTakeover = false,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
@@ -41,6 +50,7 @@ export function ProviderActions({
|
|||||||
onTest,
|
onTest,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onRemoveFromConfig,
|
||||||
onOpenTerminal,
|
onOpenTerminal,
|
||||||
// 故障转移相关
|
// 故障转移相关
|
||||||
isAutoFailoverEnabled = false,
|
isAutoFailoverEnabled = false,
|
||||||
@@ -50,12 +60,27 @@ export function ProviderActions({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const iconButtonClass = "h-8 w-8 p-1";
|
const iconButtonClass = "h-8 w-8 p-1";
|
||||||
|
|
||||||
// 故障转移模式下的按钮逻辑
|
// OpenCode 使用累加模式
|
||||||
const isFailoverMode = isAutoFailoverEnabled && onToggleFailover;
|
const isOpenCodeMode = appId === "opencode";
|
||||||
|
|
||||||
|
// 故障转移模式下的按钮逻辑(OpenCode 不支持故障转移)
|
||||||
|
const isFailoverMode = !isOpenCodeMode && isAutoFailoverEnabled && onToggleFailover;
|
||||||
|
|
||||||
// 处理主按钮点击
|
// 处理主按钮点击
|
||||||
const handleMainButtonClick = () => {
|
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);
|
onToggleFailover(!isInFailoverQueue);
|
||||||
} else {
|
} else {
|
||||||
@@ -66,8 +91,30 @@ export function ProviderActions({
|
|||||||
|
|
||||||
// 主按钮的状态和样式
|
// 主按钮的状态和样式
|
||||||
const getMainButtonState = () => {
|
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 (isFailoverMode) {
|
||||||
// 故障转移模式
|
|
||||||
if (isInFailoverQueue) {
|
if (isInFailoverQueue) {
|
||||||
return {
|
return {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
@@ -113,6 +160,9 @@ export function ProviderActions({
|
|||||||
|
|
||||||
const buttonState = getMainButtonState();
|
const buttonState = getMainButtonState();
|
||||||
|
|
||||||
|
// OpenCode 模式下删除按钮始终可用(主按钮"移除"是从 live 配置移除,删除是从数据库删除)
|
||||||
|
const canDelete = isOpenCodeMode ? true : !isCurrent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
@@ -192,12 +242,12 @@ export function ProviderActions({
|
|||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={isCurrent ? undefined : onDelete}
|
onClick={canDelete ? onDelete : undefined}
|
||||||
title={t("common.delete")}
|
title={t("common.delete")}
|
||||||
className={cn(
|
className={cn(
|
||||||
iconButtonClass,
|
iconButtonClass,
|
||||||
!isCurrent && "hover:text-red-500 dark:hover:text-red-400",
|
canDelete && "hover:text-red-500 dark:hover:text-red-400",
|
||||||
isCurrent && "opacity-40 cursor-not-allowed text-muted-foreground",
|
!canDelete && "opacity-40 cursor-not-allowed text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
|||||||
@@ -26,9 +26,12 @@ interface ProviderCardProps {
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
|
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
|
||||||
onSwitch: (provider: Provider) => void;
|
onSwitch: (provider: Provider) => void;
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void;
|
||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
|
/** OpenCode: remove from live config (not delete from database) */
|
||||||
|
onRemoveFromConfig?: (provider: Provider) => void;
|
||||||
onConfigureUsage: (provider: Provider) => void;
|
onConfigureUsage: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
@@ -85,9 +88,11 @@ export function ProviderCard({
|
|||||||
provider,
|
provider,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
appId,
|
appId,
|
||||||
|
isInConfig = true,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onRemoveFromConfig,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
@@ -134,7 +139,9 @@ export function ProviderCard({
|
|||||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
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
|
? provider.meta?.usage_script?.autoQueryInterval || 0
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
@@ -182,12 +189,16 @@ export function ProviderCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 判断是否是"当前使用中"的供应商
|
// 判断是否是"当前使用中"的供应商
|
||||||
|
// - OpenCode(累加模式):不存在"当前"概念,始终返回 false
|
||||||
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||||||
// - 代理接管模式(非故障转移):isCurrent
|
// - 代理接管模式(非故障转移):isCurrent
|
||||||
// - 普通模式:isCurrent
|
// - 普通模式:isCurrent
|
||||||
const isActiveProvider = isAutoFailoverEnabled
|
const isActiveProvider =
|
||||||
? activeProviderId === provider.id
|
appId === "opencode"
|
||||||
: isCurrent;
|
? false
|
||||||
|
: isAutoFailoverEnabled
|
||||||
|
? activeProviderId === provider.id
|
||||||
|
: isCurrent;
|
||||||
|
|
||||||
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
|
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
|
||||||
const shouldUseGreen = isProxyTakeover && isActiveProvider;
|
const shouldUseGreen = isProxyTakeover && isActiveProvider;
|
||||||
@@ -327,6 +338,7 @@ export function ProviderCard({
|
|||||||
appId={appId}
|
appId={appId}
|
||||||
usageEnabled={usageEnabled}
|
usageEnabled={usageEnabled}
|
||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
|
isInConfig={isInConfig}
|
||||||
inline={true}
|
inline={true}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -360,7 +372,9 @@ export function ProviderCard({
|
|||||||
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"
|
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
|
<ProviderActions
|
||||||
|
appId={appId}
|
||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
|
isInConfig={isInConfig}
|
||||||
isTesting={isTesting}
|
isTesting={isTesting}
|
||||||
isProxyTakeover={isProxyTakeover}
|
isProxyTakeover={isProxyTakeover}
|
||||||
onSwitch={() => onSwitch(provider)}
|
onSwitch={() => onSwitch(provider)}
|
||||||
@@ -369,6 +383,11 @@ export function ProviderCard({
|
|||||||
onTest={onTest ? () => onTest(provider) : undefined}
|
onTest={onTest ? () => onTest(provider) : undefined}
|
||||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||||
onDelete={() => onDelete(provider)}
|
onDelete={() => onDelete(provider)}
|
||||||
|
onRemoveFromConfig={
|
||||||
|
onRemoveFromConfig
|
||||||
|
? () => onRemoveFromConfig(provider)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onOpenTerminal={
|
onOpenTerminal={
|
||||||
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
|
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
|
||||||
}
|
}
|
||||||
@@ -390,6 +409,7 @@ export function ProviderCard({
|
|||||||
appId={appId}
|
appId={appId}
|
||||||
usageEnabled={usageEnabled}
|
usageEnabled={usageEnabled}
|
||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
|
isInConfig={isInConfig}
|
||||||
inline={false}
|
inline={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import {
|
|||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { Provider } from "@/types";
|
import type { Provider } from "@/types";
|
||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
|
import { providersApi } from "@/lib/api/providers";
|
||||||
import { useDragSort } from "@/hooks/useDragSort";
|
import { useDragSort } from "@/hooks/useDragSort";
|
||||||
import { useStreamCheck } from "@/hooks/useStreamCheck";
|
import { useStreamCheck } from "@/hooks/useStreamCheck";
|
||||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||||
@@ -38,6 +40,8 @@ interface ProviderListProps {
|
|||||||
onSwitch: (provider: Provider) => void;
|
onSwitch: (provider: Provider) => void;
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void;
|
||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
|
/** OpenCode: remove from live config (not delete from database) */
|
||||||
|
onRemoveFromConfig?: (provider: Provider) => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onConfigureUsage?: (provider: Provider) => void;
|
onConfigureUsage?: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
@@ -56,6 +60,7 @@ export function ProviderList({
|
|||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onRemoveFromConfig,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
@@ -72,6 +77,22 @@ export function ProviderList({
|
|||||||
appId,
|
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);
|
const { checkProvider, isChecking } = useStreamCheck(appId);
|
||||||
|
|
||||||
@@ -199,14 +220,16 @@ export function ProviderList({
|
|||||||
provider={provider}
|
provider={provider}
|
||||||
isCurrent={provider.id === currentProviderId}
|
isCurrent={provider.id === currentProviderId}
|
||||||
appId={appId}
|
appId={appId}
|
||||||
|
isInConfig={isProviderInConfig(provider.id)}
|
||||||
onSwitch={onSwitch}
|
onSwitch={onSwitch}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
onRemoveFromConfig={onRemoveFromConfig}
|
||||||
onDuplicate={onDuplicate}
|
onDuplicate={onDuplicate}
|
||||||
onConfigureUsage={onConfigureUsage}
|
onConfigureUsage={onConfigureUsage}
|
||||||
onOpenWebsite={onOpenWebsite}
|
onOpenWebsite={onOpenWebsite}
|
||||||
onOpenTerminal={onOpenTerminal}
|
onOpenTerminal={onOpenTerminal}
|
||||||
onTest={handleTest}
|
onTest={appId !== "opencode" ? handleTest : undefined}
|
||||||
isTesting={isChecking(provider.id)}
|
isTesting={isChecking(provider.id)}
|
||||||
isProxyRunning={isProxyRunning}
|
isProxyRunning={isProxyRunning}
|
||||||
isProxyTakeover={isProxyTakeover}
|
isProxyTakeover={isProxyTakeover}
|
||||||
@@ -308,14 +331,17 @@ interface SortableProviderCardProps {
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
|
isInConfig: boolean;
|
||||||
onSwitch: (provider: Provider) => void;
|
onSwitch: (provider: Provider) => void;
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void;
|
||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
|
/** OpenCode: remove from live config (not delete from database) */
|
||||||
|
onRemoveFromConfig?: (provider: Provider) => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onConfigureUsage?: (provider: Provider) => void;
|
onConfigureUsage?: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
onOpenTerminal?: (provider: Provider) => void;
|
onOpenTerminal?: (provider: Provider) => void;
|
||||||
onTest: (provider: Provider) => void;
|
onTest?: (provider: Provider) => void;
|
||||||
isTesting: boolean;
|
isTesting: boolean;
|
||||||
isProxyRunning: boolean;
|
isProxyRunning: boolean;
|
||||||
isProxyTakeover: boolean;
|
isProxyTakeover: boolean;
|
||||||
@@ -331,9 +357,11 @@ function SortableProviderCard({
|
|||||||
provider,
|
provider,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
appId,
|
appId,
|
||||||
|
isInConfig,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onRemoveFromConfig,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
@@ -368,9 +396,11 @@ function SortableProviderCard({
|
|||||||
provider={provider}
|
provider={provider}
|
||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
appId={appId}
|
appId={appId}
|
||||||
|
isInConfig={isInConfig}
|
||||||
onSwitch={onSwitch}
|
onSwitch={onSwitch}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
onRemoveFromConfig={onRemoveFromConfig}
|
||||||
onDuplicate={onDuplicate}
|
onDuplicate={onDuplicate}
|
||||||
onConfigureUsage={
|
onConfigureUsage={
|
||||||
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
@@ -24,9 +25,11 @@ import type { ProviderFormData } from "@/lib/schemas/provider";
|
|||||||
|
|
||||||
interface BasicFormFieldsProps {
|
interface BasicFormFieldsProps {
|
||||||
form: UseFormReturn<ProviderFormData>;
|
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 { t } = useTranslation();
|
||||||
const [iconDialogOpen, setIconDialogOpen] = useState(false);
|
const [iconDialogOpen, setIconDialogOpen] = useState(false);
|
||||||
|
|
||||||
@@ -78,7 +81,7 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
|||||||
>
|
>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex-shrink-0 py-4 border-b border-border-default bg-muted/40">
|
<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>
|
<DialogClose asChild>
|
||||||
<Button type="button" variant="outline" size="icon">
|
<Button type="button" variant="outline" size="icon">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
@@ -92,7 +95,7 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<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
|
<IconPicker
|
||||||
value={currentIcon}
|
value={currentIcon}
|
||||||
onValueChange={handleIconSelect}
|
onValueChange={handleIconSelect}
|
||||||
@@ -112,6 +115,9 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Slot for additional fields between icon and name */}
|
||||||
|
{beforeNameSlot}
|
||||||
|
|
||||||
{/* 基础信息 - 网格布局 */}
|
{/* 基础信息 - 网格布局 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
|||||||
import type { CustomEndpoint, EndpointCandidate } from "@/types";
|
import type { CustomEndpoint, EndpointCandidate } from "@/types";
|
||||||
|
|
||||||
// 端点测速超时配置(秒)
|
// 端点测速超时配置(秒)
|
||||||
const ENDPOINT_TIMEOUT_SECS = {
|
const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
|
||||||
codex: 12,
|
codex: 12,
|
||||||
claude: 8,
|
claude: 8,
|
||||||
gemini: 8, // 新增 gemini
|
gemini: 8,
|
||||||
} as const;
|
opencode: 8,
|
||||||
|
};
|
||||||
|
|
||||||
interface TestResult {
|
interface TestResult {
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
@@ -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 { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
import type { ProviderCategory, ProviderMeta } from "@/types";
|
import type { ProviderCategory, ProviderMeta } from "@/types";
|
||||||
@@ -20,6 +21,12 @@ import {
|
|||||||
geminiProviderPresets,
|
geminiProviderPresets,
|
||||||
type GeminiProviderPreset,
|
type GeminiProviderPreset,
|
||||||
} from "@/config/geminiProviderPresets";
|
} 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 type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||||
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
||||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||||
@@ -27,6 +34,8 @@ import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
|||||||
import CodexConfigEditor from "./CodexConfigEditor";
|
import CodexConfigEditor from "./CodexConfigEditor";
|
||||||
import { CommonConfigEditor } from "./CommonConfigEditor";
|
import { CommonConfigEditor } from "./CommonConfigEditor";
|
||||||
import GeminiConfigEditor from "./GeminiConfigEditor";
|
import GeminiConfigEditor from "./GeminiConfigEditor";
|
||||||
|
import JsonEditor from "@/components/JsonEditor";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { ProviderPresetSelector } from "./ProviderPresetSelector";
|
import { ProviderPresetSelector } from "./ProviderPresetSelector";
|
||||||
import { BasicFormFields } from "./BasicFormFields";
|
import { BasicFormFields } from "./BasicFormFields";
|
||||||
import { ClaudeFormFields } from "./ClaudeFormFields";
|
import { ClaudeFormFields } from "./ClaudeFormFields";
|
||||||
@@ -47,6 +56,7 @@ import {
|
|||||||
useGeminiConfigState,
|
useGeminiConfigState,
|
||||||
useGeminiCommonConfig,
|
useGeminiCommonConfig,
|
||||||
} from "./hooks";
|
} from "./hooks";
|
||||||
|
import { useProvidersQuery } from "@/lib/query/queries";
|
||||||
|
|
||||||
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
|
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
|
||||||
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
||||||
@@ -62,9 +72,22 @@ const GEMINI_DEFAULT_CONFIG = JSON.stringify(
|
|||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const OPENCODE_DEFAULT_CONFIG = JSON.stringify(
|
||||||
|
{
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
options: {
|
||||||
|
baseURL: "",
|
||||||
|
apiKey: "",
|
||||||
|
},
|
||||||
|
models: {},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
type PresetEntry = {
|
type PresetEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset;
|
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset | OpenCodeProviderPreset;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ProviderFormProps {
|
interface ProviderFormProps {
|
||||||
@@ -158,7 +181,9 @@ export function ProviderForm({
|
|||||||
? CODEX_DEFAULT_CONFIG
|
? CODEX_DEFAULT_CONFIG
|
||||||
: appId === "gemini"
|
: appId === "gemini"
|
||||||
? GEMINI_DEFAULT_CONFIG
|
? GEMINI_DEFAULT_CONFIG
|
||||||
: CLAUDE_DEFAULT_CONFIG,
|
: appId === "opencode"
|
||||||
|
? OPENCODE_DEFAULT_CONFIG
|
||||||
|
: CLAUDE_DEFAULT_CONFIG,
|
||||||
icon: initialData?.icon ?? "",
|
icon: initialData?.icon ?? "",
|
||||||
iconColor: initialData?.iconColor ?? "",
|
iconColor: initialData?.iconColor ?? "",
|
||||||
}),
|
}),
|
||||||
@@ -171,7 +196,7 @@ export function ProviderForm({
|
|||||||
mode: "onSubmit",
|
mode: "onSubmit",
|
||||||
});
|
});
|
||||||
|
|
||||||
const settingsConfigValue = form.watch("settingsConfig");
|
const settingsConfigValue = form.getValues("settingsConfig");
|
||||||
|
|
||||||
// 使用 API Key hook
|
// 使用 API Key hook
|
||||||
const {
|
const {
|
||||||
@@ -179,7 +204,7 @@ export function ProviderForm({
|
|||||||
handleApiKeyChange,
|
handleApiKeyChange,
|
||||||
showApiKey: shouldShowApiKey,
|
showApiKey: shouldShowApiKey,
|
||||||
} = useApiKeyState({
|
} = useApiKeyState({
|
||||||
initialConfig: form.watch("settingsConfig"),
|
initialConfig: form.getValues("settingsConfig"),
|
||||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
category,
|
category,
|
||||||
@@ -190,7 +215,7 @@ export function ProviderForm({
|
|||||||
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
|
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
|
||||||
appType: appId,
|
appType: appId,
|
||||||
category,
|
category,
|
||||||
settingsConfig: form.watch("settingsConfig"),
|
settingsConfig: form.getValues("settingsConfig"),
|
||||||
codexConfig: "",
|
codexConfig: "",
|
||||||
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
|
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
onCodexConfigChange: () => {
|
onCodexConfigChange: () => {
|
||||||
@@ -207,7 +232,7 @@ export function ProviderForm({
|
|||||||
defaultOpusModel,
|
defaultOpusModel,
|
||||||
handleModelChange,
|
handleModelChange,
|
||||||
} = useModelState({
|
} = useModelState({
|
||||||
settingsConfig: form.watch("settingsConfig"),
|
settingsConfig: form.getValues("settingsConfig"),
|
||||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -328,6 +353,11 @@ export function ProviderForm({
|
|||||||
id: `gemini-${index}`,
|
id: `gemini-${index}`,
|
||||||
preset,
|
preset,
|
||||||
}));
|
}));
|
||||||
|
} else if (appId === "opencode") {
|
||||||
|
return opencodeProviderPresets.map<PresetEntry>((preset, index) => ({
|
||||||
|
id: `opencode-${index}`,
|
||||||
|
preset,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
return providerPresets.map<PresetEntry>((preset, index) => ({
|
return providerPresets.map<PresetEntry>((preset, index) => ({
|
||||||
id: `claude-${index}`,
|
id: `claude-${index}`,
|
||||||
@@ -345,7 +375,7 @@ export function ProviderForm({
|
|||||||
} = useTemplateValues({
|
} = useTemplateValues({
|
||||||
selectedPresetId: appId === "claude" ? selectedPresetId : null,
|
selectedPresetId: appId === "claude" ? selectedPresetId : null,
|
||||||
presetEntries: appId === "claude" ? presetEntries : [],
|
presetEntries: appId === "claude" ? presetEntries : [],
|
||||||
settingsConfig: form.watch("settingsConfig"),
|
settingsConfig: form.getValues("settingsConfig"),
|
||||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -359,10 +389,11 @@ export function ProviderForm({
|
|||||||
isExtracting: isClaudeExtracting,
|
isExtracting: isClaudeExtracting,
|
||||||
handleExtract: handleClaudeExtract,
|
handleExtract: handleClaudeExtract,
|
||||||
} = useCommonConfigSnippet({
|
} = useCommonConfigSnippet({
|
||||||
settingsConfig: form.watch("settingsConfig"),
|
settingsConfig: form.getValues("settingsConfig"),
|
||||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
initialData: appId === "claude" ? initialData : undefined,
|
initialData: appId === "claude" ? initialData : undefined,
|
||||||
selectedPresetId: selectedPresetId ?? undefined,
|
selectedPresetId: selectedPresetId ?? undefined,
|
||||||
|
enabled: appId === "claude",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 使用 Codex 通用配置片段 hook (仅 Codex 模式)
|
// 使用 Codex 通用配置片段 hook (仅 Codex 模式)
|
||||||
@@ -408,7 +439,7 @@ export function ProviderForm({
|
|||||||
originalHandleGeminiApiKeyChange(key);
|
originalHandleGeminiApiKeyChange(key);
|
||||||
// 同步更新 settingsConfig
|
// 同步更新 settingsConfig
|
||||||
try {
|
try {
|
||||||
const config = JSON.parse(form.watch("settingsConfig") || "{}");
|
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
|
||||||
if (!config.env) config.env = {};
|
if (!config.env) config.env = {};
|
||||||
config.env.GEMINI_API_KEY = key.trim();
|
config.env.GEMINI_API_KEY = key.trim();
|
||||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||||
@@ -424,7 +455,7 @@ export function ProviderForm({
|
|||||||
originalHandleGeminiBaseUrlChange(url);
|
originalHandleGeminiBaseUrlChange(url);
|
||||||
// 同步更新 settingsConfig
|
// 同步更新 settingsConfig
|
||||||
try {
|
try {
|
||||||
const config = JSON.parse(form.watch("settingsConfig") || "{}");
|
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
|
||||||
if (!config.env) config.env = {};
|
if (!config.env) config.env = {};
|
||||||
config.env.GOOGLE_GEMINI_BASE_URL = url.trim().replace(/\/+$/, "");
|
config.env.GOOGLE_GEMINI_BASE_URL = url.trim().replace(/\/+$/, "");
|
||||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||||
@@ -440,7 +471,7 @@ export function ProviderForm({
|
|||||||
originalHandleGeminiModelChange(model);
|
originalHandleGeminiModelChange(model);
|
||||||
// 同步更新 settingsConfig
|
// 同步更新 settingsConfig
|
||||||
try {
|
try {
|
||||||
const config = JSON.parse(form.watch("settingsConfig") || "{}");
|
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
|
||||||
if (!config.env) config.env = {};
|
if (!config.env) config.env = {};
|
||||||
config.env.GEMINI_MODEL = model.trim();
|
config.env.GEMINI_MODEL = model.trim();
|
||||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||||
@@ -469,6 +500,180 @@ export function ProviderForm({
|
|||||||
selectedPresetId: selectedPresetId ?? undefined,
|
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 [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = (values: ProviderFormData) => {
|
const handleSubmit = (values: ProviderFormData) => {
|
||||||
@@ -496,6 +701,23 @@ export function ProviderForm({
|
|||||||
return;
|
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
|
// 非官方供应商必填校验:端点和 API Key
|
||||||
if (category !== "official") {
|
if (category !== "official") {
|
||||||
if (appId === "claude") {
|
if (appId === "claude") {
|
||||||
@@ -593,6 +815,11 @@ export function ProviderForm({
|
|||||||
settingsConfig,
|
settingsConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// OpenCode: pass provider key for ID generation
|
||||||
|
if (appId === "opencode") {
|
||||||
|
payload.providerKey = opencodeProviderKey;
|
||||||
|
}
|
||||||
|
|
||||||
if (activePreset) {
|
if (activePreset) {
|
||||||
payload.presetId = activePreset.id;
|
payload.presetId = activePreset.id;
|
||||||
if (activePreset.category) {
|
if (activePreset.category) {
|
||||||
@@ -748,6 +975,15 @@ export function ProviderForm({
|
|||||||
if (appId === "gemini") {
|
if (appId === "gemini") {
|
||||||
resetGeminiConfig({}, {});
|
resetGeminiConfig({}, {});
|
||||||
}
|
}
|
||||||
|
// OpenCode 自定义模式:重置为空配置
|
||||||
|
if (appId === "opencode") {
|
||||||
|
setOpencodeProviderKey("");
|
||||||
|
setOpencodeNpm("@ai-sdk/openai-compatible");
|
||||||
|
setOpencodeBaseUrl("");
|
||||||
|
setOpencodeApiKey("");
|
||||||
|
setOpencodeModels({});
|
||||||
|
setOpencodeExtraOptions({});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,6 +1037,42 @@ export function ProviderForm({
|
|||||||
return;
|
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 preset = entry.preset as ProviderPreset;
|
||||||
const config = applyTemplateValues(
|
const config = applyTemplateValues(
|
||||||
preset.settingsConfig,
|
preset.settingsConfig,
|
||||||
@@ -838,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 专属字段 */}
|
{/* Claude 专属字段 */}
|
||||||
{appId === "claude" && (
|
{appId === "claude" && (
|
||||||
<ClaudeFormFields
|
<ClaudeFormFields
|
||||||
providerId={providerId}
|
providerId={providerId}
|
||||||
shouldShowApiKey={shouldShowApiKey(
|
shouldShowApiKey={shouldShowApiKey(
|
||||||
form.watch("settingsConfig"),
|
form.getValues("settingsConfig"),
|
||||||
isEditMode,
|
isEditMode,
|
||||||
)}
|
)}
|
||||||
apiKey={apiKey}
|
apiKey={apiKey}
|
||||||
@@ -916,7 +1229,7 @@ export function ProviderForm({
|
|||||||
<GeminiFormFields
|
<GeminiFormFields
|
||||||
providerId={providerId}
|
providerId={providerId}
|
||||||
shouldShowApiKey={shouldShowApiKey(
|
shouldShowApiKey={shouldShowApiKey(
|
||||||
form.watch("settingsConfig"),
|
form.getValues("settingsConfig"),
|
||||||
isEditMode,
|
isEditMode,
|
||||||
)}
|
)}
|
||||||
apiKey={geminiApiKey}
|
apiKey={geminiApiKey}
|
||||||
@@ -941,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 分别使用不同的编辑器 */}
|
{/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */}
|
||||||
{appId === "codex" ? (
|
{appId === "codex" ? (
|
||||||
<>
|
<>
|
||||||
@@ -1000,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
|
<CommonConfigEditor
|
||||||
value={form.watch("settingsConfig")}
|
value={form.getValues("settingsConfig")}
|
||||||
onChange={(value) => form.setValue("settingsConfig", value)}
|
onChange={(value) => form.setValue("settingsConfig", value)}
|
||||||
useCommonConfig={useCommonConfig}
|
useCommonConfig={useCommonConfig}
|
||||||
onCommonConfigToggle={handleCommonConfigToggle}
|
onCommonConfigToggle={handleCommonConfigToggle}
|
||||||
@@ -1047,4 +1409,5 @@ export type ProviderFormValues = ProviderFormData & {
|
|||||||
presetCategory?: ProviderCategory;
|
presetCategory?: ProviderCategory;
|
||||||
isPartner?: boolean;
|
isPartner?: boolean;
|
||||||
meta?: ProviderMeta;
|
meta?: ProviderMeta;
|
||||||
|
providerKey?: string; // OpenCode: user-defined provider key
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
import type { ProviderCategory } from "@/types";
|
import type { ProviderCategory } from "@/types";
|
||||||
|
|
||||||
interface UseBaseUrlStateProps {
|
interface UseBaseUrlStateProps {
|
||||||
appType: "claude" | "codex" | "gemini";
|
appType: "claude" | "codex" | "gemini" | "opencode";
|
||||||
category: ProviderCategory | undefined;
|
category: ProviderCategory | undefined;
|
||||||
settingsConfig: string;
|
settingsConfig: string;
|
||||||
codexConfig?: string;
|
codexConfig?: string;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ interface UseCommonConfigSnippetProps {
|
|||||||
settingsConfig?: Record<string, unknown>;
|
settingsConfig?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
selectedPresetId?: string;
|
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,
|
onConfigChange,
|
||||||
initialData,
|
initialData,
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
|
enabled = true,
|
||||||
}: UseCommonConfigSnippetProps) {
|
}: UseCommonConfigSnippetProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||||
@@ -47,11 +50,16 @@ export function useCommonConfigSnippet({
|
|||||||
|
|
||||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
hasInitializedNewMode.current = false;
|
hasInitializedNewMode.current = false;
|
||||||
}, [selectedPresetId]);
|
}, [selectedPresetId, enabled]);
|
||||||
|
|
||||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
const loadSnippet = async () => {
|
const loadSnippet = async () => {
|
||||||
@@ -100,10 +108,11 @@ export function useCommonConfigSnippet({
|
|||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [enabled]);
|
||||||
|
|
||||||
// 初始化时检查通用配置片段(编辑模式)
|
// 初始化时检查通用配置片段(编辑模式)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
if (initialData && !isLoading) {
|
if (initialData && !isLoading) {
|
||||||
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
|
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
|
||||||
const hasCommon = hasCommonConfigSnippet(
|
const hasCommon = hasCommonConfigSnippet(
|
||||||
@@ -112,10 +121,11 @@ export function useCommonConfigSnippet({
|
|||||||
);
|
);
|
||||||
setUseCommonConfig(hasCommon);
|
setUseCommonConfig(hasCommon);
|
||||||
}
|
}
|
||||||
}, [initialData, commonConfigSnippet, isLoading]);
|
}, [enabled, initialData, commonConfigSnippet, isLoading]);
|
||||||
|
|
||||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
// 新建模式:如果通用配置片段存在且有效,默认启用
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
// 仅新建模式、加载完成、尚未初始化过
|
// 仅新建模式、加载完成、尚未初始化过
|
||||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
||||||
hasInitializedNewMode.current = true;
|
hasInitializedNewMode.current = true;
|
||||||
@@ -145,6 +155,7 @@ export function useCommonConfigSnippet({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
enabled,
|
||||||
initialData,
|
initialData,
|
||||||
commonConfigSnippet,
|
commonConfigSnippet,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -259,6 +270,7 @@ export function useCommonConfigSnippet({
|
|||||||
|
|
||||||
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
if (isUpdatingFromCommonConfig.current || isLoading) {
|
if (isUpdatingFromCommonConfig.current || isLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -267,7 +279,7 @@ export function useCommonConfigSnippet({
|
|||||||
commonConfigSnippet,
|
commonConfigSnippet,
|
||||||
);
|
);
|
||||||
setUseCommonConfig(hasCommon);
|
setUseCommonConfig(hasCommon);
|
||||||
}, [settingsConfig, commonConfigSnippet, isLoading]);
|
}, [enabled, settingsConfig, commonConfigSnippet, isLoading]);
|
||||||
|
|
||||||
// 从编辑器当前内容提取通用配置片段
|
// 从编辑器当前内容提取通用配置片段
|
||||||
const handleExtract = useCallback(async () => {
|
const handleExtract = useCallback(async () => {
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
|
|||||||
? "Claude"
|
? "Claude"
|
||||||
: activeApp === "codex"
|
: activeApp === "codex"
|
||||||
? "Codex"
|
? "Codex"
|
||||||
: "Gemini";
|
: activeApp === "gemini"
|
||||||
|
? "Gemini"
|
||||||
|
: "OpenCode";
|
||||||
|
|
||||||
const tooltipText = takeoverEnabled
|
const tooltipText = takeoverEnabled
|
||||||
? isRunning
|
? isRunning
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export function SettingsPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 ? (
|
{isBusy ? (
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
}, [skills, searchQuery, filterStatus]);
|
}, [skills, searchQuery, filterStatus]);
|
||||||
|
|
||||||
return (
|
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="flex-1 overflow-y-auto overflow-x-hidden animate-fade-in">
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
|
|||||||
@@ -52,12 +52,13 @@ const UnifiedSkillsPanel = React.forwardRef<
|
|||||||
|
|
||||||
// Count enabled skills per app
|
// Count enabled skills per app
|
||||||
const enabledCounts = useMemo(() => {
|
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;
|
if (!skills) return counts;
|
||||||
skills.forEach((skill) => {
|
skills.forEach((skill) => {
|
||||||
if (skill.apps.claude) counts.claude++;
|
if (skill.apps.claude) counts.claude++;
|
||||||
if (skill.apps.codex) counts.codex++;
|
if (skill.apps.codex) counts.codex++;
|
||||||
if (skill.apps.gemini) counts.gemini++;
|
if (skill.apps.gemini) counts.gemini++;
|
||||||
|
if (skill.apps.opencode) counts.opencode++;
|
||||||
});
|
});
|
||||||
return counts;
|
return counts;
|
||||||
}, [skills]);
|
}, [skills]);
|
||||||
@@ -132,14 +133,15 @@ const UnifiedSkillsPanel = React.forwardRef<
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Info Section */}
|
||||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 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">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t("skills.installed", { count: skills?.length || 0 })} ·{" "}
|
{t("skills.installed", { count: skills?.length || 0 })} ·{" "}
|
||||||
{t("skills.apps.claude")}: {enabledCounts.claude} ·{" "}
|
{t("skills.apps.claude")}: {enabledCounts.claude} ·{" "}
|
||||||
{t("skills.apps.codex")}: {enabledCounts.codex} ·{" "}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -308,6 +310,22 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 右侧:删除按钮 */}
|
{/* 右侧:删除按钮 */}
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Download, Loader2, Trash2 } from "lucide-react";
|
|
||||||
import type { MarketplaceBundle } from "@/types/template";
|
|
||||||
import type { AppType } from "@/lib/api/config";
|
|
||||||
|
|
||||||
// 组件类型图标映射
|
|
||||||
const componentTypeIcons: Record<string, string> = {
|
|
||||||
agent: "🤖",
|
|
||||||
command: "⚡",
|
|
||||||
mcp: "🔌",
|
|
||||||
setting: "⚙️",
|
|
||||||
hook: "🪝",
|
|
||||||
skill: "💡",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface BundleInstallStatus {
|
|
||||||
installed: boolean;
|
|
||||||
installedIds: number[];
|
|
||||||
totalCount: number;
|
|
||||||
installedCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BundleDetailProps {
|
|
||||||
bundle: MarketplaceBundle;
|
|
||||||
status?: BundleInstallStatus;
|
|
||||||
selectedApp: AppType;
|
|
||||||
onClose: () => void;
|
|
||||||
onInstall: () => void;
|
|
||||||
onUninstall: () => void;
|
|
||||||
installing: boolean;
|
|
||||||
uninstalling: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BundleDetail({
|
|
||||||
bundle,
|
|
||||||
status,
|
|
||||||
onClose,
|
|
||||||
onInstall,
|
|
||||||
onUninstall,
|
|
||||||
installing,
|
|
||||||
uninstalling,
|
|
||||||
}: BundleDetailProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// 按类型分组组件
|
|
||||||
const componentsByType = bundle.components.reduce(
|
|
||||||
(acc, comp) => {
|
|
||||||
const type = comp.componentType;
|
|
||||||
if (!acc[type]) acc[type] = [];
|
|
||||||
acc[type].push(comp);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, typeof bundle.components>,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
|
||||||
<DialogHeader>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-3xl">📦</span>
|
|
||||||
{status?.installed && (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
|
|
||||||
>
|
|
||||||
{t("templates.installed", { defaultValue: "已安装" })}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-2xl">{bundle.name}</DialogTitle>
|
|
||||||
<DialogDescription className="text-sm mt-2">
|
|
||||||
{bundle.description ||
|
|
||||||
t("templates.noDescription", { defaultValue: "暂无描述" })}
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* 组件列表 */}
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-6 py-4 px-1">
|
|
||||||
{Object.entries(componentsByType).map(([type, components]) => (
|
|
||||||
<div key={type}>
|
|
||||||
<div className="flex items-center justify-center gap-2 mb-3">
|
|
||||||
<span className="text-xl">
|
|
||||||
{componentTypeIcons[type] || "📦"}
|
|
||||||
</span>
|
|
||||||
<h3 className="font-medium text-foreground">
|
|
||||||
{t(`templates.type.${type}`, { defaultValue: type })}
|
|
||||||
</h3>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{components.length}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 px-2">
|
|
||||||
{components.map((comp, idx) => (
|
|
||||||
<div
|
|
||||||
key={`${comp.name}-${idx}`}
|
|
||||||
className="flex items-center gap-3 p-3 rounded-lg bg-muted/30"
|
|
||||||
>
|
|
||||||
<span className="text-lg">
|
|
||||||
{componentTypeIcons[type] || "📦"}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="font-medium text-sm truncate">
|
|
||||||
{comp.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
{comp.path}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="flex-row gap-2 justify-end border-t pt-4">
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
{t("common.close", { defaultValue: "关闭" })}
|
|
||||||
</Button>
|
|
||||||
{status && status.installedCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={onUninstall}
|
|
||||||
disabled={uninstalling}
|
|
||||||
>
|
|
||||||
{uninstalling ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{t("templates.bundle.uninstall", { defaultValue: "卸载" })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!status?.installed && (
|
|
||||||
<Button onClick={onInstall} disabled={installing}>
|
|
||||||
{installing ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{t("templates.bundle.install", { defaultValue: "安装组合" })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Download, Loader2, Package, Trash2, FileText } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
useMarketplaceBundles,
|
|
||||||
useBatchInstallComponents,
|
|
||||||
} from "@/lib/query/template";
|
|
||||||
import { templateApi } from "@/lib/api/template";
|
|
||||||
import { BundleDetail } from "./BundleDetail";
|
|
||||||
import type { MarketplaceBundle, ComponentType } from "@/types/template";
|
|
||||||
import type { AppType } from "@/lib/api/config";
|
|
||||||
|
|
||||||
// 组件类型图标映射
|
|
||||||
const componentTypeIcons: Record<string, string> = {
|
|
||||||
agent: "🤖",
|
|
||||||
command: "⚡",
|
|
||||||
mcp: "🔌",
|
|
||||||
setting: "⚙️",
|
|
||||||
hook: "🪝",
|
|
||||||
skill: "💡",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface BundleInstallStatus {
|
|
||||||
installed: boolean;
|
|
||||||
installedIds: number[];
|
|
||||||
totalCount: number;
|
|
||||||
installedCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BundleListProps {
|
|
||||||
selectedApp: AppType;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统计组件类型数量
|
|
||||||
function getComponentTypeCounts(components: MarketplaceBundle["components"]) {
|
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
for (const comp of components) {
|
|
||||||
const type = comp.componentType;
|
|
||||||
counts[type] = (counts[type] || 0) + 1;
|
|
||||||
}
|
|
||||||
return counts;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BundleList({ selectedApp }: BundleListProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [installingBundle, setInstallingBundle] = useState<string | null>(null);
|
|
||||||
const [uninstallingBundle, setUninstallingBundle] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [bundleStatuses, setBundleStatuses] = useState<
|
|
||||||
Record<string, BundleInstallStatus>
|
|
||||||
>({});
|
|
||||||
const [detailBundle, setDetailBundle] = useState<MarketplaceBundle | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: bundles = [], isLoading } = useMarketplaceBundles();
|
|
||||||
const batchInstallMutation = useBatchInstallComponents();
|
|
||||||
|
|
||||||
// 检查组合安装状态
|
|
||||||
const checkBundleStatus = useCallback(
|
|
||||||
async (bundle: MarketplaceBundle): Promise<BundleInstallStatus> => {
|
|
||||||
const componentsByType = bundle.components.reduce(
|
|
||||||
(acc, comp) => {
|
|
||||||
const type = comp.componentType;
|
|
||||||
if (!acc[type]) acc[type] = [];
|
|
||||||
acc[type].push(comp.name.toLowerCase());
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, string[]>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const installedIds: number[] = [];
|
|
||||||
let totalMatched = 0;
|
|
||||||
|
|
||||||
for (const [componentType, names] of Object.entries(componentsByType)) {
|
|
||||||
const componentsData = await templateApi.listTemplateComponents({
|
|
||||||
componentType: componentType as ComponentType,
|
|
||||||
pageSize: 1000,
|
|
||||||
appType: selectedApp,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const comp of componentsData.items) {
|
|
||||||
if (names.includes(comp.name.toLowerCase())) {
|
|
||||||
totalMatched++;
|
|
||||||
if (comp.installed && comp.id !== null) {
|
|
||||||
installedIds.push(comp.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
installed:
|
|
||||||
installedIds.length > 0 && installedIds.length === totalMatched,
|
|
||||||
installedIds,
|
|
||||||
totalCount: totalMatched,
|
|
||||||
installedCount: installedIds.length,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[selectedApp],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 加载所有组合的安装状态
|
|
||||||
useEffect(() => {
|
|
||||||
const loadStatuses = async () => {
|
|
||||||
const statuses: Record<string, BundleInstallStatus> = {};
|
|
||||||
for (const bundle of bundles) {
|
|
||||||
statuses[bundle.id] = await checkBundleStatus(bundle);
|
|
||||||
}
|
|
||||||
setBundleStatuses(statuses);
|
|
||||||
};
|
|
||||||
if (bundles.length > 0) {
|
|
||||||
loadStatuses();
|
|
||||||
}
|
|
||||||
}, [bundles, checkBundleStatus]);
|
|
||||||
|
|
||||||
const handleInstallBundle = async (bundle: MarketplaceBundle) => {
|
|
||||||
setInstallingBundle(bundle.id);
|
|
||||||
try {
|
|
||||||
// 按组件类型分组
|
|
||||||
const componentsByType = bundle.components.reduce(
|
|
||||||
(acc, comp) => {
|
|
||||||
const type = comp.componentType;
|
|
||||||
if (!acc[type]) acc[type] = [];
|
|
||||||
acc[type].push(comp.name.toLowerCase());
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, string[]>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 收集所有匹配的组件 ID
|
|
||||||
const matchedIds: number[] = [];
|
|
||||||
|
|
||||||
for (const [componentType, names] of Object.entries(componentsByType)) {
|
|
||||||
const componentsData = await templateApi.listTemplateComponents({
|
|
||||||
componentType: componentType as ComponentType,
|
|
||||||
pageSize: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ids = componentsData.items
|
|
||||||
.filter((c) => names.includes(c.name.toLowerCase()))
|
|
||||||
.map((c) => c.id)
|
|
||||||
.filter((id): id is number => id !== null);
|
|
||||||
|
|
||||||
matchedIds.push(...ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedIds.length === 0) {
|
|
||||||
toast.warning(
|
|
||||||
t("templates.bundle.noMatch", {
|
|
||||||
defaultValue: "未找到匹配的组件",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await batchInstallMutation.mutateAsync({
|
|
||||||
ids: matchedIds,
|
|
||||||
appType: selectedApp,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success(
|
|
||||||
t("templates.bundle.installSuccess", {
|
|
||||||
count: result.success.length,
|
|
||||||
defaultValue: `已安装 ${result.success.length} 个组件`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.failed.length > 0) {
|
|
||||||
toast.warning(
|
|
||||||
t("templates.bundle.partialFail", {
|
|
||||||
count: result.failed.length,
|
|
||||||
defaultValue: `${result.failed.length} 个组件安装失败`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新安装状态
|
|
||||||
const newStatus = await checkBundleStatus(bundle);
|
|
||||||
setBundleStatuses((prev) => ({ ...prev, [bundle.id]: newStatus }));
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
toast.error(
|
|
||||||
t("templates.bundle.installFailed", { defaultValue: "安装组合失败" }),
|
|
||||||
{ description: errorMessage, duration: 8000 },
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setInstallingBundle(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUninstallBundle = async (bundle: MarketplaceBundle) => {
|
|
||||||
const status = bundleStatuses[bundle.id];
|
|
||||||
if (!status || status.installedIds.length === 0) return;
|
|
||||||
|
|
||||||
setUninstallingBundle(bundle.id);
|
|
||||||
try {
|
|
||||||
let successCount = 0;
|
|
||||||
let failCount = 0;
|
|
||||||
|
|
||||||
for (const id of status.installedIds) {
|
|
||||||
try {
|
|
||||||
await templateApi.uninstallTemplateComponent(id, selectedApp);
|
|
||||||
successCount++;
|
|
||||||
} catch {
|
|
||||||
failCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (successCount > 0) {
|
|
||||||
toast.success(
|
|
||||||
t("templates.bundle.uninstallSuccess", {
|
|
||||||
count: successCount,
|
|
||||||
defaultValue: `已卸载 ${successCount} 个组件`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failCount > 0) {
|
|
||||||
toast.warning(
|
|
||||||
t("templates.bundle.uninstallPartialFail", {
|
|
||||||
count: failCount,
|
|
||||||
defaultValue: `${failCount} 个组件卸载失败`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新安装状态
|
|
||||||
const newStatus = await checkBundleStatus(bundle);
|
|
||||||
setBundleStatuses((prev) => ({ ...prev, [bundle.id]: newStatus }));
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
toast.error(
|
|
||||||
t("templates.bundle.uninstallFailed", { defaultValue: "卸载组合失败" }),
|
|
||||||
{ description: errorMessage, duration: 8000 },
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setUninstallingBundle(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bundles.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
|
||||||
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
|
||||||
<p className="text-lg font-medium text-foreground">
|
|
||||||
{t("templates.bundle.empty", { defaultValue: "暂无组合" })}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
{t("templates.bundle.emptyDescription", {
|
|
||||||
defaultValue: "请添加包含 components.json 的模板仓库",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{bundles.map((bundle) => {
|
|
||||||
const typeCounts = getComponentTypeCounts(bundle.components);
|
|
||||||
const status = bundleStatuses[bundle.id];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={bundle.id}
|
|
||||||
className="glass-card rounded-xl p-4 flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden cursor-pointer"
|
|
||||||
onClick={() => setDetailBundle(bundle)}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
|
||||||
|
|
||||||
{/* 头部 */}
|
|
||||||
<div className="flex items-start justify-between gap-2 mb-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<span className="text-2xl">📦</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="font-semibold text-foreground truncate">
|
|
||||||
{bundle.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
{status?.installed && (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="shrink-0 bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
|
|
||||||
>
|
|
||||||
{t("templates.installed", { defaultValue: "已安装" })}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 描述 */}
|
|
||||||
<p className="text-sm text-muted-foreground/90 line-clamp-2 leading-relaxed mb-3 flex-1">
|
|
||||||
{bundle.description ||
|
|
||||||
t("templates.noDescription", { defaultValue: "暂无描述" })}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 组件类型统计 */}
|
|
||||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
|
||||||
{Object.entries(typeCounts).map(([type, count]) => (
|
|
||||||
<Badge key={type} variant="secondary" className="text-xs">
|
|
||||||
<span className="mr-1">
|
|
||||||
{componentTypeIcons[type] || "📦"}
|
|
||||||
</span>
|
|
||||||
{type} {count}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 底部操作栏 */}
|
|
||||||
<div className="flex gap-2 pt-3 border-t border-border/50 relative z-10">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setDetailBundle(bundle);
|
|
||||||
}}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
{t("templates.viewDetail", { defaultValue: "查看详情" })}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{status && status.installedCount > 0 && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleUninstallBundle(bundle);
|
|
||||||
}}
|
|
||||||
disabled={uninstallingBundle === bundle.id}
|
|
||||||
className="flex-1 border-red-300 text-red-500 hover:bg-red-50 hover:text-red-600 dark:border-red-500/50 dark:text-red-400 dark:hover:bg-red-900/30 dark:hover:text-red-300"
|
|
||||||
>
|
|
||||||
{uninstallingBundle === bundle.id ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
)}
|
|
||||||
{t("templates.bundle.uninstall", { defaultValue: "卸载" })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!status?.installed && (
|
|
||||||
<Button
|
|
||||||
variant="mcp"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleInstallBundle(bundle);
|
|
||||||
}}
|
|
||||||
disabled={installingBundle === bundle.id}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{installingBundle === bundle.id ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
)}
|
|
||||||
{t("templates.bundle.install", { defaultValue: "安装" })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 详情弹窗 */}
|
|
||||||
{detailBundle && (
|
|
||||||
<BundleDetail
|
|
||||||
bundle={detailBundle}
|
|
||||||
status={bundleStatuses[detailBundle.id]}
|
|
||||||
selectedApp={selectedApp}
|
|
||||||
onClose={() => setDetailBundle(null)}
|
|
||||||
onInstall={() => handleInstallBundle(detailBundle)}
|
|
||||||
onUninstall={() => handleUninstallBundle(detailBundle)}
|
|
||||||
installing={installingBundle === detailBundle.id}
|
|
||||||
uninstalling={uninstallingBundle === detailBundle.id}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
|
|
||||||
interface CategoryFilterProps {
|
|
||||||
categories: string[];
|
|
||||||
selectedCategory?: string;
|
|
||||||
onSelectCategory: (category: string | undefined) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CategoryFilter({
|
|
||||||
categories,
|
|
||||||
selectedCategory,
|
|
||||||
onSelectCategory,
|
|
||||||
}: CategoryFilterProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="glass-card rounded-xl p-4 sticky top-0">
|
|
||||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
|
||||||
{t("templates.category.title", { defaultValue: "分类" })}
|
|
||||||
</h3>
|
|
||||||
<div className="h-[calc(100vh-16rem)] overflow-y-auto">
|
|
||||||
<div className="space-y-1 pr-2">
|
|
||||||
{/* 全部选项 */}
|
|
||||||
<Button
|
|
||||||
variant={selectedCategory === undefined ? "secondary" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onSelectCategory(undefined)}
|
|
||||||
className="w-full justify-start text-sm h-9"
|
|
||||||
>
|
|
||||||
{t("templates.category.all", { defaultValue: "全部" })}
|
|
||||||
{selectedCategory === undefined && (
|
|
||||||
<Badge variant="secondary" className="ml-auto text-xs">
|
|
||||||
✓
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 分类列表 */}
|
|
||||||
{categories.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="h-px bg-border my-2" />
|
|
||||||
{categories.map((category) => (
|
|
||||||
<Button
|
|
||||||
key={category}
|
|
||||||
variant={
|
|
||||||
selectedCategory === category ? "secondary" : "ghost"
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onSelectCategory(category)}
|
|
||||||
className="w-full justify-start text-sm h-9"
|
|
||||||
>
|
|
||||||
<span className="truncate">{category}</span>
|
|
||||||
{selectedCategory === category && (
|
|
||||||
<Badge variant="secondary" className="ml-auto text-xs">
|
|
||||||
✓
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 无分类提示 */}
|
|
||||||
{categories.length === 0 && selectedCategory === undefined && (
|
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">
|
|
||||||
{t("templates.category.empty", { defaultValue: "暂无分类" })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Download, Trash2, Loader2, FileText } from "lucide-react";
|
|
||||||
import type { TemplateComponent } from "@/types/template";
|
|
||||||
|
|
||||||
interface ComponentCardProps {
|
|
||||||
component: TemplateComponent;
|
|
||||||
onInstall: () => Promise<void>;
|
|
||||||
onUninstall: () => Promise<void>;
|
|
||||||
onViewDetail: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件类型图标映射
|
|
||||||
const componentTypeIcons: Record<string, string> = {
|
|
||||||
agent: "🤖",
|
|
||||||
command: "⚡",
|
|
||||||
mcp: "🔌",
|
|
||||||
setting: "⚙️",
|
|
||||||
hook: "🪝",
|
|
||||||
skill: "💡",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ComponentCard({
|
|
||||||
component,
|
|
||||||
onInstall,
|
|
||||||
onUninstall,
|
|
||||||
onViewDetail,
|
|
||||||
}: ComponentCardProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleInstall = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await onInstall();
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUninstall = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await onUninstall();
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeIcon = componentTypeIcons[component.componentType] || "📦";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden cursor-pointer">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
|
||||||
|
|
||||||
<div onClick={onViewDetail}>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<span className="text-2xl">{typeIcon}</span>
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-base font-semibold truncate">
|
|
||||||
{component.name}
|
|
||||||
</CardTitle>
|
|
||||||
{component.category && (
|
|
||||||
<CardDescription className="text-xs mt-1">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] px-1.5 py-0 h-4 border-border-default"
|
|
||||||
>
|
|
||||||
{component.category}
|
|
||||||
</Badge>
|
|
||||||
</CardDescription>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{component.installed && (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="shrink-0 bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
|
|
||||||
>
|
|
||||||
{t("templates.installed", { defaultValue: "已安装" })}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="flex-1 pt-0">
|
|
||||||
<p className="text-sm text-muted-foreground/90 line-clamp-3 leading-relaxed">
|
|
||||||
{component.description ||
|
|
||||||
t("templates.noDescription", { defaultValue: "暂无描述" })}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardFooter className="flex gap-2 pt-3 border-t border-border/50 relative z-10">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onViewDetail();
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
{t("templates.viewDetail", { defaultValue: "查看详情" })}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{component.installed ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleUninstall();
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
className="flex-1 border-red-300 text-red-500 hover:bg-red-50 hover:text-red-600 dark:border-red-500/50 dark:text-red-400 dark:hover:bg-red-900/30 dark:hover:text-red-300"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
)}
|
|
||||||
{loading
|
|
||||||
? t("templates.uninstalling", { defaultValue: "卸载中..." })
|
|
||||||
: t("templates.uninstall", { defaultValue: "卸载" })}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="mcp"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleInstall();
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
)}
|
|
||||||
{loading
|
|
||||||
? t("templates.installing", { defaultValue: "安装中..." })
|
|
||||||
: t("templates.install", { defaultValue: "安装" })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import {
|
|
||||||
Download,
|
|
||||||
Trash2,
|
|
||||||
Loader2,
|
|
||||||
ExternalLink,
|
|
||||||
FileCode,
|
|
||||||
FolderGit2,
|
|
||||||
Tag,
|
|
||||||
Clock,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { settingsApi } from "@/lib/api";
|
|
||||||
import {
|
|
||||||
useTemplateComponent,
|
|
||||||
useComponentPreview,
|
|
||||||
} from "@/lib/query/template";
|
|
||||||
import type { AppType } from "@/lib/api/config";
|
|
||||||
|
|
||||||
interface ComponentDetailProps {
|
|
||||||
componentId: number;
|
|
||||||
selectedApp: AppType;
|
|
||||||
onClose: () => void;
|
|
||||||
onInstall: (id: number, name: string) => Promise<void>;
|
|
||||||
onUninstall: (id: number, name: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件类型图标映射
|
|
||||||
const componentTypeIcons: Record<string, string> = {
|
|
||||||
agent: "🤖",
|
|
||||||
command: "⚡",
|
|
||||||
mcp: "🔌",
|
|
||||||
setting: "⚙️",
|
|
||||||
hook: "🪝",
|
|
||||||
skill: "💡",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ComponentDetail({
|
|
||||||
componentId,
|
|
||||||
onClose,
|
|
||||||
onInstall,
|
|
||||||
onUninstall,
|
|
||||||
}: ComponentDetailProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const { data: component, isLoading: componentLoading } =
|
|
||||||
useTemplateComponent(componentId);
|
|
||||||
const { data: preview, isLoading: previewLoading } =
|
|
||||||
useComponentPreview(componentId);
|
|
||||||
|
|
||||||
const handleInstall = async () => {
|
|
||||||
if (!component || component.id === null) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await onInstall(component.id, component.name);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUninstall = async () => {
|
|
||||||
if (!component || component.id === null) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await onUninstall(component.id, component.name);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenGithub = async () => {
|
|
||||||
if (component?.readmeUrl) {
|
|
||||||
try {
|
|
||||||
await settingsApi.openExternal(component.readmeUrl);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to open URL:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (componentLoading || !component) {
|
|
||||||
return (
|
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
|
||||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeIcon = componentTypeIcons[component.componentType] || "📦";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
|
||||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
|
|
||||||
<DialogHeader>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-3xl">{typeIcon}</span>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{t(`templates.type.${component.componentType}`, {
|
|
||||||
defaultValue: component.componentType,
|
|
||||||
})}
|
|
||||||
</Badge>
|
|
||||||
{component.category && (
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{component.category}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-2xl">{component.name}</DialogTitle>
|
|
||||||
<DialogDescription className="text-sm mt-2">
|
|
||||||
{component.description ||
|
|
||||||
t("templates.noDescription", { defaultValue: "暂无描述" })}
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
{component.installed && (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="shrink-0 bg-green-600/90 text-white border-0"
|
|
||||||
>
|
|
||||||
{t("templates.installed", { defaultValue: "已安装" })}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<Tabs defaultValue="info" className="h-full flex flex-col">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="info">
|
|
||||||
{t("templates.detail.info", { defaultValue: "信息" })}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="preview">
|
|
||||||
<FileCode className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
{t("templates.detail.preview", { defaultValue: "预览" })}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="info" className="flex-1 overflow-y-auto mt-4">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
||||||
{/* 类型 */}
|
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
|
|
||||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
|
||||||
<Tag className="h-5 w-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("templates.detail.type", { defaultValue: "类型" })}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium truncate">
|
|
||||||
{t(`templates.type.${component.componentType}`, {
|
|
||||||
defaultValue: component.componentType,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 分类 */}
|
|
||||||
{component.category && (
|
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
|
|
||||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
|
||||||
<Tag className="h-5 w-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("templates.detail.category", {
|
|
||||||
defaultValue: "分类",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium truncate">
|
|
||||||
{component.category}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 仓库 */}
|
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
|
|
||||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
|
||||||
<FolderGit2 className="h-5 w-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("templates.detail.repository", {
|
|
||||||
defaultValue: "仓库",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<p className="text-sm font-medium truncate">
|
|
||||||
{component.repoOwner}/{component.repoName}
|
|
||||||
{component.repoBranch &&
|
|
||||||
component.repoBranch !== "main" &&
|
|
||||||
` (${component.repoBranch})`}
|
|
||||||
</p>
|
|
||||||
{component.readmeUrl && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleOpenGithub}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 更新时间 */}
|
|
||||||
{component.updatedAt && (
|
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
|
|
||||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
|
||||||
<Clock className="h-5 w-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("templates.detail.updatedAt", {
|
|
||||||
defaultValue: "更新时间",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium truncate">
|
|
||||||
{new Date(component.updatedAt).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 路径 */}
|
|
||||||
<div className="mt-3 p-3 rounded-lg bg-muted/30">
|
|
||||||
<p className="text-xs text-muted-foreground mb-1">
|
|
||||||
{t("templates.detail.path", { defaultValue: "路径" })}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-mono text-muted-foreground break-all">
|
|
||||||
{component.path}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent
|
|
||||||
value="preview"
|
|
||||||
className="flex-1 overflow-y-auto mt-4"
|
|
||||||
>
|
|
||||||
{previewLoading ? (
|
|
||||||
<div className="flex items-center justify-center h-32">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : preview ? (
|
|
||||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-auto font-mono whitespace-pre-wrap break-words">
|
|
||||||
{preview}
|
|
||||||
</pre>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-8">
|
|
||||||
{t("templates.detail.noPreview", {
|
|
||||||
defaultValue: "无法预览内容",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="flex-row gap-2 justify-end border-t pt-4">
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
{t("common.close", { defaultValue: "关闭" })}
|
|
||||||
</Button>
|
|
||||||
{component.installed ? (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleUninstall}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{loading
|
|
||||||
? t("templates.uninstalling", { defaultValue: "卸载中..." })
|
|
||||||
: t("templates.uninstall", { defaultValue: "卸载" })}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button onClick={handleInstall} disabled={loading}>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{loading
|
|
||||||
? t("templates.installing", { defaultValue: "安装中..." })
|
|
||||||
: t("templates.install", { defaultValue: "安装" })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Trash2, ExternalLink, Plus, Loader2 } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { settingsApi } from "@/lib/api";
|
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
|
||||||
import {
|
|
||||||
useTemplateRepos,
|
|
||||||
useAddTemplateRepo,
|
|
||||||
useRemoveTemplateRepo,
|
|
||||||
useToggleTemplateRepo,
|
|
||||||
} from "@/lib/query/template";
|
|
||||||
|
|
||||||
interface RepoManagerProps {
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RepoManager({ onClose }: RepoManagerProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [repoUrl, setRepoUrl] = useState("");
|
|
||||||
const [branch, setBranch] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
const { data: repos = [], isLoading } = useTemplateRepos();
|
|
||||||
const addRepoMutation = useAddTemplateRepo();
|
|
||||||
const removeRepoMutation = useRemoveTemplateRepo();
|
|
||||||
const toggleRepoMutation = useToggleTemplateRepo();
|
|
||||||
|
|
||||||
const parseRepoUrl = (
|
|
||||||
url: string,
|
|
||||||
): { owner: string; name: string } | null => {
|
|
||||||
let cleaned = url.trim();
|
|
||||||
cleaned = cleaned.replace(/^https?:\/\/github\.com\//, "");
|
|
||||||
cleaned = cleaned.replace(/\.git$/, "");
|
|
||||||
|
|
||||||
const parts = cleaned.split("/");
|
|
||||||
if (parts.length === 2 && parts[0] && parts[1]) {
|
|
||||||
return { owner: parts[0], name: parts[1] };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
|
||||||
setError("");
|
|
||||||
|
|
||||||
const parsed = parseRepoUrl(repoUrl);
|
|
||||||
if (!parsed) {
|
|
||||||
setError(
|
|
||||||
t("templates.repo.invalidUrl", {
|
|
||||||
defaultValue: "仓库地址格式不正确",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addRepoMutation.mutateAsync({
|
|
||||||
owner: parsed.owner,
|
|
||||||
name: parsed.name,
|
|
||||||
branch: branch || "main",
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success(
|
|
||||||
t("templates.repo.addSuccess", {
|
|
||||||
owner: parsed.owner,
|
|
||||||
name: parsed.name,
|
|
||||||
defaultValue: `已添加仓库 ${parsed.owner}/${parsed.name}`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
setRepoUrl("");
|
|
||||||
setBranch("");
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
||||||
setError(
|
|
||||||
t("templates.repo.addFailed", {
|
|
||||||
defaultValue: "添加仓库失败",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
toast.error(
|
|
||||||
t("templates.repo.addFailed", { defaultValue: "添加仓库失败" }),
|
|
||||||
{
|
|
||||||
description: errorMessage,
|
|
||||||
duration: 8000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemove = async (id: number, owner: string, name: string) => {
|
|
||||||
try {
|
|
||||||
await removeRepoMutation.mutateAsync(id);
|
|
||||||
toast.success(
|
|
||||||
t("templates.repo.removeSuccess", {
|
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
defaultValue: `已移除仓库 ${owner}/${name}`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
||||||
toast.error(
|
|
||||||
t("templates.repo.removeFailed", { defaultValue: "移除仓库失败" }),
|
|
||||||
{
|
|
||||||
description: errorMessage,
|
|
||||||
duration: 8000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggle = async (id: number, enabled: boolean) => {
|
|
||||||
try {
|
|
||||||
await toggleRepoMutation.mutateAsync({ id, enabled });
|
|
||||||
toast.success(
|
|
||||||
enabled
|
|
||||||
? t("templates.repo.enableSuccess", { defaultValue: "已启用仓库" })
|
|
||||||
: t("templates.repo.disableSuccess", { defaultValue: "已禁用仓库" }),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
||||||
toast.error(
|
|
||||||
t("templates.repo.toggleFailed", {
|
|
||||||
defaultValue: "切换仓库状态失败",
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
description: errorMessage,
|
|
||||||
duration: 8000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenRepo = async (owner: string, name: string) => {
|
|
||||||
try {
|
|
||||||
await settingsApi.openExternal(`https://github.com/${owner}/${name}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to open URL:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FullScreenPanel
|
|
||||||
isOpen={true}
|
|
||||||
title={t("templates.repo.title", { defaultValue: "模板仓库管理" })}
|
|
||||||
onClose={onClose}
|
|
||||||
>
|
|
||||||
{/* 添加仓库表单 */}
|
|
||||||
<div className="space-y-4 glass-card rounded-xl p-6">
|
|
||||||
<h3 className="text-base font-semibold text-foreground">
|
|
||||||
{t("templates.repo.addTitle", { defaultValue: "添加模板仓库" })}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="repo-url" className="text-foreground">
|
|
||||||
{t("templates.repo.url", { defaultValue: "仓库地址" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="repo-url"
|
|
||||||
placeholder={t("templates.repo.urlPlaceholder", {
|
|
||||||
defaultValue: "owner/repo 或 https://github.com/owner/repo",
|
|
||||||
})}
|
|
||||||
value={repoUrl}
|
|
||||||
onChange={(e) => setRepoUrl(e.target.value)}
|
|
||||||
className="mt-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="branch" className="text-foreground">
|
|
||||||
{t("templates.repo.branch", { defaultValue: "分支" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="branch"
|
|
||||||
placeholder={t("templates.repo.branchPlaceholder", {
|
|
||||||
defaultValue: "main",
|
|
||||||
})}
|
|
||||||
value={branch}
|
|
||||||
onChange={(e) => setBranch(e.target.value)}
|
|
||||||
className="mt-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={handleAdd}
|
|
||||||
disabled={addRepoMutation.isPending}
|
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{addRepoMutation.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{t("templates.repo.add", { defaultValue: "添加仓库" })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 仓库列表 */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-base font-semibold text-foreground">
|
|
||||||
{t("templates.repo.list", { defaultValue: "仓库列表" })}
|
|
||||||
</h3>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12 glass-card rounded-xl">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : repos.length === 0 ? (
|
|
||||||
<div className="text-center py-12 glass-card rounded-xl">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("templates.repo.empty", { defaultValue: "暂无仓库" })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{repos.map((repo) => (
|
|
||||||
<div
|
|
||||||
key={repo.id ?? `${repo.owner}-${repo.name}`}
|
|
||||||
className="flex items-center justify-between glass-card rounded-xl px-4 py-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
|
||||||
<Switch
|
|
||||||
checked={repo.enabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
repo.id !== null && handleToggle(repo.id, checked)
|
|
||||||
}
|
|
||||||
disabled={toggleRepoMutation.isPending || repo.id === null}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium text-foreground">
|
|
||||||
{repo.owner}/{repo.name}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{t("templates.repo.branch", { defaultValue: "分支" })}:{" "}
|
|
||||||
{repo.branch}
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant={repo.enabled ? "default" : "secondary"}
|
|
||||||
className="text-[10px] px-1.5 py-0 h-4"
|
|
||||||
>
|
|
||||||
{repo.enabled
|
|
||||||
? t("templates.repo.enabled", {
|
|
||||||
defaultValue: "已启用",
|
|
||||||
})
|
|
||||||
: t("templates.repo.disabled", {
|
|
||||||
defaultValue: "已禁用",
|
|
||||||
})}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleOpenRepo(repo.owner, repo.name)}
|
|
||||||
title={t("common.view", { defaultValue: "查看" })}
|
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
repo.id !== null &&
|
|
||||||
handleRemove(repo.id, repo.owner, repo.name)
|
|
||||||
}
|
|
||||||
disabled={removeRepoMutation.isPending || repo.id === null}
|
|
||||||
title={t("common.delete", { defaultValue: "删除" })}
|
|
||||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FullScreenPanel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
import { useState, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { RefreshCw, Search, Settings, Loader2 } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ComponentCard } from "./ComponentCard";
|
|
||||||
import { ComponentDetail } from "./ComponentDetail";
|
|
||||||
import { CategoryFilter } from "./CategoryFilter";
|
|
||||||
import { RepoManager } from "./RepoManager";
|
|
||||||
import { BundleList } from "./BundleList";
|
|
||||||
import {
|
|
||||||
useTemplateComponents,
|
|
||||||
useComponentCategories,
|
|
||||||
useInstallTemplateComponent,
|
|
||||||
useUninstallTemplateComponent,
|
|
||||||
useRefreshTemplateIndex,
|
|
||||||
} from "@/lib/query/template";
|
|
||||||
import type { ComponentType, TemplateComponent } from "@/types/template";
|
|
||||||
import type { AppType } from "@/lib/api/config";
|
|
||||||
|
|
||||||
interface TemplatesPageProps {
|
|
||||||
activeApp: AppType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TemplatesPage({ activeApp }: TemplatesPageProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [selectedType, setSelectedType] = useState<ComponentType | "bundle">(
|
|
||||||
"bundle",
|
|
||||||
);
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | undefined>(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
|
|
||||||
const [detailComponent, setDetailComponent] = useState<
|
|
||||||
TemplateComponent | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
// Queries
|
|
||||||
const {
|
|
||||||
data: componentsData,
|
|
||||||
isLoading: componentsLoading,
|
|
||||||
refetch: refetchComponents,
|
|
||||||
} = useTemplateComponents({
|
|
||||||
componentType: selectedType === "bundle" ? undefined : selectedType,
|
|
||||||
category: selectedCategory,
|
|
||||||
search: searchQuery || undefined,
|
|
||||||
appType: activeApp,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: categories = [] } = useComponentCategories(
|
|
||||||
selectedType === "bundle" ? undefined : selectedType,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mutations
|
|
||||||
const installMutation = useInstallTemplateComponent();
|
|
||||||
const uninstallMutation = useUninstallTemplateComponent();
|
|
||||||
const refreshMutation = useRefreshTemplateIndex();
|
|
||||||
|
|
||||||
const handleInstall = async (id: number, name: string) => {
|
|
||||||
try {
|
|
||||||
await installMutation.mutateAsync({ id, appType: activeApp });
|
|
||||||
toast.success(
|
|
||||||
t("templates.installSuccess", { name, defaultValue: `已安装 ${name}` }),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
toast.error(
|
|
||||||
t("templates.installFailed", {
|
|
||||||
name,
|
|
||||||
defaultValue: `安装 ${name} 失败`,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
description: errorMessage,
|
|
||||||
duration: 8000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
console.error("Install component failed:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUninstall = async (id: number, name: string) => {
|
|
||||||
try {
|
|
||||||
await uninstallMutation.mutateAsync({ id, appType: activeApp });
|
|
||||||
toast.success(
|
|
||||||
t("templates.uninstallSuccess", {
|
|
||||||
name,
|
|
||||||
defaultValue: `已卸载 ${name}`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
toast.error(
|
|
||||||
t("templates.uninstallFailed", {
|
|
||||||
name,
|
|
||||||
defaultValue: `卸载 ${name} 失败`,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
description: errorMessage,
|
|
||||||
duration: 8000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
console.error("Uninstall component failed:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
try {
|
|
||||||
await refreshMutation.mutateAsync();
|
|
||||||
await refetchComponents();
|
|
||||||
toast.success(
|
|
||||||
t("templates.refreshSuccess", { defaultValue: "刷新成功" }),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
toast.error(t("templates.refreshFailed", { defaultValue: "刷新失败" }), {
|
|
||||||
description: errorMessage,
|
|
||||||
duration: 8000,
|
|
||||||
});
|
|
||||||
console.error("Refresh index failed:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const components = componentsData?.items || [];
|
|
||||||
|
|
||||||
// 过滤组件
|
|
||||||
const filteredComponents = useMemo(() => {
|
|
||||||
if (!searchQuery.trim()) return components;
|
|
||||||
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
return components.filter((component) => {
|
|
||||||
const name = component.name?.toLowerCase() || "";
|
|
||||||
const description = component.description?.toLowerCase() || "";
|
|
||||||
const category = component.category?.toLowerCase() || "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
name.includes(query) ||
|
|
||||||
description.includes(query) ||
|
|
||||||
category.includes(query)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [components, searchQuery]);
|
|
||||||
|
|
||||||
const componentTypeOptions: {
|
|
||||||
value: ComponentType | "bundle";
|
|
||||||
icon: string;
|
|
||||||
}[] = [
|
|
||||||
{ value: "bundle", icon: "📦" },
|
|
||||||
{ value: "agent", icon: "🤖" },
|
|
||||||
{ value: "command", icon: "⚡" },
|
|
||||||
{ value: "mcp", icon: "🔌" },
|
|
||||||
{ value: "setting", icon: "⚙️" },
|
|
||||||
{ value: "hook", icon: "🪝" },
|
|
||||||
{ value: "skill", icon: "💡" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-[80rem] px-6 flex h-[calc(100vh-8rem)] overflow-hidden bg-background/50">
|
|
||||||
{/* 左侧分类过滤器 */}
|
|
||||||
<div className="w-48 shrink-0 mr-6 overflow-y-auto">
|
|
||||||
<CategoryFilter
|
|
||||||
categories={categories}
|
|
||||||
selectedCategory={selectedCategory}
|
|
||||||
onSelectCategory={setSelectedCategory}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧主内容区 */}
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
{/* 顶部工具栏 */}
|
|
||||||
<div className="mb-6 space-y-4">
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="flex items-center justify-end gap-4">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={refreshMutation.isPending}
|
|
||||||
>
|
|
||||||
{refreshMutation.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{t("templates.refresh", { defaultValue: "刷新索引" })}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setRepoManagerOpen(true)}
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
|
||||||
{t("templates.manageRepos", { defaultValue: "管理仓库" })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 搜索框 */}
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder={t("templates.searchPlaceholder", {
|
|
||||||
defaultValue: "搜索组件...",
|
|
||||||
})}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-9 pr-3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 类型标签页 */}
|
|
||||||
<Tabs
|
|
||||||
value={selectedType}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setSelectedType(value as ComponentType | "bundle");
|
|
||||||
setSelectedCategory(undefined);
|
|
||||||
}}
|
|
||||||
className="flex-1 flex flex-col overflow-hidden"
|
|
||||||
>
|
|
||||||
<TabsList className="w-full justify-start mb-4">
|
|
||||||
{componentTypeOptions.map((option) => (
|
|
||||||
<TabsTrigger key={option.value} value={option.value}>
|
|
||||||
<span className="mr-1.5">{option.icon}</span>
|
|
||||||
{t(`templates.type.${option.value}`, {
|
|
||||||
defaultValue: option.value,
|
|
||||||
})}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* 组合标签页 */}
|
|
||||||
<TabsContent value="bundle" className="flex-1 overflow-y-auto mt-0">
|
|
||||||
<div className="py-4">
|
|
||||||
<BundleList selectedApp={activeApp} />
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* 组件类型标签页 */}
|
|
||||||
{componentTypeOptions
|
|
||||||
.filter((opt) => opt.value !== "bundle")
|
|
||||||
.map((option) => (
|
|
||||||
<TabsContent
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
className="flex-1 overflow-y-auto mt-0"
|
|
||||||
>
|
|
||||||
<div className="py-4">
|
|
||||||
{componentsLoading ? (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : filteredComponents.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
|
||||||
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("templates.empty", { defaultValue: "暂无组件" })}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{t("templates.emptyDescription", {
|
|
||||||
defaultValue: "请添加模板仓库来获取组件",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{searchQuery && (
|
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
|
||||||
{t("templates.count", {
|
|
||||||
count: filteredComponents.length,
|
|
||||||
defaultValue: `找到 ${filteredComponents.length} 个组件`,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{filteredComponents.map((component) => (
|
|
||||||
<ComponentCard
|
|
||||||
key={
|
|
||||||
component.id ??
|
|
||||||
`${component.repoId}-${component.path}`
|
|
||||||
}
|
|
||||||
component={component}
|
|
||||||
onInstall={async () => {
|
|
||||||
if (component.id !== null) {
|
|
||||||
await handleInstall(
|
|
||||||
component.id,
|
|
||||||
component.name,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onUninstall={async () => {
|
|
||||||
if (component.id !== null) {
|
|
||||||
await handleUninstall(
|
|
||||||
component.id,
|
|
||||||
component.name,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onViewDetail={() => setDetailComponent(component)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 仓库管理弹窗 */}
|
|
||||||
{repoManagerOpen && (
|
|
||||||
<RepoManager onClose={() => setRepoManagerOpen(false)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 组件详情弹窗 */}
|
|
||||||
{detailComponent && detailComponent.id !== null && (
|
|
||||||
<ComponentDetail
|
|
||||||
componentId={detailComponent.id}
|
|
||||||
selectedApp={activeApp}
|
|
||||||
onClose={() => setDetailComponent(undefined)}
|
|
||||||
onInstall={handleInstall}
|
|
||||||
onUninstall={handleUninstall}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export { TemplatesPage } from "./TemplatesPage";
|
|
||||||
export { ComponentCard } from "./ComponentCard";
|
|
||||||
export { ComponentDetail } from "./ComponentDetail";
|
|
||||||
export { CategoryFilter } from "./CategoryFilter";
|
|
||||||
export { RepoManager } from "./RepoManager";
|
|
||||||
@@ -13,7 +13,7 @@ const buttonVariants = cva(
|
|||||||
"bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
"bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||||||
// 危险按钮:红底白字(对应旧版 danger)
|
// 危险按钮:红底白字(对应旧版 danger)
|
||||||
destructive:
|
destructive:
|
||||||
"bg-red-500 text-white hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600",
|
"bg-red-500 text-white hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700",
|
||||||
// 轮廓按钮
|
// 轮廓按钮
|
||||||
outline:
|
outline:
|
||||||
"border border-border-default bg-background hover:bg-gray-100 hover:border-border-hover dark:hover:bg-gray-800",
|
"border border-border-default bg-background hover:bg-gray-100 hover:border-border-hover dark:hover:bg-gray-800",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root;
|
const Select = SelectPrimitive.Root;
|
||||||
@@ -37,7 +37,7 @@ const SelectContent = React.forwardRef<
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
@@ -87,12 +87,6 @@ const SelectItem = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...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.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -54,7 +54,7 @@ export function useProviderActions(activeApp: AppId) {
|
|||||||
|
|
||||||
// 添加供应商
|
// 添加供应商
|
||||||
const addProvider = useCallback(
|
const addProvider = useCallback(
|
||||||
async (provider: Omit<Provider, "id">) => {
|
async (provider: Omit<Provider, "id"> & { providerKey?: string }) => {
|
||||||
await addProviderMutation.mutateAsync(provider);
|
await addProviderMutation.mutateAsync(provider);
|
||||||
},
|
},
|
||||||
[addProviderMutation],
|
[addProviderMutation],
|
||||||
@@ -115,6 +115,11 @@ export function useProviderActions(activeApp: AppId) {
|
|||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["providers", activeApp],
|
queryKey: ["providers", activeApp],
|
||||||
});
|
});
|
||||||
|
// 🔧 保存用量脚本后,也应该失效该 provider 的用量查询缓存
|
||||||
|
// 这样主页列表会使用新配置重新查询,而不是使用测试时的缓存
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["usage", provider.id, activeApp],
|
||||||
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
t("provider.usageSaved", {
|
t("provider.usageSaved", {
|
||||||
defaultValue: "用量查询配置已保存",
|
defaultValue: "用量查询配置已保存",
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ export function useProxyStatus() {
|
|||||||
? "Claude"
|
? "Claude"
|
||||||
: variables.appType === "codex"
|
: variables.appType === "codex"
|
||||||
? "Codex"
|
? "Codex"
|
||||||
: "Gemini";
|
: variables.appType === "gemini"
|
||||||
|
? "Gemini"
|
||||||
|
: "OpenCode";
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
variables.enabled
|
variables.enabled
|
||||||
|
|||||||
+46
-87
@@ -84,6 +84,10 @@
|
|||||||
"addClaudeProvider": "Add Claude Code Provider",
|
"addClaudeProvider": "Add Claude Code Provider",
|
||||||
"addCodexProvider": "Add Codex Provider",
|
"addCodexProvider": "Add Codex Provider",
|
||||||
"addGeminiProvider": "Add Gemini 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.",
|
"addProviderHint": "Fill in the information to quickly switch providers in the list.",
|
||||||
"editClaudeProvider": "Edit Claude Code Provider",
|
"editClaudeProvider": "Edit Claude Code Provider",
|
||||||
"editCodexProvider": "Edit Codex Provider",
|
"editCodexProvider": "Edit Codex Provider",
|
||||||
@@ -133,6 +137,8 @@
|
|||||||
"providerSaved": "Provider configuration saved",
|
"providerSaved": "Provider configuration saved",
|
||||||
"providerDeleted": "Provider deleted successfully",
|
"providerDeleted": "Provider deleted successfully",
|
||||||
"switchSuccess": "Switch successful!",
|
"switchSuccess": "Switch successful!",
|
||||||
|
"addToConfigSuccess": "Added to config",
|
||||||
|
"removeFromConfigSuccess": "Removed from config",
|
||||||
"switchFailedTitle": "Switch failed",
|
"switchFailedTitle": "Switch failed",
|
||||||
"switchFailed": "Switch failed: {{error}}",
|
"switchFailed": "Switch failed: {{error}}",
|
||||||
"autoImported": "Default provider created from existing configuration",
|
"autoImported": "Default provider created from existing configuration",
|
||||||
@@ -153,7 +159,9 @@
|
|||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"deleteProvider": "Delete Provider",
|
"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": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
@@ -304,7 +312,8 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"claude": "Claude Code",
|
"claude": "Claude Code",
|
||||||
"codex": "Codex",
|
"codex": "Codex",
|
||||||
"gemini": "Gemini"
|
"gemini": "Gemini",
|
||||||
|
"opencode": "OpenCode"
|
||||||
},
|
},
|
||||||
"console": {
|
"console": {
|
||||||
"providerSwitchReceived": "Received provider switch event:",
|
"providerSwitchReceived": "Received provider switch event:",
|
||||||
@@ -455,6 +464,36 @@
|
|||||||
"configMergeFailed": "Config merge failed: {{error}}",
|
"configMergeFailed": "Config merge failed: {{error}}",
|
||||||
"configReplaceFailed": "Config replace 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": {
|
"providerPreset": {
|
||||||
"label": "Provider Preset",
|
"label": "Provider Preset",
|
||||||
"custom": "Custom Configuration",
|
"custom": "Custom Configuration",
|
||||||
@@ -584,6 +623,7 @@
|
|||||||
"testFailed": "Test failed",
|
"testFailed": "Test failed",
|
||||||
"formatSuccess": "Format successful",
|
"formatSuccess": "Format successful",
|
||||||
"formatFailed": "Format failed",
|
"formatFailed": "Format failed",
|
||||||
|
"supportedVariables": "Supported Variables",
|
||||||
"variablesHint": "Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object",
|
"variablesHint": "Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object",
|
||||||
"scriptConfig": "Request configuration",
|
"scriptConfig": "Request configuration",
|
||||||
"extractorCode": "Extractor code",
|
"extractorCode": "Extractor code",
|
||||||
@@ -633,7 +673,8 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"claude": "Claude",
|
"claude": "Claude",
|
||||||
"codex": "Codex",
|
"codex": "Codex",
|
||||||
"gemini": "Gemini"
|
"gemini": "Gemini",
|
||||||
|
"opencode": "OpenCode"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"userLevelPath": "User-level MCP path",
|
"userLevelPath": "User-level MCP path",
|
||||||
@@ -941,7 +982,8 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"claude": "Claude",
|
"claude": "Claude",
|
||||||
"codex": "Codex",
|
"codex": "Codex",
|
||||||
"gemini": "Gemini"
|
"gemini": "Gemini",
|
||||||
|
"opencode": "OpenCode"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deeplink": {
|
"deeplink": {
|
||||||
@@ -1046,89 +1088,6 @@
|
|||||||
"circuitOpen": "Circuit Open",
|
"circuitOpen": "Circuit Open",
|
||||||
"consecutiveFailures": "{{count}} consecutive failures"
|
"consecutiveFailures": "{{count}} consecutive failures"
|
||||||
},
|
},
|
||||||
"templates": {
|
|
||||||
"title": "Template Market",
|
|
||||||
"search": "Search components...",
|
|
||||||
"refresh": "Refresh Index",
|
|
||||||
"refreshing": "Refreshing...",
|
|
||||||
"types": {
|
|
||||||
"agent": "Agent",
|
|
||||||
"command": "Command",
|
|
||||||
"mcp": "MCP Service",
|
|
||||||
"setting": "Setting",
|
|
||||||
"hook": "Hook",
|
|
||||||
"skill": "Skill"
|
|
||||||
},
|
|
||||||
"categories": {
|
|
||||||
"all": "All",
|
|
||||||
"security": "Security",
|
|
||||||
"development": "Development",
|
|
||||||
"database": "Database",
|
|
||||||
"web-tools": "Web Tools"
|
|
||||||
},
|
|
||||||
"card": {
|
|
||||||
"install": "Install",
|
|
||||||
"installed": "Installed",
|
|
||||||
"uninstall": "Uninstall",
|
|
||||||
"installing": "Installing..."
|
|
||||||
},
|
|
||||||
"detail": {
|
|
||||||
"description": "Description",
|
|
||||||
"metadata": "Metadata",
|
|
||||||
"content": "Content Preview",
|
|
||||||
"viewOnGithub": "View on GitHub",
|
|
||||||
"installTo": "Install to",
|
|
||||||
"type": "Type",
|
|
||||||
"category": "Category",
|
|
||||||
"model": "Model",
|
|
||||||
"tools": "Tools"
|
|
||||||
},
|
|
||||||
"repos": {
|
|
||||||
"title": "Template Repositories",
|
|
||||||
"add": "Add Repository",
|
|
||||||
"remove": "Remove",
|
|
||||||
"enable": "Enable",
|
|
||||||
"disable": "Disable",
|
|
||||||
"owner": "Owner",
|
|
||||||
"name": "Name",
|
|
||||||
"branch": "Branch"
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"refreshFailed": "Failed to refresh index",
|
|
||||||
"installFailed": "Installation failed",
|
|
||||||
"uninstallFailed": "Uninstallation failed",
|
|
||||||
"loadFailed": "Failed to load components"
|
|
||||||
},
|
|
||||||
"empty": {
|
|
||||||
"noComponents": "No components",
|
|
||||||
"noResults": "No matching components found"
|
|
||||||
},
|
|
||||||
"notifications": {
|
|
||||||
"installSuccess": "Component installed successfully",
|
|
||||||
"uninstallSuccess": "Component uninstalled successfully",
|
|
||||||
"refreshSuccess": "Index refreshed successfully"
|
|
||||||
},
|
|
||||||
"type": {
|
|
||||||
"bundle": "Bundle",
|
|
||||||
"agent": "Agent",
|
|
||||||
"command": "Command",
|
|
||||||
"mcp": "MCP",
|
|
||||||
"setting": "Setting",
|
|
||||||
"hook": "Hook",
|
|
||||||
"skill": "Skill"
|
|
||||||
},
|
|
||||||
"bundle": {
|
|
||||||
"empty": "No bundles",
|
|
||||||
"emptyDescription": "Add a template repository with components.json",
|
|
||||||
"installTo": "Install to:",
|
|
||||||
"componentCount": "{{count}} components",
|
|
||||||
"install": "Install Bundle",
|
|
||||||
"noMatch": "No matching components found",
|
|
||||||
"installSuccess": "Installed {{count}} components",
|
|
||||||
"partialFail": "{{count}} components failed to install",
|
|
||||||
"installFailed": "Failed to install bundle"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"proxy": {
|
"proxy": {
|
||||||
"panel": {
|
"panel": {
|
||||||
"serviceAddress": "Service Address",
|
"serviceAddress": "Service Address",
|
||||||
|
|||||||
+46
-87
@@ -84,6 +84,10 @@
|
|||||||
"addClaudeProvider": "Claude Code プロバイダーを追加",
|
"addClaudeProvider": "Claude Code プロバイダーを追加",
|
||||||
"addCodexProvider": "Codex プロバイダーを追加",
|
"addCodexProvider": "Codex プロバイダーを追加",
|
||||||
"addGeminiProvider": "Gemini プロバイダーを追加",
|
"addGeminiProvider": "Gemini プロバイダーを追加",
|
||||||
|
"addOpenCodeProvider": "OpenCode プロバイダーを追加",
|
||||||
|
"addToConfig": "追加",
|
||||||
|
"removeFromConfig": "削除",
|
||||||
|
"inConfig": "追加済み",
|
||||||
"addProviderHint": "一覧にすばやく切り替えられるよう、ここに情報を入力してください。",
|
"addProviderHint": "一覧にすばやく切り替えられるよう、ここに情報を入力してください。",
|
||||||
"editClaudeProvider": "Claude Code プロバイダーを編集",
|
"editClaudeProvider": "Claude Code プロバイダーを編集",
|
||||||
"editCodexProvider": "Codex プロバイダーを編集",
|
"editCodexProvider": "Codex プロバイダーを編集",
|
||||||
@@ -133,6 +137,8 @@
|
|||||||
"providerSaved": "プロバイダー設定を保存しました",
|
"providerSaved": "プロバイダー設定を保存しました",
|
||||||
"providerDeleted": "プロバイダーを削除しました",
|
"providerDeleted": "プロバイダーを削除しました",
|
||||||
"switchSuccess": "切り替え成功!",
|
"switchSuccess": "切り替え成功!",
|
||||||
|
"addToConfigSuccess": "設定に追加しました",
|
||||||
|
"removeFromConfigSuccess": "設定から削除しました",
|
||||||
"switchFailedTitle": "切り替えに失敗しました",
|
"switchFailedTitle": "切り替えに失敗しました",
|
||||||
"switchFailed": "切り替えに失敗しました: {{error}}",
|
"switchFailed": "切り替えに失敗しました: {{error}}",
|
||||||
"autoImported": "既存設定からデフォルトプロバイダーを自動作成しました",
|
"autoImported": "既存設定からデフォルトプロバイダーを自動作成しました",
|
||||||
@@ -153,7 +159,9 @@
|
|||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"deleteProvider": "プロバイダーを削除",
|
"deleteProvider": "プロバイダーを削除",
|
||||||
"deleteProviderMessage": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。"
|
"deleteProviderMessage": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||||
|
"removeProvider": "プロバイダーを解除",
|
||||||
|
"removeProviderMessage": "プロバイダー「{{name}}」を設定から解除してもよろしいですか?\n\n解除後、このプロバイダーは無効になりますが、設定データは CC Switch に保持されます。いつでも再追加できます。"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "設定",
|
"title": "設定",
|
||||||
@@ -304,7 +312,8 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"claude": "Claude Code",
|
"claude": "Claude Code",
|
||||||
"codex": "Codex",
|
"codex": "Codex",
|
||||||
"gemini": "Gemini"
|
"gemini": "Gemini",
|
||||||
|
"opencode": "OpenCode"
|
||||||
},
|
},
|
||||||
"console": {
|
"console": {
|
||||||
"providerSwitchReceived": "プロバイダー切り替えイベントを受信:",
|
"providerSwitchReceived": "プロバイダー切り替えイベントを受信:",
|
||||||
@@ -455,6 +464,36 @@
|
|||||||
"configMergeFailed": "設定のマージに失敗しました: {{error}}",
|
"configMergeFailed": "設定のマージに失敗しました: {{error}}",
|
||||||
"configReplaceFailed": "設定の置換に失敗しました: {{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": {
|
"providerPreset": {
|
||||||
"label": "プロバイダータイプ",
|
"label": "プロバイダータイプ",
|
||||||
"custom": "カスタム設定",
|
"custom": "カスタム設定",
|
||||||
@@ -584,6 +623,7 @@
|
|||||||
"testFailed": "テストに失敗しました",
|
"testFailed": "テストに失敗しました",
|
||||||
"formatSuccess": "整形に成功しました",
|
"formatSuccess": "整形に成功しました",
|
||||||
"formatFailed": "整形に失敗しました",
|
"formatFailed": "整形に失敗しました",
|
||||||
|
"supportedVariables": "使用可能な変数",
|
||||||
"variablesHint": "使用可能な変数: {{apiKey}}, {{baseUrl}} | extractor 関数には API 応答の JSON オブジェクトが渡されます",
|
"variablesHint": "使用可能な変数: {{apiKey}}, {{baseUrl}} | extractor 関数には API 応答の JSON オブジェクトが渡されます",
|
||||||
"scriptConfig": "リクエスト設定",
|
"scriptConfig": "リクエスト設定",
|
||||||
"extractorCode": "抽出コード",
|
"extractorCode": "抽出コード",
|
||||||
@@ -633,7 +673,8 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"claude": "Claude",
|
"claude": "Claude",
|
||||||
"codex": "Codex",
|
"codex": "Codex",
|
||||||
"gemini": "Gemini"
|
"gemini": "Gemini",
|
||||||
|
"opencode": "OpenCode"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"userLevelPath": "ユーザーレベルの MCP パス",
|
"userLevelPath": "ユーザーレベルの MCP パス",
|
||||||
@@ -941,7 +982,8 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"claude": "Claude",
|
"claude": "Claude",
|
||||||
"codex": "Codex",
|
"codex": "Codex",
|
||||||
"gemini": "Gemini"
|
"gemini": "Gemini",
|
||||||
|
"opencode": "OpenCode"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deeplink": {
|
"deeplink": {
|
||||||
@@ -1039,89 +1081,6 @@
|
|||||||
"agents": {
|
"agents": {
|
||||||
"title": "エージェント"
|
"title": "エージェント"
|
||||||
},
|
},
|
||||||
"templates": {
|
|
||||||
"title": "テンプレートマーケット",
|
|
||||||
"search": "コンポーネントを検索...",
|
|
||||||
"refresh": "インデックスを更新",
|
|
||||||
"refreshing": "更新中...",
|
|
||||||
"types": {
|
|
||||||
"agent": "エージェント",
|
|
||||||
"command": "コマンド",
|
|
||||||
"mcp": "MCP サービス",
|
|
||||||
"setting": "設定",
|
|
||||||
"hook": "フック",
|
|
||||||
"skill": "スキル"
|
|
||||||
},
|
|
||||||
"categories": {
|
|
||||||
"all": "すべて",
|
|
||||||
"security": "セキュリティ",
|
|
||||||
"development": "開発",
|
|
||||||
"database": "データベース",
|
|
||||||
"web-tools": "ウェブツール"
|
|
||||||
},
|
|
||||||
"card": {
|
|
||||||
"install": "インストール",
|
|
||||||
"installed": "インストール済み",
|
|
||||||
"uninstall": "アンインストール",
|
|
||||||
"installing": "インストール中..."
|
|
||||||
},
|
|
||||||
"detail": {
|
|
||||||
"description": "説明",
|
|
||||||
"metadata": "メタデータ",
|
|
||||||
"content": "コンテンツプレビュー",
|
|
||||||
"viewOnGithub": "GitHub で見る",
|
|
||||||
"installTo": "インストール先",
|
|
||||||
"type": "タイプ",
|
|
||||||
"category": "カテゴリ",
|
|
||||||
"model": "モデル",
|
|
||||||
"tools": "ツール"
|
|
||||||
},
|
|
||||||
"repos": {
|
|
||||||
"title": "テンプレートリポジトリ",
|
|
||||||
"add": "リポジトリを追加",
|
|
||||||
"remove": "削除",
|
|
||||||
"enable": "有効化",
|
|
||||||
"disable": "無効化",
|
|
||||||
"owner": "オーナー",
|
|
||||||
"name": "名前",
|
|
||||||
"branch": "ブランチ"
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"refreshFailed": "インデックスの更新に失敗しました",
|
|
||||||
"installFailed": "インストールに失敗しました",
|
|
||||||
"uninstallFailed": "アンインストールに失敗しました",
|
|
||||||
"loadFailed": "コンポーネントの読み込みに失敗しました"
|
|
||||||
},
|
|
||||||
"empty": {
|
|
||||||
"noComponents": "コンポーネントなし",
|
|
||||||
"noResults": "一致するコンポーネントが見つかりません"
|
|
||||||
},
|
|
||||||
"notifications": {
|
|
||||||
"installSuccess": "コンポーネントのインストールに成功しました",
|
|
||||||
"uninstallSuccess": "コンポーネントのアンインストールに成功しました",
|
|
||||||
"refreshSuccess": "インデックスの更新に成功しました"
|
|
||||||
},
|
|
||||||
"type": {
|
|
||||||
"bundle": "バンドル",
|
|
||||||
"agent": "エージェント",
|
|
||||||
"command": "コマンド",
|
|
||||||
"mcp": "MCP",
|
|
||||||
"setting": "設定",
|
|
||||||
"hook": "フック",
|
|
||||||
"skill": "スキル"
|
|
||||||
},
|
|
||||||
"bundle": {
|
|
||||||
"empty": "バンドルなし",
|
|
||||||
"emptyDescription": "components.json を含むテンプレートリポジトリを追加してください",
|
|
||||||
"installTo": "インストール先:",
|
|
||||||
"componentCount": "{{count}} 個のコンポーネント",
|
|
||||||
"install": "バンドルをインストール",
|
|
||||||
"noMatch": "一致するコンポーネントが見つかりません",
|
|
||||||
"installSuccess": "{{count}} 個のコンポーネントをインストールしました",
|
|
||||||
"partialFail": "{{count}} 個のコンポーネントのインストールに失敗しました",
|
|
||||||
"installFailed": "バンドルのインストールに失敗しました"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"health": {
|
"health": {
|
||||||
"operational": "正常",
|
"operational": "正常",
|
||||||
"degraded": "低下",
|
"degraded": "低下",
|
||||||
|
|||||||
+46
-87
@@ -84,6 +84,10 @@
|
|||||||
"addClaudeProvider": "添加 Claude Code 供应商",
|
"addClaudeProvider": "添加 Claude Code 供应商",
|
||||||
"addCodexProvider": "添加 Codex 供应商",
|
"addCodexProvider": "添加 Codex 供应商",
|
||||||
"addGeminiProvider": "添加 Gemini 供应商",
|
"addGeminiProvider": "添加 Gemini 供应商",
|
||||||
|
"addOpenCodeProvider": "添加 OpenCode 供应商",
|
||||||
|
"addToConfig": "添加",
|
||||||
|
"removeFromConfig": "移除",
|
||||||
|
"inConfig": "已添加",
|
||||||
"addProviderHint": "填写信息后即可在列表中快速切换供应商。",
|
"addProviderHint": "填写信息后即可在列表中快速切换供应商。",
|
||||||
"editClaudeProvider": "编辑 Claude Code 供应商",
|
"editClaudeProvider": "编辑 Claude Code 供应商",
|
||||||
"editCodexProvider": "编辑 Codex 供应商",
|
"editCodexProvider": "编辑 Codex 供应商",
|
||||||
@@ -133,6 +137,8 @@
|
|||||||
"providerSaved": "供应商配置已保存",
|
"providerSaved": "供应商配置已保存",
|
||||||
"providerDeleted": "供应商删除成功",
|
"providerDeleted": "供应商删除成功",
|
||||||
"switchSuccess": "切换成功!",
|
"switchSuccess": "切换成功!",
|
||||||
|
"addToConfigSuccess": "已添加到配置",
|
||||||
|
"removeFromConfigSuccess": "已从配置移除",
|
||||||
"switchFailedTitle": "切换失败",
|
"switchFailedTitle": "切换失败",
|
||||||
"switchFailed": "切换失败:{{error}}",
|
"switchFailed": "切换失败:{{error}}",
|
||||||
"autoImported": "已从现有配置创建默认供应商",
|
"autoImported": "已从现有配置创建默认供应商",
|
||||||
@@ -153,7 +159,9 @@
|
|||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"deleteProvider": "删除供应商",
|
"deleteProvider": "删除供应商",
|
||||||
"deleteProviderMessage": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。"
|
"deleteProviderMessage": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。",
|
||||||
|
"removeProvider": "移除供应商",
|
||||||
|
"removeProviderMessage": "确定要从配置中移除供应商 \"{{name}}\" 吗?\n\n移除后该供应商将不再生效,但配置数据会保留在 CC Switch 中,您可以随时重新添加。"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "设置",
|
"title": "设置",
|
||||||
@@ -304,7 +312,8 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"claude": "Claude Code",
|
"claude": "Claude Code",
|
||||||
"codex": "Codex",
|
"codex": "Codex",
|
||||||
"gemini": "Gemini"
|
"gemini": "Gemini",
|
||||||
|
"opencode": "OpenCode"
|
||||||
},
|
},
|
||||||
"console": {
|
"console": {
|
||||||
"providerSwitchReceived": "收到供应商切换事件:",
|
"providerSwitchReceived": "收到供应商切换事件:",
|
||||||
@@ -455,6 +464,36 @@
|
|||||||
"configMergeFailed": "配置合并失败: {{error}}",
|
"configMergeFailed": "配置合并失败: {{error}}",
|
||||||
"configReplaceFailed": "配置替换失败: {{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": {
|
"providerPreset": {
|
||||||
"label": "预设供应商",
|
"label": "预设供应商",
|
||||||
"custom": "自定义配置",
|
"custom": "自定义配置",
|
||||||
@@ -584,6 +623,7 @@
|
|||||||
"testFailed": "测试失败",
|
"testFailed": "测试失败",
|
||||||
"formatSuccess": "格式化成功",
|
"formatSuccess": "格式化成功",
|
||||||
"formatFailed": "格式化失败",
|
"formatFailed": "格式化失败",
|
||||||
|
"supportedVariables": "支持的变量",
|
||||||
"variablesHint": "支持变量: {{apiKey}}, {{baseUrl}} | extractor 函数接收 API 响应的 JSON 对象",
|
"variablesHint": "支持变量: {{apiKey}}, {{baseUrl}} | extractor 函数接收 API 响应的 JSON 对象",
|
||||||
"scriptConfig": "请求配置",
|
"scriptConfig": "请求配置",
|
||||||
"extractorCode": "提取器代码",
|
"extractorCode": "提取器代码",
|
||||||
@@ -633,7 +673,8 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"claude": "Claude",
|
"claude": "Claude",
|
||||||
"codex": "Codex",
|
"codex": "Codex",
|
||||||
"gemini": "Gemini"
|
"gemini": "Gemini",
|
||||||
|
"opencode": "OpenCode"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"userLevelPath": "用户级 MCP 配置路径",
|
"userLevelPath": "用户级 MCP 配置路径",
|
||||||
@@ -941,7 +982,8 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"claude": "Claude",
|
"claude": "Claude",
|
||||||
"codex": "Codex",
|
"codex": "Codex",
|
||||||
"gemini": "Gemini"
|
"gemini": "Gemini",
|
||||||
|
"opencode": "OpenCode"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deeplink": {
|
"deeplink": {
|
||||||
@@ -1046,89 +1088,6 @@
|
|||||||
"circuitOpen": "熔断",
|
"circuitOpen": "熔断",
|
||||||
"consecutiveFailures": "连续失败 {{count}} 次"
|
"consecutiveFailures": "连续失败 {{count}} 次"
|
||||||
},
|
},
|
||||||
"templates": {
|
|
||||||
"title": "模板市场",
|
|
||||||
"search": "搜索组件...",
|
|
||||||
"refresh": "刷新索引",
|
|
||||||
"refreshing": "刷新中...",
|
|
||||||
"types": {
|
|
||||||
"agent": "代理",
|
|
||||||
"command": "命令",
|
|
||||||
"mcp": "MCP 服务",
|
|
||||||
"setting": "设置",
|
|
||||||
"hook": "钩子",
|
|
||||||
"skill": "技能"
|
|
||||||
},
|
|
||||||
"categories": {
|
|
||||||
"all": "全部",
|
|
||||||
"security": "安全",
|
|
||||||
"development": "开发",
|
|
||||||
"database": "数据库",
|
|
||||||
"web-tools": "Web 工具"
|
|
||||||
},
|
|
||||||
"card": {
|
|
||||||
"install": "安装",
|
|
||||||
"installed": "已安装",
|
|
||||||
"uninstall": "卸载",
|
|
||||||
"installing": "安装中..."
|
|
||||||
},
|
|
||||||
"detail": {
|
|
||||||
"description": "描述",
|
|
||||||
"metadata": "元数据",
|
|
||||||
"content": "内容预览",
|
|
||||||
"viewOnGithub": "在 GitHub 上查看",
|
|
||||||
"installTo": "安装到",
|
|
||||||
"type": "类型",
|
|
||||||
"category": "分类",
|
|
||||||
"model": "模型",
|
|
||||||
"tools": "工具"
|
|
||||||
},
|
|
||||||
"repos": {
|
|
||||||
"title": "模板仓库",
|
|
||||||
"add": "添加仓库",
|
|
||||||
"remove": "删除",
|
|
||||||
"enable": "启用",
|
|
||||||
"disable": "禁用",
|
|
||||||
"owner": "所有者",
|
|
||||||
"name": "名称",
|
|
||||||
"branch": "分支"
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"refreshFailed": "刷新索引失败",
|
|
||||||
"installFailed": "安装失败",
|
|
||||||
"uninstallFailed": "卸载失败",
|
|
||||||
"loadFailed": "加载组件失败"
|
|
||||||
},
|
|
||||||
"empty": {
|
|
||||||
"noComponents": "暂无组件",
|
|
||||||
"noResults": "未找到匹配的组件"
|
|
||||||
},
|
|
||||||
"notifications": {
|
|
||||||
"installSuccess": "组件安装成功",
|
|
||||||
"uninstallSuccess": "组件卸载成功",
|
|
||||||
"refreshSuccess": "索引刷新成功"
|
|
||||||
},
|
|
||||||
"type": {
|
|
||||||
"bundle": "组合",
|
|
||||||
"agent": "代理",
|
|
||||||
"command": "命令",
|
|
||||||
"mcp": "MCP",
|
|
||||||
"setting": "设置",
|
|
||||||
"hook": "钩子",
|
|
||||||
"skill": "技能"
|
|
||||||
},
|
|
||||||
"bundle": {
|
|
||||||
"empty": "暂无组合",
|
|
||||||
"emptyDescription": "请添加包含 components.json 的模板仓库",
|
|
||||||
"installTo": "安装到:",
|
|
||||||
"componentCount": "{{count}} 个组件",
|
|
||||||
"install": "安装组合",
|
|
||||||
"noMatch": "未找到匹配的组件",
|
|
||||||
"installSuccess": "已安装 {{count}} 个组件",
|
|
||||||
"partialFail": "{{count}} 个组件安装失败",
|
|
||||||
"installFailed": "安装组合失败"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"proxy": {
|
"proxy": {
|
||||||
"panel": {
|
"panel": {
|
||||||
"serviceAddress": "服务地址",
|
"serviceAddress": "服务地址",
|
||||||
|
|||||||
@@ -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>`,
|
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>`,
|
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>`,
|
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);
|
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 |
@@ -38,6 +38,14 @@ export const providersApi = {
|
|||||||
return await invoke("delete_provider", { id, app: appId });
|
return await invoke("delete_provider", { id, app: appId });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove provider from live config only (for additive mode apps like OpenCode)
|
||||||
|
* Does NOT delete from database - provider remains in the list
|
||||||
|
*/
|
||||||
|
async removeFromLiveConfig(id: string, appId: AppId): Promise<boolean> {
|
||||||
|
return await invoke("remove_provider_from_live_config", { id, app: appId });
|
||||||
|
},
|
||||||
|
|
||||||
async switch(id: string, appId: AppId): Promise<boolean> {
|
async switch(id: string, appId: AppId): Promise<boolean> {
|
||||||
return await invoke("switch_provider", { id, app: appId });
|
return await invoke("switch_provider", { id, app: appId });
|
||||||
},
|
},
|
||||||
@@ -74,6 +82,22 @@ export const providersApi = {
|
|||||||
async openTerminal(providerId: string, appId: AppId): Promise<boolean> {
|
async openTerminal(providerId: string, appId: AppId): Promise<boolean> {
|
||||||
return await invoke("open_provider_terminal", { providerId, app: appId });
|
return await invoke("open_provider_terminal", { providerId, app: appId });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 OpenCode live 配置导入供应商到数据库
|
||||||
|
* OpenCode 特有功能:由于累加模式,用户可能已在 opencode.json 中配置供应商
|
||||||
|
*/
|
||||||
|
async importOpenCodeFromLive(): Promise<number> {
|
||||||
|
return await invoke("import_opencode_providers_from_live");
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 OpenCode live 配置中的供应商 ID 列表
|
||||||
|
* 用于前端判断供应商是否已添加到 opencode.json
|
||||||
|
*/
|
||||||
|
async getOpenCodeLiveProviderIds(): Promise<string[]> {
|
||||||
|
return await invoke("get_opencode_live_provider_ids");
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
|
|
||||||
// ========== 类型定义 ==========
|
// ========== 类型定义 ==========
|
||||||
|
|
||||||
export type AppType = "claude" | "codex" | "gemini";
|
export type AppType = "claude" | "codex" | "gemini" | "opencode";
|
||||||
|
|
||||||
/** Skill 应用启用状态 */
|
/** Skill 应用启用状态 */
|
||||||
export interface SkillApps {
|
export interface SkillApps {
|
||||||
claude: boolean;
|
claude: boolean;
|
||||||
codex: boolean;
|
codex: boolean;
|
||||||
gemini: boolean;
|
gemini: boolean;
|
||||||
|
opencode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 已安装的 Skill(v3.10.0+ 统一结构) */
|
/** 已安装的 Skill(v3.10.0+ 统一结构) */
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import type {
|
|
||||||
TemplateRepo,
|
|
||||||
TemplateComponent,
|
|
||||||
ComponentDetail,
|
|
||||||
PaginatedResult,
|
|
||||||
ComponentFilter,
|
|
||||||
BatchInstallResult,
|
|
||||||
InstalledComponent,
|
|
||||||
ComponentType,
|
|
||||||
} from "@/types/template";
|
|
||||||
import type { AppType } from "./config";
|
|
||||||
|
|
||||||
export const templateApi = {
|
|
||||||
// 模板仓库管理
|
|
||||||
async listTemplateRepos(): Promise<TemplateRepo[]> {
|
|
||||||
return invoke("list_template_repos");
|
|
||||||
},
|
|
||||||
|
|
||||||
async addTemplateRepo(
|
|
||||||
owner: string,
|
|
||||||
name: string,
|
|
||||||
branch: string,
|
|
||||||
): Promise<void> {
|
|
||||||
return invoke("add_template_repo", { owner, name, branch });
|
|
||||||
},
|
|
||||||
|
|
||||||
async removeTemplateRepo(id: number): Promise<void> {
|
|
||||||
return invoke("remove_template_repo", { id });
|
|
||||||
},
|
|
||||||
|
|
||||||
async toggleTemplateRepo(id: number, enabled: boolean): Promise<void> {
|
|
||||||
return invoke("toggle_template_repo", { id, enabled });
|
|
||||||
},
|
|
||||||
|
|
||||||
// 模板索引刷新
|
|
||||||
async refreshTemplateIndex(): Promise<void> {
|
|
||||||
return invoke("refresh_template_index");
|
|
||||||
},
|
|
||||||
|
|
||||||
// 模板组件查询
|
|
||||||
async listTemplateComponents(
|
|
||||||
filter: ComponentFilter,
|
|
||||||
): Promise<PaginatedResult<TemplateComponent>> {
|
|
||||||
return invoke("list_template_components", {
|
|
||||||
componentType: filter.componentType,
|
|
||||||
category: filter.category,
|
|
||||||
search: filter.search,
|
|
||||||
page: filter.page ?? 1,
|
|
||||||
pageSize: filter.pageSize ?? 20,
|
|
||||||
appType: filter.appType,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async getTemplateComponent(id: number): Promise<ComponentDetail> {
|
|
||||||
return invoke("get_template_component", { id });
|
|
||||||
},
|
|
||||||
|
|
||||||
async getComponentCategories(
|
|
||||||
componentType?: ComponentType,
|
|
||||||
): Promise<string[]> {
|
|
||||||
return invoke("list_template_categories", { componentType });
|
|
||||||
},
|
|
||||||
|
|
||||||
// 组件安装管理
|
|
||||||
async installTemplateComponent(id: number, appType: AppType): Promise<void> {
|
|
||||||
return invoke("install_template_component", { id, appType });
|
|
||||||
},
|
|
||||||
|
|
||||||
async uninstallTemplateComponent(
|
|
||||||
id: number,
|
|
||||||
appType: AppType,
|
|
||||||
): Promise<void> {
|
|
||||||
return invoke("uninstall_template_component", { id, appType });
|
|
||||||
},
|
|
||||||
|
|
||||||
async batchInstallComponents(
|
|
||||||
ids: number[],
|
|
||||||
appType: AppType,
|
|
||||||
): Promise<BatchInstallResult> {
|
|
||||||
return invoke("batch_install_template_components", { ids, appType });
|
|
||||||
},
|
|
||||||
|
|
||||||
async listInstalledComponents(
|
|
||||||
appType?: AppType,
|
|
||||||
componentType?: ComponentType,
|
|
||||||
): Promise<InstalledComponent[]> {
|
|
||||||
return invoke("list_installed_components", { appType, componentType });
|
|
||||||
},
|
|
||||||
|
|
||||||
// 组件内容预览
|
|
||||||
async previewComponentContent(id: number): Promise<string> {
|
|
||||||
return invoke("preview_component_content", { id });
|
|
||||||
},
|
|
||||||
|
|
||||||
// 市场组合
|
|
||||||
async listMarketplaceBundles(): Promise<
|
|
||||||
import("@/types/template").MarketplaceBundle[]
|
|
||||||
> {
|
|
||||||
return invoke("list_marketplace_bundles");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
// 前端统一使用 AppId 作为应用标识(与后端命令参数 `app` 一致)
|
// 前端统一使用 AppId 作为应用标识(与后端命令参数 `app` 一致)
|
||||||
export type AppId = "claude" | "codex" | "gemini"; // 新增 gemini
|
export type AppId = "claude" | "codex" | "gemini" | "opencode";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user