Compare commits

..

18 Commits

Author SHA1 Message Date
YoVinchen 12128d8fa4 feat(proxy): update failover timeout and circuit breaker defaults
- Double all timeout values (streaming/non-streaming)
- Codex/Gemini: circuit_failure_threshold 5→4, error_rate 0.5→0.6
- Claude: circuit_error_rate_threshold 0.6→0.7
2026-01-04 10:56:14 +08:00
Jason 2105f2d05b fix(i18n): remove hardcoded defaultValue and unused placeholder for reasoning model 2026-01-03 12:04:34 +08:00
Jason 0a9de282a3 fix(skills): skip hidden directories when scanning for skills
Filter out directories starting with '.' (e.g., .system) during skill
scanning to avoid exposing internal system directories from Codex.
2026-01-03 11:42:10 +08:00
Jason 0e085aa01a fix(prompts): change toggle color from blue to emerald
Align with the app-wide Switch component color scheme for
visual consistency across all toggle elements.
2026-01-03 10:15:28 +08:00
Jason 2c95f697cd fix(prompts): unify add button style with other panels
Replace circular orange icon button with ghost text button
to match Skills and MCP panel styling.
2026-01-03 10:13:32 +08:00
Jason ba97a5f373 fix(mcp): unify header buttons style with skills panel
Changed MCP panel buttons to match Skills panel styling:
- Replace circular orange icon button with ghost text button
- Update button labels: "Import" → "Import Existing", add "Add MCP"
2026-01-03 10:11:26 +08:00
Jason 86e802bd4b feat(mcp): add import button to import MCP servers from apps
- Add import_mcp_from_apps command that reuses existing import logic
- Add Import button in MCP panel header (consistent with Skills)
- Fix import count to only return truly new servers (not already in DB)
- Update translations for import success/no-import messages (zh/en/ja)
2026-01-03 10:04:46 +08:00
Jason 6460c1d5dd fix(skills): move import button to header for better discoverability
- Add import button next to discover button in skills page header
- Expose openImport method via UnifiedSkillsPanel ref
- Show toast instead of dialog when no unmanaged skills found
- Remove redundant buttons from empty state view
2026-01-03 09:21:15 +08:00
Jason 6b73e55bfe fix(ui): remove hover scale effect from skill cards
The hover:scale-[1.01] effect caused cards to overflow their container
boundaries. Keeping only hover:shadow-lg provides sufficient visual
feedback without the overflow issue.
2026-01-02 23:57:22 +08:00
Jason 47aa4c6bee fix(skills): show loading indicator when refreshing discovery list
Use isFetching instead of just isLoading to show the loading spinner.
isLoading is only true on initial load, while isFetching is true
during any fetch operation including refetch.
2026-01-02 23:51:03 +08:00
Jason 4dc59dff21 fix(skills): remove refresh button from installed skills panel
The refresh button is only needed in the discovery panel (to fetch
latest skills from GitHub). The installed skills panel uses local
database which auto-updates on install/uninstall operations.
2026-01-02 23:41:28 +08:00
Jason c8750f5550 fix(i18n): rename Skills title to be app-agnostic
- Remove "Claude" prefix from Skills management title
- Update descriptions to include Gemini alongside Claude Code/Codex
- Applied to all three locales: zh, en, ja
2026-01-02 23:15:22 +08:00
Jason 22460de976 perf(skills): use infinite cache for discoverable skills
Change staleTime from 5 minutes to Infinity. Cache is still properly
invalidated when repos are added/removed or skills are installed/uninstalled.
2026-01-02 23:04:40 +08:00
Jason e69c1bd8aa fix(ui): align FullScreenPanel header with App.tsx layout
- Use same DRAG_BAR_HEIGHT (28px) and HEADER_HEIGHT (64px) as App.tsx
- Remove border-b divider line from header
- Add rounded-lg class to back button for consistency
2026-01-02 22:35:33 +08:00
Jason a17fa8098b fix(skills): remove redundant navigation buttons in skills pages
- Remove duplicate "Repo Manager" button from installed skills view
  (should only appear in discovery view)
- Remove redundant back button from SkillsPage component
  (header already provides unified navigation)
- Clean up unused openRepoManagerOnDiscovery state and related useEffect
- Remove unused onClose prop and ArrowLeft import from SkillsPage
2026-01-02 22:33:33 +08:00
Jason ff03ca1e63 feat(skills): unified management architecture with SSOT and React Query
- Introduce SSOT (Single Source of Truth) at ~/.cc-switch/skills/
- Add three-app toggle support (Claude/Codex/Gemini) for each skill
- Refactor frontend to use TanStack Query hooks instead of manual state
- Add UnifiedSkillsPanel for managing installed skills with app toggles
- Add useSkills.ts with declarative data fetching hooks
- Extend skills.ts API with unified install/uninstall/toggle methods
- Support importing unmanaged skills from app directories
- Add v2→v3 database migration for new skills table structure
2026-01-02 22:04:02 +08:00
Jason cce6ae86a5 fix: prevent env check card border overflow on hover
Add horizontal padding to the grid container to accommodate the scale
transform effect when hovering over environment check cards.
2025-12-31 22:58:03 +08:00
Dex Miller 5376ea042b Feat/usage improvements (#508)
* i18n: update cache terminology across all languages

- Change 'Cache Read' to 'Cache Hit' in all languages
- Change 'Cache Write' to 'Cache Creation' in all languages
- Update zh: 缓存读取 → 缓存命中, 缓存写入 → 缓存创建
- Update en: Cache Read → Cache Hit, Cache Write → Cache Creation
- Update ja: キャッシュ読取 → キャッシュヒット, キャッシュ書込 → キャッシュ作成

Affected keys: cacheReadTokens, cacheCreationTokens, cacheReadCost,
cacheWriteCost, cacheRead, cacheWrite

* feat(usage): add cache metrics to trend chart

- Add cache creation tokens visualization (orange line)
- Add cache hit tokens visualization (purple line)
- Add gradient definitions for new cache metrics
- Include cache data in hourly aggregation
- Display cache metrics alongside input/output tokens

This provides better visibility into cache usage patterns over time.

* fix(usage): fix timezone handling in datetime picker

- Add timestampToLocalDatetime() to convert Unix timestamp to local datetime
- Add localDatetimeToTimestamp() with validation for incomplete input
- Fix issue where typing hours/minutes would jump to previous day
- Validate datetime format completeness before conversion
- Use local timezone instead of UTC for datetime-local input

This resolves the issue where users couldn't fine-tune time selection
and the input would jump unexpectedly when editing hours or minutes.

* feat(usage): add auto-refresh for usage statistics

- Add 30-second auto-refresh interval for all usage queries
- Disable background refresh to save resources
- Apply to: summary, trends, provider stats, model stats, request logs
- Queries automatically update when tab is active
- Pause refresh when user switches to another tab

This keeps usage data fresh without manual refresh.

* fix(proxy): improve usage logging and cache token parsing

- Log requests even when usage parsing fails (with default values)
- Add detailed debug logging for usage metrics
- Support cache_read_input_tokens field in Codex responses
- Fallback to input_tokens_details.cached_tokens if needed
- Add test case for cached_tokens in input_tokens_details
- Ensure all requests are tracked in database for analytics

This fixes missing request logs when API responses lack usage data
and improves cache token detection across different response formats.

* style(rust): use inline format args in format! macros

- Replace format!("...", var) with format!("...{var}")
- Update universal provider ID formatting
- Update error message formatting
- Update config.toml generation in Codex provider

Fixes clippy::uninlined_format_args warnings.

* feat(proxy): enhance provider router logging

- Add debug logs for failover queue provider count
- Log circuit breaker state for each provider check
- Add logs for missing current provider scenarios
- Log when no current provider is configured
- Use inline format args for better readability

This improves debugging of provider selection and failover behavior.

* feat(database): update model pricing data

- Update Claude models to full version format (e.g. claude-opus-4-5-20251101)
- Add GPT-5.2 series model pricing (10 models)
- Add GPT-5.1 series model pricing (10 models)
- Add GPT-5 series model pricing (12 models)
- Add Gemini 3 series model pricing (2 models)
- Update Gemini 2.5 series model ID format (use dot separator)
- Unify display names by removing thinking level suffixes

* fix(usage): correct Gemini output token calculation

Fix Gemini API output token parsing to use totalTokenCount - promptTokenCount
instead of candidatesTokenCount alone. This ensures thoughtsTokenCount is
included in output statistics.

- Update from_gemini_response to calculate output from total - input
- Update from_gemini_stream_chunks with same logic for consistency
- Fix from_codex_stream_events to use adjusted token calculation
- Add test case for responses with thoughtsTokenCount
- Update existing tests to match new calculation logic

* fix(usage): correct cache token billing and add Codex format auto-detection

- Avoid double-billing cache tokens by subtracting from input before calculation
- Add smart Codex parser that auto-detects OpenAI vs Codex API format
- Extract model name from Codex responses for accurate tracking

* fix(proxy): improve takeover detection with live config check

- Add live config takeover detection for hot-switch decision
- Rebuild takeover when backup is missing or placeholder remains
- Make detect_takeover_in_live_config_for_app public
- Fix is_takeover_active to use actual takeover status

* refactor(usage): simplify model pricing lookup by removing suffix fallback

Replace complex suffix-stripping fallback with direct prefix/suffix cleanup.
Model IDs are now cleaned by removing vendor prefix (before /) and colon
suffix (after :), then matched exactly against pricing table.

* feat(database): add Chinese AI model pricing data

Add pricing for domestic AI models (CNY/1M tokens):
- Doubao-Seed-Code (ByteDance)
- DeepSeek V3/V3.1/V3.2
- Kimi K2/K2-Thinking/K2-Turbo (Moonshot)
- MiniMax M2/M2.1/M2.1-Lightning
- GLM-4.6/4.7 (Zhipu)
- Mimo V2 Flash (Xiaomi)

Also fix test case to use correct model ID and remove invalid currency column.

* refactor(proxy): improve header forwarding with blacklist approach

Change from whitelist to blacklist mode for request header forwarding.
Only skip headers that will be overridden (auth, host, content-length).
This preserves client's original headers and improves compatibility.

* fix(proxy): bypass timeout and retry configs when failover is disabled

When auto_failover_enabled is false, timeout and retry configurations
should not affect normal request flow. This change ensures:

- create_forwarder: passes 0 for all timeout/retry params when failover
  is disabled, effectively bypassing these checks
- streaming_timeout_config: returns 0 for both first_byte_timeout and
  idle_timeout when failover is disabled

This prevents unnecessary timeout errors and retry attempts when users
have explicitly disabled the failover feature.

* fix(proxy): handle zero value input in failover config fields

* refactor(proxy): remove retry logic and add enabled check for failover

* refactor(proxy): distinguish circuit-open from no-provider errors

* Align usage stats to sliding windows

* feat(proxy): add body and header filtering for upstream requests

* feat(proxy): enable transparent passthrough for headers

- Passthrough anthropic-beta header as-is from client
- Passthrough anthropic-version header from client
- Passthrough client IP headers (x-forwarded-for, x-real-ip) by default
- Filter private params (underscore-prefixed fields) from request body
- No database changes required

* feat(proxy): extract session ID from client requests for logging

- Add SessionIdExtractor to parse session ID from Claude/Codex requests
- Support extraction from metadata.user_id, headers, previous_response_id
- Pass session_id through RequestContext to usage logger
- Enable request correlation by session in proxy_request_logs
2025-12-31 22:57:00 +08:00
35 changed files with 2335 additions and 694 deletions
+104
View File
@@ -55,6 +55,110 @@ impl McpApps {
}
}
/// Skill 应用启用状态(标记 Skill 应用到哪些客户端)
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct SkillApps {
#[serde(default)]
pub claude: bool,
#[serde(default)]
pub codex: bool,
#[serde(default)]
pub gemini: bool,
}
impl SkillApps {
/// 检查指定应用是否启用
pub fn is_enabled_for(&self, app: &AppType) -> bool {
match app {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
}
}
/// 设置指定应用的启用状态
pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
match app {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
}
}
/// 获取所有启用的应用列表
pub fn enabled_apps(&self) -> Vec<AppType> {
let mut apps = Vec::new();
if self.claude {
apps.push(AppType::Claude);
}
if self.codex {
apps.push(AppType::Codex);
}
if self.gemini {
apps.push(AppType::Gemini);
}
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini
}
/// 仅启用指定应用(其他应用设为禁用)
pub fn only(app: &AppType) -> Self {
let mut apps = Self::default();
apps.set_enabled_for(app, true);
apps
}
}
/// 已安装的 Skillv3.10.0+ 统一结构)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstalledSkill {
/// 唯一标识符(格式:"owner/repo:directory" 或 "local:directory"
pub id: String,
/// 显示名称
pub name: String,
/// 描述
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// 安装目录名(在 SSOT 目录中的子目录名)
pub directory: String,
/// 仓库所有者(GitHub 用户/组织)
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_owner: Option<String>,
/// 仓库名称
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_name: Option<String>,
/// 仓库分支
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_branch: Option<String>,
/// README URL
#[serde(skip_serializing_if = "Option::is_none")]
pub readme_url: Option<String>,
/// 应用启用状态
pub apps: SkillApps,
/// 安装时间(Unix 时间戳)
pub installed_at: i64,
}
/// 未管理的 Skill(在应用目录中发现但未被 CC Switch 管理)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UnmanagedSkill {
/// 目录名
pub directory: String,
/// 显示名称(从 SKILL.md 解析)
pub name: String,
/// 描述
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// 在哪些应用目录中发现(如 ["claude", "codex"]
pub found_in: Vec<String>,
}
/// MCP 服务器定义(v3.7.0 统一结构)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServer {
+10
View File
@@ -192,3 +192,13 @@ pub async fn toggle_mcp_app(
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string())
}
/// 从所有应用导入 MCP 服务器(复用已有的导入逻辑)
#[tauri::command]
pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, String> {
let mut total = 0;
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);
Ok(total)
}
+8 -1
View File
@@ -1,6 +1,6 @@
#![allow(non_snake_case)]
use crate::init_status::InitErrorPayload;
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt;
@@ -65,6 +65,13 @@ pub async fn get_migration_result() -> Result<bool, String> {
Ok(crate::init_status::take_migration_success())
}
/// 获取 Skills 自动导入(SSOT)迁移结果(若有)。
/// 只返回一次 Some({count}),之后返回 None,用于前端显示一次性 Toast 通知。
#[tauri::command]
pub async fn get_skills_migration_result() -> Result<Option<SkillsMigrationPayload>, String> {
Ok(crate::init_status::take_skills_migration_result())
}
#[derive(serde::Serialize)]
pub struct ToolVersion {
name: String,
+142 -127
View File
@@ -1,12 +1,17 @@
use crate::app_config::AppType;
//! Skills 命令层
//!
//! v3.10.0+ 统一管理架构:
//! - 支持三应用开关(Claude/Codex/Gemini
//! - SSOT 存储在 ~/.cc-switch/skills/
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
use crate::error::format_skill_error;
use crate::services::skill::SkillState;
use crate::services::{Skill, SkillRepo, SkillService};
use crate::services::skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
use crate::store::AppState;
use chrono::Utc;
use std::sync::Arc;
use tauri::State;
/// SkillService 状态包装
pub struct SkillServiceState(pub Arc<SkillService>);
/// 解析 app 参数为 AppType
@@ -19,65 +24,117 @@ fn parse_app_type(app: &str) -> Result<AppType, String> {
}
}
/// 根据 app_type 生成带前缀的 skill key
fn get_skill_key(app_type: &AppType, directory: &str) -> String {
let prefix = match app_type {
AppType::Claude => "claude",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
};
format!("{prefix}:{directory}")
// ========== 统一管理命令 ==========
/// 获取所有已安装的 Skills
#[tauri::command]
pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result<Vec<InstalledSkill>, String> {
SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())
}
/// 安装 Skill(新版统一安装)
///
/// 参数:
/// - skill: 从发现列表获取的技能信息
/// - current_app: 当前选中的应用,安装后默认启用该应用
#[tauri::command]
pub async fn install_skill_unified(
skill: DiscoverableSkill,
current_app: String,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<InstalledSkill, String> {
let app_type = parse_app_type(&current_app)?;
service
.0
.install(&app_state.db, &skill, &app_type)
.await
.map_err(|e| e.to_string())
}
/// 卸载 Skill(新版统一卸载)
#[tauri::command]
pub fn uninstall_skill_unified(id: String, app_state: State<'_, AppState>) -> Result<bool, String> {
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())?;
Ok(true)
}
/// 切换 Skill 的应用启用状态
#[tauri::command]
pub fn toggle_skill_app(
id: String,
app: String,
enabled: bool,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
let app_type = parse_app_type(&app)?;
SkillService::toggle_app(&app_state.db, &id, &app_type, enabled).map_err(|e| e.to_string())?;
Ok(true)
}
/// 扫描未管理的 Skills
#[tauri::command]
pub fn scan_unmanaged_skills(
app_state: State<'_, AppState>,
) -> Result<Vec<UnmanagedSkill>, String> {
SkillService::scan_unmanaged(&app_state.db).map_err(|e| e.to_string())
}
/// 从应用目录导入 Skills
#[tauri::command]
pub fn import_skills_from_apps(
directories: Vec<String>,
app_state: State<'_, AppState>,
) -> Result<Vec<InstalledSkill>, String> {
SkillService::import_from_apps(&app_state.db, directories).map_err(|e| e.to_string())
}
// ========== 发现功能命令 ==========
/// 发现可安装的 Skills(从仓库获取)
#[tauri::command]
pub async fn discover_available_skills(
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<DiscoverableSkill>, String> {
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
service
.0
.discover_available(repos)
.await
.map_err(|e| e.to_string())
}
// ========== 兼容旧 API 的命令 ==========
/// 获取技能列表(兼容旧 API)
#[tauri::command]
pub async fn get_skills(
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<Skill>, String> {
get_skills_for_app("claude".to_string(), service, app_state).await
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
service
.0
.list_skills(repos, &app_state.db)
.await
.map_err(|e| e.to_string())
}
/// 获取指定应用的技能列表(兼容旧 API)
#[tauri::command]
pub async fn get_skills_for_app(
app: String,
_service: State<'_, SkillServiceState>,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<Skill>, String> {
let app_type = parse_app_type(&app)?;
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
let skills = service
.list_skills(repos)
.await
.map_err(|e| e.to_string())?;
// 自动同步本地已安装的 skills 到数据库
// 这样用户在首次运行时,已有的 skills 会被自动记录
let existing_states = app_state.db.get_skills().unwrap_or_default();
for skill in &skills {
if skill.installed {
let key = get_skill_key(&app_type, &skill.directory);
if !existing_states.contains_key(&key) {
// 本地有该 skill,但数据库中没有记录,自动添加
if let Err(e) = app_state.db.update_skill_state(
&key,
&SkillState {
installed: true,
installed_at: Utc::now(),
},
) {
log::warn!("同步本地 skill {key} 状态到数据库失败: {e}");
}
}
}
}
Ok(skills)
// 新版本不再区分应用,统一返回所有技能
let _ = parse_app_type(&app)?; // 验证 app 参数有效
get_skills(service, app_state).await
}
/// 安装技能(兼容旧 API
#[tauri::command]
pub async fn install_skill(
directory: String,
@@ -87,27 +144,34 @@ pub async fn install_skill(
install_skill_for_app("claude".to_string(), directory, service, app_state).await
}
/// 安装指定应用的技能(兼容旧 API)
#[tauri::command]
pub async fn install_skill_for_app(
app: String,
directory: String,
_service: State<'_, SkillServiceState>,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
let app_type = parse_app_type(&app)?;
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
// 先在不持有写锁的情况下收集仓库与技能信息
// 先获取技能信息
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
let skills = service
.list_skills(repos)
.0
.discover_available(repos)
.await
.map_err(|e| e.to_string())?;
let skill = skills
.iter()
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
.into_iter()
.find(|s| {
let install_name = std::path::Path::new(&s.directory)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| s.directory.clone());
install_name.eq_ignore_ascii_case(&directory)
|| s.directory.eq_ignore_ascii_case(&directory)
})
.ok_or_else(|| {
format_skill_error(
"SKILL_NOT_FOUND",
@@ -116,103 +180,54 @@ pub async fn install_skill_for_app(
)
})?;
if !skill.installed {
let repo = SkillRepo {
owner: skill.repo_owner.clone().ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "owner")],
None,
)
})?,
name: skill.repo_name.clone().ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "name")],
None,
)
})?,
branch: skill
.repo_branch
.clone()
.unwrap_or_else(|| "main".to_string()),
enabled: true,
};
service
.install_skill(directory.clone(), repo)
.await
.map_err(|e| e.to_string())?;
}
let key = get_skill_key(&app_type, &directory);
app_state
.db
.update_skill_state(
&key,
&SkillState {
installed: true,
installed_at: Utc::now(),
},
)
service
.0
.install(&app_state.db, &skill, &app_type)
.await
.map_err(|e| e.to_string())?;
Ok(true)
}
/// 卸载技能(兼容旧 API
#[tauri::command]
pub fn uninstall_skill(
directory: String,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
uninstall_skill_for_app("claude".to_string(), directory, service, app_state)
pub fn uninstall_skill(directory: String, app_state: State<'_, AppState>) -> Result<bool, String> {
uninstall_skill_for_app("claude".to_string(), directory, app_state)
}
/// 卸载指定应用的技能(兼容旧 API)
#[tauri::command]
pub fn uninstall_skill_for_app(
app: String,
directory: String,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
let app_type = parse_app_type(&app)?;
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
let _ = parse_app_type(&app)?; // 验证参数
service
.uninstall_skill(directory.clone())
.map_err(|e| e.to_string())?;
// 通过 directory 找到对应的 skill id
let skills = SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())?;
// Remove from database by setting installed = false
let key = get_skill_key(&app_type, &directory);
app_state
.db
.update_skill_state(
&key,
&SkillState {
installed: false,
installed_at: Utc::now(),
},
)
.map_err(|e| e.to_string())?;
let skill = skills
.into_iter()
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
.ok_or_else(|| format!("未找到已安装的 Skill: {directory}"))?;
SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())?;
Ok(true)
}
// ========== 仓库管理命令 ==========
/// 获取技能仓库列表
#[tauri::command]
pub fn get_skill_repos(
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<SkillRepo>, String> {
pub fn get_skill_repos(app_state: State<'_, AppState>) -> Result<Vec<SkillRepo>, String> {
app_state.db.get_skill_repos().map_err(|e| e.to_string())
}
/// 添加技能仓库
#[tauri::command]
pub fn add_skill_repo(
repo: SkillRepo,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result<bool, String> {
app_state
.db
.save_skill_repo(&repo)
@@ -220,11 +235,11 @@ pub fn add_skill_repo(
Ok(true)
}
/// 删除技能仓库
#[tauri::command]
pub fn remove_skill_repo(
owner: String,
name: String,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
app_state
+9 -9
View File
@@ -121,13 +121,13 @@ impl Database {
enabled: false,
auto_failover_enabled: false,
max_retries: 3,
streaming_first_byte_timeout: 30,
streaming_idle_timeout: 60,
non_streaming_timeout: 300,
circuit_failure_threshold: 5,
streaming_first_byte_timeout: 60,
streaming_idle_timeout: 120,
non_streaming_timeout: 600,
circuit_failure_threshold: 4,
circuit_success_threshold: 2,
circuit_timeout_seconds: 60,
circuit_error_rate_threshold: 0.5,
circuit_error_rate_threshold: 0.6,
circuit_min_requests: 10,
})
}
@@ -210,12 +210,12 @@ impl Database {
listen_address: row.get(0)?,
listen_port: row.get::<_, i32>(1)? as u16,
max_retries: row.get::<_, i32>(2)? as u8,
request_timeout: 300, // 废弃字段,返回默认值
request_timeout: 600, // 废弃字段,返回默认值
enable_logging: row.get::<_, i32>(3)? != 0,
live_takeover_active: false, // 废弃字段
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(30) as u64,
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(60) as u64,
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(300) as u64,
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(60) as u64,
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(120) as u64,
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(600) as u64,
})
},
)
+121 -37
View File
@@ -1,73 +1,156 @@
//! Skills 数据访问对象
//!
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
//!
//! v3.10.0+ 统一管理架构:
//! - Skills 使用统一的 id 主键,支持三应用启用标志
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
use crate::app_config::{InstalledSkill, SkillApps};
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::services::skill::{SkillRepo, SkillState};
use crate::services::skill::SkillRepo;
use indexmap::IndexMap;
use rusqlite::params;
impl Database {
/// 获取所有 Skills 状态
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
// ========== InstalledSkill CRUD ==========
/// 获取所有已安装的 Skills
pub fn get_all_installed_skills(&self) -> Result<IndexMap<String, InstalledSkill>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC")
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
FROM skills ORDER BY name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let skill_iter = stmt
.query_map([], |row| {
let directory: String = row.get(0)?;
let app_type: String = row.get(1)?;
let installed: bool = row.get(2)?;
let installed_at_ts: i64 = row.get(3)?;
let installed_at =
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
// 构建复合 key"app_type:directory"
let key = format!("{app_type}:{directory}");
Ok((
key,
SkillState {
installed,
installed_at,
Ok(InstalledSkill {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
directory: row.get(3)?,
repo_owner: row.get(4)?,
repo_name: row.get(5)?,
repo_branch: row.get(6)?,
readme_url: row.get(7)?,
apps: SkillApps {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
},
))
installed_at: row.get(11)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut skills = IndexMap::new();
for skill_res in skill_iter {
let (key, skill) = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
skills.insert(key, skill);
let skill = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
skills.insert(skill.id.clone(), skill);
}
Ok(skills)
}
/// 更新 Skill 状态
/// key 格式为 "app_type:directory"
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
// 解析 key
let (app_type, directory) = if let Some(idx) = key.find(':') {
let (app, dir) = key.split_at(idx);
(app, &dir[1..]) // 跳过冒号
} else {
// 向后兼容:如果没有前缀,默认为 claude
("claude", key)
};
/// 获取单个已安装的 Skill
pub fn get_installed_skill(&self, id: &str) -> Result<Option<InstalledSkill>, AppError> {
let conn = lock_conn!(self.conn);
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
FROM skills WHERE id = ?1",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let result = stmt.query_row([id], |row| {
Ok(InstalledSkill {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
directory: row.get(3)?,
repo_owner: row.get(4)?,
repo_name: row.get(5)?,
repo_branch: row.get(6)?,
readme_url: row.get(7)?,
apps: SkillApps {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
},
installed_at: row.get(11)?,
})
});
match result {
Ok(skill) => Ok(Some(skill)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 保存 Skill(添加或更新)
pub fn save_skill(&self, skill: &InstalledSkill) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
params![directory, app_type, state.installed, state.installed_at.timestamp()],
"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)",
params![
skill.id,
skill.name,
skill.description,
skill.directory,
skill.repo_owner,
skill.repo_name,
skill.repo_branch,
skill.readme_url,
skill.apps.claude,
skill.apps.codex,
skill.apps.gemini,
skill.installed_at,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 删除 Skill
pub fn delete_skill(&self, id: &str) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let affected = conn
.execute("DELETE FROM skills WHERE id = ?1", params![id])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
}
/// 清空所有 Skills(用于迁移)
pub fn clear_skills(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM skills", [])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 更新 Skill 的应用启用状态
pub fn update_skill_apps(&self, id: &str, apps: &SkillApps) -> Result<bool, AppError> {
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],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
}
// ========== SkillRepo CRUD(保持原有) ==========
/// 获取所有 Skill 仓库
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
let conn = lock_conn!(self.conn);
@@ -101,7 +184,8 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)",
params![repo.owner, repo.name, repo.branch, repo.enabled],
).map_err(|e| AppError::Database(e.to_string()))?;
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
+10 -7
View File
@@ -192,13 +192,16 @@ impl Database {
tx: &rusqlite::Transaction<'_>,
config: &MultiAppConfig,
) -> Result<(), AppError> {
for (key, state) in &config.skills.skills {
tx.execute(
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
params![key, state.installed, state.installed_at.timestamp()],
)
.map_err(|e| AppError::Database(format!("Migrate skill failed: {e}")))?;
}
// v3.10.0+Skills 的 SSOT 已迁移到文件系统(~/.cc-switch/skills/+ 数据库统一结构。
//
// 旧版 config.json 里的 `skills.skills` 仅记录“安装状态”,但不包含完整元数据,
// 且无法保证 SSOT 目录中一定存在对应的 skill 文件。
//
// 因此这里不再直接把旧的安装状态写入新 skills 表,避免产生“数据库显示已安装但文件缺失”的不一致。
// 迁移后可通过:
// - 前端「导入已有」(扫描各应用的 skills 目录并复制到 SSOT)
// - 或后续启动时的自动扫描逻辑
// 来重建已安装技能记录。
for repo in &config.skills.repos {
tx.execute(
+1 -1
View File
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 2;
pub(crate) const SCHEMA_VERSION: i32 = 3;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+113 -24
View File
@@ -71,11 +71,21 @@ impl Database {
PRIMARY KEY (id, app_type)
)", []).map_err(|e| AppError::Database(e.to_string()))?;
// 5. Skills 表
// 5. Skills 表v3.10.0+ 统一结构)
conn.execute(
"CREATE TABLE IF NOT EXISTS skills (
directory TEXT NOT NULL, app_type TEXT NOT NULL, installed BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (directory, app_type)
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
directory TEXT NOT NULL,
repo_owner TEXT,
repo_name TEXT,
repo_branch TEXT DEFAULT 'main',
readme_url TEXT,
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0
)",
[],
)
@@ -104,10 +114,10 @@ impl Database {
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", []).map_err(|e| AppError::Database(e.to_string()))?;
@@ -123,7 +133,7 @@ impl Database {
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)",
VALUES ('claude', 6, 90, 180, 600, 8, 3, 90, 0.7, 15)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -132,7 +142,7 @@ impl Database {
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)",
VALUES ('codex', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -141,7 +151,7 @@ impl Database {
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)",
VALUES ('gemini', 5, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -253,15 +263,15 @@ impl Database {
// 尝试添加超时配置列到 proxy_config 表
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30",
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60",
[],
);
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 60",
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 120",
[],
);
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 300",
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 600",
[],
);
@@ -331,6 +341,11 @@ impl Database {
Self::migrate_v1_to_v2(conn)?;
Self::set_user_version(conn, 2)?;
}
2 => {
log::info!("迁移数据库从 v2 到 v3(Skills 统一管理架构)");
Self::migrate_v2_to_v3(conn)?;
Self::set_user_version(conn, 3)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -467,19 +482,19 @@ impl Database {
conn,
"proxy_config",
"streaming_first_byte_timeout",
"INTEGER NOT NULL DEFAULT 30",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"streaming_idle_timeout",
"INTEGER NOT NULL DEFAULT 60",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"streaming_idle_timeout",
"INTEGER NOT NULL DEFAULT 120",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"non_streaming_timeout",
"INTEGER NOT NULL DEFAULT 300",
"INTEGER NOT NULL DEFAULT 600",
)?;
}
@@ -651,10 +666,10 @@ impl Database {
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", [])?;
@@ -689,6 +704,17 @@ impl Database {
/// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键
fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> {
// v3 结构(统一管理架构)已经是更高版本的 skills 表:
// - 主键为 id
// - 包含 enabled_claude / enabled_codex / enabled_gemini 等列
// 在这种情况下,不应再执行 v1 -> v2 的迁移逻辑,否则会因列不匹配而失败。
if Self::has_column(conn, "skills", "enabled_claude")?
|| Self::has_column(conn, "skills", "id")?
{
log::info!("skills 表已经是 v3 结构,跳过 v1 -> v2 迁移");
return Ok(());
}
// 检查是否已经是新表结构
if Self::has_column(conn, "skills", "app_type")? {
log::info!("skills 表已经包含 app_type 字段,跳过迁移");
@@ -760,6 +786,69 @@ impl Database {
Ok(())
}
/// v2 -> v3 迁移:Skills 统一管理架构
///
/// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构,
/// 支持三应用启用标志(enabled_claude, enabled_codex, enabled_gemini)。
///
/// 迁移策略:
/// 1. 旧数据库只存储安装记录,真正的 skill 文件在文件系统
/// 2. 直接重建新表结构,后续由 SkillService 在首次启动时扫描文件系统重建数据
fn migrate_v2_to_v3(conn: &Connection) -> Result<(), AppError> {
// 检查是否已经是新结构(通过检查是否有 enabled_claude 列)
if Self::has_column(conn, "skills", "enabled_claude")? {
log::info!("skills 表已经是 v3 结构,跳过迁移");
return Ok(());
}
log::info!("开始迁移 skills 表到 v3 结构(统一管理架构)...");
// 1. 备份旧数据(用于日志)
let old_count: i64 = conn
.query_row("SELECT COUNT(*) FROM skills", [], |row| row.get(0))
.unwrap_or(0);
log::info!("旧 skills 表有 {old_count} 条记录");
// 标记:需要在启动后从文件系统扫描并重建 Skills 数据
// 说明:v3 结构将 Skills 的 SSOT 迁移到 ~/.cc-switch/skills/
// 旧表只存“安装记录”,无法直接无损迁移到新结构,因此改为启动后扫描 app 目录导入。
let _ = conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_pending', 'true')",
[],
);
// 2. 删除旧表
conn.execute("DROP TABLE IF EXISTS skills", [])
.map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?;
// 3. 创建新表
conn.execute(
"CREATE TABLE skills (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
directory TEXT NOT NULL,
repo_owner TEXT,
repo_name TEXT,
repo_branch TEXT DEFAULT 'main',
readme_url TEXT,
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0
)",
[],
)
.map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?;
log::info!(
"skills 表已迁移到 v3 结构。\n\
注意:旧的安装记录已清除,首次启动时将自动扫描文件系统重建数据。"
);
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
+41
View File
@@ -52,6 +52,47 @@ pub fn take_migration_success() -> bool {
}
}
// ============================================================
// Skills SSOT 迁移结果状态
// ============================================================
#[derive(Debug, Clone, Serialize)]
pub struct SkillsMigrationPayload {
pub count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
static SKILLS_MIGRATION_RESULT: OnceLock<RwLock<Option<SkillsMigrationPayload>>> = OnceLock::new();
fn skills_migration_cell() -> &'static RwLock<Option<SkillsMigrationPayload>> {
SKILLS_MIGRATION_RESULT.get_or_init(|| RwLock::new(None))
}
pub fn set_skills_migration_result(count: usize) {
if let Ok(mut guard) = skills_migration_cell().write() {
*guard = Some(SkillsMigrationPayload { count, error: None });
}
}
pub fn set_skills_migration_error(error: String) {
if let Ok(mut guard) = skills_migration_cell().write() {
*guard = Some(SkillsMigrationPayload {
count: 0,
error: Some(error),
});
}
}
/// 获取并消费 Skills 迁移结果(只返回一次 Some,之后返回 None)
pub fn take_skills_migration_result() -> Option<SkillsMigrationPayload> {
if let Ok(mut guard) = skills_migration_cell().write() {
guard.take()
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
+54 -9
View File
@@ -324,6 +324,47 @@ pub fn run() {
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
}
// 1.1. Skills 统一管理迁移:当数据库迁移到 v3 结构后,自动从各应用目录导入到 SSOT
// 触发条件由 schema 迁移设置 settings.skills_ssot_migration_pending = true 控制。
match app_state.db.get_setting("skills_ssot_migration_pending") {
Ok(Some(flag)) if flag == "true" || flag == "1" => {
// 安全保护:如果用户已经有 v3 结构的 Skills 数据,就不要自动清空重建。
let has_existing = app_state
.db
.get_all_installed_skills()
.map(|skills| !skills.is_empty())
.unwrap_or(false);
if has_existing {
log::info!(
"Detected skills_ssot_migration_pending but skills table not empty; skipping auto import."
);
let _ = app_state
.db
.set_setting("skills_ssot_migration_pending", "false");
} else {
match crate::services::skill::migrate_skills_to_ssot(&app_state.db) {
Ok(count) => {
log::info!("✓ Auto imported {count} skill(s) into SSOT");
if count > 0 {
crate::init_status::set_skills_migration_result(count);
}
let _ = app_state
.db
.set_setting("skills_ssot_migration_pending", "false");
}
Err(e) => {
log::warn!("✗ Failed to auto import legacy skills to SSOT: {e}");
crate::init_status::set_skills_migration_error(e.to_string());
// 保留 pending 标志,方便下次启动重试
}
}
}
}
Ok(_) => {} // 未开启迁移标志,静默跳过
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
}
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过)
for app in [
crate::app_config::AppType::Claude,
@@ -507,14 +548,8 @@ pub fn run() {
app.manage(app_state);
// 初始化 SkillService
match SkillService::new() {
Ok(skill_service) => {
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
}
Err(e) => {
log::warn!("初始化 SkillService 失败: {e}");
}
}
let skill_service = SkillService::new();
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
// 异常退出恢复 + 代理状态自动恢复
let app_handle = app.handle().clone();
@@ -564,6 +599,7 @@ pub fn run() {
commands::open_external,
commands::get_init_error,
commands::get_migration_result,
commands::get_skills_migration_result,
commands::get_app_config_path,
commands::open_app_config_folder,
commands::get_claude_common_config_snippet,
@@ -601,6 +637,7 @@ pub fn run() {
commands::upsert_mcp_server,
commands::delete_mcp_server,
commands::toggle_mcp_app,
commands::import_mcp_from_apps,
// Prompt management
commands::get_prompts,
commands::upsert_prompt,
@@ -635,7 +672,15 @@ pub fn run() {
commands::check_env_conflicts,
commands::delete_env_vars,
commands::restore_env_backup,
// Skill management
// Skill management (v3.10.0+ unified)
commands::get_installed_skills,
commands::install_skill_unified,
commands::uninstall_skill_unified,
commands::toggle_skill_app,
commands::scan_unmanaged_skills,
commands::import_skills_from_apps,
commands::discover_available_skills,
// Skill management (legacy API compatibility)
commands::get_skills,
commands::get_skills_for_app,
commands::install_skill,
+2 -2
View File
@@ -49,10 +49,10 @@ pub struct CircuitBreakerConfig {
impl Default for CircuitBreakerConfig {
fn default() -> Self {
Self {
failure_threshold: 5,
failure_threshold: 4,
success_threshold: 2,
timeout_seconds: 60,
error_rate_threshold: 0.5,
error_rate_threshold: 0.6,
min_requests: 10,
}
}
+5 -5
View File
@@ -28,11 +28,11 @@ pub struct ProxyConfig {
}
fn default_streaming_first_byte_timeout() -> u64 {
30
60
}
fn default_streaming_idle_timeout() -> u64 {
60
120
}
fn default_non_streaming_timeout() -> u64 {
@@ -45,11 +45,11 @@ impl Default for ProxyConfig {
listen_address: "127.0.0.1".to_string(),
listen_port: 15721, // 使用较少占用的高位端口
max_retries: 3,
request_timeout: 300,
request_timeout: 600,
enable_logging: true,
live_takeover_active: false,
streaming_first_byte_timeout: 30,
streaming_idle_timeout: 60,
streaming_first_byte_timeout: 60,
streaming_idle_timeout: 120,
non_streaming_timeout: 600,
}
}
+15 -3
View File
@@ -206,6 +206,8 @@ impl McpService {
// 调用原有的导入逻辑(从 mcp.rs)
let count = crate::mcp::import_from_claude(&mut temp_config)?;
let mut new_count = 0;
// 如果有导入的服务器,保存到数据库
if count > 0 {
if let Some(servers) = &temp_config.mcp.servers {
@@ -217,6 +219,8 @@ impl McpService {
merged.apps.claude = true;
merged
} else {
// 真正的新服务器
new_count += 1;
server.clone()
};
@@ -229,7 +233,7 @@ impl McpService {
}
}
Ok(count)
Ok(new_count)
}
/// 从 Codex 导入 MCPv3.7.0 已更新为统一结构)
@@ -240,6 +244,8 @@ impl McpService {
// 调用原有的导入逻辑(从 mcp.rs)
let count = crate::mcp::import_from_codex(&mut temp_config)?;
let mut new_count = 0;
// 如果有导入的服务器,保存到数据库
if count > 0 {
if let Some(servers) = &temp_config.mcp.servers {
@@ -251,6 +257,8 @@ impl McpService {
merged.apps.codex = true;
merged
} else {
// 真正的新服务器
new_count += 1;
server.clone()
};
@@ -263,7 +271,7 @@ impl McpService {
}
}
Ok(count)
Ok(new_count)
}
/// 从 Gemini 导入 MCPv3.7.0 已更新为统一结构)
@@ -274,6 +282,8 @@ impl McpService {
// 调用原有的导入逻辑(从 mcp.rs)
let count = crate::mcp::import_from_gemini(&mut temp_config)?;
let mut new_count = 0;
// 如果有导入的服务器,保存到数据库
if count > 0 {
if let Some(servers) = &temp_config.mcp.servers {
@@ -285,6 +295,8 @@ impl McpService {
merged.apps.gemini = true;
merged
} else {
// 真正的新服务器
new_count += 1;
server.clone()
};
@@ -297,6 +309,6 @@ impl McpService {
}
}
Ok(count)
Ok(new_count)
}
}
+2 -1
View File
@@ -15,7 +15,8 @@ pub use mcp::McpService;
pub use prompt::PromptService;
pub use provider::{ProviderService, ProviderSortUpdate};
pub use proxy::ProxyService;
pub use skill::{Skill, SkillRepo, SkillService};
#[allow(unused_imports)]
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
pub use speedtest::{EndpointLatency, SpeedtestService};
#[allow(unused_imports)]
pub use usage_stats::{
File diff suppressed because it is too large Load Diff
+95 -16
View File
@@ -13,6 +13,8 @@ import {
Wrench,
Server,
RefreshCw,
Search,
Download,
} from "lucide-react";
import type { Provider } from "@/types";
import type { EnvConflict } from "@/types/env";
@@ -42,6 +44,7 @@ import UsageScriptModal from "@/components/UsageScriptModal";
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
import PromptPanel from "@/components/prompts/PromptPanel";
import { SkillsPage } from "@/components/skills/SkillsPage";
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { AgentsPanel } from "@/components/agents/AgentsPanel";
import { UniversalProviderPanel } from "@/components/universal";
@@ -52,6 +55,7 @@ type View =
| "settings"
| "prompts"
| "skills"
| "skillsDiscovery"
| "mcp"
| "agents"
| "universal";
@@ -81,6 +85,7 @@ function App() {
const promptPanelRef = useRef<any>(null);
const mcpPanelRef = useRef<any>(null);
const skillsPageRef = useRef<any>(null);
const unifiedSkillsPanelRef = useRef<any>(null);
const addActionButtonClass =
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
@@ -106,8 +111,7 @@ function App() {
});
const providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? "";
// Skills 功能仅支持 Claude 和 Codex
const hasSkillsSupport = activeApp === "claude" || activeApp === "codex";
const hasSkillsSupport = true;
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
const {
@@ -218,6 +222,35 @@ function App() {
checkMigration();
}, [t]);
// 应用启动时检查是否刚完成了 Skills 自动导入(统一管理 SSOT)
useEffect(() => {
const checkSkillsMigration = async () => {
try {
const result = await invoke<{ count: number; error?: string } | null>(
"get_skills_migration_result",
);
if (result?.error) {
toast.error(t("migration.skillsFailed"), {
description: t("migration.skillsFailedDescription"),
closeButton: true,
});
console.error("[App] Skills SSOT migration failed:", result.error);
return;
}
if (result && result.count > 0) {
toast.success(t("migration.skillsSuccess", { count: result.count }), {
closeButton: true,
});
await queryClient.invalidateQueries({ queryKey: ["skills"] });
}
} catch (error) {
console.error("[App] Failed to check skills migration result:", error);
}
};
checkSkillsMigration();
}, [t, queryClient]);
// 切换应用时检测当前应用的环境变量冲突
useEffect(() => {
const checkEnvOnSwitch = async () => {
@@ -390,10 +423,16 @@ function App() {
/>
);
case "skills":
return (
<UnifiedSkillsPanel
ref={unifiedSkillsPanelRef}
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
/>
);
case "skillsDiscovery":
return (
<SkillsPage
ref={skillsPageRef}
onClose={() => setCurrentView("providers")}
initialApp={activeApp}
/>
);
@@ -532,7 +571,11 @@ function App() {
<Button
variant="outline"
size="icon"
onClick={() => setCurrentView("providers")}
onClick={() =>
setCurrentView(
currentView === "skillsDiscovery" ? "skills" : "providers",
)
}
className="mr-2 rounded-lg"
>
<ArrowLeft className="w-4 h-4" />
@@ -542,6 +585,7 @@ function App() {
{currentView === "prompts" &&
t("prompts.title", { appName: t(`apps.${activeApp}`) })}
{currentView === "skills" && t("skills.title")}
{currentView === "skillsDiscovery" && t("skills.title")}
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
{currentView === "agents" && t("agents.title")}
{currentView === "universal" &&
@@ -587,25 +631,60 @@ function App() {
>
{currentView === "prompts" && (
<Button
size="icon"
variant="ghost"
size="sm"
onClick={() => promptPanelRef.current?.openAdd()}
className={`ml-auto ${addActionButtonClass}`}
title={t("prompts.add")}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Plus className="w-5 h-5" />
<Plus className="w-4 h-4 mr-2" />
{t("prompts.add")}
</Button>
)}
{currentView === "mcp" && (
<Button
size="icon"
onClick={() => mcpPanelRef.current?.openAdd()}
className={`ml-auto ${addActionButtonClass}`}
title={t("mcp.unifiedPanel.addServer")}
>
<Plus className="w-5 h-5" />
</Button>
<>
<Button
variant="ghost"
size="sm"
onClick={() => mcpPanelRef.current?.openImport()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Download className="w-4 h-4 mr-2" />
{t("mcp.importExisting")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => mcpPanelRef.current?.openAdd()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Plus className="w-4 h-4 mr-2" />
{t("mcp.addMcp")}
</Button>
</>
)}
{currentView === "skills" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => unifiedSkillsPanelRef.current?.openImport()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Download className="w-4 h-4 mr-2" />
{t("skills.import")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skillsDiscovery")}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Search className="w-4 h-4 mr-2" />
{t("skills.discover")}
</Button>
</>
)}
{currentView === "skillsDiscovery" && (
<>
<Button
variant="ghost"
+20 -5
View File
@@ -12,6 +12,9 @@ interface FullScreenPanelProps {
footer?: React.ReactNode;
}
const DRAG_BAR_HEIGHT = 28; // px - match App.tsx
const HEADER_HEIGHT = 64; // px - match App.tsx
/**
* Reusable full-screen panel component
* Handles portal rendering, header with back button, and footer
@@ -44,18 +47,30 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
className="fixed inset-0 z-[60] flex flex-col"
style={{ backgroundColor: "hsl(var(--background))" }}
>
{/* Header */}
{/* Drag region - match App.tsx */}
<div
className="flex-shrink-0 py-3 border-b border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
data-tauri-drag-region
style={{
WebkitAppRegion: "drag",
height: DRAG_BAR_HEIGHT,
} as React.CSSProperties}
/>
{/* Header - match App.tsx */}
<div
className="flex-shrink-0 flex items-center"
style={{
backgroundColor: "hsl(var(--background))",
height: HEADER_HEIGHT,
}}
>
<div className="h-4 w-full" data-tauri-drag-region />
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<div className="mx-auto max-w-[56rem] px-6 w-full flex items-center gap-4">
<Button
type="button"
variant="outline"
size="icon"
onClick={onClose}
className="rounded-lg"
>
<ArrowLeft className="h-4 w-4" />
</Button>
+19 -2
View File
@@ -3,12 +3,11 @@ import { useTranslation } from "react-i18next";
import { Server } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useAllMcpServers, useToggleMcpApp } from "@/hooks/useMcp";
import { useAllMcpServers, useToggleMcpApp, useDeleteMcpServer, useImportMcpFromApps } from "@/hooks/useMcp";
import type { McpServer } from "@/types";
import type { AppId } from "@/lib/api/types";
import McpFormModal from "./McpFormModal";
import { ConfirmDialog } from "../ConfirmDialog";
import { useDeleteMcpServer } from "@/hooks/useMcp";
import { Edit3, Trash2 } from "lucide-react";
import { settingsApi } from "@/lib/api";
import { mcpPresets } from "@/config/mcpPresets";
@@ -24,6 +23,7 @@ interface UnifiedMcpPanelProps {
*/
export interface UnifiedMcpPanelHandle {
openAdd: () => void;
openImport: () => void;
}
const UnifiedMcpPanel = React.forwardRef<
@@ -44,6 +44,7 @@ const UnifiedMcpPanel = React.forwardRef<
const { data: serversMap, isLoading } = useAllMcpServers();
const toggleAppMutation = useToggleMcpApp();
const deleteServerMutation = useDeleteMcpServer();
const importMutation = useImportMcpFromApps();
// Convert serversMap to array for easier rendering
const serverEntries = useMemo((): Array<[string, McpServer]> => {
@@ -86,8 +87,24 @@ const UnifiedMcpPanel = React.forwardRef<
setIsFormOpen(true);
};
const handleImport = async () => {
try {
const count = await importMutation.mutateAsync();
if (count === 0) {
toast.success(t("mcp.unifiedPanel.noImportFound"), { closeButton: true });
} else {
toast.success(t("mcp.unifiedPanel.importSuccess", { count }), { closeButton: true });
}
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
React.useImperativeHandle(ref, () => ({
openAdd: handleAdd,
openImport: handleImport,
}));
const handleDelete = (id: string) => {
+3 -3
View File
@@ -8,7 +8,7 @@ interface PromptToggleProps {
/**
* Toggle 开关组件(提示词专用)
* 启用时为色,禁用时为灰色
* 启用时为绿色,禁用时为灰色
*/
const PromptToggle: React.FC<PromptToggleProps> = ({
enabled,
@@ -23,8 +23,8 @@ const PromptToggle: React.FC<PromptToggleProps> = ({
disabled={disabled}
onClick={() => onChange(!enabled)}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/20
${enabled ? "bg-blue-500 dark:bg-blue-600" : "bg-gray-300 dark:bg-gray-600"}
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/20
${enabled ? "bg-emerald-500 dark:bg-emerald-600" : "bg-gray-300 dark:bg-gray-600"}
${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
`}
>
@@ -222,9 +222,7 @@ export function ClaudeFormFields({
{/* 推理模型 */}
<div className="space-y-2">
<FormLabel htmlFor="reasoningModel">
{t("providerForm.anthropicReasoningModel", {
defaultValue: "推理模型 (Thinking)",
})}
{t("providerForm.anthropicReasoningModel")}
</FormLabel>
<Input
id="reasoningModel"
@@ -233,9 +231,6 @@ export function ClaudeFormFields({
onChange={(e) =>
onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value)
}
placeholder={t("providerForm.reasoningModelPlaceholder", {
defaultValue: "",
})}
autoComplete="off"
/>
</div>
+1 -1
View File
@@ -312,7 +312,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-3 px-1">
{["claude", "codex", "gemini"].map((toolName, index) => {
const tool = toolVersions.find((item) => item.name === toolName);
const displayName = tool?.name ?? toolName;
+2 -2
View File
@@ -12,13 +12,13 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Trash2, ExternalLink, Plus } from "lucide-react";
import { settingsApi } from "@/lib/api";
import type { Skill, SkillRepo } from "@/lib/api/skills";
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
interface RepoManagerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
repos: SkillRepo[];
skills: Skill[];
skills: DiscoverableSkill[];
onAdd: (repo: SkillRepo) => Promise<void>;
onRemove: (owner: string, name: string) => Promise<void>;
}
+3 -3
View File
@@ -6,11 +6,11 @@ import { Label } from "@/components/ui/label";
import { Trash2, ExternalLink, Plus } from "lucide-react";
import { settingsApi } from "@/lib/api";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { Skill, SkillRepo } from "@/lib/api/skills";
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
interface RepoManagerPanelProps {
repos: SkillRepo[];
skills: Skill[];
skills: DiscoverableSkill[];
onAdd: (repo: SkillRepo) => Promise<void>;
onRemove: (owner: string, name: string) => Promise<void>;
onClose: () => void;
@@ -92,7 +92,7 @@ export function RepoManagerPanel({
{/* 添加仓库表单 */}
<div className="space-y-4 glass-card rounded-xl p-6">
<h3 className="text-base font-semibold text-foreground">
{t("skills.addRepo")}
</h3>
<div className="space-y-4">
<div>
+5 -3
View File
@@ -12,10 +12,12 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ExternalLink, Download, Trash2, Loader2 } from "lucide-react";
import { settingsApi } from "@/lib/api";
import type { Skill } from "@/lib/api/skills";
import type { DiscoverableSkill } from "@/lib/api/skills";
type SkillCardSkill = DiscoverableSkill & { installed: boolean };
interface SkillCardProps {
skill: Skill;
skill: SkillCardSkill;
onInstall: (directory: string) => Promise<void>;
onUninstall: (directory: string) => Promise<void>;
}
@@ -57,7 +59,7 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
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">
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:shadow-lg group relative overflow-hidden">
<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" />
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
+101 -129
View File
@@ -1,10 +1,4 @@
import {
useState,
useEffect,
useMemo,
forwardRef,
useImperativeHandle,
} from "react";
import { useState, useMemo, forwardRef, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -20,15 +14,18 @@ import { toast } from "sonner";
import { SkillCard } from "./SkillCard";
import { RepoManagerPanel } from "./RepoManagerPanel";
import {
skillsApi,
type Skill,
type SkillRepo,
useDiscoverableSkills,
useInstalledSkills,
useInstallSkill,
useSkillRepos,
useAddSkillRepo,
useRemoveSkillRepo,
type AppType,
} from "@/lib/api/skills";
} from "@/hooks/useSkills";
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
import { formatSkillError } from "@/lib/errors/skillErrorParser";
interface SkillsPageProps {
onClose?: () => void;
initialApp?: AppType;
}
@@ -37,163 +34,138 @@ export interface SkillsPageHandle {
openRepoManager: () => void;
}
/**
* Skills 发现面板
* 用于浏览和安装来自仓库的 Skills
*/
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
({ onClose: _onClose, initialApp = "claude" }, ref) => {
({ initialApp = "claude" }, ref) => {
const { t } = useTranslation();
const [skills, setSkills] = useState<Skill[]>([]);
const [repos, setRepos] = useState<SkillRepo[]>([]);
const [loading, setLoading] = useState(true);
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [filterStatus, setFilterStatus] = useState<
"all" | "installed" | "uninstalled"
>("all");
// 使用 initialApp,不允许切换
const selectedApp = initialApp;
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
try {
setLoading(true);
const data = await skillsApi.getAll(selectedApp);
setSkills(data);
if (afterLoad) {
afterLoad(data);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
// currentApp 用于安装时的默认应用
const currentApp = initialApp;
// 传入 "skills.loadFailed" 作为标题
const { title, description } = formatSkillError(
errorMessage,
t,
"skills.loadFailed",
);
// Queries
const {
data: discoverableSkills,
isLoading: loadingDiscoverable,
isFetching: fetchingDiscoverable,
refetch: refetchDiscoverable,
} = useDiscoverableSkills();
const { data: installedSkills } = useInstalledSkills();
const { data: repos = [], refetch: refetchRepos } = useSkillRepos();
toast.error(title, {
description,
duration: 8000,
});
// Mutations
const installMutation = useInstallSkill();
const addRepoMutation = useAddSkillRepo();
const removeRepoMutation = useRemoveSkillRepo();
console.error("Load skills failed:", error);
} finally {
setLoading(false);
}
};
// 已安装的 directory 集合
const installedDirs = useMemo(() => {
if (!installedSkills) return new Set<string>();
return new Set(installedSkills.map((s) => s.directory.toLowerCase()));
}, [installedSkills]);
const loadRepos = async () => {
try {
const data = await skillsApi.getRepos();
setRepos(data);
} catch (error) {
console.error("Failed to load repos:", error);
}
};
type DiscoverableSkillItem = DiscoverableSkill & { installed: boolean };
useEffect(() => {
Promise.all([loadSkills(), loadRepos()]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 为发现列表补齐 installed 状态,供 SkillCard 使用
const skills: DiscoverableSkillItem[] = useMemo(() => {
if (!discoverableSkills) return [];
return discoverableSkills.map((d) => {
const installName =
d.directory.split("/").pop()?.toLowerCase() ||
d.directory.toLowerCase();
return {
...d,
installed: installedDirs.has(installName),
};
});
}, [discoverableSkills, installedDirs]);
const loading = loadingDiscoverable || fetchingDiscoverable;
useImperativeHandle(ref, () => ({
refresh: () => loadSkills(),
refresh: () => {
refetchDiscoverable();
refetchRepos();
},
openRepoManager: () => setRepoManagerOpen(true),
}));
const handleInstall = async (directory: string) => {
// 找到对应的 DiscoverableSkill
const skill = discoverableSkills?.find(
(s) =>
s.directory === directory ||
s.directory.split("/").pop() === directory,
);
if (!skill) {
toast.error(t("skills.notFound"));
return;
}
try {
await skillsApi.install(directory, selectedApp);
toast.success(t("skills.installSuccess", { name: directory }), {
await installMutation.mutateAsync({
skill,
currentApp,
});
toast.success(t("skills.installSuccess", { name: skill.name }), {
closeButton: true,
});
await loadSkills();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
// 使用错误解析器格式化错误,传入 "skills.installFailed"
const { title, description } = formatSkillError(
errorMessage,
t,
"skills.installFailed",
);
toast.error(title, {
description,
duration: 10000, // 延长显示时间让用户看清
});
console.error("Install skill failed:", {
directory,
error,
message: errorMessage,
});
}
};
const handleUninstall = async (directory: string) => {
try {
await skillsApi.uninstall(directory, selectedApp);
toast.success(t("skills.uninstallSuccess", { name: directory }), {
closeButton: true,
});
await loadSkills();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
// 使用错误解析器格式化错误,传入 "skills.uninstallFailed"
const { title, description } = formatSkillError(
errorMessage,
t,
"skills.uninstallFailed",
);
toast.error(title, {
description,
duration: 10000,
});
console.error("Install skill failed:", error);
}
};
console.error("Uninstall skill failed:", {
directory,
error,
message: errorMessage,
const handleUninstall = async (_directory: string) => {
// 在发现面板中,不支持卸载,需要在主面板中操作
toast.info(t("skills.uninstallInMainPanel"));
};
const handleAddRepo = async (repo: SkillRepo) => {
try {
await addRepoMutation.mutateAsync(repo);
toast.success(
t("skills.repo.addSuccess", {
owner: repo.owner,
name: repo.name,
}),
{ closeButton: true },
);
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
const handleAddRepo = async (repo: SkillRepo) => {
await skillsApi.addRepo(repo);
let repoSkillCount = 0;
await Promise.all([
loadRepos(),
loadSkills((data) => {
repoSkillCount = data.filter(
(skill) =>
skill.repoOwner === repo.owner &&
skill.repoName === repo.name &&
(skill.repoBranch || "main") === (repo.branch || "main"),
).length;
}),
]);
toast.success(
t("skills.repo.addSuccess", {
owner: repo.owner,
name: repo.name,
count: repoSkillCount,
}),
{ closeButton: true },
);
};
const handleRemoveRepo = async (owner: string, name: string) => {
await skillsApi.removeRepo(owner, name);
toast.success(t("skills.repo.removeSuccess", { owner, name }), {
closeButton: true,
});
await Promise.all([loadRepos(), loadSkills()]);
try {
await removeRepoMutation.mutateAsync({ owner, name });
toast.success(t("skills.repo.removeSuccess", { owner, name }), {
closeButton: true,
});
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
// 过滤技能列表
@@ -0,0 +1,417 @@
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Sparkles, Trash2, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
useInstalledSkills,
useToggleSkillApp,
useUninstallSkill,
useScanUnmanagedSkills,
useImportSkillsFromApps,
type InstalledSkill,
type AppType,
} from "@/hooks/useSkills";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { settingsApi } from "@/lib/api";
import { toast } from "sonner";
interface UnifiedSkillsPanelProps {
onOpenDiscovery: () => void;
}
/**
* 统一 Skills 管理面板
* v3.10.0 新架构:所有 Skills 统一管理,每个 Skill 通过开关控制应用到哪些客户端
*/
export interface UnifiedSkillsPanelHandle {
openDiscovery: () => void;
openImport: () => void;
}
const UnifiedSkillsPanel = React.forwardRef<
UnifiedSkillsPanelHandle,
UnifiedSkillsPanelProps
>(({ onOpenDiscovery }, ref) => {
const { t } = useTranslation();
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
} | null>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
// Queries and Mutations
const { data: skills, isLoading } = useInstalledSkills();
const toggleAppMutation = useToggleSkillApp();
const uninstallMutation = useUninstallSkill();
const { data: unmanagedSkills, refetch: scanUnmanaged } =
useScanUnmanagedSkills();
const importMutation = useImportSkillsFromApps();
// Count enabled skills per app
const enabledCounts = useMemo(() => {
const counts = { claude: 0, codex: 0, gemini: 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++;
});
return counts;
}, [skills]);
const handleToggleApp = async (
id: string,
app: AppType,
enabled: boolean,
) => {
try {
await toggleAppMutation.mutateAsync({ id, app, enabled });
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
const handleUninstall = (skill: InstalledSkill) => {
setConfirmDialog({
isOpen: true,
title: t("skills.uninstall"),
message: t("skills.uninstallConfirm", { name: skill.name }),
onConfirm: async () => {
try {
await uninstallMutation.mutateAsync(skill.id);
setConfirmDialog(null);
toast.success(t("skills.uninstallSuccess", { name: skill.name }), {
closeButton: true,
});
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
},
});
};
const handleOpenImport = async () => {
try {
const result = await scanUnmanaged();
if (!result.data || result.data.length === 0) {
toast.success(t("skills.noUnmanagedFound"), { closeButton: true });
return;
}
setImportDialogOpen(true);
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
const handleImport = async (directories: string[]) => {
try {
const imported = await importMutation.mutateAsync(directories);
setImportDialogOpen(false);
toast.success(
t("skills.importSuccess", { count: imported.length }),
{ closeButton: true },
);
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
React.useImperativeHandle(ref, () => ({
openDiscovery: onOpenDiscovery,
openImport: handleOpenImport,
}));
return (
<div className="mx-auto max-w-[56rem] 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}
</div>
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
{isLoading ? (
<div className="text-center py-12 text-muted-foreground">
{t("skills.loading")}
</div>
) : !skills || skills.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
<Sparkles size={24} className="text-muted-foreground" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">
{t("skills.noInstalled")}
</h3>
<p className="text-muted-foreground text-sm">
{t("skills.noInstalledDescription")}
</p>
</div>
) : (
<div className="space-y-3">
{skills.map((skill) => (
<InstalledSkillListItem
key={skill.id}
skill={skill}
onToggleApp={handleToggleApp}
onUninstall={() => handleUninstall(skill)}
/>
))}
</div>
)}
</div>
{/* Confirm Dialog */}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={confirmDialog.title}
message={confirmDialog.message}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
{/* Import Dialog */}
{importDialogOpen && unmanagedSkills && (
<ImportSkillsDialog
skills={unmanagedSkills}
onImport={handleImport}
onClose={() => setImportDialogOpen(false)}
/>
)}
</div>
);
});
UnifiedSkillsPanel.displayName = "UnifiedSkillsPanel";
/**
* 已安装 Skill 列表项组件
*/
interface InstalledSkillListItemProps {
skill: InstalledSkill;
onToggleApp: (id: string, app: AppType, enabled: boolean) => void;
onUninstall: () => void;
}
const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
skill,
onToggleApp,
onUninstall,
}) => {
const { t } = useTranslation();
const openDocs = async () => {
if (!skill.readmeUrl) return;
try {
await settingsApi.openExternal(skill.readmeUrl);
} catch {
// ignore
}
};
// 生成来源标签
const sourceLabel = useMemo(() => {
if (skill.repoOwner && skill.repoName) {
return `${skill.repoOwner}/${skill.repoName}`;
}
return t("skills.local");
}, [skill.repoOwner, skill.repoName, t]);
return (
<div className="group relative flex items-center gap-4 p-4 rounded-xl border border-border-default bg-muted/50 hover:bg-muted hover:border-border-default/80 hover:shadow-sm transition-all duration-300">
{/* 左侧:Skill 信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-foreground">{skill.name}</h3>
{skill.readmeUrl && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={openDocs}
className="h-6 px-2"
>
<ExternalLink size={14} />
</Button>
)}
</div>
{skill.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{skill.description}
</p>
)}
<p className="text-xs text-muted-foreground/70 mt-1">{sourceLabel}</p>
</div>
{/* 中间:应用开关 */}
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${skill.id}-claude`}
className="text-sm text-foreground/80 cursor-pointer"
>
{t("skills.apps.claude")}
</label>
<Switch
id={`${skill.id}-claude`}
checked={skill.apps.claude}
onCheckedChange={(checked: boolean) =>
onToggleApp(skill.id, "claude", checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${skill.id}-codex`}
className="text-sm text-foreground/80 cursor-pointer"
>
{t("skills.apps.codex")}
</label>
<Switch
id={`${skill.id}-codex`}
checked={skill.apps.codex}
onCheckedChange={(checked: boolean) =>
onToggleApp(skill.id, "codex", checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${skill.id}-gemini`}
className="text-sm text-foreground/80 cursor-pointer"
>
{t("skills.apps.gemini")}
</label>
<Switch
id={`${skill.id}-gemini`}
checked={skill.apps.gemini}
onCheckedChange={(checked: boolean) =>
onToggleApp(skill.id, "gemini", checked)
}
/>
</div>
</div>
{/* 右侧:删除按钮 */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
onClick={onUninstall}
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
title={t("skills.uninstall")}
>
<Trash2 size={16} />
</Button>
</div>
</div>
);
};
/**
* 导入 Skills 对话框
*/
interface ImportSkillsDialogProps {
skills: Array<{
directory: string;
name: string;
description?: string;
foundIn: string[];
}>;
onImport: (directories: string[]) => void;
onClose: () => void;
}
const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
skills,
onImport,
onClose,
}) => {
const { t } = useTranslation();
const [selected, setSelected] = useState<Set<string>>(
new Set(skills.map((s) => s.directory)),
);
const toggleSelect = (directory: string) => {
const newSelected = new Set(selected);
if (newSelected.has(directory)) {
newSelected.delete(directory);
} else {
newSelected.add(directory);
}
setSelected(newSelected);
};
const handleImport = () => {
onImport(Array.from(selected));
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-background rounded-xl p-6 max-w-lg w-full mx-4 shadow-xl max-h-[80vh] flex flex-col">
<h2 className="text-lg font-semibold mb-2">{t("skills.import")}</h2>
<p className="text-sm text-muted-foreground mb-4">
{t("skills.importDescription")}
</p>
<div className="flex-1 overflow-y-auto space-y-2 mb-4">
{skills.map((skill) => (
<label
key={skill.directory}
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted cursor-pointer"
>
<input
type="checkbox"
checked={selected.has(skill.directory)}
onChange={() => toggleSelect(skill.directory)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<div className="font-medium">{skill.name}</div>
{skill.description && (
<div className="text-sm text-muted-foreground line-clamp-1">
{skill.description}
</div>
)}
<div className="text-xs text-muted-foreground/70 mt-1">
{t("skills.foundIn")}: {skill.foundIn.join(", ")}
</div>
</div>
</label>
))}
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button onClick={handleImport} disabled={selected.size === 0}>
{t("skills.importSelected", { count: selected.size })}
</Button>
</div>
</div>
</div>
);
};
export default UnifiedSkillsPanel;
+13
View File
@@ -59,3 +59,16 @@ export function useDeleteMcpServer() {
},
});
}
/**
* 从所有应用导入 MCP 服务器
*/
export function useImportMcpFromApps() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => mcpApi.importFromApps(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
},
});
}
+151
View File
@@ -0,0 +1,151 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
skillsApi,
type AppType,
type DiscoverableSkill,
type InstalledSkill,
} from "@/lib/api/skills";
/**
* 查询所有已安装的 Skills
*/
export function useInstalledSkills() {
return useQuery({
queryKey: ["skills", "installed"],
queryFn: () => skillsApi.getInstalled(),
});
}
/**
* 发现可安装的 Skills(从仓库获取)
*/
export function useDiscoverableSkills() {
return useQuery({
queryKey: ["skills", "discoverable"],
queryFn: () => skillsApi.discoverAvailable(),
staleTime: Infinity, // 无限缓存,直到仓库变化时 invalidate
});
}
/**
* 安装 Skill
*/
export function useInstallSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skill,
currentApp,
}: {
skill: DiscoverableSkill;
currentApp: AppType;
}) => skillsApi.installUnified(skill, currentApp),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
},
});
}
/**
* 卸载 Skill
*/
export function useUninstallSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => skillsApi.uninstallUnified(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
},
});
}
/**
* 切换 Skill 在特定应用的启用状态
*/
export function useToggleSkillApp() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
app,
enabled,
}: {
id: string;
app: AppType;
enabled: boolean;
}) => skillsApi.toggleApp(id, app, enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
},
});
}
/**
* 扫描未管理的 Skills
*/
export function useScanUnmanagedSkills() {
return useQuery({
queryKey: ["skills", "unmanaged"],
queryFn: () => skillsApi.scanUnmanaged(),
enabled: false, // 手动触发
});
}
/**
* 从应用目录导入 Skills
*/
export function useImportSkillsFromApps() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (directories: string[]) => skillsApi.importFromApps(directories),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] });
},
});
}
/**
* 获取仓库列表
*/
export function useSkillRepos() {
return useQuery({
queryKey: ["skills", "repos"],
queryFn: () => skillsApi.getRepos(),
});
}
/**
* 添加仓库
*/
export function useAddSkillRepo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: skillsApi.addRepo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "repos"] });
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
},
});
}
/**
* 删除仓库
*/
export function useRemoveSkillRepo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ owner, name }: { owner: string; name: string }) =>
skillsApi.removeRepo(owner, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "repos"] });
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
},
});
}
// ========== 辅助类型 ==========
export type { InstalledSkill, DiscoverableSkill, AppType };
+30 -5
View File
@@ -343,7 +343,6 @@
"anthropicModel": "Main Model",
"anthropicSmallFastModel": "Fast Model",
"anthropicReasoningModel": "Reasoning Model (Thinking)",
"reasoningModelPlaceholder": "e.g. claude-sonnet-4-20250514",
"openrouterCompatMode": "OpenRouter Compatibility Mode",
"openrouterCompatModeHint": "Use OpenAI Chat Completions interface and convert to Anthropic SSE.",
"anthropicDefaultHaikuModel": "Default Haiku Model",
@@ -565,6 +564,9 @@
},
"mcp": {
"title": "MCP Management",
"import": "Import",
"importExisting": "Import Existing",
"addMcp": "Add MCP",
"claudeTitle": "Claude Code MCP Management",
"codexTitle": "Codex MCP Management",
"geminiTitle": "Gemini MCP Management",
@@ -576,6 +578,8 @@
"deleteConfirm": "Are you sure you want to delete server \"{{id}}\"? This action cannot be undone.",
"noServers": "No servers yet",
"enabledApps": "Enabled Apps",
"noImportFound": "No MCP servers to import found. All servers are already managed by CC Switch.",
"importSuccess": "Successfully imported {{count}} MCP servers",
"apps": {
"claude": "Claude",
"codex": "Codex",
@@ -793,8 +797,8 @@
},
"skills": {
"manage": "Skills",
"title": "Claude Skills Management",
"description": "Discover and install Claude skills from popular repositories to extend Claude Code/Codex capabilities",
"title": "Skills Management",
"description": "Discover and install skills from popular repositories to extend Claude Code/Codex/Gemini capabilities",
"refresh": "Refresh",
"refreshing": "Refreshing...",
"repoManager": "Repository Management",
@@ -870,7 +874,25 @@
"installed": "Installed",
"uninstalled": "Not installed"
},
"noResults": "No matching skills found"
"noResults": "No matching skills found",
"noInstalled": "No skills installed",
"noInstalledDescription": "Discover and install skills from repositories, or import existing skills",
"discover": "Discover Skills",
"import": "Import Existing",
"importDescription": "Select skills to import into CC Switch unified management",
"importSuccess": "Successfully imported {{count}} skills",
"importSelected": "Import Selected ({{count}})",
"noUnmanagedFound": "No skills to import found. All skills are already managed by CC Switch.",
"foundIn": "Found in",
"local": "Local",
"uninstallConfirm": "Are you sure you want to uninstall \"{{name}}\"? This will remove the skill from all apps.",
"uninstallInMainPanel": "Please uninstall skills from the main panel",
"notFound": "Skill not found",
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
}
},
"deeplink": {
"confirmImport": "Confirm Import Provider",
@@ -958,7 +980,10 @@
"clickToSelect": "Click to select icon"
},
"migration": {
"success": "Configuration migrated successfully"
"success": "Configuration migrated successfully",
"skillsSuccess": "Automatically imported {{count}} skill(s) into unified management",
"skillsFailed": "Failed to auto import skills",
"skillsFailedDescription": "Open the Skills page and click \"Import Existing\" to import manually (or restart and try again)."
},
"agents": {
"title": "Agents"
+30 -5
View File
@@ -343,7 +343,6 @@
"anthropicModel": "メインモデル",
"anthropicSmallFastModel": "高速モデル",
"anthropicReasoningModel": "推論モデル(Thinking",
"reasoningModelPlaceholder": "例: claude-sonnet-4-20250514",
"openrouterCompatMode": "OpenRouter 互換モード",
"openrouterCompatModeHint": "OpenAI Chat Completions インターフェースを使用し、Anthropic SSE に変換します。",
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
@@ -565,6 +564,9 @@
},
"mcp": {
"title": "MCP 管理",
"import": "インポート",
"importExisting": "既存をインポート",
"addMcp": "MCPを追加",
"claudeTitle": "Claude Code MCP 管理",
"codexTitle": "Codex MCP 管理",
"geminiTitle": "Gemini MCP 管理",
@@ -576,6 +578,8 @@
"deleteConfirm": "サーバー「{{id}}」を削除しますか?この操作は元に戻せません。",
"noServers": "まだサーバーがありません",
"enabledApps": "有効なアプリ",
"noImportFound": "インポートする MCP サーバーが見つかりませんでした。すべてのサーバーは CC Switch で管理されています。",
"importSuccess": "{{count}} 個の MCP サーバーをインポートしました",
"apps": {
"claude": "Claude",
"codex": "Codex",
@@ -793,8 +797,8 @@
},
"skills": {
"manage": "Skills",
"title": "Claude スキル管理",
"description": "人気リポジトリから Claude Skills を探してインストールし、Claude Code/Codex を拡張",
"title": "Skills 管理",
"description": "人気リポジトリからスキルを探してインストールし、Claude Code/Codex/Gemini を拡張",
"refresh": "更新",
"refreshing": "更新中...",
"repoManager": "リポジトリ管理",
@@ -870,7 +874,25 @@
"installed": "インストール済み",
"uninstalled": "未インストール"
},
"noResults": "一致するスキルが見つかりませんでした"
"noResults": "一致するスキルが見つかりませんでした",
"noInstalled": "インストールされたスキルがありません",
"noInstalledDescription": "リポジトリからスキルを発見してインストールするか、既存のスキルをインポートしてください",
"discover": "スキルを発見",
"import": "既存をインポート",
"importDescription": "CC Switch 統合管理にインポートするスキルを選択してください",
"importSuccess": "{{count}} 件のスキルをインポートしました",
"importSelected": "選択をインポート ({{count}})",
"noUnmanagedFound": "インポートするスキルが見つかりませんでした。すべてのスキルは CC Switch で管理されています。",
"foundIn": "発見場所",
"local": "ローカル",
"uninstallConfirm": "「{{name}}」をアンインストールしますか?すべてのアプリからこのスキルが削除されます。",
"uninstallInMainPanel": "メインパネルからスキルをアンインストールしてください",
"notFound": "スキルが見つかりません",
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
}
},
"deeplink": {
"confirmImport": "プロバイダーのインポートを確認",
@@ -958,7 +980,10 @@
"clickToSelect": "クリックでアイコンを選択"
},
"migration": {
"success": "設定の移行が完了しました"
"success": "設定の移行が完了しました",
"skillsSuccess": "スキルを {{count}} 件、自動的に統合管理へインポートしました",
"skillsFailed": "スキルの自動インポートに失敗しました",
"skillsFailedDescription": "Skills 画面で「既存をインポート」をクリックして手動でインポートしてください(または再起動して再試行)。"
},
"agents": {
"title": "エージェント"
+30 -5
View File
@@ -343,7 +343,6 @@
"anthropicModel": "主模型",
"anthropicSmallFastModel": "快速模型",
"anthropicReasoningModel": "推理模型 (Thinking)",
"reasoningModelPlaceholder": "如 claude-sonnet-4-20250514",
"openrouterCompatMode": "OpenRouter 兼容模式",
"openrouterCompatModeHint": "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
"anthropicDefaultHaikuModel": "Haiku 默认模型",
@@ -565,6 +564,9 @@
},
"mcp": {
"title": "MCP 管理",
"import": "导入",
"importExisting": "导入已有",
"addMcp": "添加MCP",
"claudeTitle": "Claude Code MCP 管理",
"codexTitle": "Codex MCP 管理",
"geminiTitle": "Gemini MCP 管理",
@@ -576,6 +578,8 @@
"deleteConfirm": "确定要删除服务器 \"{{id}}\" 吗?此操作无法撤销。",
"noServers": "暂无服务器",
"enabledApps": "启用的应用",
"noImportFound": "未发现需要导入的 MCP 服务器。所有服务器已在 CC Switch 统一管理中。",
"importSuccess": "成功导入 {{count}} 个 MCP 服务器",
"apps": {
"claude": "Claude",
"codex": "Codex",
@@ -793,8 +797,8 @@
},
"skills": {
"manage": "Skills",
"title": "Claude Skills 管理",
"description": "从流行的仓库发现并安装 Claude 技能,扩展 Claude Code/Codex 的能力",
"title": "Skills 管理",
"description": "从流行的仓库发现并安装技能,扩展 Claude Code/Codex/Gemini 的能力",
"refresh": "刷新",
"refreshing": "刷新中...",
"repoManager": "仓库管理",
@@ -870,7 +874,25 @@
"installed": "已安装",
"uninstalled": "未安装"
},
"noResults": "未找到匹配的技能"
"noResults": "未找到匹配的技能",
"noInstalled": "暂无已安装的技能",
"noInstalledDescription": "从仓库发现并安装技能,或导入已有的技能",
"discover": "发现技能",
"import": "导入已有",
"importDescription": "选择要导入到 CC Switch 统一管理的技能",
"importSuccess": "成功导入 {{count}} 个技能",
"importSelected": "导入已选 ({{count}})",
"noUnmanagedFound": "未发现需要导入的技能。所有技能已在 CC Switch 统一管理中。",
"foundIn": "发现于",
"local": "本地",
"uninstallConfirm": "确定要卸载技能 \"{{name}}\" 吗?这将从所有应用中移除该技能。",
"uninstallInMainPanel": "请在主面板中卸载技能",
"notFound": "未找到技能",
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
}
},
"deeplink": {
"confirmImport": "确认导入供应商配置",
@@ -958,7 +980,10 @@
"clickToSelect": "点击选择图标"
},
"migration": {
"success": "配置迁移成功"
"success": "配置迁移成功",
"skillsSuccess": "已自动导入 {{count}} 个技能到统一管理",
"skillsFailed": "自动导入技能失败",
"skillsFailedDescription": "请打开 Skills 页面点击“导入已有”手动导入(或重启后再试)。"
},
"agents": {
"title": "智能体"
+7
View File
@@ -119,4 +119,11 @@ export const mcpApi = {
): Promise<void> {
return await invoke("toggle_mcp_app", { serverId, app, enabled });
},
/**
* 从所有应用导入 MCP 服务器
*/
async importFromApps(): Promise<number> {
return await invoke("import_mcp_from_apps");
},
};
+102 -1
View File
@@ -1,5 +1,51 @@
import { invoke } from "@tauri-apps/api/core";
// ========== 类型定义 ==========
export type AppType = "claude" | "codex" | "gemini";
/** Skill 应用启用状态 */
export interface SkillApps {
claude: boolean;
codex: boolean;
gemini: boolean;
}
/** 已安装的 Skillv3.10.0+ 统一结构) */
export interface InstalledSkill {
id: string;
name: string;
description?: string;
directory: string;
repoOwner?: string;
repoName?: string;
repoBranch?: string;
readmeUrl?: string;
apps: SkillApps;
installedAt: number;
}
/** 可发现的 Skill(来自仓库) */
export interface DiscoverableSkill {
key: string;
name: string;
description: string;
directory: string;
readmeUrl?: string;
repoOwner: string;
repoName: string;
repoBranch: string;
}
/** 未管理的 Skill(用于导入) */
export interface UnmanagedSkill {
directory: string;
name: string;
description?: string;
foundIn: string[];
}
/** 技能对象(兼容旧 API) */
export interface Skill {
key: string;
name: string;
@@ -12,6 +58,7 @@ export interface Skill {
repoBranch?: string;
}
/** 仓库配置 */
export interface SkillRepo {
owner: string;
name: string;
@@ -19,9 +66,56 @@ export interface SkillRepo {
enabled: boolean;
}
export type AppType = "claude" | "codex" | "gemini";
// ========== API ==========
export const skillsApi = {
// ========== 统一管理 API (v3.10.0+) ==========
/** 获取所有已安装的 Skills */
async getInstalled(): Promise<InstalledSkill[]> {
return await invoke("get_installed_skills");
},
/** 安装 Skill(统一安装) */
async installUnified(
skill: DiscoverableSkill,
currentApp: AppType,
): Promise<InstalledSkill> {
return await invoke("install_skill_unified", { skill, currentApp });
},
/** 卸载 Skill(统一卸载) */
async uninstallUnified(id: string): Promise<boolean> {
return await invoke("uninstall_skill_unified", { id });
},
/** 切换 Skill 的应用启用状态 */
async toggleApp(
id: string,
app: AppType,
enabled: boolean,
): Promise<boolean> {
return await invoke("toggle_skill_app", { id, app, enabled });
},
/** 扫描未管理的 Skills */
async scanUnmanaged(): Promise<UnmanagedSkill[]> {
return await invoke("scan_unmanaged_skills");
},
/** 从应用目录导入 Skills */
async importFromApps(directories: string[]): Promise<InstalledSkill[]> {
return await invoke("import_skills_from_apps", { directories });
},
/** 发现可安装的 Skills(从仓库获取) */
async discoverAvailable(): Promise<DiscoverableSkill[]> {
return await invoke("discover_available_skills");
},
// ========== 兼容旧 API ==========
/** 获取技能列表(兼容旧 API) */
async getAll(app: AppType = "claude"): Promise<Skill[]> {
if (app === "claude") {
return await invoke("get_skills");
@@ -29,6 +123,7 @@ export const skillsApi = {
return await invoke("get_skills_for_app", { app });
},
/** 安装技能(兼容旧 API) */
async install(directory: string, app: AppType = "claude"): Promise<boolean> {
if (app === "claude") {
return await invoke("install_skill", { directory });
@@ -36,6 +131,7 @@ export const skillsApi = {
return await invoke("install_skill_for_app", { app, directory });
},
/** 卸载技能(兼容旧 API) */
async uninstall(
directory: string,
app: AppType = "claude",
@@ -46,14 +142,19 @@ export const skillsApi = {
return await invoke("uninstall_skill_for_app", { app, directory });
},
// ========== 仓库管理 ==========
/** 获取仓库列表 */
async getRepos(): Promise<SkillRepo[]> {
return await invoke("get_skill_repos");
},
/** 添加仓库 */
async addRepo(repo: SkillRepo): Promise<boolean> {
return await invoke("add_skill_repo", { repo });
},
/** 删除仓库 */
async removeRepo(owner: string, name: string): Promise<boolean> {
return await invoke("remove_skill_repo", { owner, name });
},
+2
View File
@@ -36,6 +36,8 @@ const withJson = async <T>(request: Request): Promise<T> => {
const success = <T>(payload: T) => HttpResponse.json(payload as any);
export const handlers = [
http.post(`${TAURI_ENDPOINT}/get_migration_result`, () => success(false)),
http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () => success(null)),
http.post(`${TAURI_ENDPOINT}/get_providers`, async ({ request }) => {
const { app } = await withJson<{ app: AppId }>(request);
return success(getProviders(app));