Compare commits

..

44 Commits

Author SHA1 Message Date
Jason c847fff768 feat(provider): update OpenCode presets with Claude 4.5 models and SDK changes 2026-01-19 16:35:39 +08:00
Jason 1eb0a0d7ac fix(opencode): move model options add button to bottom 2026-01-19 15:32:56 +08:00
Jason 3bd3845ec0 feat(opencode): add model-level options editor
Add support for configuring per-model options like provider routing.
Each model row now has an expand/collapse toggle to show a key-value
editor for model-specific options (e.g., provider order, fallbacks).

- Add options field to OpenCodeModel in Rust and TypeScript
- Add expandable key-value editor UI for each model
- Use local state pattern for option key input to prevent focus loss
- Add i18n translations for zh/en/ja
2026-01-19 15:05:01 +08:00
Jason b0d0a2c466 feat(opencode): add extra options editor for SDK configuration
Add key-value pair editor for configuring additional SDK options like
timeout, setCacheKey, etc. Values are automatically parsed to appropriate
types (number, boolean, object) on save.

- Add `extra` field with serde flatten in Rust backend
- Add index signature to OpenCodeProviderOptions type
- Create ExtraOptionKeyInput component with local state pattern
- Place extra options section above models configuration
2026-01-19 11:42:24 +08:00
Jason 73013c10af feat(opencode): add column headers for model configuration 2026-01-18 21:57:36 +08:00
Jason 1a0872c153 fix(opencode): prevent model ID input focus loss on keystroke
Use local state + onBlur pattern for ModelIdInput to keep React key
stable during editing. Previously, each keystroke changed the object
key, causing React to unmount/remount the input and lose focus.
2026-01-18 21:52:44 +08:00
Jason 255a7f570a fix(opencode): use AGENTS.md as prompt filename
OpenCode follows the same convention as Codex, using AGENTS.md
instead of OPENCODE.md for the system prompt file.
2026-01-17 23:36:51 +08:00
Jason fb44fb136f fix(opencode): hide test model button for unsupported adapter
OpenCode lacks a dedicated adapter and falls back to Codex adapter,
which has incompatible config structure. Hide the test button in UI
to prevent users from triggering unsupported operations.
2026-01-17 22:42:56 +08:00
Jason 58d3bb89d2 fix(opencode): generate unique provider key when duplicating
OpenCode uses user-provided provider keys as IDs instead of random UUIDs.
When duplicating a provider, generate a unique key by appending "-copy"
suffix (or "-copy-2", "-copy-3", etc. if already exists).
2026-01-17 22:10:08 +08:00
Jason 403227c901 feat(opencode): import providers and MCP on first launch
Add startup import logic for OpenCode providers and MCP servers.
Unlike other apps that use replacement mode, OpenCode uses additive
mode where multiple providers can coexist in the config file.
2026-01-17 21:15:16 +08:00
Jason 5bcf5bf382 feat(opencode): add manual provider key input with duplicate check
- Add Provider Key input field for OpenCode providers (between icon and name)
- User must manually enter a unique key instead of auto-generating from name
- Real-time validation: format check and duplicate detection
- Key is immutable after creation (disabled in edit mode)
- Remove slugify auto-generation logic from mutations
- Add beforeNameSlot prop to BasicFormFields for extensibility
- Add i18n translations for zh/en/ja
2026-01-17 20:28:11 +08:00
Jason ad6f5b388b fix(opencode): fix add/remove provider flow and toast messages
- Create separate removeFromLiveConfig API for additive mode apps
  (remove only removes from live config, not database)
- Fix useSwitchProviderMutation to invalidate opencodeLiveProviderIds
  cache so button state updates correctly after add operation
- Show appropriate toast messages:
  - Add: "已添加到配置" / "Added to config"
  - Remove: "已从配置移除" / "Removed from config"
- Add i18n texts for addToConfigSuccess and removeFromConfigSuccess
2026-01-17 17:51:32 +08:00
Jason 2844f7c557 fix(opencode): distinguish remove and delete confirmation dialogs
Separate the confirmation dialogs for "remove from config" and "delete
provider" operations in OpenCode mode to help users understand the
different impacts of each action.
2026-01-17 16:45:31 +08:00
Jason 882c73234f fix(opencode): enable usage auto-query for providers in config
For OpenCode (additive mode), use isInConfig instead of isCurrent to
determine whether to enable usage auto-query. This allows providers
that have been added to the config to have their usage queried
automatically.
2026-01-17 15:48:44 +08:00
Jason b70de25de4 fix(opencode): allow delete button for all providers in additive mode
OpenCode uses additive mode where the main "Remove" button removes from
live config, while the delete button should delete from database. The
delete button should always be enabled for OpenCode providers.
2026-01-17 15:29:02 +08:00
Jason 88dbeb5335 fix(opencode): remove current provider concept for additive mode
OpenCode uses additive mode where all providers coexist in config file,
so there's no "current" provider concept. This commit:

- Skip setting is_current in switch_normal for OpenCode
- Return empty string from ProviderService::current for OpenCode
- Disable active provider highlight in ProviderCard for OpenCode
2026-01-17 15:24:09 +08:00
Jason 966d7b5782 fix(opencode): show Base URL field for all SDK types
Previously Base URL was only shown for @ai-sdk/openai-compatible.
Now it's always visible to support proxy scenarios for official SDKs
like DeepSeek, Anthropic, etc.
2026-01-17 11:46:50 +08:00
Jason 42a92c712a fix(opencode): skip reading live config when editing provider
OpenCode's read_live_settings returns the full opencode.json file
instead of just the provider fragment. This caused the edit dialog
to save the complete config structure as settingsConfig, creating
nested provider configurations.

For OpenCode's additive mode, use DB config directly since each
provider's config is stored independently.
2026-01-17 11:34:39 +08:00
Jason 2f0998c6c8 fix(opencode): prevent config nesting and use slugified provider IDs
- Skip backfill logic for OpenCode (additive mode doesn't need it)
- Add defensive check in write_live_snapshot to extract provider fragment
- Use slugified name as provider ID for readable config keys
2026-01-17 11:16:58 +08:00
Jason 938e2eb563 feat(opencode): add provider presets and fix preset selection handler
- Add 19 new provider presets for OpenCode (cn_official, aggregator, third_party)
- Add OpenCode handling branch in handlePresetChange to properly populate
  form fields (baseURL, apiKey, npm, models) when selecting a preset
- Add OpenCode reset logic in custom mode branch
2026-01-16 22:33:04 +08:00
Jason 2cc36b3950 fix(opencode): add OpenCode support to skills functionality
Add missing OpenCode branch in parse_app_type() and include OpenCode
in all app iteration loops for skills operations (uninstall, scan,
import, migrate).
2026-01-16 21:23:54 +08:00
Jason e06c6176d9 fix(opencode): hide common config snippet UI and prevent auto-merge
- Add `enabled` parameter to useCommonConfigSnippet hook
- Skip all loading and auto-merge logic when enabled=false
- Replace CommonConfigEditor with simplified JsonEditor for OpenCode
- Prevent Claude's common config snippet from being injected into OpenCode
2026-01-16 21:03:33 +08:00
Jason 9b4485e111 refactor(opencode): simplify API format selector
- Reduce npm package options from 10 to 4 core API formats (OpenAI, OpenAI Compatible, Anthropic, Google)
- Rename "AI SDK Package" to "API Format" in i18n (zh/en/ja)
- Remove check icon from Select dropdown items for cleaner UI
2026-01-16 20:29:12 +08:00
Jason f349d85e85 fix(ui): resolve Select dropdown not appearing in FullScreenPanel
- Increase SelectContent z-index from z-50 to z-[100] to appear above FullScreenPanel (z-[60])
- Replace form.watch() with form.getValues() in useCallback handlers for correct react-hook-form usage
- Remove max-w-[56rem] constraints from various panels for consistent full-width layout
2026-01-16 20:21:00 +08:00
Jason 58a13cc69a feat(provider): hide universal tab for OpenCode
OpenCode doesn't support universal providers, so the tab is
hidden to avoid confusion for users.
2026-01-16 16:09:43 +08:00
Jason d765364a18 chore(opencode): remove unused functions and legacy code
Remove dead code that was never called after v3.7.0 architecture change:
- mcp/opencode.rs: sync_enabled_to_opencode, collect_enabled_servers
- opencode_config.rs: 8 unused utility functions
- provider.rs: OpenCodeProviderConfig impl block (4 methods)

These functions were designed for batch operations but McpService uses
per-server sync pattern instead. No functionality affected.
2026-01-16 15:56:03 +08:00
Jason 5c6956b6e2 feat(opencode): add OpenCode toggle switches to MCP and Skills panels
- Add opencode to AppType and SkillApps interfaces in skills.ts
- Add OpenCode Switch component to UnifiedMcpPanel list items
- Add OpenCode Switch component to UnifiedSkillsPanel list items
- Include OpenCode in enabled counts and header statistics for both panels
2026-01-16 15:40:33 +08:00
Jason cb1b45ae4e feat(opencode): add OpenCode icon to icon system
Register OpenCode SVG icon in the icons index so AppSwitcher displays
the proper logo instead of fallback initials.
2026-01-16 13:03:21 +08:00
Jason e4df1a32a5 feat(opencode): implement isInConfig semantics for additive provider management
OpenCode uses additive provider management where providers can exist in
the database but not necessarily in the live opencode.json config. This
commit implements proper isInConfig state:

Backend:
- Add get_opencode_live_provider_ids command to query live config

Frontend:
- Add getOpenCodeLiveProviderIds API method
- ProviderList queries live provider IDs and computes isInConfig
- ProviderCard receives and passes isInConfig to ProviderActions
- ProviderActions already handles the add/remove button logic
2026-01-15 19:37:35 +08:00
Jason 2494eaaa32 feat(opencode): hide proxy UI for OpenCode
OpenCode uses additive provider management (multiple providers coexist),
so proxy/failover features are not applicable. This commit:
- Conditionally renders ProxyToggle only for non-OpenCode apps
- Fixes toast message label to properly handle opencode app type
2026-01-15 19:32:34 +08:00
Jason 45b9cf1df0 feat(opencode): add OpenCode button to AppSwitcher
Add the fourth tab button for OpenCode in the app switcher component,
making the OpenCode providers page accessible from the main navigation.
2026-01-15 19:31:44 +08:00
Jason de3a22535d fix(opencode): address issues found during OpenCode integration review
- Fix MCP server not removed from opencode.json when unchecked in edit modal
- Fix Windows atomic write failure when opencode.json already exists
- Fix i18n keys mismatch in OpenCodeFormFields (use opencode.* namespace)
- Fix unit test missing apps.opencode field assertion
2026-01-15 19:07:49 +08:00
Jason 36d6d48002 feat(opencode): Phase 10 - Internationalization for OpenCode
Update i18n translation files (zh.json, en.json, ja.json):

- Add "opencode" to apps section: "OpenCode"
- Add provider section keys:
  - addOpenCodeProvider: Add OpenCode Provider
  - addToConfig: Add (for additive mode button)
  - removeFromConfig: Remove
  - inConfig: Added
- Add new "opencode" section with UI labels:
  - npmPackage: AI SDK Package
  - npmPackageHint: Package selection hint
  - baseUrl, baseUrlHint: Base URL configuration
  - models, modelsHint: Model configuration
  - addModel, modelId, modelName: Model editor labels
  - noModels: Empty state message
- Add "opencode" to mcp.unifiedPanel.apps section
- Add "opencode" to skills.apps section
2026-01-15 16:42:15 +08:00
Jason 864884926a feat(opencode): Phase 9 - Frontend UI components for OpenCode
- Create OpenCodeFormFields.tsx with:
  - NPM package selector (from AI SDK ecosystem)
  - API Key input using shared ApiKeySection component
  - Base URL input (shown for openai-compatible)
  - Dynamic models editor (add/remove models)

- Update ProviderForm.tsx:
  - Import OpenCode presets and form fields
  - Add OPENCODE_DEFAULT_CONFIG constant
  - Add OpenCode to PresetEntry type union
  - Add OpenCode preset entries in useMemo
  - Add OpenCode state hooks (npm, apiKey, baseUrl, models)
  - Add OpenCode change handlers syncing to form
  - Add OpenCodeFormFields rendering section
  - Add OpenCode config editor using CommonConfigEditor

- Update ProviderActions.tsx for OpenCode additive mode:
  - Add appId and isInConfig props
  - Implement "Add to Config" / "Remove from Config" buttons
  - Disable failover mode for OpenCode
  - Update delete button logic for additive mode

- Update ProviderCard.tsx:
  - Pass appId and isInConfig to ProviderActions

- Update AddProviderDialog.tsx:
  - Add OpenCode base URL extraction from options.baseURL
2026-01-15 16:38:26 +08:00
Jason 093ff0ba29 feat(opencode): Phase 8 - OpenCode provider presets configuration
- Create src/config/opencodeProviderPresets.ts with:
  - OpenCodeProviderPreset interface for preset structure
  - opencodeNpmPackages: AI SDK npm package options
  - Provider presets: OpenAI, Anthropic, Google, DeepSeek, Mistral, Groq
  - OpenAI Compatible custom template for third-party providers
- Each preset uses OpenCodeProviderConfig with npm, options, models structure
- Includes apiKeyUrl for quick access to API key pages
- Template values for dynamic API key input
2026-01-15 16:26:46 +08:00
Jason 21754a7349 feat(opencode): Phase 7 - Frontend TypeScript type definitions
- Add "opencode" to AppId type in lib/api/types.ts
- Extend McpApps interface with opencode field in types.ts
- Add OpenCode-specific types: OpenCodeModel, OpenCodeProviderOptions,
  OpenCodeProviderConfig, OpenCodeMcpServerSpec
- Add opencodeConfigDir to Settings interface
- Add importOpenCodeFromLive() to providersApi
- Fix type errors across components:
  - AppSwitcher: add opencode to icon/name mappings
  - McpFormModal: add opencode to enabledApps state
  - PromptFormModal/Panel: add opencode filename mapping
  - EndpointSpeedTest: add opencode timeout config
  - useBaseUrlState: add opencode to appType union
  - ProxyToggle: add opencode label
  - App.tsx: handle opencode fallback for SkillsPage
- Update ProxyTakeoverStatus with opencode field (always false)
- Fix test mocks in tests/msw/state.ts
2026-01-15 16:25:14 +08:00
Jason 7997b2c7b3 feat(opencode): Phase 6 - Tauri command extensions for OpenCode
- Add import_opencode_providers_from_live command to provider.rs
- Register new command in lib.rs invoke_handler
- Update commands/mcp.rs: include OpenCode in sync_other_side logic
- Add McpService::import_from_opencode to import_mcp_from_apps
- Implement MCP sync/remove for OpenCode in services/mcp.rs
  - sync_server_to_app_no_config now calls sync_single_server_to_opencode
  - remove_server_from_app now calls remove_server_from_opencode
2026-01-15 16:20:03 +08:00
Jason b8538b211d feat(opencode): complete Phase 5 - provider service layer
Implement OpenCode-specific provider service logic with additive mode:
- add(): Always write to live config (no is_current check needed)
- update(): Always sync changes to live config
- delete(): Remove from both DB and live config (no is_current check)

New helper functions in live.rs:
- write_live_snapshot(): Write provider to opencode.json provider section
- remove_opencode_provider_from_live(): Remove provider from live config
- import_opencode_providers_from_live(): Import existing providers from
  ~/.config/opencode/opencode.json into CC Switch database

Key design: OpenCode uses additive mode where all providers coexist
in the config file, unlike Claude/Codex/Gemini which use replacement
mode with a single active provider.
2026-01-15 16:15:01 +08:00
Jason 7ea2c3452b feat(opencode): complete Phase 4 - MCP sync module
Add mcp/opencode.rs with format conversion between CC Switch and OpenCode:
- stdio ↔ local type conversion
- command+args ↔ command array format
- env ↔ environment field mapping
- sse/http ↔ remote type conversion

Public API:
- sync_enabled_to_opencode: Batch sync all enabled servers
- sync_single_server_to_opencode: Sync individual server
- remove_server_from_opencode: Remove from live config
- import_from_opencode: Import servers from OpenCode config

Also fix test files to include new opencode field in McpApps struct.
All 4 unit tests pass for format conversion.
2026-01-15 16:11:25 +08:00
Jason a30d72bb68 feat(opencode): complete Phase 3 - enhanced config read/write module
Add typed provider functions and utilities to opencode_config.rs:
- Typed provider operations (get_typed_providers, get_typed_provider,
  set_typed_provider, set_providers_batch)
- MCP batch operations (set_mcp_servers_batch, clear_mcp_servers)
- Utility functions (create_provider_config, validate_provider_config,
  provider_to_opencode_config, opencode_config_to_provider)
- Enhanced module documentation with config file format examples

The typed API layer provides type-safe access to OpenCode provider
configurations while the untyped layer allows raw JSON operations
when needed.
2026-01-15 16:07:14 +08:00
Jason 58ecc44ee6 feat(opencode): Phase 2 - Add OpenCode provider data structures
Add OpenCode-specific configuration structures for the AI SDK format:

- OpenCodeProviderConfig: Main config with npm package, options, and models
- OpenCodeProviderOptions: baseURL, apiKey, and headers support
- OpenCodeModel: Model definition with name and token limits
- OpenCodeModelLimit: Context and output token limits

Key features:
- Supports environment variable references (e.g., "{env:API_KEY}")
- Custom headers support for specialized providers
- Model-level token limits for context management
- Helper methods for parsing and validation
2026-01-15 15:59:44 +08:00
Jason 5658d93924 feat(opencode): Phase 1 - Backend data structure expansion for OpenCode support
Add OpenCode as the 4th supported application with additive provider management:

- Add OpenCode variant to AppType enum with all related match statements
- Add enabled_opencode field to McpApps and SkillApps structures
- Add opencode field to McpRoot and PromptRoot
- Add database schema migration v3→v4 with enabled_opencode columns
- Add settings.rs support for opencode_config_dir and current_provider_opencode
- Create opencode_config.rs module for config file I/O operations
- Update all services (proxy, mcp, skill, provider, stream_check) for OpenCode
- Add OpenCode support to deeplink provider and MCP parsing
- Update commands/config.rs for OpenCode config status and paths

Key design decisions:
- OpenCode uses additive mode (no is_current needed, no proxy support)
- Config path: ~/.config/opencode/opencode.json
- MCP format: stdio→local, sse/http→remote conversion planned
- Stream check returns error (not yet implemented for OpenCode)
2026-01-15 15:54:29 +08:00
Jason e4d24f2df9 docs: add OpenCode implementation plan
Add comprehensive implementation plan for OpenCode (4th app) support:
- Additive provider management (vs. replacement model)
- MCP format conversion (stdio<->local, sse<->remote)
- Config file: ~/.config/opencode/opencode.json
- No proxy/failover support (OpenCode handles internally)
2026-01-15 15:31:38 +08:00
杨永安 07d022ba9f feat(usage): improve custom template system with variable hints and validation fixes (#628)
* feat(usage): improve custom template with variables display and explicit type detection

Combine two feature improvements:
1. Display supported variables ({{baseUrl}}, {{apiKey}}) with actual values in custom template mode
2. Add explicit templateType field for accurate template mode detection

## Changes

### Frontend
- Display template variables with actual values extracted from provider settings
- Add templateType field to UsageScript for explicit mode detection
- Support template mode persistence across sessions

### Backend
- Add template_type field to UsageScript struct
- Improve validation logic based on explicit template type
- Maintain backward compatibility with type inference

### I18n
- Add "Supported Variables" section translation (zh/en/ja)

### Benefits
- More accurate template mode detection (no more guessing)
- Better user experience with variable hints
- Clearer validation rules per template type

* fix(usage): resolve custom template cache and validation issues

Combine three bug fixes to make custom template mode work correctly:

1. **Update cache after test**: Testing usage script successfully now updates the main list cache immediately
2. **Fix same-origin check**: Custom template mode can now access different domains (SSRF protection still active)
3. **Fix field naming**: Unified to use autoQueryInterval consistently between frontend and backend

## Problems Solved

- Main provider list showing "Query failed" after successful test
- Custom templates blocked by overly strict same-origin validation
- Auto-query intervals not saved correctly due to inconsistent naming

## Changes

### Frontend (UsageScriptModal)
- Import useQueryClient and update cache after successful test
- Invalidate usage cache when saving script configuration
- Use standardized autoQueryInterval field name

### Backend (usage_script.rs)
- Allow custom template mode to bypass same-origin checks
- Maintain SSRF protection for all modes

### Hooks (useProviderActions)
- Invalidate usage query cache when saving script

## Impact

Users can now use custom templates freely while security validations remain intact for general templates.

* fix(usage): correct provider credential field names

- Claude: support both ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN
- Gemini: use GEMINI_API_KEY instead of GOOGLE_GEMINI_API_KEY
- Codex: use OPENAI_API_KEY and parse base_url from TOML config string

Addresses review feedback from PR #628

* style: format code

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-01-14 15:42:05 +08:00
108 changed files with 4504 additions and 6616 deletions
+485
View File
@@ -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>` - 自定义配置目录
-1
View File
@@ -727,7 +727,6 @@ dependencies = [
"serde_json",
"serde_yaml",
"serial_test",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
-1
View File
@@ -61,7 +61,6 @@ rusqlite = { version = "0.31", features = ["bundled", "backup"] }
indexmap = { version = "2", features = ["serde"] }
rust_decimal = "1.33"
uuid = { version = "1.11", features = ["v4"] }
sha2 = "0.10"
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"
+49 -9
View File
@@ -13,6 +13,8 @@ pub struct McpApps {
pub codex: bool,
#[serde(default)]
pub gemini: bool,
#[serde(default)]
pub opencode: bool,
}
impl McpApps {
@@ -22,6 +24,7 @@ impl McpApps {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
}
}
@@ -31,6 +34,7 @@ impl McpApps {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
}
}
@@ -46,12 +50,15 @@ impl McpApps {
if self.gemini {
apps.push(AppType::Gemini);
}
if self.opencode {
apps.push(AppType::OpenCode);
}
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini
!self.claude && !self.codex && !self.gemini && !self.opencode
}
}
@@ -64,6 +71,8 @@ pub struct SkillApps {
pub codex: bool,
#[serde(default)]
pub gemini: bool,
#[serde(default)]
pub opencode: bool,
}
impl SkillApps {
@@ -73,6 +82,7 @@ impl SkillApps {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
}
}
@@ -82,6 +92,7 @@ impl SkillApps {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
}
}
@@ -97,12 +108,15 @@ impl SkillApps {
if self.gemini {
apps.push(AppType::Gemini);
}
if self.opencode {
apps.push(AppType::OpenCode);
}
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini
!self.claude && !self.codex && !self.gemini && !self.opencode
}
/// 仅启用指定应用(其他应用设为禁用)
@@ -205,6 +219,9 @@ pub struct McpRoot {
pub codex: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub gemini: McpConfig,
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub opencode: McpConfig,
}
impl Default for McpRoot {
@@ -216,6 +233,7 @@ impl Default for McpRoot {
claude: McpConfig::default(),
codex: McpConfig::default(),
gemini: McpConfig::default(),
opencode: McpConfig::default(),
}
}
}
@@ -236,6 +254,8 @@ pub struct PromptRoot {
pub codex: PromptConfig,
#[serde(default)]
pub gemini: PromptConfig,
#[serde(default)]
pub opencode: PromptConfig,
}
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
@@ -249,7 +269,8 @@ use crate::provider::ProviderManager;
pub enum AppType {
Claude,
Codex,
Gemini, // 新增
Gemini,
OpenCode,
}
impl AppType {
@@ -257,7 +278,8 @@ impl AppType {
match self {
AppType::Claude => "claude",
AppType::Codex => "codex",
AppType::Gemini => "gemini", // 新增
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
}
}
}
@@ -270,11 +292,12 @@ impl FromStr for AppType {
match normalized.as_str() {
"claude" => Ok(AppType::Claude),
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini), // 新增
"gemini" => Ok(AppType::Gemini),
"opencode" => Ok(AppType::OpenCode),
other => Err(AppError::localized(
"unsupported_app",
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode"),
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode."),
)),
}
}
@@ -291,6 +314,9 @@ pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode: Option<String>,
}
impl CommonConfigSnippets {
@@ -300,6 +326,7 @@ impl CommonConfigSnippets {
AppType::Claude => self.claude.as_ref(),
AppType::Codex => self.codex.as_ref(),
AppType::Gemini => self.gemini.as_ref(),
AppType::OpenCode => self.opencode.as_ref(),
}
}
@@ -309,6 +336,7 @@ impl CommonConfigSnippets {
AppType::Claude => self.claude = snippet,
AppType::Codex => self.codex = snippet,
AppType::Gemini => self.gemini = snippet,
AppType::OpenCode => self.opencode = snippet,
}
}
}
@@ -347,7 +375,8 @@ impl Default for MultiAppConfig {
let mut apps = HashMap::new();
apps.insert("claude".to_string(), ProviderManager::default());
apps.insert("codex".to_string(), ProviderManager::default());
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
apps.insert("gemini".to_string(), ProviderManager::default());
apps.insert("opencode".to_string(), ProviderManager::default());
Self {
version: 2,
@@ -506,6 +535,7 @@ impl MultiAppConfig {
AppType::Claude => &self.mcp.claude,
AppType::Codex => &self.mcp.codex,
AppType::Gemini => &self.mcp.gemini,
AppType::OpenCode => &self.mcp.opencode,
}
}
@@ -515,6 +545,7 @@ impl MultiAppConfig {
AppType::Claude => &mut self.mcp.claude,
AppType::Codex => &mut self.mcp.codex,
AppType::Gemini => &mut self.mcp.gemini,
AppType::OpenCode => &mut self.mcp.opencode,
}
}
@@ -528,6 +559,7 @@ impl MultiAppConfig {
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
Ok(config)
}
@@ -547,6 +579,7 @@ impl MultiAppConfig {
if !self.prompts.claude.prompts.is_empty()
|| !self.prompts.codex.prompts.is_empty()
|| !self.prompts.gemini.prompts.is_empty()
|| !self.prompts.opencode.prompts.is_empty()
{
return Ok(false);
}
@@ -554,7 +587,12 @@ impl MultiAppConfig {
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
let mut imported = false;
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
imported = true;
@@ -623,6 +661,7 @@ impl MultiAppConfig {
AppType::Claude => &mut config.prompts.claude.prompts,
AppType::Codex => &mut config.prompts.codex.prompts,
AppType::Gemini => &mut config.prompts.gemini.prompts,
AppType::OpenCode => &mut config.prompts.opencode.prompts,
};
prompts.insert(id, prompt);
@@ -656,6 +695,7 @@ impl MultiAppConfig {
AppType::Claude => &self.mcp.claude.servers,
AppType::Codex => &self.mcp.codex.servers,
AppType::Gemini => &self.mcp.gemini.servers,
AppType::OpenCode => &self.mcp.opencode.servers,
};
for (id, entry) in old_servers {
+11
View File
@@ -51,6 +51,15 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
Ok(ConfigStatus { exists, path })
}
AppType::OpenCode => {
let config_path = crate::opencode_config::get_opencode_config_path();
let exists = config_path.exists();
let path = crate::opencode_config::get_opencode_dir()
.to_string_lossy()
.to_string();
Ok(ConfigStatus { exists, path })
}
}
}
@@ -67,6 +76,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
};
Ok(dir.to_string_lossy().to_string())
@@ -79,6 +89,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
};
if !config_dir.exists() {
+2
View File
@@ -122,6 +122,7 @@ pub async fn upsert_mcp_server_in_config(
new_server.apps.claude = true;
new_server.apps.codex = true;
new_server.apps.gemini = true;
new_server.apps.opencode = true;
}
McpService::upsert_server(&state, new_server)
@@ -200,5 +201,6 @@ pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, S
total += McpService::import_from_claude(&state).unwrap_or(0);
total += McpService::import_from_codex(&state).unwrap_or(0);
total += McpService::import_from_gemini(&state).unwrap_or(0);
total += McpService::import_from_opencode(&state).unwrap_or(0);
Ok(total)
}
-2
View File
@@ -15,7 +15,6 @@ mod proxy;
mod settings;
pub mod skill;
mod stream_check;
mod template;
mod usage;
pub use config::*;
@@ -33,5 +32,4 @@ pub use proxy::*;
pub use settings::*;
pub use skill::*;
pub use stream_check::*;
pub use template::*;
pub use usage::*;
+36
View File
@@ -60,6 +60,16 @@ pub fn delete_provider(
.map_err(|e| e.to_string())
}
/// Remove provider from live config only (for additive mode apps like OpenCode)
/// Does NOT delete from database - provider remains in the list
#[tauri::command]
pub fn remove_provider_from_live_config(app: String, id: String) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::remove_from_live_config(app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
}
/// 切换供应商
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
ProviderService::switch(state, app_type, id)
@@ -133,6 +143,7 @@ pub async fn testUsageScript(
#[allow(non_snake_case)] baseUrl: Option<String>,
#[allow(non_snake_case)] accessToken: Option<String>,
#[allow(non_snake_case)] userId: Option<String>,
#[allow(non_snake_case)] templateType: Option<String>,
) -> Result<crate::provider::UsageResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::test_usage_script(
@@ -145,6 +156,7 @@ pub async fn testUsageScript(
baseUrl.as_deref(),
accessToken.as_deref(),
userId.as_deref(),
templateType.as_deref(),
)
.await
.map_err(|e| e.to_string())
@@ -323,3 +335,27 @@ pub fn sync_universal_provider(
Ok(result)
}
// ============================================================================
// OpenCode 专属命令
// ============================================================================
/// 从 OpenCode live 配置导入供应商到数据库
///
/// 这是 OpenCode 特有的功能,因为 OpenCode 使用累加模式,
/// 用户可能已经在 opencode.json 中配置了供应商。
#[tauri::command]
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
crate::services::provider::import_opencode_providers_from_live(state.inner())
.map_err(|e| e.to_string())
}
/// 获取 OpenCode live 配置中的供应商 ID 列表
///
/// 用于前端判断供应商是否已添加到 opencode.json
#[tauri::command]
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
crate::opencode_config::get_providers()
.map(|providers| providers.keys().cloned().collect())
.map_err(|e| e.to_string())
}
+1
View File
@@ -20,6 +20,7 @@ fn parse_app_type(app: &str) -> Result<AppType, String> {
"claude" => Ok(AppType::Claude),
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini),
"opencode" => Ok(AppType::OpenCode),
_ => Err(format!("不支持的 app 类型: {app}")),
}
}
-278
View File
@@ -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}"))?
}
+6 -3
View File
@@ -13,7 +13,7 @@ impl Database {
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare(
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
FROM mcp_servers
ORDER BY name ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -30,6 +30,7 @@ impl Database {
let enabled_claude: bool = row.get(7)?;
let enabled_codex: bool = row.get(8)?;
let enabled_gemini: bool = row.get(9)?;
let enabled_opencode: bool = row.get(10)?;
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
@@ -44,6 +45,7 @@ impl Database {
claude: enabled_claude,
codex: enabled_codex,
gemini: enabled_gemini,
opencode: enabled_opencode,
},
description,
homepage,
@@ -68,8 +70,8 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO mcp_servers (
id, name, server_config, description, homepage, docs, tags,
enabled_claude, enabled_codex, enabled_gemini
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
server.id,
server.name,
@@ -84,6 +86,7 @@ impl Database {
server.apps.claude,
server.apps.codex,
server.apps.gemini,
server.apps.opencode,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
-1
View File
@@ -10,7 +10,6 @@ pub mod proxy;
pub mod settings;
pub mod skills;
pub mod stream_check;
pub mod template;
pub mod universal_providers;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
+12 -9
View File
@@ -3,7 +3,7 @@
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
//!
//! v3.10.0+ 统一管理架构:
//! - Skills 使用统一的 id 主键,支持应用启用标志
//! - Skills 使用统一的 id 主键,支持应用启用标志
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
use crate::app_config::{InstalledSkill, SkillApps};
@@ -22,7 +22,7 @@ impl Database {
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
FROM skills ORDER BY name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -42,8 +42,9 @@ impl Database {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
},
installed_at: row.get(11)?,
installed_at: row.get(12)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -62,7 +63,7 @@ impl Database {
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
FROM skills WHERE id = ?1",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -81,8 +82,9 @@ impl Database {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
},
installed_at: row.get(11)?,
installed_at: row.get(12)?,
})
});
@@ -99,8 +101,8 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO skills
(id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
skill.id,
skill.name,
@@ -113,6 +115,7 @@ impl Database {
skill.apps.claude,
skill.apps.codex,
skill.apps.gemini,
skill.apps.opencode,
skill.installed_at,
],
)
@@ -142,8 +145,8 @@ impl Database {
let conn = lock_conn!(self.conn);
let affected = conn
.execute(
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3 WHERE id = ?4",
params![apps.claude, apps.codex, apps.gemini, id],
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5",
params![apps.claude, apps.codex, apps.gemini, apps.opencode, id],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
-595
View File
@@ -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(&params_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(&params_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(&params_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(&params_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)
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 3;
pub(crate) const SCHEMA_VERSION: i32 = 4;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+31 -182
View File
@@ -58,7 +58,7 @@ impl Database {
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0
)",
[],
)
@@ -85,6 +85,7 @@ impl Database {
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0
)",
[],
@@ -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(())
}
@@ -430,12 +347,11 @@ impl Database {
Self::migrate_v2_to_v3(conn)?;
Self::set_user_version(conn, 3)?;
}
// v3 -> v4: Claude Code Templates 市场功能(暂未启用)
// 3 => {
// log::info!("迁移数据库从 v3 到 v4Claude Code Templates 市场功能)");
// Self::migrate_v3_to_v4(conn)?;
// Self::set_user_version(conn, 4)?;
// }
3 => {
log::info!("迁移数据库从 v3 到 v4OpenCode 支持)");
Self::migrate_v3_to_v4(conn)?;
Self::set_user_version(conn, 4)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -876,97 +792,6 @@ impl Database {
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 统一管理架构
///
/// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构,
@@ -1030,6 +855,30 @@ impl Database {
Ok(())
}
/// v3 -> v4 迁移:添加 OpenCode 支持
///
/// 为 mcp_servers 和 skills 表添加 enabled_opencode 列。
fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> {
// 为 mcp_servers 表添加 enabled_opencode 列
Self::add_column_if_missing(
conn,
"mcp_servers",
"enabled_opencode",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
// 为 skills 表添加 enabled_opencode 列
Self::add_column_if_missing(
conn,
"skills",
"enabled_opencode",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
log::info!("v3 -> v4 迁移完成:已添加 OpenCode 支持");
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
+8 -88
View File
@@ -518,7 +518,8 @@ fn model_pricing_is_seeded_on_init() {
assert!(
count > 0,
"模型定价数据应该在初始化时自动填充,实际数量: {count}"
"模型定价数据应该在初始化时自动填充,实际数量: {}",
count
);
// 验证包含 Claude 模型
@@ -531,7 +532,8 @@ fn model_pricing_is_seeded_on_init() {
.expect("check claude");
assert!(
claude_count > 0,
"应该包含 Claude 模型定价,实际数量: {claude_count}"
"应该包含 Claude 模型定价,实际数量: {}",
claude_count
);
// 验证包含 GPT 模型
@@ -544,7 +546,8 @@ fn model_pricing_is_seeded_on_init() {
.expect("check gpt");
assert!(
gpt_count > 0,
"应该包含 GPT 模型定价,实际数量: {gpt_count}"
"应该包含 GPT 模型定价,实际数量: {}",
gpt_count
);
// 验证包含 Gemini 模型
@@ -557,90 +560,7 @@ fn model_pricing_is_seeded_on_init() {
.expect("check gemini");
assert!(
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 个外键约束");
}
+2
View File
@@ -166,6 +166,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
claude: false,
codex: false,
gemini: false,
opencode: false,
};
for app in apps_str.split(',') {
@@ -173,6 +174,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
"claude" => apps.claude = true,
"codex" => apps.codex = true,
"gemini" => apps.gemini = true,
"opencode" => apps.opencode = true,
other => {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': {other}"
+29
View File
@@ -145,6 +145,7 @@ pub(crate) fn build_provider_from_request(
AppType::Claude => build_claude_settings(request),
AppType::Codex => build_codex_settings(request),
AppType::Gemini => build_gemini_settings(request),
AppType::OpenCode => build_opencode_settings(request),
};
// Build usage script configuration if provided
@@ -225,6 +226,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
}),
access_token: request.usage_access_token.clone(),
user_id: request.usage_user_id.clone(),
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
auto_query_interval: request.usage_auto_interval,
};
@@ -362,6 +364,33 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
json!({ "env": env })
}
/// Build OpenCode settings configuration
fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let endpoint = get_primary_endpoint(request);
// Build options object
let mut options = serde_json::Map::new();
if !endpoint.is_empty() {
options.insert("baseURL".to_string(), json!(endpoint));
}
if let Some(api_key) = &request.api_key {
options.insert("apiKey".to_string(), json!(api_key));
}
// Build models object
let mut models = serde_json::Map::new();
if let Some(model) = &request.model {
models.insert(model.clone(), json!({ "name": model }));
}
// Default to openai-compatible npm package
json!({
"npm": "@ai-sdk/openai-compatible",
"options": options,
"models": models
})
}
// =============================================================================
// Config Merge Logic
// =============================================================================
+4 -2
View File
@@ -365,7 +365,8 @@ fn test_parse_prompt_deeplink() {
let content = "Hello World";
let content_b64 = BASE64_STANDARD.encode(content);
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();
@@ -382,7 +383,8 @@ fn test_parse_mcp_deeplink() {
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
let config_b64 = BASE64_STANDARD.encode(config);
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();
+24 -16
View File
@@ -13,6 +13,7 @@ mod gemini_config;
mod gemini_mcp;
mod init_status;
mod mcp;
mod opencode_config;
mod panic_hook;
mod prompt;
mod prompt_files;
@@ -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 服务器配置(表空时触发)
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
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"),
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. 导入提示词文件(表空时触发)
@@ -712,6 +732,7 @@ pub fn run() {
commands::add_provider,
commands::update_provider,
commands::delete_provider,
commands::remove_provider_from_live_config,
commands::switch_provider,
commands::import_default_config,
commands::get_claude_config_status,
@@ -874,28 +895,15 @@ pub fn run() {
commands::upsert_universal_provider,
commands::delete_universal_provider,
commands::sync_universal_provider,
// OpenCode specific
commands::import_opencode_providers_from_live,
commands::get_opencode_live_provider_ids,
// Global upstream proxy
commands::get_global_proxy_url,
commands::set_global_proxy_url,
commands::test_proxy_url,
commands::get_upstream_proxy_status,
commands::scan_local_proxies,
// 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
+1
View File
@@ -91,6 +91,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
claude: true,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
+1
View File
@@ -235,6 +235,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
claude: false,
codex: true,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
+1
View File
@@ -87,6 +87,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
claude: false,
codex: false,
gemini: true,
opencode: false,
},
description: None,
homepage: None,
+5
View File
@@ -8,10 +8,12 @@
//! - `claude` - Claude MCP 同步和导入
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
//! - `gemini` - Gemini MCP 同步和导入
//! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换)
mod claude;
mod codex;
mod gemini;
mod opencode;
mod validation;
// 重新导出公共 API
@@ -26,3 +28,6 @@ pub use gemini::{
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
sync_single_server_to_gemini,
};
pub use opencode::{
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
};
+358
View File
@@ -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");
}
}
+222
View File
@@ -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)
}
+3
View File
@@ -5,6 +5,7 @@ use crate::codex_config::get_codex_auth_path;
use crate::config::get_claude_settings_path;
use crate::error::AppError;
use crate::gemini_config::get_gemini_dir;
use crate::opencode_config::get_opencode_dir;
/// 返回指定应用所使用的提示词文件路径。
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
@@ -12,12 +13,14 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
AppType::Gemini => get_gemini_dir(),
AppType::OpenCode => get_opencode_dir(),
};
let filename = match app {
AppType::Claude => "CLAUDE.md",
AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md",
AppType::OpenCode => "AGENTS.md",
};
Ok(base_dir.join(filename))
+96
View File
@@ -98,6 +98,10 @@ pub struct UsageScript {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "userId")]
pub user_id: Option<String>,
/// 模板类型(用于后端判断验证规则)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "templateType")]
pub template_type: Option<String>,
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "autoQueryInterval")]
@@ -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>,
}
+8
View File
@@ -132,6 +132,10 @@ impl ProviderType {
}
ProviderType::Gemini
}
AppType::OpenCode => {
// OpenCode doesn't support proxy, but return a default type for completeness
ProviderType::Codex // Fallback to Codex-like type
}
}
}
@@ -176,6 +180,10 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
AppType::Claude => Box::new(ClaudeAdapter::new()),
AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()),
AppType::OpenCode => {
// OpenCode doesn't support proxy, fallback to Codex adapter
Box::new(CodexAdapter::new())
}
}
}
+4
View File
@@ -122,6 +122,10 @@ impl ConfigService {
AppType::Codex => Self::sync_codex_live(config, &current_id, &provider)?,
AppType::Claude => Self::sync_claude_live(config, &current_id, &provider)?,
AppType::Gemini => Self::sync_gemini_live(config, &current_id, &provider)?,
AppType::OpenCode => {
// OpenCode uses additive mode, no live sync needed
// OpenCode providers are managed directly in the config file
}
}
Ok(())
+51
View File
@@ -37,6 +37,9 @@ impl McpService {
if prev_apps.gemini && !server.apps.gemini {
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
}
if prev_apps.opencode && !server.apps.opencode {
Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?;
}
// 同步到各个启用的应用
Self::sync_server_to_apps(state, &server)?;
@@ -113,6 +116,13 @@ impl McpService {
AppType::Gemini => {
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
}
AppType::OpenCode => {
mcp::sync_single_server_to_opencode(
&Default::default(),
&server.id,
&server.server,
)?;
}
}
Ok(())
}
@@ -135,6 +145,9 @@ impl McpService {
AppType::Claude => mcp::remove_server_from_claude(id)?,
AppType::Codex => mcp::remove_server_from_codex(id)?,
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
AppType::OpenCode => {
mcp::remove_server_from_opencode(id)?;
}
}
Ok(())
}
@@ -311,4 +324,42 @@ impl McpService {
Ok(new_count)
}
/// 从 OpenCode 导入 MCPv3.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)
}
}
-7
View File
@@ -8,7 +8,6 @@ pub mod proxy;
pub mod skill;
pub mod speedtest;
pub mod stream_check;
pub mod template;
pub mod usage_stats;
pub use config::ConfigService;
@@ -20,12 +19,6 @@ pub use proxy::ProxyService;
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
pub use speedtest::{EndpointLatency, SpeedtestService};
#[allow(unused_imports)]
pub use template::{
BatchInstallResult, ComponentDetail, ComponentMetadata, ComponentType, InstalledComponent,
MarketplaceBundle, MarketplaceBundleItem, PaginatedResult, TemplateComponent, TemplateRepo,
TemplateService,
};
#[allow(unused_imports)]
pub use usage_stats::{
DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,
RequestLogDetail, UsageSummary,
+172
View File
@@ -120,6 +120,64 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
// Delegate to write_gemini_live which handles env file writing correctly
write_gemini_live(provider)?;
}
AppType::OpenCode => {
// OpenCode uses additive mode - write provider to config
use crate::opencode_config;
use crate::provider::OpenCodeProviderConfig;
// Defensive check: if settings_config is a full config structure, extract provider fragment
let config_to_write = if let Some(obj) = provider.settings_config.as_object() {
// Detect full config structure (has $schema or top-level provider field)
if obj.contains_key("$schema") || obj.contains_key("provider") {
log::warn!(
"OpenCode provider '{}' has full config structure in settings_config, attempting to extract fragment",
provider.id
);
// Try to extract from provider.{id}
obj.get("provider")
.and_then(|p| p.get(&provider.id))
.cloned()
.unwrap_or_else(|| provider.settings_config.clone())
} else {
provider.settings_config.clone()
}
} else {
provider.settings_config.clone()
};
// Convert settings_config to OpenCodeProviderConfig
let opencode_config_result =
serde_json::from_value::<OpenCodeProviderConfig>(config_to_write.clone());
match opencode_config_result {
Ok(config) => {
opencode_config::set_typed_provider(&provider.id, &config)?;
log::info!("OpenCode provider '{}' written to live config", provider.id);
}
Err(e) => {
log::warn!(
"Failed to parse OpenCode provider config for '{}': {}",
provider.id,
e
);
// Only write if config looks like a valid provider fragment
if config_to_write.get("npm").is_some()
|| config_to_write.get("options").is_some()
{
opencode_config::set_provider(&provider.id, config_to_write)?;
log::info!(
"OpenCode provider '{}' written as raw JSON to live config",
provider.id
);
} else {
log::error!(
"OpenCode provider '{}' has invalid config structure, skipping write",
provider.id
);
}
}
}
}
}
Ok(())
}
@@ -220,6 +278,21 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
"config": config_obj
}))
}
AppType::OpenCode => {
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
let config_path = get_opencode_config_path();
if !config_path.exists() {
return Err(AppError::localized(
"opencode.config.missing",
"OpenCode 配置文件不存在",
"OpenCode configuration file not found",
));
}
let config = read_opencode_config()?;
Ok(config)
}
}
}
@@ -295,6 +368,24 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
"config": config_obj
})
}
AppType::OpenCode => {
// OpenCode uses additive mode - import from live is not the same pattern
// For now, return an empty config structure
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
let config_path = get_opencode_config_path();
if !config_path.exists() {
return Err(AppError::localized(
"opencode.live.missing",
"OpenCode 配置文件不存在",
"OpenCode configuration file is missing",
));
}
// For OpenCode, we return the full config - but note that OpenCode
// uses additive mode, so importing defaults works differently
read_opencode_config()?
}
};
let mut provider = Provider::with_id(
@@ -399,3 +490,84 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
Ok(())
}
/// Remove an OpenCode provider from the live configuration
///
/// This is specific to OpenCode's additive mode - removing a provider
/// from the opencode.json file.
pub(crate) fn remove_opencode_provider_from_live(provider_id: &str) -> Result<(), AppError> {
use crate::opencode_config;
// Check if OpenCode config directory exists
if !opencode_config::get_opencode_dir().exists() {
log::debug!(
"OpenCode config directory doesn't exist, skipping removal of '{}'",
provider_id
);
return Ok(());
}
opencode_config::remove_provider(provider_id)?;
log::info!(
"OpenCode provider '{}' removed from live config",
provider_id
);
Ok(())
}
/// Import all providers from OpenCode live config to database
///
/// This imports existing providers from ~/.config/opencode/opencode.json
/// into the CC Switch database. Each provider found will be added to the
/// database with is_current set to false.
pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, AppError> {
use crate::opencode_config;
let providers = opencode_config::get_typed_providers()?;
if providers.is_empty() {
return Ok(0);
}
let mut imported = 0;
let existing = state.db.get_all_providers("opencode")?;
for (id, config) in providers {
// Skip if already exists in database
if existing.contains_key(&id) {
log::debug!(
"OpenCode provider '{}' already exists in database, skipping",
id
);
continue;
}
// Convert to Value for settings_config
let settings_config = match serde_json::to_value(&config) {
Ok(v) => v,
Err(e) => {
log::warn!("Failed to serialize OpenCode provider '{}': {}", id, e);
continue;
}
};
// Create provider
let provider = Provider::with_id(
id.clone(),
config.name.clone().unwrap_or_else(|| id.clone()),
settings_config,
None,
);
// Save to database
if let Err(e) = state.db.save_provider("opencode", &provider) {
log::warn!("Failed to import OpenCode provider '{}': {}", id, e);
continue;
}
imported += 1;
log::info!("Imported OpenCode provider '{}' from live config", id);
}
Ok(imported)
}
+149 -18
View File
@@ -20,13 +20,16 @@ use crate::settings::CustomEndpoint;
use crate::store::AppState;
// Re-export sub-module functions for external access
pub use live::{import_default_config, read_live_settings, sync_current_to_live};
pub use live::{
import_default_config, import_opencode_providers_from_live, read_live_settings,
sync_current_to_live,
};
// Internal re-exports (pub(crate))
pub(crate) use live::write_live_snapshot;
// Internal re-exports
use live::write_gemini_live;
use live::{remove_opencode_provider_from_live, write_gemini_live};
use usage::validate_usage_script;
/// Provider business logic service
@@ -137,7 +140,13 @@ impl ProviderService {
/// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
///
/// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
// OpenCode uses additive mode - no "current" provider concept
if matches!(app_type, AppType::OpenCode) {
return Ok(String::new());
}
crate::settings::get_effective_current_provider(&state.db, &app_type)
.map(|opt| opt.unwrap_or_default())
}
@@ -152,7 +161,13 @@ impl ProviderService {
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
// Check if sync is needed (if this is current provider, or no current provider)
// OpenCode uses additive mode - always write to live config
if matches!(app_type, AppType::OpenCode) {
write_live_snapshot(&app_type, &provider)?;
return Ok(true);
}
// For other apps: Check if sync is needed (if this is current provider, or no current provider)
let current = state.db.get_current_provider(app_type.as_str())?;
if current.is_none() {
// No current provider, set as current and sync
@@ -176,14 +191,20 @@ impl ProviderService {
Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?;
// Check if this is current provider (use effective current, not just DB)
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
// OpenCode uses additive mode - always update in live config
if matches!(app_type, AppType::OpenCode) {
write_live_snapshot(&app_type, &provider)?;
return Ok(true);
}
// For other apps: Check if this is current provider (use effective current, not just DB)
let effective_current =
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
let is_current = effective_current.as_deref() == Some(provider.id.as_str());
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
if is_current {
// 如果代理接管模式处于激活状态,并且代理服务正在运行:
// - 不写 Live 配置(否则会破坏接管)
@@ -216,8 +237,18 @@ impl ProviderService {
/// Delete a provider
///
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
/// 对于 OpenCode(累加模式),可以随时删除任意供应商,同时从 live 配置中移除。
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// Check both local settings and database
// OpenCode uses additive mode - no current provider concept
if matches!(app_type, AppType::OpenCode) {
// Remove from database
state.db.delete_provider(app_type.as_str(), id)?;
// Also remove from live config
remove_opencode_provider_from_live(id)?;
return Ok(());
}
// For other apps: Check both local settings and database
let local_current = crate::settings::get_current_provider(&app_type);
let db_current = state.db.get_current_provider(app_type.as_str())?;
@@ -230,6 +261,27 @@ impl ProviderService {
state.db.delete_provider(app_type.as_str(), id)
}
/// Remove provider from live config only (for additive mode apps like OpenCode)
///
/// Does NOT delete from database - provider remains in the list.
/// This is used when user wants to "remove" a provider from active config
/// but keep it available for future use.
pub fn remove_from_live_config(app_type: AppType, id: &str) -> Result<(), AppError> {
match app_type {
AppType::OpenCode => {
remove_opencode_provider_from_live(id)?;
}
// Future: add other additive mode apps here
_ => {
return Err(AppError::Message(format!(
"App {} does not support remove from live config",
app_type.as_str()
)));
}
}
Ok(())
}
/// Switch to a provider
///
/// Switch flow:
@@ -326,22 +378,29 @@ impl ProviderService {
if let Some(current_id) = current_id {
if current_id != id {
// Only backfill when switching to a different provider
if let Ok(live_config) = read_live_settings(app_type.clone()) {
if let Some(mut current_provider) = providers.get(&current_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(), &current_provider);
// OpenCode uses additive mode - all providers coexist in the same file,
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
if !matches!(app_type, AppType::OpenCode) {
// Only backfill when switching to a different provider
if let Ok(live_config) = read_live_settings(app_type.clone()) {
if let Some(mut current_provider) = providers.get(&current_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(), &current_provider);
}
}
}
}
}
// Update local settings (device-level, takes priority)
crate::settings::set_current_provider(&app_type, Some(id))?;
// OpenCode uses additive mode - skip setting is_current (no such concept)
if !matches!(app_type, AppType::OpenCode) {
// Update local settings (device-level, takes priority)
crate::settings::set_current_provider(&app_type, Some(id))?;
// Update database is_current (as default for new devices)
state.db.set_current_provider(app_type.as_str(), id)?;
// Update database is_current (as default for new devices)
state.db.set_current_provider(app_type.as_str(), id)?;
}
// Sync to live (write_gemini_live handles security flag internally for Gemini)
write_live_snapshot(&app_type, provider)?;
@@ -380,6 +439,7 @@ impl ProviderService {
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
}
}
@@ -392,6 +452,7 @@ impl ProviderService {
AppType::Claude => Self::extract_claude_common_config(settings_config),
AppType::Codex => Self::extract_codex_common_config(settings_config),
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
}
}
@@ -525,6 +586,29 @@ impl ProviderService {
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Extract common config for OpenCode (JSON format)
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
// OpenCode uses a different config structure with npm, options, models
// For common config, we exclude provider-specific fields like apiKey
let mut config = settings.clone();
// Remove provider-specific fields
if let Some(obj) = config.as_object_mut() {
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
options.remove("apiKey");
options.remove("baseURL");
}
// Keep npm and models as they might be common
}
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
return Ok("{}".to_string());
}
serde_json::to_string_pretty(&config)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Import default configuration from live files (re-export)
///
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
@@ -615,6 +699,7 @@ impl ProviderService {
base_url: Option<&str>,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> {
usage::test_usage_script(
state,
@@ -626,6 +711,7 @@ impl ProviderService {
base_url,
access_token,
user_id,
template_type,
)
.await
}
@@ -689,6 +775,17 @@ impl ProviderService {
use crate::gemini_config::validate_gemini_settings;
validate_gemini_settings(&provider.settings_config)?
}
AppType::OpenCode => {
// OpenCode uses a different config structure: { npm, options, models }
// Basic validation - must be an object
if !provider.settings_config.is_object() {
return Err(AppError::localized(
"provider.opencode.settings.not_object",
"OpenCode 配置必须是 JSON 对象",
"OpenCode configuration must be a JSON object",
));
}
}
}
// Validate and clean UsageScript configuration (common for all app types)
@@ -826,6 +923,40 @@ impl ProviderService {
Ok((api_key, base_url))
}
AppType::OpenCode => {
// OpenCode uses options.apiKey and options.baseURL
let options = provider
.settings_config
.get("options")
.and_then(|v| v.as_object())
.ok_or_else(|| {
AppError::localized(
"provider.opencode.options.missing",
"配置格式错误: 缺少 options",
"Invalid configuration: missing options section",
)
})?;
let api_key = options
.get("apiKey")
.and_then(|v| v.as_str())
.ok_or_else(|| {
AppError::localized(
"provider.opencode.api_key.missing",
"缺少 API Key",
"API key is missing",
)
})?
.to_string();
let base_url = options
.get("baseURL")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok((api_key, base_url))
}
}
}
}
+7 -1
View File
@@ -17,6 +17,7 @@ pub(crate) async fn execute_and_format_usage_result(
timeout: u64,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> {
match usage_script::execute_usage_script(
script_code,
@@ -25,6 +26,7 @@ pub(crate) async fn execute_and_format_usage_result(
timeout,
access_token,
user_id,
template_type,
)
.await
{
@@ -113,7 +115,7 @@ pub async fn query_usage(
app_type: AppType,
provider_id: &str,
) -> Result<UsageResult, AppError> {
let (script_code, timeout, api_key, base_url, access_token, user_id) = {
let (script_code, timeout, api_key, base_url, access_token, user_id, template_type) = {
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers.get(provider_id).ok_or_else(|| {
AppError::localized(
@@ -164,6 +166,7 @@ pub async fn query_usage(
base_url,
usage_script.access_token.clone(),
usage_script.user_id.clone(),
usage_script.template_type.clone(),
)
};
@@ -174,6 +177,7 @@ pub async fn query_usage(
timeout,
access_token.as_deref(),
user_id.as_deref(),
template_type.as_deref(),
)
.await
}
@@ -190,6 +194,7 @@ pub async fn test_usage_script(
base_url: Option<&str>,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> {
// Use provided credential parameters directly for testing
execute_and_format_usage_result(
@@ -199,6 +204,7 @@ pub async fn test_usage_script(
timeout,
access_token,
user_id,
template_type,
)
.await
}
+33
View File
@@ -368,6 +368,10 @@ impl ProxyService {
AppType::Claude => self.read_claude_live()?,
AppType::Codex => self.read_codex_live()?,
AppType::Gemini => self.read_gemini_live()?,
AppType::OpenCode => {
// OpenCode doesn't support proxy features
return Err("OpenCode 不支持代理功能".to_string());
}
};
self.sync_live_config_to_provider(app_type, &live_config)
@@ -581,6 +585,9 @@ impl ProxyService {
}
}
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
}
Ok(())
@@ -759,6 +766,10 @@ impl ProxyService {
AppType::Claude => ("claude", self.read_claude_live()?),
AppType::Codex => ("codex", self.read_codex_live()?),
AppType::Gemini => ("gemini", self.read_gemini_live()?),
AppType::OpenCode => {
// OpenCode doesn't support proxy features
return Err("OpenCode 不支持代理功能".to_string());
}
};
let json_str = serde_json::to_string(&config)
@@ -967,6 +978,10 @@ impl ProxyService {
self.write_gemini_live(&live_config)?;
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features
return Err("OpenCode 不支持代理功能".to_string());
}
}
Ok(())
@@ -1050,6 +1065,9 @@ impl ProxyService {
let _ = self.write_gemini_live(&live_config);
}
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
}
Ok(())
@@ -1082,6 +1100,9 @@ impl ProxyService {
log::info!("Gemini Live 配置已恢复");
}
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
}
Ok(())
@@ -1161,6 +1182,10 @@ impl ProxyService {
AppType::Claude => self.write_claude_live(config),
AppType::Codex => self.write_codex_live(config),
AppType::Gemini => self.write_gemini_live(config),
AppType::OpenCode => {
// OpenCode doesn't support proxy features
Err("OpenCode 不支持代理功能".to_string())
}
}
}
@@ -1178,6 +1203,10 @@ impl ProxyService {
Ok(config) => Self::is_gemini_live_taken_over(&config),
Err(_) => false,
},
AppType::OpenCode => {
// OpenCode doesn't support proxy takeover
false
}
}
}
@@ -1217,6 +1246,10 @@ impl ProxyService {
AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
AppType::OpenCode => {
// OpenCode doesn't support proxy features
Ok(())
}
}
}
+13 -4
View File
@@ -183,6 +183,11 @@ impl SkillService {
return Ok(custom.join("skills"));
}
}
AppType::OpenCode => {
if let Some(custom) = crate::settings::get_opencode_override_dir() {
return Ok(custom.join("skills"));
}
}
}
// 默认路径:回退到用户主目录下的标准位置
@@ -196,6 +201,7 @@ impl SkillService {
AppType::Claude => home.join(".claude").join("skills"),
AppType::Codex => home.join(".codex").join("skills"),
AppType::Gemini => home.join(".gemini").join("skills"),
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
})
}
@@ -317,7 +323,7 @@ impl SkillService {
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
// 从所有应用目录删除
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] {
let _ = Self::remove_from_app(&skill.directory, &app);
}
@@ -376,7 +382,7 @@ impl SkillService {
let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] {
let app_dir = match Self::get_app_skills_dir(&app) {
Ok(d) => d,
Err(_) => continue,
@@ -425,6 +431,7 @@ impl SkillService {
AppType::Claude => "claude",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
};
unmanaged
@@ -457,7 +464,7 @@ impl SkillService {
let mut source_path: Option<PathBuf> = None;
let mut found_in: Vec<String> = Vec::new();
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] {
if let Ok(app_dir) = Self::get_app_skills_dir(&app) {
let skill_path = app_dir.join(&dir_name);
if skill_path.exists() {
@@ -468,6 +475,7 @@ impl SkillService {
AppType::Claude => "claude",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
};
found_in.push(app_str.to_string());
}
@@ -506,6 +514,7 @@ impl SkillService {
"claude" => apps.claude = true,
"codex" => apps.codex = 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();
// 扫描各应用目录
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] {
let app_dir = match SkillService::get_app_skills_dir(&app) {
Ok(d) => d,
Err(_) => continue,
+23
View File
@@ -185,6 +185,14 @@ impl StreamCheckService {
)
.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;
@@ -477,9 +485,24 @@ impl StreamCheckService {
}
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
.unwrap_or_else(|| config.gemini_model.clone()),
AppType::OpenCode => {
// OpenCode uses models map in settings_config
// Try to extract first model from the models object
Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
}
}
}
fn extract_opencode_model(provider: &Provider) -> Option<String> {
let models = provider
.settings_config
.get("models")
.and_then(|m| m.as_object())?;
// Return the first model ID from the models map
models.keys().next().map(|s| s.to_string())
}
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
provider
.settings_config
@@ -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, &current)?;
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, &current)?;
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, &current)?;
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, &current)?;
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, &current)?;
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, &current)?;
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, &current)?;
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, &current)?;
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, &current)?;
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, &current)?;
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()),
}
}
-746
View File
@@ -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)
}
}
-357
View File
@@ -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 失败")
}
}
-239
View File
@@ -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);
}
}
+24
View File
@@ -47,6 +47,8 @@ pub struct AppSettings {
pub codex_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode_config_dir: Option<String>,
// ===== 当前供应商 ID(设备级)=====
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current
@@ -58,6 +60,9 @@ pub struct AppSettings {
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_gemini: Option<String>,
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_opencode: Option<String>,
}
fn default_show_in_tray() -> bool {
@@ -84,9 +89,11 @@ impl Default for AppSettings {
claude_config_dir: None,
codex_config_dir: None,
gemini_config_dir: None,
opencode_config_dir: None,
current_provider_claude: None,
current_provider_codex: None,
current_provider_gemini: None,
current_provider_opencode: None,
}
}
}
@@ -119,6 +126,13 @@ impl AppSettings {
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.opencode_config_dir = self
.opencode_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.language = self
.language
.as_ref()
@@ -251,6 +265,14 @@ pub fn get_gemini_override_dir() -> Option<PathBuf> {
.map(|p| resolve_override_path(p))
}
pub fn get_opencode_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.opencode_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}
// ===== 当前供应商管理函数 =====
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
@@ -263,6 +285,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
AppType::Claude => settings.current_provider_claude.clone(),
AppType::Codex => settings.current_provider_codex.clone(),
AppType::Gemini => settings.current_provider_gemini.clone(),
AppType::OpenCode => settings.current_provider_opencode.clone(),
}
}
@@ -277,6 +300,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()),
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()),
}
update_settings(settings)
+95 -63
View File
@@ -13,13 +13,21 @@ pub async fn execute_usage_script(
timeout_secs: u64,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<Value, AppError> {
// 检测是否为自定义模板模式
// 优先使用前端传递的 template_type
let is_custom_template = template_type.map(|t| t == "custom").unwrap_or(false);
// 1. 替换模板变量,避免泄露敏感信息
let script_with_vars =
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
// 2. 验证 base_url 的安全性
validate_base_url(base_url)?;
// 2. 验证 base_url 的安全性(仅当提供了 base_url 时)
// 自定义模板模式下,用户可能不使用模板变量,而是直接在脚本中写完整 URL
if !base_url.is_empty() {
validate_base_url(base_url)?;
}
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
let request_config = {
@@ -97,7 +105,8 @@ pub async fn execute_usage_script(
})?;
// 5. 验证请求 URL 是否安全(防止 SSRF)
validate_request_url(&request.url, base_url)?;
// 如果提供了 base_url,则验证同源;否则只做基本安全检查
validate_request_url(&request.url, base_url, is_custom_template)?;
// 6. 发送 HTTP 请求
let response_data = send_http_request(&request, timeout_secs).await?;
@@ -472,7 +481,11 @@ fn validate_base_url(base_url: &str) -> Result<(), AppError> {
}
/// 验证请求 URL 是否安全(防止 SSRF)
fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppError> {
fn validate_request_url(
request_url: &str,
base_url: &str,
is_custom_template: bool,
) -> Result<(), AppError> {
// 解析请求 URL
let parsed_request = Url::parse(request_url).map_err(|e| {
AppError::localized(
@@ -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);
// 必须使用 HTTPS(允许 localhost 用于开发)
if parsed_request.scheme() != "https" && !is_request_loopback {
// 自定义模板模式下,允许用户自行决定是否使用 HTTP(用户需自行承担安全风险)
if !is_custom_template && parsed_request.scheme() != "https" && !is_request_loopback {
return Err(AppError::localized(
"usage_script.request_https_required",
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
@@ -502,60 +507,85 @@ fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppErro
));
}
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
if parsed_request.host_str() != parsed_base.host_str() {
return Err(AppError::localized(
"usage_script.request_host_mismatch",
format!(
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
format!(
"Request host {} must match base_url host {} (same-origin required)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
));
}
// 如果提供了 base_url(非空),则进行同源检查
// 🔧 自定义模板模式下,用户可以自由访问任意 HTTPS 域名,跳过同源检查
if !base_url.is_empty() && !is_custom_template {
// 解析 base URL
let parsed_base = Url::parse(base_url).map_err(|e| {
AppError::localized(
"usage_script.base_url_invalid",
format!("无效的 base_url: {e}"),
format!("Invalid base_url: {e}"),
)
})?;
// 检查端口是否匹配(考虑默认端口)
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443
match (
parsed_request.port_or_known_default(),
parsed_base.port_or_known_default(),
) {
(Some(request_port), Some(base_port)) if request_port == base_port => {
// 端口匹配,继续执行
}
(Some(request_port), Some(base_port)) => {
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
if parsed_request.host_str() != parsed_base.host_str() {
return Err(AppError::localized(
"usage_script.request_port_mismatch",
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
format!("Request port {request_port} must match base_url port {base_port}"),
"usage_script.request_host_mismatch",
format!(
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
format!(
"Request host {} must match base_url host {} (same-origin required)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
));
}
_ => {
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
return Err(AppError::localized(
"usage_script.request_port_unknown",
"无法确定端口号",
"Unable to determine port number",
));
// 检查端口是否匹配(考虑默认端口)
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443
match (
parsed_request.port_or_known_default(),
parsed_base.port_or_known_default(),
) {
(Some(request_port), Some(base_port)) if request_port == base_port => {
// 端口匹配,继续执行
}
(Some(request_port), Some(base_port)) => {
return Err(AppError::localized(
"usage_script.request_port_mismatch",
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
format!("Request port {request_port} must match base_url port {base_port}"),
));
}
_ => {
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
return Err(AppError::localized(
"usage_script.request_port_unknown",
"无法确定端口号",
"Unable to determine port number",
));
}
}
}
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
if let Some(host) = parsed_request.host_str() {
let base_host = parsed_base.host_str().unwrap_or("");
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
if let Some(host) = parsed_request.host_str() {
let base_host = parsed_base.host_str().unwrap_or("");
// 如果 base_url 不是私有地址,则禁止访问私有IP
if !is_private_ip(base_host) && is_private_ip(host) {
return Err(AppError::localized(
"usage_script.private_ip_blocked",
"禁止访问私有 IP 地址",
"Access to private IP addresses is blocked",
));
// 如果 base_url 不是私有地址,则禁止访问私有IP
if !is_private_ip(base_host) && is_private_ip(host) {
return Err(AppError::localized(
"usage_script.private_ip_blocked",
"禁止访问私有 IP 地址",
"Access to private IP addresses is blocked",
));
}
}
} else {
// 自定义模板模式:没有 base_url,需要额外的安全检查
// 禁止访问私有 IP 地址(SSRF 防护)
if let Some(host) = parsed_request.host_str() {
if is_private_ip(host) && !is_request_loopback {
return Err(AppError::localized(
"usage_script.private_ip_blocked",
"禁止访问私有 IP 地址(localhost 除外)",
"Access to private IP addresses is blocked (localhost allowed)",
));
}
}
}
@@ -843,7 +873,7 @@ mod tests {
];
for (base_url, request_url, should_match) in test_cases {
let result = validate_request_url(request_url, base_url);
let result = validate_request_url(request_url, base_url, false);
if should_match {
assert!(
@@ -856,7 +886,9 @@ mod tests {
} else {
assert!(
result.is_err(),
"应该不匹配的URL被允许: base_url={base_url}, request_url={request_url}"
"应该不匹配的URL被允许: base_url={}, request_url={}",
base_url,
request_url
);
}
}
+2
View File
@@ -553,6 +553,7 @@ command = "echo"
claude: false,
codex: false, // 初始未启用
gemini: false,
opencode: false,
},
description: None,
homepage: None,
@@ -680,6 +681,7 @@ fn import_from_claude_merges_into_config() {
claude: false, // 初始未启用
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
+6
View File
@@ -214,6 +214,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
claude: false,
codex: false, // 初始未启用
gemini: false,
opencode: false,
},
description: None,
homepage: None,
@@ -277,6 +278,7 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() {
claude: false,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
@@ -320,6 +322,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
claude: true,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
@@ -352,6 +355,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
claude: false,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
@@ -483,6 +487,7 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
claude: false,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
@@ -536,6 +541,7 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
claude: false,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
+1
View File
@@ -74,6 +74,7 @@ command = "say"
claude: false,
codex: true, // 启用 Codex
gemini: false,
opencode: false,
},
description: None,
homepage: None,
+1
View File
@@ -88,6 +88,7 @@ command = "say"
claude: false,
codex: true,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
+84 -36
View File
@@ -13,7 +13,6 @@ import {
Wrench,
Server,
RefreshCw,
Package,
Search,
Download,
} from "lucide-react";
@@ -49,7 +48,6 @@ import { SkillsPage } from "@/components/skills/SkillsPage";
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { AgentsPanel } from "@/components/agents/AgentsPanel";
import { TemplatesPage } from "@/components/templates/TemplatesPage";
import { UniversalProviderPanel } from "@/components/universal";
import { Button } from "@/components/ui/button";
@@ -60,7 +58,6 @@ type View =
| "skills"
| "skillsDiscovery"
| "mcp"
| "templates"
| "agents"
| "universal";
@@ -80,7 +77,11 @@ function App() {
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
// Confirm action state: 'remove' = remove from live config, 'delete' = delete from database
const [confirmAction, setConfirmAction] = useState<{
provider: Provider;
action: "remove" | "delete";
} | null>(null);
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
const [showEnvBanner, setShowEnvBanner] = useState(false);
@@ -325,11 +326,46 @@ function App() {
setEditingProvider(null);
};
// 确认删除供应商
const handleConfirmDelete = async () => {
if (!confirmDelete) return;
await deleteProvider(confirmDelete.id);
setConfirmDelete(null);
// 确认删除/移除供应商
const handleConfirmAction = async () => {
if (!confirmAction) return;
const { provider, action } = confirmAction;
if (action === "remove") {
// Remove from live config only (for additive mode apps like OpenCode)
// Does NOT delete from database - provider remains in the list
await providersApi.removeFromLiveConfig(provider.id, activeApp);
// Invalidate queries to refresh the isInConfig state
await queryClient.invalidateQueries({
queryKey: ["opencodeLiveProviderIds"],
});
toast.success(
t("notifications.removeFromConfigSuccess", {
defaultValue: "已从配置移除",
}),
{ closeButton: true },
);
} else {
// Delete from database
await deleteProvider(provider.id);
}
setConfirmAction(null);
};
// Generate a unique provider key for OpenCode duplication
const generateUniqueOpencodeKey = (originalKey: string, existingKeys: string[]): string => {
const baseKey = `${originalKey}-copy`;
if (!existingKeys.includes(baseKey)) {
return baseKey;
}
// If -copy already exists, try -copy-2, -copy-3, ...
let counter = 2;
while (existingKeys.includes(`${baseKey}-${counter}`)) {
counter++;
}
return `${baseKey}-${counter}`;
};
// 复制供应商
@@ -338,7 +374,7 @@ function App() {
const newSortIndex =
provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined;
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> = {
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> & { providerKey?: string } = {
name: `${provider.name} copy`,
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
websiteUrl: provider.websiteUrl,
@@ -351,6 +387,12 @@ function App() {
iconColor: provider.iconColor,
};
// OpenCode: generate unique provider key (used as ID)
if (activeApp === "opencode") {
const existingKeys = Object.keys(providers);
duplicatedProvider.providerKey = generateUniqueOpencodeKey(provider.id, existingKeys);
}
// 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1
if (provider.sortIndex !== undefined) {
const updates = Object.values(providers)
@@ -457,7 +499,7 @@ function App() {
/>
);
case "skillsDiscovery":
return <SkillsPage ref={skillsPageRef} initialApp={activeApp} />;
return <SkillsPage ref={skillsPageRef} initialApp={activeApp === "opencode" ? "claude" : activeApp} />;
case "mcp":
return (
<UnifiedMcpPanel
@@ -471,15 +513,13 @@ function App() {
);
case "universal":
return (
<div className="mx-auto max-w-[56rem] px-5 pt-4">
<div className="px-6 pt-4">
<UniversalProviderPanel />
</div>
);
case "templates":
return <TemplatesPage activeApp={activeApp} />;
default:
return (
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
<AnimatePresence mode="wait">
@@ -503,7 +543,15 @@ function App() {
activeProviderId={activeProviderId}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDelete={(provider) =>
setConfirmAction({ provider, action: "delete" })
}
onRemoveFromConfig={
activeApp === "opencode"
? (provider) =>
setConfirmAction({ provider, action: "remove" })
: undefined
}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
@@ -585,7 +633,7 @@ function App() {
}
>
<div
className="mx-auto flex h-full max-w-[56rem] flex-wrap items-center justify-between gap-2 px-6"
className="flex h-full items-center justify-between gap-2 px-6"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any}
>
@@ -617,7 +665,6 @@ function App() {
{currentView === "skillsDiscovery" && t("skills.title")}
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
{currentView === "agents" && t("agents.title")}
{currentView === "templates" && t("templates.title")}
{currentView === "universal" &&
t("universalProvider.title", {
defaultValue: "统一供应商",
@@ -746,7 +793,9 @@ function App() {
)}
{currentView === "providers" && (
<>
<ProxyToggle activeApp={activeApp} />
{activeApp !== "opencode" && (
<ProxyToggle activeApp={activeApp} />
)}
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
@@ -796,15 +845,6 @@ function App() {
>
<Server className="w-4 h-4" />
</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>
<Button
@@ -860,17 +900,25 @@ function App() {
)}
<ConfirmDialog
isOpen={Boolean(confirmDelete)}
title={t("confirm.deleteProvider")}
isOpen={Boolean(confirmAction)}
title={
confirmAction?.action === "remove"
? t("confirm.removeProvider")
: t("confirm.deleteProvider")
}
message={
confirmDelete
? t("confirm.deleteProviderMessage", {
name: confirmDelete.name,
})
confirmAction
? confirmAction.action === "remove"
? t("confirm.removeProviderMessage", {
name: confirmAction.provider.name,
})
: t("confirm.deleteProviderMessage", {
name: confirmAction.provider.name,
})
: ""
}
onConfirm={() => void handleConfirmDelete()}
onCancel={() => setConfirmDelete(null)}
onConfirm={() => void handleConfirmAction()}
onCancel={() => setConfirmAction(null)}
/>
<DeepLinkImportDialog />
+24
View File
@@ -16,11 +16,13 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
claude: "claude",
codex: "openai",
gemini: "gemini",
opencode: "opencode",
};
const appDisplayName: Record<AppId, string> = {
claude: "Claude",
codex: "Codex",
gemini: "Gemini",
opencode: "OpenCode",
};
return (
@@ -90,6 +92,28 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
/>
<span>{appDisplayName.gemini}</span>
</button>
<button
type="button"
onClick={() => handleSwitch("opencode")}
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "opencode"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
}`}
>
<ProviderIcon
icon={appIconName.opencode}
name={appDisplayName.opencode}
size={iconSize}
className={
activeApp === "opencode"
? "text-foreground"
: "text-muted-foreground group-hover:text-foreground transition-colors"
}
/>
<span>{appDisplayName.opencode}</span>
</button>
</div>
);
}
+5 -1
View File
@@ -11,6 +11,7 @@ interface UsageFooterProps {
appId: AppId;
usageEnabled: boolean; // 是否启用了用量查询
isCurrent: boolean; // 是否为当前激活的供应商
isInConfig?: boolean; // OpenCode: 是否已添加到配置
inline?: boolean; // 是否内联显示(在按钮左侧)
}
@@ -20,12 +21,15 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
appId,
usageEnabled,
isCurrent,
isInConfig = false,
inline = false,
}) => {
const { t } = useTranslation();
// 统一的用量查询(自动查询仅对当前激活的供应商启用)
const autoQueryInterval = isCurrent
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
const autoQueryInterval = shouldAutoQuery
? provider.meta?.usage_script?.autoQueryInterval || 0
: 0;
+154 -12
View File
@@ -2,8 +2,10 @@ import React, { useState } from "react";
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Provider, UsageScript, UsageData } from "@/types";
import { usageApi, type AppId } from "@/lib/api";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone";
import * as parserBabel from "prettier/parser-babel";
@@ -109,19 +111,67 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
onSave,
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// 生成带国际化的预设模板
const PRESET_TEMPLATES = generatePresetTemplates(t);
const [script, setScript] = useState<UsageScript>(() => {
return (
provider.meta?.usage_script || {
enabled: false,
language: "javascript",
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
timeout: 10,
// 从 provider 的 settingsConfig 中提取 API Key 和 Base URL
const getProviderCredentials = (): {
apiKey: string | undefined;
baseUrl: string | undefined;
} => {
try {
const config = provider.settingsConfig;
if (!config) return { apiKey: undefined, baseUrl: undefined };
// 处理不同应用的配置格式
if (appId === "claude") {
// Claude: { env: { ANTHROPIC_AUTH_TOKEN | ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL } }
const env = (config as any).env || {};
return {
apiKey: env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY,
baseUrl: env.ANTHROPIC_BASE_URL,
};
} else if (appId === "codex") {
// Codex: { auth: { OPENAI_API_KEY }, config: TOML string with base_url }
const auth = (config as any).auth || {};
const configToml = (config as any).config || "";
return {
apiKey: auth.OPENAI_API_KEY,
baseUrl: extractCodexBaseUrl(configToml),
};
} else if (appId === "gemini") {
// Gemini: { env: { GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL } }
const env = (config as any).env || {};
return {
apiKey: env.GEMINI_API_KEY,
baseUrl: env.GOOGLE_GEMINI_BASE_URL,
};
}
);
return { apiKey: undefined, baseUrl: undefined };
} catch (error) {
console.error("Failed to extract provider credentials:", error);
return { apiKey: undefined, baseUrl: undefined };
}
};
const providerCredentials = getProviderCredentials();
const [script, setScript] = useState<UsageScript>(() => {
const savedScript = provider.meta?.usage_script;
const defaultScript = {
enabled: false,
language: "javascript" as const,
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
timeout: 10,
};
if (!savedScript) {
return defaultScript;
}
return savedScript;
});
const [testing, setTesting] = useState(false);
@@ -176,6 +226,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
() => {
const existingScript = provider.meta?.usage_script;
// 优先使用保存的 templateType
if (existingScript?.templateType) {
return existingScript.templateType;
}
// 向后兼容:根据字段推断模板类型
// 检测 NEW_API 模板(有 accessToken 或 userId
if (existingScript?.accessToken || existingScript?.userId) {
return TEMPLATE_KEYS.NEW_API;
@@ -201,7 +256,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
return;
}
onSave(script);
// 保存时记录当前选择的模板类型
const scriptWithTemplate = {
...script,
templateType: selectedTemplate as
| "custom"
| "general"
| "newapi"
| undefined,
};
onSave(scriptWithTemplate);
onClose();
};
@@ -217,6 +281,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
script.baseUrl,
script.accessToken,
script.userId,
selectedTemplate as "custom" | "general" | "newapi" | undefined,
);
if (result.success && result.data && result.data.length > 0) {
const summary = result.data
@@ -229,6 +294,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
duration: 3000,
closeButton: true,
});
// 🔧 测试成功后,更新主界面列表的用量查询缓存
queryClient.setQueryData(["usage", provider.id, appId], result);
} else {
toast.error(
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
@@ -278,9 +346,13 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const preset = PRESET_TEMPLATES[presetName];
if (preset) {
if (presetName === TEMPLATE_KEYS.CUSTOM) {
// 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换
// 这样可以避免同源检查导致的问题
// 如果用户想使用变量,需要手动在配置中设置 baseUrl/apiKey
setScript({
...script,
code: preset,
// 清除凭证,用户可选择手动输入或保持空
apiKey: undefined,
baseUrl: undefined,
accessToken: undefined,
@@ -401,6 +473,74 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
})}
</div>
{/* 自定义模式:变量提示和具体值 */}
{selectedTemplate === TEMPLATE_KEYS.CUSTOM && (
<div className="space-y-2 border-t border-white/10 pt-3">
<h4 className="text-sm font-medium text-foreground">
{t("usageScript.supportedVariables")}
</h4>
<div className="space-y-1 text-xs">
{/* baseUrl */}
<div className="flex items-center gap-2 py-1">
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
{"{{baseUrl}}"}
</code>
<span className="text-muted-foreground/50">=</span>
{providerCredentials.baseUrl ? (
<code className="text-foreground/70 break-all font-mono">
{providerCredentials.baseUrl}
</code>
) : (
<span className="text-muted-foreground/50 italic">
{t("common.notSet") || "未设置"}
</span>
)}
</div>
{/* apiKey */}
<div className="flex items-center gap-2 py-1">
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
{"{{apiKey}}"}
</code>
<span className="text-muted-foreground/50">=</span>
{providerCredentials.apiKey ? (
<>
{showApiKey ? (
<code className="text-foreground/70 break-all font-mono">
{providerCredentials.apiKey}
</code>
) : (
<code className="text-foreground/70 font-mono">
</code>
)}
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="text-muted-foreground hover:text-foreground transition-colors ml-1"
aria-label={
showApiKey
? t("apiKeyInput.hide")
: t("apiKeyInput.show")
}
>
{showApiKey ? (
<EyeOff size={12} />
) : (
<Eye size={12} />
)}
</button>
</>
) : (
<span className="text-muted-foreground/50 italic">
{t("common.notSet") || "未设置"}
</span>
)}
</div>
</div>
</div>
)}
{/* 凭证配置 */}
{shouldShowCredentialsConfig && (
<div className="space-y-4">
@@ -601,11 +741,13 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
type="number"
min={0}
max={1440}
value={script.autoIntervalMinutes ?? 0}
value={
script.autoQueryInterval ?? script.autoIntervalMinutes ?? 0
}
onChange={(e) =>
setScript({
...script,
autoIntervalMinutes: validateAndClampInterval(
autoQueryInterval: validateAndClampInterval(
e.target.value,
),
})
@@ -613,7 +755,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
onBlur={(e) =>
setScript({
...script,
autoIntervalMinutes: validateAndClampInterval(
autoQueryInterval: validateAndClampInterval(
e.target.value,
),
})
+3 -3
View File
@@ -72,7 +72,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
}
>
<div
className="mx-auto max-w-[56rem] px-6 w-full flex items-center gap-4"
className="px-6 w-full flex items-center gap-4"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
@@ -94,7 +94,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
{/* Content */}
<div className="flex-1 overflow-y-auto scroll-overlay">
<div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
<div className="px-6 py-6 space-y-6 w-full">
{children}
</div>
</div>
@@ -105,7 +105,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
className="flex-shrink-0 py-4 border-t border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
<div className="px-6 flex items-center justify-end gap-3">
{footer}
</div>
</div>
+2
View File
@@ -65,6 +65,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
claude: boolean;
codex: boolean;
gemini: boolean;
opencode: boolean;
}>(() => {
if (initialData?.apps) {
return { ...initialData.apps };
@@ -73,6 +74,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
claude: defaultEnabledApps.includes("claude"),
codex: defaultEnabledApps.includes("codex"),
gemini: defaultEnabledApps.includes("gemini"),
opencode: defaultEnabledApps.includes("opencode"),
};
});
+21 -3
View File
@@ -59,11 +59,12 @@ const UnifiedMcpPanel = React.forwardRef<
// Count enabled servers per app
const enabledCounts = useMemo(() => {
const counts = { claude: 0, codex: 0, gemini: 0 };
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
serverEntries.forEach(([_, server]) => {
if (server.apps.claude) counts.claude++;
if (server.apps.codex) counts.codex++;
if (server.apps.gemini) counts.gemini++;
if (server.apps.opencode) counts.opencode++;
});
return counts;
}, [serverEntries]);
@@ -141,14 +142,15 @@ const UnifiedMcpPanel = React.forwardRef<
};
return (
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
{/* Info Section */}
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
<div className="text-sm text-muted-foreground">
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini}
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini} ·{" "}
{t("mcp.unifiedPanel.apps.opencode")}: {enabledCounts.opencode}
</div>
</div>
@@ -337,6 +339,22 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-opencode`}
className="text-sm text-foreground/80 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.opencode")}
</label>
<Switch
id={`${id}-opencode`}
checked={server.apps.opencode}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "opencode", checked)
}
/>
</div>
</div>
{/* 右侧:操作按钮 */}
@@ -34,6 +34,7 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
claude: "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
opencode: "AGENTS.md",
};
const filename = filenameMap[appId];
const [name, setName] = useState("");
@@ -28,6 +28,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
claude: "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
opencode: "AGENTS.md",
};
const filename = filenameMap[appId];
const [name, setName] = useState("");
+1 -1
View File
@@ -96,7 +96,7 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
return (
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] px-6">
<div className="flex flex-col h-[calc(100vh-8rem)] px-6">
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
<div className="text-sm text-muted-foreground">
{t("prompts.count", { count: promptEntries.length })} ·{" "}
+62 -34
View File
@@ -17,13 +17,14 @@ import { UniversalProviderPanel } from "@/components/universal";
import { providerPresets } from "@/config/claudeProviderPresets";
import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
// Note: opencodeProviderPresets is loaded via ProviderForm, not needed here
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
interface AddProviderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
appId: AppId;
onSubmit: (provider: Omit<Provider, "id">) => Promise<void> | void;
onSubmit: (provider: Omit<Provider, "id"> & { providerKey?: string }) => Promise<void> | void;
}
export function AddProviderDialog({
@@ -33,6 +34,8 @@ export function AddProviderDialog({
onSubmit,
}: AddProviderDialogProps) {
const { t } = useTranslation();
// OpenCode doesn't support universal providers
const showUniversalTab = appId !== "opencode";
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
"app-specific",
);
@@ -82,7 +85,7 @@ export function AddProviderDialog({
>;
// 构造基础提交数据
const providerData: Omit<Provider, "id"> = {
const providerData: Omit<Provider, "id"> & { providerKey?: string } = {
name: values.name.trim(),
notes: values.notes?.trim() || undefined,
websiteUrl: values.websiteUrl?.trim() || undefined,
@@ -93,6 +96,11 @@ export function AddProviderDialog({
...(values.meta ? { meta: values.meta } : {}),
};
// OpenCode: pass providerKey for ID generation
if (appId === "opencode" && values.providerKey) {
providerData.providerKey = values.providerKey;
}
const hasCustomEndpoints =
providerData.meta?.custom_endpoints &&
Object.keys(providerData.meta.custom_endpoints).length > 0;
@@ -153,6 +161,7 @@ export function AddProviderDialog({
}
}
}
// Note: OpenCode doesn't use endpointCandidates - it handles endpoints internally
}
if (appId === "claude") {
@@ -175,6 +184,12 @@ export function AddProviderDialog({
if (env?.GOOGLE_GEMINI_BASE_URL) {
addUrl(env.GOOGLE_GEMINI_BASE_URL);
}
} else if (appId === "opencode") {
// OpenCode uses options.baseURL
const options = parsedConfig.options as Record<string, any> | undefined;
if (options?.baseURL) {
addUrl(options.baseURL);
}
}
const urls = Array.from(urlSet);
@@ -204,7 +219,7 @@ export function AddProviderDialog({
// 动态 footer:根据当前 Tab 显示不同按钮
const footer =
activeTab === "app-specific" ? (
!showUniversalTab || activeTab === "app-specific" ? (
<>
<Button
variant="outline"
@@ -248,41 +263,54 @@ export function AddProviderDialog({
onClose={() => onOpenChange(false)}
footer={footer}
>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="app-specific">
{t(`apps.${appId}`)} {t("provider.tabProvider")}
</TabsTrigger>
<TabsTrigger value="universal">
{t("provider.tabUniversal")}
</TabsTrigger>
</TabsList>
{showUniversalTab ? (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="app-specific">
{t(`apps.${appId}`)} {t("provider.tabProvider")}
</TabsTrigger>
<TabsTrigger value="universal">
{t("provider.tabUniversal")}
</TabsTrigger>
</TabsList>
<TabsContent value="app-specific" className="mt-0">
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
showButtons={false}
/>
</TabsContent>
<TabsContent value="app-specific" className="mt-0">
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
showButtons={false}
/>
</TabsContent>
<TabsContent value="universal" className="mt-0">
<UniversalProviderPanel />
</TabsContent>
</Tabs>
<TabsContent value="universal" className="mt-0">
<UniversalProviderPanel />
</TabsContent>
</Tabs>
) : (
// OpenCode: directly show form without tabs
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
showButtons={false}
/>
)}
{/* Universal Provider Form Modal */}
<UniversalProviderFormModal
isOpen={universalFormOpen}
onClose={handleUniversalFormClose}
onSave={handleUniversalProviderSave}
initialPreset={selectedUniversalPreset}
/>
{showUniversalTab && (
<UniversalProviderFormModal
isOpen={universalFormOpen}
onClose={handleUniversalFormClose}
onSave={handleUniversalProviderSave}
initialPreset={selectedUniversalPreset}
/>
)}
</FullScreenPanel>
);
}
@@ -62,6 +62,17 @@ export function EditProviderDialog({
return;
}
// OpenCode uses additive mode - each provider's config is stored independently in DB
// Reading live config would return the full opencode.json (with $schema, provider, mcp etc.)
// instead of just the provider fragment, causing incorrect nested structure on save
if (appId === "opencode") {
if (!cancelled) {
setLiveSettings(null);
setHasLoadedLive(true);
}
return;
}
try {
const currentId = await providersApi.getCurrent(appId);
if (currentId && provider.id === currentId) {
+57 -7
View File
@@ -4,6 +4,7 @@ import {
Copy,
Edit,
Loader2,
Minus,
Play,
Plus,
Terminal,
@@ -13,9 +14,13 @@ import {
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { AppId } from "@/lib/api";
interface ProviderActionsProps {
appId?: AppId;
isCurrent: boolean;
/** OpenCode: 是否已添加到配置 */
isInConfig?: boolean;
isTesting?: boolean;
isProxyTakeover?: boolean;
onSwitch: () => void;
@@ -24,6 +29,8 @@ interface ProviderActionsProps {
onTest?: () => void;
onConfigureUsage: () => void;
onDelete: () => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: () => void;
onOpenTerminal?: () => void;
// 故障转移相关
isAutoFailoverEnabled?: boolean;
@@ -32,7 +39,9 @@ interface ProviderActionsProps {
}
export function ProviderActions({
appId,
isCurrent,
isInConfig = false,
isTesting,
isProxyTakeover = false,
onSwitch,
@@ -41,6 +50,7 @@ export function ProviderActions({
onTest,
onConfigureUsage,
onDelete,
onRemoveFromConfig,
onOpenTerminal,
// 故障转移相关
isAutoFailoverEnabled = false,
@@ -50,12 +60,27 @@ export function ProviderActions({
const { t } = useTranslation();
const iconButtonClass = "h-8 w-8 p-1";
// 故障转移模式下的按钮逻辑
const isFailoverMode = isAutoFailoverEnabled && onToggleFailover;
// OpenCode 使用累加模式
const isOpenCodeMode = appId === "opencode";
// 故障转移模式下的按钮逻辑(OpenCode 不支持故障转移)
const isFailoverMode = !isOpenCodeMode && isAutoFailoverEnabled && onToggleFailover;
// 处理主按钮点击
const handleMainButtonClick = () => {
if (isFailoverMode) {
if (isOpenCodeMode) {
// OpenCode 模式:切换配置状态(添加/移除)
if (isInConfig) {
// Use onRemoveFromConfig if available, otherwise fall back to onDelete
if (onRemoveFromConfig) {
onRemoveFromConfig();
} else {
onDelete();
}
} else {
onSwitch(); // 添加到配置
}
} else if (isFailoverMode) {
// 故障转移模式:切换队列状态
onToggleFailover(!isInFailoverQueue);
} else {
@@ -66,8 +91,30 @@ export function ProviderActions({
// 主按钮的状态和样式
const getMainButtonState = () => {
// OpenCode 累加模式
if (isOpenCodeMode) {
if (isInConfig) {
return {
disabled: false,
variant: "secondary" as const,
className:
"bg-orange-100 text-orange-600 hover:bg-orange-200 dark:bg-orange-900/50 dark:text-orange-400 dark:hover:bg-orange-900/70",
icon: <Minus className="h-4 w-4" />,
text: t("provider.removeFromConfig", { defaultValue: "移除" }),
};
}
return {
disabled: false,
variant: "default" as const,
className:
"bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
icon: <Plus className="h-4 w-4" />,
text: t("provider.addToConfig", { defaultValue: "添加" }),
};
}
// 故障转移模式
if (isFailoverMode) {
// 故障转移模式
if (isInFailoverQueue) {
return {
disabled: false,
@@ -113,6 +160,9 @@ export function ProviderActions({
const buttonState = getMainButtonState();
// OpenCode 模式下删除按钮始终可用(主按钮"移除"是从 live 配置移除,删除是从数据库删除)
const canDelete = isOpenCodeMode ? true : !isCurrent;
return (
<div className="flex items-center gap-1.5">
<Button
@@ -192,12 +242,12 @@ export function ProviderActions({
<Button
size="icon"
variant="ghost"
onClick={isCurrent ? undefined : onDelete}
onClick={canDelete ? onDelete : undefined}
title={t("common.delete")}
className={cn(
iconButtonClass,
!isCurrent && "hover:text-red-500 dark:hover:text-red-400",
isCurrent && "opacity-40 cursor-not-allowed text-muted-foreground",
canDelete && "hover:text-red-500 dark:hover:text-red-400",
!canDelete && "opacity-40 cursor-not-allowed text-muted-foreground",
)}
>
<Trash2 className="h-4 w-4" />
+24 -4
View File
@@ -26,9 +26,12 @@ interface ProviderCardProps {
provider: Provider;
isCurrent: boolean;
appId: AppId;
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: (provider: Provider) => void;
onConfigureUsage: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onDuplicate: (provider: Provider) => void;
@@ -85,9 +88,11 @@ export function ProviderCard({
provider,
isCurrent,
appId,
isInConfig = true,
onSwitch,
onEdit,
onDelete,
onRemoveFromConfig,
onConfigureUsage,
onOpenWebsite,
onDuplicate,
@@ -134,7 +139,9 @@ export function ProviderCard({
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
// 获取用量数据以判断是否有多套餐
const autoQueryInterval = isCurrent
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
const autoQueryInterval = shouldAutoQuery
? provider.meta?.usage_script?.autoQueryInterval || 0
: 0;
@@ -182,12 +189,16 @@ export function ProviderCard({
};
// 判断是否是"当前使用中"的供应商
// - OpenCode(累加模式):不存在"当前"概念,始终返回 false
// - 故障转移模式:代理实际使用的供应商(activeProviderId
// - 代理接管模式(非故障转移):isCurrent
// - 普通模式:isCurrent
const isActiveProvider = isAutoFailoverEnabled
? activeProviderId === provider.id
: isCurrent;
const isActiveProvider =
appId === "opencode"
? false
: isAutoFailoverEnabled
? activeProviderId === provider.id
: isCurrent;
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
const shouldUseGreen = isProxyTakeover && isActiveProvider;
@@ -327,6 +338,7 @@ export function ProviderCard({
appId={appId}
usageEnabled={usageEnabled}
isCurrent={isCurrent}
isInConfig={isInConfig}
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"
>
<ProviderActions
appId={appId}
isCurrent={isCurrent}
isInConfig={isInConfig}
isTesting={isTesting}
isProxyTakeover={isProxyTakeover}
onSwitch={() => onSwitch(provider)}
@@ -369,6 +383,11 @@ export function ProviderCard({
onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)}
onRemoveFromConfig={
onRemoveFromConfig
? () => onRemoveFromConfig(provider)
: undefined
}
onOpenTerminal={
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
}
@@ -390,6 +409,7 @@ export function ProviderCard({
appId={appId}
usageEnabled={usageEnabled}
isCurrent={isCurrent}
isInConfig={isInConfig}
inline={false}
/>
</div>
+32 -2
View File
@@ -15,8 +15,10 @@ import {
import { AnimatePresence, motion } from "framer-motion";
import { Search, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import type { Provider } from "@/types";
import type { AppId } from "@/lib/api";
import { providersApi } from "@/lib/api/providers";
import { useDragSort } from "@/hooks/useDragSort";
import { useStreamCheck } from "@/hooks/useStreamCheck";
import { ProviderCard } from "@/components/providers/ProviderCard";
@@ -38,6 +40,8 @@ interface ProviderListProps {
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: (provider: Provider) => void;
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
@@ -56,6 +60,7 @@ export function ProviderList({
onSwitch,
onEdit,
onDelete,
onRemoveFromConfig,
onDuplicate,
onConfigureUsage,
onOpenWebsite,
@@ -72,6 +77,22 @@ export function ProviderList({
appId,
);
// OpenCode: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig
const { data: opencodeLiveIds } = useQuery({
queryKey: ["opencodeLiveProviderIds"],
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
enabled: appId === "opencode",
});
// OpenCode: 判断供应商是否已添加到 opencode.json
const isProviderInConfig = useCallback(
(providerId: string): boolean => {
if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true
return opencodeLiveIds?.includes(providerId) ?? false;
},
[appId, opencodeLiveIds],
);
// 流式健康检查
const { checkProvider, isChecking } = useStreamCheck(appId);
@@ -199,14 +220,16 @@ export function ProviderList({
provider={provider}
isCurrent={provider.id === currentProviderId}
appId={appId}
isInConfig={isProviderInConfig(provider.id)}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
onTest={handleTest}
onTest={appId !== "opencode" ? handleTest : undefined}
isTesting={isChecking(provider.id)}
isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyTakeover}
@@ -308,14 +331,17 @@ interface SortableProviderCardProps {
provider: Provider;
isCurrent: boolean;
appId: AppId;
isInConfig: boolean;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: (provider: Provider) => void;
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onOpenTerminal?: (provider: Provider) => void;
onTest: (provider: Provider) => void;
onTest?: (provider: Provider) => void;
isTesting: boolean;
isProxyRunning: boolean;
isProxyTakeover: boolean;
@@ -331,9 +357,11 @@ function SortableProviderCard({
provider,
isCurrent,
appId,
isInConfig,
onSwitch,
onEdit,
onDelete,
onRemoveFromConfig,
onDuplicate,
onConfigureUsage,
onOpenWebsite,
@@ -368,9 +396,11 @@ function SortableProviderCard({
provider={provider}
isCurrent={isCurrent}
appId={appId}
isInConfig={isInConfig}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDuplicate={onDuplicate}
onConfigureUsage={
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import type { ReactNode } from "react";
import {
FormControl,
FormField,
@@ -24,9 +25,11 @@ import type { ProviderFormData } from "@/lib/schemas/provider";
interface BasicFormFieldsProps {
form: UseFormReturn<ProviderFormData>;
/** Slot to render content between icon and name fields */
beforeNameSlot?: ReactNode;
}
export function BasicFormFields({ form }: BasicFormFieldsProps) {
export function BasicFormFields({ form, beforeNameSlot }: BasicFormFieldsProps) {
const { t } = useTranslation();
const [iconDialogOpen, setIconDialogOpen] = useState(false);
@@ -78,7 +81,7 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
>
<div className="flex h-full flex-col">
<div className="flex-shrink-0 py-4 border-b border-border-default bg-muted/40">
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<div className="px-6 flex items-center gap-4">
<DialogClose asChild>
<Button type="button" variant="outline" size="icon">
<ArrowLeft className="h-4 w-4" />
@@ -92,7 +95,7 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="space-y-2 mx-auto max-w-[56rem] px-6 py-6 w-full">
<div className="space-y-2 px-6 py-6 w-full">
<IconPicker
value={currentIcon}
onValueChange={handleIconSelect}
@@ -112,6 +115,9 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
</Dialog>
</div>
{/* Slot for additional fields between icon and name */}
{beforeNameSlot}
{/* 基础信息 - 网格布局 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
@@ -9,11 +9,12 @@ import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { CustomEndpoint, EndpointCandidate } from "@/types";
// 端点测速超时配置(秒)
const ENDPOINT_TIMEOUT_SECS = {
const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
codex: 12,
claude: 8,
gemini: 8, // 新增 gemini
} as const;
gemini: 8,
opencode: 8,
};
interface TestResult {
url: string;
@@ -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>
</>
);
}
+378 -15
View File
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import type { AppId } from "@/lib/api";
import type { ProviderCategory, ProviderMeta } from "@/types";
@@ -20,6 +21,12 @@ import {
geminiProviderPresets,
type GeminiProviderPreset,
} from "@/config/geminiProviderPresets";
import {
opencodeProviderPresets,
type OpenCodeProviderPreset,
} from "@/config/opencodeProviderPresets";
import { OpenCodeFormFields } from "./OpenCodeFormFields";
import type { OpenCodeModel } from "@/types";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import { applyTemplateValues } from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
@@ -27,6 +34,8 @@ import { getCodexCustomTemplate } from "@/config/codexTemplates";
import CodexConfigEditor from "./CodexConfigEditor";
import { CommonConfigEditor } from "./CommonConfigEditor";
import GeminiConfigEditor from "./GeminiConfigEditor";
import JsonEditor from "@/components/JsonEditor";
import { Label } from "@/components/ui/label";
import { ProviderPresetSelector } from "./ProviderPresetSelector";
import { BasicFormFields } from "./BasicFormFields";
import { ClaudeFormFields } from "./ClaudeFormFields";
@@ -47,6 +56,7 @@ import {
useGeminiConfigState,
useGeminiCommonConfig,
} from "./hooks";
import { useProvidersQuery } from "@/lib/query/queries";
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
@@ -62,9 +72,22 @@ const GEMINI_DEFAULT_CONFIG = JSON.stringify(
2,
);
const OPENCODE_DEFAULT_CONFIG = JSON.stringify(
{
npm: "@ai-sdk/openai-compatible",
options: {
baseURL: "",
apiKey: "",
},
models: {},
},
null,
2,
);
type PresetEntry = {
id: string;
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset;
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset | OpenCodeProviderPreset;
};
interface ProviderFormProps {
@@ -158,7 +181,9 @@ export function ProviderForm({
? CODEX_DEFAULT_CONFIG
: appId === "gemini"
? GEMINI_DEFAULT_CONFIG
: CLAUDE_DEFAULT_CONFIG,
: appId === "opencode"
? OPENCODE_DEFAULT_CONFIG
: CLAUDE_DEFAULT_CONFIG,
icon: initialData?.icon ?? "",
iconColor: initialData?.iconColor ?? "",
}),
@@ -171,7 +196,7 @@ export function ProviderForm({
mode: "onSubmit",
});
const settingsConfigValue = form.watch("settingsConfig");
const settingsConfigValue = form.getValues("settingsConfig");
// 使用 API Key hook
const {
@@ -179,7 +204,7 @@ export function ProviderForm({
handleApiKeyChange,
showApiKey: shouldShowApiKey,
} = useApiKeyState({
initialConfig: form.watch("settingsConfig"),
initialConfig: form.getValues("settingsConfig"),
onConfigChange: (config) => form.setValue("settingsConfig", config),
selectedPresetId,
category,
@@ -190,7 +215,7 @@ export function ProviderForm({
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
appType: appId,
category,
settingsConfig: form.watch("settingsConfig"),
settingsConfig: form.getValues("settingsConfig"),
codexConfig: "",
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
onCodexConfigChange: () => {
@@ -207,7 +232,7 @@ export function ProviderForm({
defaultOpusModel,
handleModelChange,
} = useModelState({
settingsConfig: form.watch("settingsConfig"),
settingsConfig: form.getValues("settingsConfig"),
onConfigChange: (config) => form.setValue("settingsConfig", config),
});
@@ -328,6 +353,11 @@ export function ProviderForm({
id: `gemini-${index}`,
preset,
}));
} else if (appId === "opencode") {
return opencodeProviderPresets.map<PresetEntry>((preset, index) => ({
id: `opencode-${index}`,
preset,
}));
}
return providerPresets.map<PresetEntry>((preset, index) => ({
id: `claude-${index}`,
@@ -345,7 +375,7 @@ export function ProviderForm({
} = useTemplateValues({
selectedPresetId: appId === "claude" ? selectedPresetId : null,
presetEntries: appId === "claude" ? presetEntries : [],
settingsConfig: form.watch("settingsConfig"),
settingsConfig: form.getValues("settingsConfig"),
onConfigChange: (config) => form.setValue("settingsConfig", config),
});
@@ -359,10 +389,11 @@ export function ProviderForm({
isExtracting: isClaudeExtracting,
handleExtract: handleClaudeExtract,
} = useCommonConfigSnippet({
settingsConfig: form.watch("settingsConfig"),
settingsConfig: form.getValues("settingsConfig"),
onConfigChange: (config) => form.setValue("settingsConfig", config),
initialData: appId === "claude" ? initialData : undefined,
selectedPresetId: selectedPresetId ?? undefined,
enabled: appId === "claude",
});
// 使用 Codex 通用配置片段 hook (仅 Codex 模式)
@@ -408,7 +439,7 @@ export function ProviderForm({
originalHandleGeminiApiKeyChange(key);
// 同步更新 settingsConfig
try {
const config = JSON.parse(form.watch("settingsConfig") || "{}");
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
if (!config.env) config.env = {};
config.env.GEMINI_API_KEY = key.trim();
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
@@ -424,7 +455,7 @@ export function ProviderForm({
originalHandleGeminiBaseUrlChange(url);
// 同步更新 settingsConfig
try {
const config = JSON.parse(form.watch("settingsConfig") || "{}");
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
if (!config.env) config.env = {};
config.env.GOOGLE_GEMINI_BASE_URL = url.trim().replace(/\/+$/, "");
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
@@ -440,7 +471,7 @@ export function ProviderForm({
originalHandleGeminiModelChange(model);
// 同步更新 settingsConfig
try {
const config = JSON.parse(form.watch("settingsConfig") || "{}");
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
if (!config.env) config.env = {};
config.env.GEMINI_MODEL = model.trim();
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
@@ -469,6 +500,180 @@ export function ProviderForm({
selectedPresetId: selectedPresetId ?? undefined,
});
// OpenCode: query existing providers for duplicate key checking
const { data: opencodeProvidersData } = useProvidersQuery("opencode");
const existingOpencodeKeys = useMemo(() => {
if (!opencodeProvidersData?.providers) return [];
// Exclude current provider ID when in edit mode
return Object.keys(opencodeProvidersData.providers).filter(
(k) => k !== providerId
);
}, [opencodeProvidersData?.providers, providerId]);
// OpenCode Provider Key state
const [opencodeProviderKey, setOpencodeProviderKey] = useState<string>(() => {
if (appId !== "opencode") return "";
// In edit mode, use the existing provider ID as the key
return providerId || "";
});
// OpenCode 配置状态
const [opencodeNpm, setOpencodeNpm] = useState<string>(() => {
if (appId !== "opencode") return "@ai-sdk/openai-compatible";
try {
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
return config.npm || "@ai-sdk/openai-compatible";
} catch {
return "@ai-sdk/openai-compatible";
}
});
const [opencodeApiKey, setOpencodeApiKey] = useState<string>(() => {
if (appId !== "opencode") return "";
try {
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
return config.options?.apiKey || "";
} catch {
return "";
}
});
const [opencodeBaseUrl, setOpencodeBaseUrl] = useState<string>(() => {
if (appId !== "opencode") return "";
try {
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
return config.options?.baseURL || "";
} catch {
return "";
}
});
const [opencodeModels, setOpencodeModels] = useState<Record<string, OpenCodeModel>>(() => {
if (appId !== "opencode") return {};
try {
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
return config.models || {};
} catch {
return {};
}
});
// OpenCode extra options state (e.g., timeout, setCacheKey)
const [opencodeExtraOptions, setOpencodeExtraOptions] = useState<Record<string, string>>(() => {
if (appId !== "opencode") return {};
try {
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
const options = config.options || {};
const extra: Record<string, string> = {};
const knownKeys = ["baseURL", "apiKey", "headers"];
for (const [k, v] of Object.entries(options)) {
if (!knownKeys.includes(k)) {
// Convert value to string for display
extra[k] = typeof v === "string" ? v : JSON.stringify(v);
}
}
return extra;
} catch {
return {};
}
});
// OpenCode handlers - sync state to form
const handleOpencodeNpmChange = useCallback(
(npm: string) => {
setOpencodeNpm(npm);
try {
const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
config.npm = npm;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const handleOpencodeApiKeyChange = useCallback(
(apiKey: string) => {
setOpencodeApiKey(apiKey);
try {
const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
if (!config.options) config.options = {};
config.options.apiKey = apiKey;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const handleOpencodeBaseUrlChange = useCallback(
(baseUrl: string) => {
setOpencodeBaseUrl(baseUrl);
try {
const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
if (!config.options) config.options = {};
config.options.baseURL = baseUrl.trim().replace(/\/+$/, "");
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const handleOpencodeModelsChange = useCallback(
(models: Record<string, OpenCodeModel>) => {
setOpencodeModels(models);
try {
const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
config.models = models;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const handleOpencodeExtraOptionsChange = useCallback(
(options: Record<string, string>) => {
setOpencodeExtraOptions(options);
try {
const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
if (!config.options) config.options = {};
// Remove old extra options (keep only known keys)
const knownKeys = ["baseURL", "apiKey", "headers"];
for (const k of Object.keys(config.options)) {
if (!knownKeys.includes(k)) {
delete config.options[k];
}
}
// Add new extra options (auto-parse value types)
for (const [k, v] of Object.entries(options)) {
const trimmedKey = k.trim();
if (trimmedKey && !trimmedKey.startsWith("option-")) {
try {
// Try to parse as JSON (number, boolean, object, array)
config.options[trimmedKey] = JSON.parse(v);
} catch {
// If parsing fails, keep as string
config.options[trimmedKey] = v;
}
}
}
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
const handleSubmit = (values: ProviderFormData) => {
@@ -496,6 +701,23 @@ export function ProviderForm({
return;
}
// OpenCode: validate provider key
if (appId === "opencode") {
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
if (!opencodeProviderKey.trim()) {
toast.error(t("opencode.providerKeyRequired"));
return;
}
if (!keyPattern.test(opencodeProviderKey)) {
toast.error(t("opencode.providerKeyInvalid"));
return;
}
if (!isEditMode && existingOpencodeKeys.includes(opencodeProviderKey)) {
toast.error(t("opencode.providerKeyDuplicate"));
return;
}
}
// 非官方供应商必填校验:端点和 API Key
if (category !== "official") {
if (appId === "claude") {
@@ -593,6 +815,11 @@ export function ProviderForm({
settingsConfig,
};
// OpenCode: pass provider key for ID generation
if (appId === "opencode") {
payload.providerKey = opencodeProviderKey;
}
if (activePreset) {
payload.presetId = activePreset.id;
if (activePreset.category) {
@@ -748,6 +975,15 @@ export function ProviderForm({
if (appId === "gemini") {
resetGeminiConfig({}, {});
}
// OpenCode 自定义模式:重置为空配置
if (appId === "opencode") {
setOpencodeProviderKey("");
setOpencodeNpm("@ai-sdk/openai-compatible");
setOpencodeBaseUrl("");
setOpencodeApiKey("");
setOpencodeModels({});
setOpencodeExtraOptions({});
}
return;
}
@@ -801,6 +1037,42 @@ export function ProviderForm({
return;
}
// OpenCode preset handling
if (appId === "opencode") {
const preset = entry.preset as OpenCodeProviderPreset;
const config = preset.settingsConfig;
// Clear provider key (user must enter their own unique key)
setOpencodeProviderKey("");
// Update OpenCode-specific states
setOpencodeNpm(config.npm || "@ai-sdk/openai-compatible");
setOpencodeBaseUrl(config.options?.baseURL || "");
setOpencodeApiKey(config.options?.apiKey || "");
setOpencodeModels(config.models || {});
// Extract extra options from preset
const options = config.options || {};
const extra: Record<string, string> = {};
const knownKeys = ["baseURL", "apiKey", "headers"];
for (const [k, v] of Object.entries(options)) {
if (!knownKeys.includes(k)) {
extra[k] = typeof v === "string" ? v : JSON.stringify(v);
}
}
setOpencodeExtraOptions(extra);
// Update form fields
form.reset({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
const preset = entry.preset as ProviderPreset;
const config = applyTemplateValues(
preset.settingsConfig,
@@ -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 专属字段 */}
{appId === "claude" && (
<ClaudeFormFields
providerId={providerId}
shouldShowApiKey={shouldShowApiKey(
form.watch("settingsConfig"),
form.getValues("settingsConfig"),
isEditMode,
)}
apiKey={apiKey}
@@ -916,7 +1229,7 @@ export function ProviderForm({
<GeminiFormFields
providerId={providerId}
shouldShowApiKey={shouldShowApiKey(
form.watch("settingsConfig"),
form.getValues("settingsConfig"),
isEditMode,
)}
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 分别使用不同的编辑器 */}
{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
value={form.watch("settingsConfig")}
value={form.getValues("settingsConfig")}
onChange={(value) => form.setValue("settingsConfig", value)}
useCommonConfig={useCommonConfig}
onCommonConfigToggle={handleCommonConfigToggle}
@@ -1047,4 +1409,5 @@ export type ProviderFormValues = ProviderFormData & {
presetCategory?: ProviderCategory;
isPartner?: boolean;
meta?: ProviderMeta;
providerKey?: string; // OpenCode: user-defined provider key
};
@@ -6,7 +6,7 @@ import {
import type { ProviderCategory } from "@/types";
interface UseBaseUrlStateProps {
appType: "claude" | "codex" | "gemini";
appType: "claude" | "codex" | "gemini" | "opencode";
category: ProviderCategory | undefined;
settingsConfig: string;
codexConfig?: string;
@@ -19,6 +19,8 @@ interface UseCommonConfigSnippetProps {
settingsConfig?: Record<string, unknown>;
};
selectedPresetId?: string;
/** When false, the hook skips all logic and returns disabled state. Default: true */
enabled?: boolean;
}
/**
@@ -30,6 +32,7 @@ export function useCommonConfigSnippet({
onConfigChange,
initialData,
selectedPresetId,
enabled = true,
}: UseCommonConfigSnippetProps) {
const { t } = useTranslation();
const [useCommonConfig, setUseCommonConfig] = useState(false);
@@ -47,11 +50,16 @@ export function useCommonConfigSnippet({
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
useEffect(() => {
if (!enabled) return;
hasInitializedNewMode.current = false;
}, [selectedPresetId]);
}, [selectedPresetId, enabled]);
// 初始化:从 config.json 加载,支持从 localStorage 迁移
useEffect(() => {
if (!enabled) {
setIsLoading(false);
return;
}
let mounted = true;
const loadSnippet = async () => {
@@ -100,10 +108,11 @@ export function useCommonConfigSnippet({
return () => {
mounted = false;
};
}, []);
}, [enabled]);
// 初始化时检查通用配置片段(编辑模式)
useEffect(() => {
if (!enabled) return;
if (initialData && !isLoading) {
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
const hasCommon = hasCommonConfigSnippet(
@@ -112,10 +121,11 @@ export function useCommonConfigSnippet({
);
setUseCommonConfig(hasCommon);
}
}, [initialData, commonConfigSnippet, isLoading]);
}, [enabled, initialData, commonConfigSnippet, isLoading]);
// 新建模式:如果通用配置片段存在且有效,默认启用
useEffect(() => {
if (!enabled) return;
// 仅新建模式、加载完成、尚未初始化过
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
hasInitializedNewMode.current = true;
@@ -145,6 +155,7 @@ export function useCommonConfigSnippet({
}
}
}, [
enabled,
initialData,
commonConfigSnippet,
isLoading,
@@ -259,6 +270,7 @@ export function useCommonConfigSnippet({
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
useEffect(() => {
if (!enabled) return;
if (isUpdatingFromCommonConfig.current || isLoading) {
return;
}
@@ -267,7 +279,7 @@ export function useCommonConfigSnippet({
commonConfigSnippet,
);
setUseCommonConfig(hasCommon);
}, [settingsConfig, commonConfigSnippet, isLoading]);
}, [enabled, settingsConfig, commonConfigSnippet, isLoading]);
// 从编辑器当前内容提取通用配置片段
const handleExtract = useCallback(async () => {
+3 -1
View File
@@ -37,7 +37,9 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
? "Claude"
: activeApp === "codex"
? "Codex"
: "Gemini";
: activeApp === "gemini"
? "Gemini"
: "OpenCode";
const tooltipText = takeoverEnabled
? isRunning
+1 -1
View File
@@ -202,7 +202,7 @@ export function SettingsPage({
};
return (
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
<div className="flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
{isBusy ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+1 -1
View File
@@ -193,7 +193,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
}, [skills, searchQuery, filterStatus]);
return (
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden bg-background/50">
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden bg-background/50">
{/* 技能网格(可滚动详情区域) */}
<div className="flex-1 overflow-y-auto overflow-x-hidden animate-fade-in">
<div className="py-4">
+21 -3
View File
@@ -52,12 +52,13 @@ const UnifiedSkillsPanel = React.forwardRef<
// Count enabled skills per app
const enabledCounts = useMemo(() => {
const counts = { claude: 0, codex: 0, gemini: 0 };
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
if (!skills) return counts;
skills.forEach((skill) => {
if (skill.apps.claude) counts.claude++;
if (skill.apps.codex) counts.codex++;
if (skill.apps.gemini) counts.gemini++;
if (skill.apps.opencode) counts.opencode++;
});
return counts;
}, [skills]);
@@ -132,14 +133,15 @@ const UnifiedSkillsPanel = React.forwardRef<
}));
return (
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
{/* Info Section */}
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
<div className="text-sm text-muted-foreground">
{t("skills.installed", { count: skills?.length || 0 })} ·{" "}
{t("skills.apps.claude")}: {enabledCounts.claude} ·{" "}
{t("skills.apps.codex")}: {enabledCounts.codex} ·{" "}
{t("skills.apps.gemini")}: {enabledCounts.gemini}
{t("skills.apps.gemini")}: {enabledCounts.gemini} ·{" "}
{t("skills.apps.opencode")}: {enabledCounts.opencode}
</div>
</div>
@@ -308,6 +310,22 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${skill.id}-opencode`}
className="text-sm text-foreground/80 cursor-pointer"
>
{t("skills.apps.opencode")}
</label>
<Switch
id={`${skill.id}-opencode`}
checked={skill.apps.opencode}
onCheckedChange={(checked: boolean) =>
onToggleApp(skill.id, "opencode", checked)
}
/>
</div>
</div>
{/* 右侧:删除按钮 */}
-163
View File
@@ -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>
);
}
-399
View File
@@ -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>
);
}
-165
View File
@@ -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>
);
}
-294
View File
@@ -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>
);
}
-334
View File
@@ -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>
);
}
-5
View File
@@ -1,5 +0,0 @@
export { TemplatesPage } from "./TemplatesPage";
export { ComponentCard } from "./ComponentCard";
export { ComponentDetail } from "./ComponentDetail";
export { CategoryFilter } from "./CategoryFilter";
export { RepoManager } from "./RepoManager";
+1 -1
View File
@@ -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",
// 危险按钮:红底白字(对应旧版 danger)
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:
"border border-border-default bg-background hover:bg-gray-100 hover:border-border-hover dark:hover:bg-gray-800",
+2 -8
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
@@ -37,7 +37,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-border-default bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"relative z-[100] min-w-[8rem] overflow-hidden rounded-md border border-border-default bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
position={position}
@@ -87,12 +87,6 @@ const SelectItem = React.forwardRef<
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
+678
View File
@@ -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: "",
},
},
},
];
+6 -1
View File
@@ -54,7 +54,7 @@ export function useProviderActions(activeApp: AppId) {
// 添加供应商
const addProvider = useCallback(
async (provider: Omit<Provider, "id">) => {
async (provider: Omit<Provider, "id"> & { providerKey?: string }) => {
await addProviderMutation.mutateAsync(provider);
},
[addProviderMutation],
@@ -115,6 +115,11 @@ export function useProviderActions(activeApp: AppId) {
await queryClient.invalidateQueries({
queryKey: ["providers", activeApp],
});
// 🔧 保存用量脚本后,也应该失效该 provider 的用量查询缓存
// 这样主页列表会使用新配置重新查询,而不是使用测试时的缓存
await queryClient.invalidateQueries({
queryKey: ["usage", provider.id, activeApp],
});
toast.success(
t("provider.usageSaved", {
defaultValue: "用量查询配置已保存",
+3 -1
View File
@@ -101,7 +101,9 @@ export function useProxyStatus() {
? "Claude"
: variables.appType === "codex"
? "Codex"
: "Gemini";
: variables.appType === "gemini"
? "Gemini"
: "OpenCode";
toast.success(
variables.enabled
+46 -87
View File
@@ -84,6 +84,10 @@
"addClaudeProvider": "Add Claude Code Provider",
"addCodexProvider": "Add Codex Provider",
"addGeminiProvider": "Add Gemini Provider",
"addOpenCodeProvider": "Add OpenCode Provider",
"addToConfig": "Add",
"removeFromConfig": "Remove",
"inConfig": "Added",
"addProviderHint": "Fill in the information to quickly switch providers in the list.",
"editClaudeProvider": "Edit Claude Code Provider",
"editCodexProvider": "Edit Codex Provider",
@@ -133,6 +137,8 @@
"providerSaved": "Provider configuration saved",
"providerDeleted": "Provider deleted successfully",
"switchSuccess": "Switch successful!",
"addToConfigSuccess": "Added to config",
"removeFromConfigSuccess": "Removed from config",
"switchFailedTitle": "Switch failed",
"switchFailed": "Switch failed: {{error}}",
"autoImported": "Default provider created from existing configuration",
@@ -153,7 +159,9 @@
},
"confirm": {
"deleteProvider": "Delete Provider",
"deleteProviderMessage": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone."
"deleteProviderMessage": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone.",
"removeProvider": "Remove Provider",
"removeProviderMessage": "Are you sure you want to remove provider \"{{name}}\" from the configuration?\n\nAfter removal, this provider will no longer be active, but the configuration data will be retained in CC Switch. You can re-add it at any time."
},
"settings": {
"title": "Settings",
@@ -304,7 +312,8 @@
"apps": {
"claude": "Claude Code",
"codex": "Codex",
"gemini": "Gemini"
"gemini": "Gemini",
"opencode": "OpenCode"
},
"console": {
"providerSwitchReceived": "Received provider switch event:",
@@ -455,6 +464,36 @@
"configMergeFailed": "Config merge failed: {{error}}",
"configReplaceFailed": "Config replace failed: {{error}}"
},
"opencode": {
"npmPackage": "API Format",
"selectPackage": "Select API format",
"npmPackageHint": "Select the API format for the AI service",
"baseUrl": "Base URL",
"baseUrlHint": "Custom API endpoint URL",
"models": "Models",
"modelsHint": "Configure available models and their display names",
"addModel": "Add Model",
"modelId": "Model ID",
"modelName": "Display Name",
"noModels": "No models configured",
"providerKey": "Provider Key",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
"providerKeyRequired": "Provider key is required",
"providerKeyDuplicate": "This key is already in use",
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
"extraOptions": "Extra Options",
"extraOptionsHint": "Configure extra SDK options like timeout, setCacheKey, etc. Values are auto-parsed to appropriate types (number, boolean, etc.).",
"addExtraOption": "Add",
"extraOptionKey": "Key",
"extraOptionValue": "Value",
"extraOptionKeyPlaceholder": "timeout",
"extraOptionValuePlaceholder": "600000",
"noExtraOptions": "No extra options configured",
"noModelOptions": "Model options, click + to add",
"modelOptionKeyPlaceholder": "provider",
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
},
"providerPreset": {
"label": "Provider Preset",
"custom": "Custom Configuration",
@@ -584,6 +623,7 @@
"testFailed": "Test failed",
"formatSuccess": "Format successful",
"formatFailed": "Format failed",
"supportedVariables": "Supported Variables",
"variablesHint": "Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object",
"scriptConfig": "Request configuration",
"extractorCode": "Extractor code",
@@ -633,7 +673,8 @@
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
"gemini": "Gemini",
"opencode": "OpenCode"
}
},
"userLevelPath": "User-level MCP path",
@@ -941,7 +982,8 @@
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
"gemini": "Gemini",
"opencode": "OpenCode"
}
},
"deeplink": {
@@ -1046,89 +1088,6 @@
"circuitOpen": "Circuit Open",
"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": {
"panel": {
"serviceAddress": "Service Address",
+46 -87
View File
@@ -84,6 +84,10 @@
"addClaudeProvider": "Claude Code プロバイダーを追加",
"addCodexProvider": "Codex プロバイダーを追加",
"addGeminiProvider": "Gemini プロバイダーを追加",
"addOpenCodeProvider": "OpenCode プロバイダーを追加",
"addToConfig": "追加",
"removeFromConfig": "削除",
"inConfig": "追加済み",
"addProviderHint": "一覧にすばやく切り替えられるよう、ここに情報を入力してください。",
"editClaudeProvider": "Claude Code プロバイダーを編集",
"editCodexProvider": "Codex プロバイダーを編集",
@@ -133,6 +137,8 @@
"providerSaved": "プロバイダー設定を保存しました",
"providerDeleted": "プロバイダーを削除しました",
"switchSuccess": "切り替え成功!",
"addToConfigSuccess": "設定に追加しました",
"removeFromConfigSuccess": "設定から削除しました",
"switchFailedTitle": "切り替えに失敗しました",
"switchFailed": "切り替えに失敗しました: {{error}}",
"autoImported": "既存設定からデフォルトプロバイダーを自動作成しました",
@@ -153,7 +159,9 @@
},
"confirm": {
"deleteProvider": "プロバイダーを削除",
"deleteProviderMessage": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。"
"deleteProviderMessage": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
"removeProvider": "プロバイダーを解除",
"removeProviderMessage": "プロバイダー「{{name}}」を設定から解除してもよろしいですか?\n\n解除後、このプロバイダーは無効になりますが、設定データは CC Switch に保持されます。いつでも再追加できます。"
},
"settings": {
"title": "設定",
@@ -304,7 +312,8 @@
"apps": {
"claude": "Claude Code",
"codex": "Codex",
"gemini": "Gemini"
"gemini": "Gemini",
"opencode": "OpenCode"
},
"console": {
"providerSwitchReceived": "プロバイダー切り替えイベントを受信:",
@@ -455,6 +464,36 @@
"configMergeFailed": "設定のマージに失敗しました: {{error}}",
"configReplaceFailed": "設定の置換に失敗しました: {{error}}"
},
"opencode": {
"npmPackage": "API フォーマット",
"selectPackage": "API フォーマットを選択",
"npmPackageHint": "AI サービスの API フォーマットを選択",
"baseUrl": "Base URL",
"baseUrlHint": "カスタム API エンドポイント URL",
"models": "モデル設定",
"modelsHint": "利用可能なモデルとその表示名を設定",
"addModel": "モデルを追加",
"modelId": "モデル ID",
"modelName": "表示名",
"noModels": "モデルが設定されていません",
"providerKey": "プロバイダーキー",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。",
"providerKeyRequired": "プロバイダーキーを入力してください",
"providerKeyDuplicate": "このキーは既に使用されています",
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。",
"extraOptions": "追加オプション",
"extraOptionsHint": "timeout、setCacheKey などの SDK オプションを設定。値は自動的に適切な型(数値、真偽値など)に変換されます。",
"addExtraOption": "追加",
"extraOptionKey": "キー名",
"extraOptionValue": "値",
"extraOptionKeyPlaceholder": "timeout",
"extraOptionValuePlaceholder": "600000",
"noExtraOptions": "追加オプションはありません",
"noModelOptions": "モデルオプション、+ をクリックして追加",
"modelOptionKeyPlaceholder": "provider",
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
},
"providerPreset": {
"label": "プロバイダータイプ",
"custom": "カスタム設定",
@@ -584,6 +623,7 @@
"testFailed": "テストに失敗しました",
"formatSuccess": "整形に成功しました",
"formatFailed": "整形に失敗しました",
"supportedVariables": "使用可能な変数",
"variablesHint": "使用可能な変数: {{apiKey}}, {{baseUrl}} | extractor 関数には API 応答の JSON オブジェクトが渡されます",
"scriptConfig": "リクエスト設定",
"extractorCode": "抽出コード",
@@ -633,7 +673,8 @@
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
"gemini": "Gemini",
"opencode": "OpenCode"
}
},
"userLevelPath": "ユーザーレベルの MCP パス",
@@ -941,7 +982,8 @@
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
"gemini": "Gemini",
"opencode": "OpenCode"
}
},
"deeplink": {
@@ -1039,89 +1081,6 @@
"agents": {
"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": {
"operational": "正常",
"degraded": "低下",
+46 -87
View File
@@ -84,6 +84,10 @@
"addClaudeProvider": "添加 Claude Code 供应商",
"addCodexProvider": "添加 Codex 供应商",
"addGeminiProvider": "添加 Gemini 供应商",
"addOpenCodeProvider": "添加 OpenCode 供应商",
"addToConfig": "添加",
"removeFromConfig": "移除",
"inConfig": "已添加",
"addProviderHint": "填写信息后即可在列表中快速切换供应商。",
"editClaudeProvider": "编辑 Claude Code 供应商",
"editCodexProvider": "编辑 Codex 供应商",
@@ -133,6 +137,8 @@
"providerSaved": "供应商配置已保存",
"providerDeleted": "供应商删除成功",
"switchSuccess": "切换成功!",
"addToConfigSuccess": "已添加到配置",
"removeFromConfigSuccess": "已从配置移除",
"switchFailedTitle": "切换失败",
"switchFailed": "切换失败:{{error}}",
"autoImported": "已从现有配置创建默认供应商",
@@ -153,7 +159,9 @@
},
"confirm": {
"deleteProvider": "删除供应商",
"deleteProviderMessage": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。"
"deleteProviderMessage": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。",
"removeProvider": "移除供应商",
"removeProviderMessage": "确定要从配置中移除供应商 \"{{name}}\" 吗?\n\n移除后该供应商将不再生效,但配置数据会保留在 CC Switch 中,您可以随时重新添加。"
},
"settings": {
"title": "设置",
@@ -304,7 +312,8 @@
"apps": {
"claude": "Claude Code",
"codex": "Codex",
"gemini": "Gemini"
"gemini": "Gemini",
"opencode": "OpenCode"
},
"console": {
"providerSwitchReceived": "收到供应商切换事件:",
@@ -455,6 +464,36 @@
"configMergeFailed": "配置合并失败: {{error}}",
"configReplaceFailed": "配置替换失败: {{error}}"
},
"opencode": {
"npmPackage": "接口格式",
"selectPackage": "选择接口格式",
"npmPackageHint": "选择 AI 服务的 API 接口格式",
"baseUrl": "Base URL",
"baseUrlHint": "自定义 API 端点地址",
"models": "模型配置",
"modelsHint": "配置可用的模型及其显示名称",
"addModel": "添加模型",
"modelId": "模型 ID",
"modelName": "显示名称",
"noModels": "暂无模型配置",
"providerKey": "供应商标识",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
"providerKeyRequired": "请填写供应商标识",
"providerKeyDuplicate": "此标识已被使用,请更换",
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
"extraOptions": "额外选项",
"extraOptionsHint": "配置额外的 SDK 选项,如 timeout、setCacheKey 等。值会自动解析类型(数字、布尔值等)。",
"addExtraOption": "添加",
"extraOptionKey": "键名",
"extraOptionValue": "值",
"extraOptionKeyPlaceholder": "timeout",
"extraOptionValuePlaceholder": "600000",
"noExtraOptions": "暂无额外选项",
"noModelOptions": "模型选项,点击 + 添加",
"modelOptionKeyPlaceholder": "provider",
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
},
"providerPreset": {
"label": "预设供应商",
"custom": "自定义配置",
@@ -584,6 +623,7 @@
"testFailed": "测试失败",
"formatSuccess": "格式化成功",
"formatFailed": "格式化失败",
"supportedVariables": "支持的变量",
"variablesHint": "支持变量: {{apiKey}}, {{baseUrl}} | extractor 函数接收 API 响应的 JSON 对象",
"scriptConfig": "请求配置",
"extractorCode": "提取器代码",
@@ -633,7 +673,8 @@
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
"gemini": "Gemini",
"opencode": "OpenCode"
}
},
"userLevelPath": "用户级 MCP 配置路径",
@@ -941,7 +982,8 @@
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
"gemini": "Gemini",
"opencode": "OpenCode"
}
},
"deeplink": {
@@ -1046,89 +1088,6 @@
"circuitOpen": "熔断",
"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": {
"panel": {
"serviceAddress": "服务地址",
+1
View File
@@ -53,6 +53,7 @@ export const icons: Record<string, string> = {
longcat: `<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LongCat</title><path clip-rule="evenodd" d="M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z" fill="#29E154" fill-rule="evenodd"></path><path d="M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z"></path></svg>`,
modelscope: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelScope</title><path d="M0 7.967h2.667v2.667H0zM8 10.633h2.667V13.3H8z" fill="#36CED0"></path><path d="M0 10.633h2.667V13.3H0zM2.667 13.3h2.666v2.667H8v2.666H2.667V13.3zM2.667 5.3H8v2.667H5.333v2.666H2.667V5.3zM10.667 13.3h2.667v2.667h-2.667z" fill="#624AFF"></path><path d="M24 7.967h-2.667v2.667H24zM16 10.633h-2.667V13.3H16z" fill="#36CED0"></path><path d="M24 10.633h-2.667V13.3H24zM21.333 13.3h-2.666v2.667H16v2.666h5.333V13.3zM21.333 5.3H16v2.667h2.667v2.666h2.666V5.3z" fill="#624AFF"></path></svg>`,
aihubmix: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AiHubMix</title><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" fill="#006FFB"></path><path clip-rule="evenodd" d="M11.24 8.393c.095-.644.302-1.47.624-2.48L12 5.496l.136.417c.322 1.01.53 1.836.624 2.48.071.472.071 1.072 0 1.8-.072.731-.072 1.336 0 1.814.106.7.426 1.281.96 1.744a2.795 2.795 0 001.89.708 2.78 2.78 0 002.034-.84c.56-.559.842-1.234.848-2.024.003-.7.075-1.472.216-2.316.069-.422.14-.775.21-1.06l.095-.384.168.356a7.862 7.862 0 01.76 3.244v.16a7.84 7.84 0 01-.624 3.089 7.952 7.952 0 01-4.228 4.228 7.841 7.841 0 01-3.089.623 7.84 7.84 0 01-3.089-.623 7.952 7.952 0 01-4.228-4.228 7.84 7.84 0 01-.623-3.09v-.159a7.862 7.862 0 01.759-3.244l.169-.356.093.385c.072.284.143.637.211 1.059.141.844.213 1.616.216 2.316.006.79.29 1.465.848 2.024.563.56 1.241.84 2.035.84.715 0 1.345-.236 1.889-.708a2.79 2.79 0 00.96-1.744c.073-.478.073-1.083 0-1.814-.071-.728-.071-1.328 0-1.8zm.76 9.694c1.097 0 2.125-.26 3.085-.778a6.379 6.379 0 001.77-1.399c.063-.07-.01-.178-.101-.153-.37.1-.75.15-1.144.15a4.236 4.236 0 01-2.18-.59 4.253 4.253 0 01-1.35-1.233.099.099 0 00-.16 0 4.253 4.253 0 01-1.35 1.232 4.236 4.236 0 01-2.18.591c-.393 0-.774-.05-1.143-.15-.091-.025-.165.083-.102.153a6.38 6.38 0 001.77 1.399c.96.518 1.988.778 3.085.778z" fill="#fff" fill-rule="evenodd"></path></svg>`,
opencode: `<svg height="1em" width="1em" style="flex:none;line-height:1" viewBox="0 0 240 300" xmlns="http://www.w3.org/2000/svg"><title>OpenCode</title><g clip-path="url(#clip0_1401_86274)"><mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300"><path d="M240 0H0V300H240V0Z" fill="white"/></mask><g mask="url(#mask0_1401_86274)"><path d="M180 240H60V120H180V240Z" fill="#CFCECD"/><path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E"/></g></g><defs><clipPath id="clip0_1401_86274"><rect width="240" height="300" fill="white"/></clipPath></defs></svg>`,
};
export const iconList = Object.keys(icons);
@@ -0,0 +1 @@
<svg width='240' height='300' viewBox='0 0 240 300' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_1401_86274)'><mask id='mask0_1401_86274' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='240' height='300'><path d='M240 0H0V300H240V0Z' fill='white'/></mask><g mask='url(#mask0_1401_86274)'><path d='M180 240H60V120H180V240Z' fill='#CFCECD'/><path d='M180 60H60V240H180V60ZM240 300H0V0H240V300Z' fill='#211E1E'/></g></g><defs><clipPath id='clip0_1401_86274'><rect width='240' height='300' fill='white'/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 577 B

+24
View File
@@ -38,6 +38,14 @@ export const providersApi = {
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> {
return await invoke("switch_provider", { id, app: appId });
},
@@ -74,6 +82,22 @@ export const providersApi = {
async openTerminal(providerId: string, appId: AppId): Promise<boolean> {
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 -1
View File
@@ -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 应用启用状态 */
export interface SkillApps {
claude: boolean;
codex: boolean;
gemini: boolean;
opencode: boolean;
}
/** 已安装的 Skillv3.10.0+ 统一结构) */
-102
View File
@@ -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 -1
View File
@@ -1,2 +1,2 @@
// 前端统一使用 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