Compare commits

...

26 Commits

Author SHA1 Message Date
YoVinchen d9a4c7cb86 refactor(skill): remove skillsPath configuration
Remove the skillsPath field from SkillRepo and Skill structs since
recursive scanning now automatically discovers skills in all directories.
Simplify the UI by removing the path input field.
2025-11-28 16:23:06 +08:00
Jason 00f78e4546 feat(i18n): add Japanese language support
- Add complete Japanese translation file (ja.json)
- Update frontend types and hooks to support "ja" language option
- Add Japanese tray menu texts in Rust backend
- Add Japanese option to language settings component
- Update Zod schema to include Japanese language validation
- Add test case for Japanese language preference
- Update i18n documentation to reflect three-language support
2025-11-28 15:14:39 +08:00
Jason 1ee1e9cb2e refactor(startup): improve first-launch import logic with per-table detection
- Replace global `is_empty_for_first_import()` with independent checks:
  - `is_mcp_table_empty()` for MCP server imports
  - `is_prompts_table_empty()` for prompt imports
  - Skills and providers already have built-in idempotency checks

- Fix misleading logs in provider import:
  - Change `import_default_config` return type from `Result<()>` to `Result<bool>`
  - Return `true` when actually imported, `false` when skipped
  - Only log success message when import actually occurred

- Add idempotency protection to `import_from_file_on_first_launch`

This allows each data type to be independently recovered if deleted,
rather than requiring all tables to be empty for any import to trigger.
2025-11-28 12:05:29 +08:00
YoVinchen 7db4b8d976 feat(skill): implement recursive scanning for skill repositories (#309)
Add recursive directory scanning to discover SKILL.md files in nested
directories. When a SKILL.md is found, treat sibling directories as
functional folders rather than separate skills.
2025-11-28 12:01:20 +08:00
Jason 924ad44e6c fix(usage): correct selectedTemplate initialization logic
Previously, selectedTemplate was initialized to null when no NEW_API
credentials were detected, which caused the credentials config section
to be hidden even for new configurations or GENERAL template users.

Now properly detects:
- NEW_API template (has accessToken or userId)
- GENERAL template (has apiKey or baseUrl)
- Default to GENERAL for new configurations (matches default code template)
2025-11-28 11:10:22 +08:00
Jason eecd6a3a2b refactor(usage): simplify usage script modal UI
- Remove redundant "Request Configuration" section (URL, method, headers, body editors)
- Move timeout and auto query interval fields to preset template section
- Simplify "Enable usage query" toggle styling
- Remove redundant hints and variables description
- Add i18n support for Base URL label
- Include "0 to disable" hint directly in auto interval label
2025-11-28 11:09:34 +08:00
farion1231 2a6980417b fix(auto-launch): use platform-specific API for AutoLaunch::new
Windows version of auto-launch crate takes 3 arguments while
Linux/macOS takes 4 (includes hidden parameter). Use conditional
compilation to handle the difference.
2025-11-28 09:58:47 +08:00
Jason 6a9a0a7a7e feat(preset): add icon configuration to provider presets
- Add icon/iconColor fields to CodexProviderPreset and GeminiProviderPreset interfaces
- Configure icons for Claude presets (DeepSeek, Zhipu, Qwen, Kimi, MiniMax, DouBao, etc.)
- Configure icons for Codex presets (OpenAI, Azure, PackyCode)
- Configure icons for Gemini presets (Google, PackyCode)
- Fix handlePresetChange to pass icon fields when resetting form

This ensures preset icons are displayed in the icon picker when selecting a provider preset.
2025-11-27 23:28:11 +08:00
Jason fcb090dd15 fix(css): downgrade Tailwind CSS from v4 to v3.4 for better browser compatibility
Tailwind CSS v4 requires modern CSS features (@layer, @property, color-mix)
that are not supported in older WebView2 versions, causing styles to fail
on some Windows 11 systems.

Changes:
- Replace @tailwindcss/vite with postcss + autoprefixer
- Downgrade tailwindcss from 4.1.13 to 3.4.17
- Convert CSS imports from v4 syntax to v3 @tailwind directives
- Add darkMode: "selector" to tailwind.config.js
- Create postcss.config.js for PostCSS pipeline

This improves compatibility with older browsers and WebView2 runtimes
while maintaining the same visual appearance.
2025-11-27 22:55:14 +08:00
Jason ce4f0c02cb fix(linux): resolve WebKitGTK DMA-BUF rendering issue and preserve user .desktop customizations
- Set WEBKIT_DISABLE_DMABUF_RENDERER=1 at startup to fix blank screen on Debian 13.2 and Nvidia GPUs
- Only register deep-link handler on first run to avoid overwriting user customizations
- Use correct Tauri path API (app.path().data_dir()) instead of dirs::data_dir() to match plugin's actual .desktop file location
2025-11-27 19:37:18 +08:00
Jason 4ca4ad6bca feat(migration): add error dialog for config.json loading failure
- Show system dialog when config.json fails to load during migration
- User can retry loading or exit the program
- Exit before database creation ensures clean retry on next launch
- Support Chinese/English localization based on system locale
2025-11-27 16:03:50 +08:00
Jason 964ead5d0d fix(provider): validate current provider ID existence before use
Add get_effective_current_provider() to validate local settings ID
against database, with automatic cleanup and fallback to DB is_current.

This fixes edge cases in multi-device cloud sync scenarios where local
settings may contain stale provider IDs:

- current(): now returns validated effective provider ID
- update(): correctly syncs live config when local ID differs from DB
- delete(): checks both local settings and DB to prevent deletion
- switch(): backfill logic now targets valid provider
- sync_current_to_live(): uses validated ID with auto-fallback
- tray menu: displays correct checkmark on startup

Also fixes test issues:
- Add missing test setup calls (mutex, reset_test_fs, ensure_test_home)
- Correct Gemini security settings path to ~/.gemini/settings.json
2025-11-27 15:01:24 +08:00
Jason 2c90ae3509 refactor(provider): use local settings for current provider selection
Complete the device-level settings separation for cloud sync support.

Backend changes:
- Modify switch() to update both local settings and database is_current
- Modify current() to read from local settings first, fallback to database
- Rename sync_current_from_db() to sync_current_to_live()
- Update tray menu to read current provider from local settings

Frontend changes:
- Update Settings interface: remove legacy fields (customEndpoints*, security)
- Add currentProviderClaude/Codex/Gemini fields
- Update settings schema accordingly

Test fixes:
- Update Gemini security tests to check ~/.gemini/settings.json
  instead of ~/.cc-switch/settings.json (security field was never
  stored in CC Switch settings)

This ensures each device maintains its own current provider selection
independently when database is synced across devices.
2025-11-27 11:42:19 +08:00
Jason ea7169abc0 refactor(settings): move device-level settings to local file storage
Separate device-level settings from database to support cloud sync scenarios
where multiple devices share the same database but need independent settings.

Changes:
- Remove database storage logic (bind_db, save_to_db, load_from_db)
- Remove legacy custom_endpoints_claude/codex fields (actual data in provider.meta)
- Add current_provider_claude/codex/gemini fields for device-level provider selection
- Add get_current_provider() and set_current_provider() helper functions
- Settings now stored only in ~/.cc-switch/settings.json

This ensures that when database is synced across devices, each device
maintains its own current provider selection independently.
2025-11-27 11:16:43 +08:00
Jason 120ac92e77 refactor(gemini): improve config handling and code consistency
- Fix envObjToString to preserve custom environment variables
- Add bidirectional MCP format conversion (httpUrl <-> url)
- Merge provider config with existing settings.json instead of overwriting
- Remove redundant is_packycode_gemini function
- Simplify is_google_official_gemini using detect_gemini_auth_type
- Add handleGeminiModelChange for consistent field handling
2025-11-27 10:21:01 +08:00
Jason a1e7961af3 fix(gemini): correctly write custom provider env to .env file
write_live_snapshot was incorrectly passing the already-extracted env
sub-field to json_to_env, which expects the full settings_config object.
This caused json_to_env to look for env.env (nested), returning an empty
HashMap and writing an empty .env file.

Fix by delegating to write_gemini_live which correctly handles env file
writing and security flag configuration in one place.
2025-11-27 09:48:37 +08:00
Jason dc79e3a3da fix(gemini): write security auth config only to Gemini settings file
- Remove redundant security.auth.selectedType from CC Switch settings
- Fix Generic provider type not writing security flag on switch
- All non-Google Official providers now correctly write "gemini-api-key"
- Delete unused ensure_packycode_security_flag function
- Clean up SecuritySettings and SecurityAuthSettings types from AppSettings
2025-11-27 09:31:54 +08:00
Jason 7adc38eb53 feat(preset): add MiniMax as official partner with Black Friday promotion
- Mark MiniMax as partner with custom theme color (#f64551)
- Add Black Friday promotional text for Starter plan ($2/mo, 80% OFF)
- Update apiKeyUrl to MiniMax coding plan subscription page
2025-11-27 08:51:54 +08:00
Jason 3f28648931 fix(query): prevent redundant usage queries when switching apps
Add staleTime and gcTime to useUsageQuery to avoid triggering
unnecessary API calls when switching between app tabs. The staleTime
is set dynamically based on the auto-refresh interval (or 5 minutes
by default), and gcTime is set to 10 minutes to preserve cache after
component unmount.
2025-11-26 19:34:00 +08:00
Jason 90d530fa7a fix(ui): improve ConfirmDialog styling for better visual consistency
- Remove header/footer borders and backgrounds for compact alert style
- Use theme-aware overlay color (bg-background/80) instead of black
- Add zIndex="alert" to ensure dialog appears above other dialogs
2025-11-26 15:36:53 +08:00
Jason 5cd2e9fb88 refactor(provider): unify validation errors to use toast notifications
Change template parameter validation from form.setError to toast.error
for consistent UX across all required field validations.
2025-11-26 12:35:33 +08:00
Jason 8bf6ce2c25 feat(provider): add required field validation with toast notifications
- Add validation for provider name (required for all providers)
- Add validation for API endpoint and API Key (required for non-official providers)
- Use toast notifications instead of inline form errors for better visibility
- Move name validation from zod schema to handleSubmit for consistent UX
- Add i18n keys: endpointRequired, apiKeyRequired
2025-11-26 12:26:02 +08:00
Jason c41f3dcccb fix(preset): use ANTHROPIC_AUTH_TOKEN for DMXAPI and correct endpoint candidates
- Change DMXAPI preset from ANTHROPIC_API_KEY to ANTHROPIC_AUTH_TOKEN
- Fix endpointCandidates that were incorrectly copied from AiHubMix
2025-11-26 11:57:47 +08:00
Jason 1caa240d6c refactor(config): remove dead code from JSON-era import/export
Remove obsolete methods that were superseded by SQLite-based
import/export in v3.7.0:

- export_config_to_path: replaced by Database::export_sql
- load_config_for_import: no longer used
- import_config_from_path: stub that only returned error

Also remove unused AppState import.
2025-11-26 11:18:23 +08:00
Jason 7ffd3ba165 fix(provider): preserve custom endpoints when updating provider
- Replace INSERT OR REPLACE with UPDATE for existing providers to avoid
  triggering ON DELETE CASCADE on provider_endpoints table
- Fix endpoint merging logic to correctly mark database endpoints as
  isCustom when they overlap with preset endpoints

The root cause was that INSERT OR REPLACE performs DELETE + INSERT under
the hood, which triggered the foreign key cascade and deleted all
associated endpoints from provider_endpoints table.
2025-11-26 10:27:07 +08:00
YoVinchen ad131486d2 fix(skill): use full key for deduplication to allow same-name skills from different repos (#299) 2025-11-26 09:39:05 +08:00
61 changed files with 2646 additions and 1412 deletions
+4 -3
View File
@@ -4,7 +4,7 @@
1. **安装依赖**:添加了 `react-i18next``i18next`
2. **配置国际化**:在 `src/i18n/` 目录下创建了配置文件
3. **翻译文件**:创建了英文和中文翻译文件
3. **翻译文件**:创建了英文、中文、日文翻译文件
4. **组件更新**:替换了主要组件中的硬编码文案
5. **语言切换器**:添加了语言切换按钮
@@ -16,6 +16,7 @@ src/
│ ├── index.ts # 国际化配置文件
│ └── locales/
│ ├── en.json # 英文翻译
│ ├── ja.json # 日文翻译
│ └── zh.json # 中文翻译
├── components/
│ └── LanguageSwitcher.tsx # 语言切换组件
@@ -24,7 +25,7 @@ src/
## 默认语言设置
- **默认语言**文 (en)
- **默认语言**文 (zh)(无首选时根据浏览器/系统语言选择 zh/en/ja)
- **回退语言**:英文 (en)
## 使用方式
@@ -57,7 +58,7 @@ src/
## 测试功能
应用已添加了语言切换按钮(地球图标),点击可以在中英文之间切换,验证国际化功能是否正常工作。
应用已添加了语言切换按钮,支持中文、英文、日文三种语言切换,验证国际化功能是否正常工作。
## 已更新的组件
+4 -3
View File
@@ -32,7 +32,10 @@
"prettier": "^3.6.2",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vitest": "^2.0.5"
"vitest": "^2.0.5",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.4",
@@ -56,7 +59,6 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tailwindcss/vite": "^4.1.13",
"@tanstack/react-query": "^5.90.3",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
@@ -76,7 +78,6 @@
"smol-toml": "^1.4.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"zod": "^4.1.12"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
+580 -257
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+7
View File
@@ -7,7 +7,14 @@ fn get_auto_launch() -> Result<AutoLaunch, AppError> {
let app_path =
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
// Windows 平台的 AutoLaunch::new 只接受 3 个参数
// Linux/macOS 平台需要 4 个参数(包含 hidden 参数)
#[cfg(target_os = "windows")]
let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), &[] as &[&str]);
#[cfg(not(target_os = "windows"))]
let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), false, &[] as &[&str]);
Ok(auto_launch)
}
+2 -2
View File
@@ -44,7 +44,7 @@ pub async fn import_config_from_file(
// 导入后同步当前供应商到各自的 live 配置
let app_state = AppState::new(db_for_state);
if let Err(err) = ProviderService::sync_current_from_db(&app_state) {
if let Err(err) = ProviderService::sync_current_to_live(&app_state) {
log::warn!("导入后同步 live 配置失败: {err}");
}
@@ -69,7 +69,7 @@ pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<V
let db = state.db.clone();
tauri::async_runtime::spawn_blocking(move || {
let app_state = AppState::new(db);
ProviderService::sync_current_from_db(&app_state)?;
ProviderService::sync_current_to_live(&app_state)?;
Ok::<_, AppError>(json!({
"success": true,
"message": "Live configuration synchronized"
+3 -5
View File
@@ -86,7 +86,7 @@ pub fn switch_provider(
.map_err(|e| e.to_string())
}
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<(), AppError> {
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
ProviderService::import_default_config(state, app_type)
}
@@ -94,7 +94,7 @@ fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result
pub fn import_default_config_test_hook(
state: &AppState,
app_type: AppType,
) -> Result<(), AppError> {
) -> Result<bool, AppError> {
import_default_config_internal(state, app_type)
}
@@ -102,9 +102,7 @@ pub fn import_default_config_test_hook(
#[tauri::command]
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
import_default_config_internal(&state, app_type)
.map(|_| true)
.map_err(Into::into)
import_default_config_internal(&state, app_type).map_err(Into::into)
}
/// 查询供应商用量
-1
View File
@@ -90,7 +90,6 @@ pub async fn install_skill(
.clone()
.unwrap_or_else(|| "main".to_string()),
enabled: true,
skills_path: skill.skills_path.clone(), // 使用技能记录的 skills_path
};
service
+73 -37
View File
@@ -122,62 +122,98 @@ impl Database {
}
/// 保存供应商(新增或更新)
///
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
/// add_custom_endpoint / remove_custom_endpoint),避免覆盖用户的修改。
pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
// 处理 meta endpoints
// 处理 meta:取出 endpoints 以便单独处理
let mut meta_clone = provider.meta.clone().unwrap_or_default();
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
// 检查是否存在保留 is_current
let is_current: bool = tx
// 检查是否存在(用于判断新增/更新,以及保留 is_current
let existing: Option<bool> = tx
.query_row(
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
params![provider.id, app_type],
|row| row.get(0),
)
.unwrap_or(false);
.ok();
tx.execute(
"INSERT OR REPLACE INTO providers (
id, app_type, name, settings_config, website_url, category,
created_at, sort_index, notes, icon, icon_color, meta, is_current
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
provider.id,
app_type,
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
provider.website_url,
provider.category,
provider.created_at,
provider.sort_index,
provider.notes,
provider.icon,
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
is_current,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
let is_update = existing.is_some();
let is_current = existing.unwrap_or(false);
// 同步 endpoints:删除全部后重新插入
tx.execute(
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2",
params![provider.id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
for (url, endpoint) in endpoints {
if is_update {
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
tx.execute(
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
VALUES (?1, ?2, ?3, ?4)",
params![provider.id, app_type, url, endpoint.added_at],
"UPDATE providers SET
name = ?1,
settings_config = ?2,
website_url = ?3,
category = ?4,
created_at = ?5,
sort_index = ?6,
notes = ?7,
icon = ?8,
icon_color = ?9,
meta = ?10,
is_current = ?11
WHERE id = ?12 AND app_type = ?13",
params![
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
provider.website_url,
provider.category,
provider.created_at,
provider.sort_index,
provider.notes,
provider.icon,
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
is_current,
provider.id,
app_type,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
} else {
// 新增模式:使用 INSERT
tx.execute(
"INSERT INTO providers (
id, app_type, name, settings_config, website_url, category,
created_at, sort_index, notes, icon, icon_color, meta, is_current
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
provider.id,
app_type,
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
provider.website_url,
provider.category,
provider.created_at,
provider.sort_index,
provider.notes,
provider.icon,
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
is_current,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 只有新增时才同步 endpoints
for (url, endpoint) in endpoints {
tx.execute(
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
VALUES (?1, ?2, ?3, ?4)",
params![provider.id, app_type, url, endpoint.added_at],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
}
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
+5 -4
View File
@@ -58,7 +58,9 @@ impl Database {
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT owner, name, branch, enabled, skills_path FROM skill_repos ORDER BY owner ASC, name ASC")
.prepare(
"SELECT owner, name, branch, enabled FROM skill_repos ORDER BY owner ASC, name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let repo_iter = stmt
@@ -68,7 +70,6 @@ impl Database {
name: row.get(1)?,
branch: row.get(2)?,
enabled: row.get(3)?,
skills_path: row.get(4)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -84,8 +85,8 @@ impl Database {
pub fn save_skill_repo(&self, repo: &SkillRepo) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)",
params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path],
"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()))?;
Ok(())
}
+13 -11
View File
@@ -153,12 +153,14 @@ impl Database {
tx: &rusqlite::Transaction<'_>,
config: &MultiAppConfig,
) -> Result<(), AppError> {
let migrate_app_prompts =
|prompts_map: &std::collections::HashMap<String, crate::prompt::Prompt>,
app_type: &str|
-> Result<(), AppError> {
for (id, prompt) in prompts_map {
tx.execute(
let migrate_app_prompts = |prompts_map: &std::collections::HashMap<
String,
crate::prompt::Prompt,
>,
app_type: &str|
-> Result<(), AppError> {
for (id, prompt) in prompts_map {
tx.execute(
"INSERT OR REPLACE INTO prompts (
id, app_type, name, content, description, enabled, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
@@ -174,9 +176,9 @@ impl Database {
],
)
.map_err(|e| AppError::Database(format!("Migrate prompt failed: {e}")))?;
}
Ok(())
};
}
Ok(())
};
migrate_app_prompts(&config.prompts.claude.prompts, "claude")?;
migrate_app_prompts(&config.prompts.codex.prompts, "codex")?;
@@ -200,8 +202,8 @@ impl Database {
for repo in &config.skills.repos {
tx.execute(
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)",
params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path],
"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(format!("Migrate skill repo failed: {e}")))?;
}
+10 -17
View File
@@ -115,28 +115,21 @@ impl Database {
Ok(db)
}
/// 检查数据库是否为空(需要首次导入)
///
/// 通过检查是否有任何 MCP 服务器、提示词、Skills 仓库或供应商来判断
pub fn is_empty_for_first_import(&self) -> Result<bool, AppError> {
/// 检查 MCP 服务器表是否为空
pub fn is_mcp_table_empty(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let mcp_count: i64 = conn
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count == 0)
}
let prompt_count: i64 = conn
/// 检查提示词表是否为空
pub fn is_prompts_table_empty(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?;
let skill_repo_count: i64 = conn
.query_row("SELECT COUNT(*) FROM skill_repos", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?;
let provider_count: i64 = conn
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(mcp_count == 0 && prompt_count == 0 && skill_repo_count == 0 && provider_count == 0)
Ok(count == 0)
}
}
+11 -13
View File
@@ -104,7 +104,6 @@ impl Database {
name TEXT NOT NULL,
branch TEXT NOT NULL DEFAULT 'main',
enabled BOOLEAN NOT NULL DEFAULT 1,
skills_path TEXT,
PRIMARY KEY (owner, name)
)",
[],
@@ -226,14 +225,14 @@ impl Database {
Self::add_column_if_missing(conn, "skills", "installed_at", "INTEGER NOT NULL DEFAULT 0")?;
// skill_repos 表
Self::add_column_if_missing(conn, "skill_repos", "branch", "TEXT NOT NULL DEFAULT 'main'")?;
Self::add_column_if_missing(
conn,
"skill_repos",
"enabled",
"BOOLEAN NOT NULL DEFAULT 1",
"branch",
"TEXT NOT NULL DEFAULT 'main'",
)?;
Self::add_column_if_missing(conn, "skill_repos", "skills_path", "TEXT")?;
Self::add_column_if_missing(conn, "skill_repos", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
// 注意: skills_path 字段已被移除,因为现在支持全仓库递归扫描
Ok(())
}
@@ -247,9 +246,7 @@ impl Database {
pub(crate) fn set_user_version(conn: &Connection, version: i32) -> Result<(), AppError> {
if version < 0 {
return Err(AppError::Database(
"user_version 不能为负数".to_string(),
));
return Err(AppError::Database("user_version 不能为负数".to_string()));
}
let sql = format!("PRAGMA user_version = {version};");
conn.execute(&sql, [])
@@ -261,10 +258,7 @@ impl Database {
if s.is_empty() {
return Err(AppError::Database(format!("{kind} 不能为空")));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(AppError::Database(format!(
"非法{kind}: {s},仅允许字母、数字和下划线"
)));
@@ -292,7 +286,11 @@ impl Database {
Ok(false)
}
pub(crate) fn has_column(conn: &Connection, table: &str, column: &str) -> Result<bool, AppError> {
pub(crate) fn has_column(
conn: &Connection,
table: &str,
column: &str,
) -> Result<bool, AppError> {
Self::validate_identifier(table, "表名")?;
Self::validate_identifier(column, "列名")?;
+4 -10
View File
@@ -108,8 +108,8 @@ fn migration_rejects_future_version() {
Database::create_tables_on_conn(&conn).expect("create tables");
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
let err = Database::apply_schema_migrations_on_conn(&conn)
.expect_err("should reject higher version");
let err =
Database::apply_schema_migrations_on_conn(&conn).expect_err("should reject higher version");
assert!(
err.to_string().contains("数据库版本过新"),
"unexpected error: {err}"
@@ -168,10 +168,7 @@ fn migration_aligns_column_defaults_and_types() {
let is_current = get_column_info(&conn, "providers", "is_current");
assert_eq!(is_current.r#type, "BOOLEAN");
assert_eq!(is_current.notnull, 1);
assert_eq!(
normalize_default(&is_current.default).as_deref(),
Some("0")
);
assert_eq!(normalize_default(&is_current.default).as_deref(), Some("0"));
let tags = get_column_info(&conn, "mcp_servers", "tags");
assert_eq!(tags.r#type, "TEXT");
@@ -181,10 +178,7 @@ fn migration_aligns_column_defaults_and_types() {
let enabled = get_column_info(&conn, "prompts", "enabled");
assert_eq!(enabled.r#type, "BOOLEAN");
assert_eq!(enabled.notnull, 1);
assert_eq!(
normalize_default(&enabled.default).as_deref(),
Some("1")
);
assert_eq!(normalize_default(&enabled.default).as_deref(), Some("1"));
let installed_at = get_column_info(&conn, "skills", "installed_at");
assert_eq!(installed_at.r#type, "INTEGER");
-4
View File
@@ -100,12 +100,8 @@ pub struct DeepLinkImportRequest {
/// Skill directory name
#[serde(skip_serializing_if = "Option::is_none")]
pub directory: Option<String>,
/// Repository branch (default: "main")
#[serde(skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
/// Skills subdirectory path (e.g., "skills")
#[serde(skip_serializing_if = "Option::is_none")]
pub skills_path: Option<String>,
// ============ Config file fields (v3.8+) ============
/// Base64 encoded config content
-8
View File
@@ -143,7 +143,6 @@ fn parse_provider_deeplink(
repo: None,
directory: None,
branch: None,
skills_path: None,
config,
config_format,
config_url,
@@ -204,7 +203,6 @@ fn parse_prompt_deeplink(
repo: None,
directory: None,
branch: None,
skills_path: None,
config: None,
config_format: None,
config_url: None,
@@ -262,7 +260,6 @@ fn parse_mcp_deeplink(
repo: None,
directory: None,
branch: None,
skills_path: None,
config_url: None,
})
}
@@ -287,10 +284,6 @@ fn parse_skill_deeplink(
let directory = params.get("directory").cloned();
let branch = params.get("branch").cloned();
let skills_path = params
.get("skills_path")
.or_else(|| params.get("skillsPath"))
.cloned();
Ok(DeepLinkImportRequest {
version,
@@ -298,7 +291,6 @@ fn parse_skill_deeplink(
repo: Some(repo),
directory,
branch,
skills_path,
icon: None,
app: Some("claude".to_string()), // Skills are Claude-only
name: None,
-1
View File
@@ -40,7 +40,6 @@ pub fn import_skill_from_deeplink(
name: name.clone(),
branch: request.branch.unwrap_or_else(|| "main".to_string()),
enabled: request.enabled.unwrap_or(true),
skills_path: request.skills_path,
};
// Save using Database
+2 -8
View File
@@ -142,7 +142,6 @@ fn test_build_gemini_provider_with_model() {
repo: None,
directory: None,
branch: None,
skills_path: None,
content: None,
description: None,
enabled: None,
@@ -189,7 +188,6 @@ fn test_build_gemini_provider_without_model() {
repo: None,
directory: None,
branch: None,
skills_path: None,
content: None,
description: None,
enabled: None,
@@ -231,7 +229,6 @@ fn test_parse_and_merge_config_claude() {
repo: None,
directory: None,
branch: None,
skills_path: None,
content: None,
description: None,
enabled: None,
@@ -275,7 +272,6 @@ fn test_parse_and_merge_config_url_override() {
repo: None,
directory: None,
branch: None,
skills_path: None,
content: None,
description: None,
enabled: None,
@@ -307,8 +303,7 @@ fn test_import_prompt_allows_space_in_base64_content() {
let db = Arc::new(Database::memory().expect("create memory db"));
let state = AppState::new(db.clone());
let prompt_id =
import_prompt_from_deeplink(&state, request.clone()).expect("import prompt");
let prompt_id = import_prompt_from_deeplink(&state, request.clone()).expect("import prompt");
let prompts = state.db.get_prompts("codex").expect("get prompts");
let prompt = prompts.get(&prompt_id).expect("prompt saved");
@@ -373,12 +368,11 @@ fn test_parse_mcp_deeplink() {
#[test]
fn test_parse_skill_deeplink() {
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev&skills_path=src";
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
let request = parse_deeplink_url(&url).unwrap();
assert_eq!(request.resource, "skill");
assert_eq!(request.repo.unwrap(), "owner/repo");
assert_eq!(request.directory.unwrap(), "skills");
assert_eq!(request.branch.unwrap(), "dev");
assert_eq!(request.skills_path.unwrap(), "src");
}
+19 -1
View File
@@ -49,6 +49,11 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
}
/// 读取 Gemini settings.json 中的 mcpServers 映射
///
/// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
/// - httpUrl → url + type: "http"
/// - 仅有 url 字段 → 保持不变(SSE 类型)
/// - 仅有 command 字段 → 保持不变(stdio 类型)
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
let path = user_config_path();
if !path.exists() {
@@ -56,12 +61,25 @@ pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>
}
let root = read_json_value(&path)?;
let servers = root
let mut servers: std::collections::HashMap<String, Value> = root
.get("mcpServers")
.and_then(|v| v.as_object())
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
// 反向格式转换:Gemini 特有格式 → 统一 MCP 格式
for (_, spec) in servers.iter_mut() {
if let Some(obj) = spec.as_object_mut() {
// httpUrl → url + type: "http"
if let Some(http_url) = obj.remove("httpUrl") {
obj.insert("url".to_string(), http_url);
obj.insert("type".to_string(), Value::String("http".to_string()));
}
// 如果有 url 但没有 type,不添加 type(默认为 SSE
// 如果有 command 但没有 type,不添加 type(默认为 stdio
}
}
Ok(servers)
}
+203 -101
View File
@@ -43,6 +43,7 @@ pub use services::{
pub use settings::{update_settings, AppSettings};
pub use store::AppState;
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use std::sync::Arc;
use tauri::{
@@ -68,6 +69,11 @@ impl TrayTexts {
no_provider_hint: " (No providers yet, please add them from the main window)",
quit: "Quit",
},
"ja" => Self {
show_main: "メインウィンドウを開く",
no_provider_hint: " (プロバイダーがまだありません。メイン画面から追加してください)",
quit: "終了",
},
_ => Self {
show_main: "打开主界面",
no_provider_hint: " (无供应商,请在主界面添加)",
@@ -220,10 +226,11 @@ fn create_tray_menu(
for section in TRAY_SECTIONS.iter() {
let app_type_str = section.app_type.as_str();
let providers = app_state.db.get_all_providers(app_type_str)?;
let current_id = app_state
.db
.get_current_provider(app_type_str)?
.unwrap_or_default();
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
let current_id =
crate::settings::get_effective_current_provider(&app_state.db, &section.app_type)?
.unwrap_or_default();
let manager = crate::provider::ProviderManager {
providers,
@@ -546,91 +553,111 @@ pub fn run() {
let has_json = json_path.exists();
let has_db = db_path.exists();
// 如果需要迁移,先验证 config.json 是否可以加载(在创建数据库之前)
// 这样如果加载失败用户选择退出,数据库文件还没被创建,下次可以正常重试
let migration_config = if !has_db && has_json {
log::info!("检测到旧版配置文件,验证配置文件...");
// 循环:支持用户重试加载配置文件
loop {
match crate::app_config::MultiAppConfig::load() {
Ok(config) => {
log::info!("✓ 配置文件加载成功");
break Some(config);
}
Err(e) => {
log::error!("加载旧配置文件失败: {e}");
// 弹出系统对话框让用户选择
if !show_migration_error_dialog(app.handle(), &e.to_string()) {
// 用户选择退出(此时数据库还没创建,下次启动可以重试)
log::info!("用户选择退出程序");
std::process::exit(1);
}
// 用户选择重试,继续循环
log::info!("用户选择重试加载配置文件");
}
}
}
} else {
None
};
// 现在创建数据库
let db = match crate::database::Database::init() {
Ok(db) => Arc::new(db),
Err(e) => {
log::error!("Failed to init database: {e}");
// 这里的错误处理比较棘手,因为 setup 返回 Result<Box<dyn Error>>
// 我们暂时记录日志并让应用继续运行(可能会崩溃)或者返回错误
return Err(Box::new(e));
}
};
// 静默迁移:检测到旧版 config.json 且数据库不存在时自动迁移
if !has_db && has_json {
log::info!("检测到旧版配置文件,开始自动迁移到数据库...");
match crate::app_config::MultiAppConfig::load() {
Ok(config) => {
if let Err(e) = db.migrate_from_json(&config) {
log::error!("配置迁移失败: {e},将从现有配置导入");
// 如果有预加载的配置,执行迁移
if let Some(config) = migration_config {
log::info!("开始执行数据迁移...");
match db.migrate_from_json(&config) {
Ok(_) => {
log::info!("配置迁移成功");
// 标记迁移成功,供前端显示 Toast
crate::init_status::set_migration_success();
// 归档旧配置文件(重命名而非删除,便于用户恢复)
let archive_path = json_path.with_extension("json.migrated");
if let Err(e) = std::fs::rename(&json_path, &archive_path) {
log::warn!("归档旧配置文件失败: {e}");
} else {
log::info!("✓ 配置迁移成功");
// 标记迁移成功,供前端显示 Toast
crate::init_status::set_migration_success();
// 归档旧配置文件(重命名而非删除,便于用户恢复)
let archive_path = json_path.with_extension("json.migrated");
if let Err(e) = std::fs::rename(&json_path, &archive_path) {
log::warn!("归档旧配置文件失败: {e}");
} else {
log::info!("✓ 旧配置已归档为 config.json.migrated");
}
log::info!("配置已归档为 config.json.migrated");
}
}
Err(e) => log::error!("加载旧配置文件失败: {e},将从现有配置导入"),
Err(e) => {
// 配置加载成功但迁移失败的情况极少(磁盘满等),仅记录日志
log::error!("配置迁移失败: {e},将从现有配置导入");
}
}
}
crate::settings::bind_db(db.clone());
let app_state = AppState::new(db);
// 检查是否需要首次导入(数据库为空)
let need_first_import = app_state
.db
.is_empty_for_first_import()
.unwrap_or_else(|e| {
log::warn!("Failed to check if database is empty: {e}");
false
});
// ============================================================
// 按表独立判断的导入逻辑(各类数据独立检查,互不影响)
// ============================================================
if need_first_import {
// 数据库为空,尝试从用户现有的配置文件导入数据并初始化默认配置
log::info!(
"Empty database detected, importing existing configurations and initializing defaults..."
);
// 1. 初始化默认 Skills 仓库(3个)
match app_state.db.init_default_skill_repos() {
Ok(count) if count > 0 => {
log::info!("✓ Initialized {count} default skill repositories");
}
Ok(_) => log::debug!("No default skill repositories to initialize"),
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
// 1. 初始化默认 Skills 仓库(已有内置检查:表非空则跳过)
match app_state.db.init_default_skill_repos() {
Ok(count) if count > 0 => {
log::info!("✓ Initialized {count} default skill repositories");
}
Ok(_) => {} // 表非空,静默跳过
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
}
// 2. 导入供应商配置(从 live 配置文件
for app in [
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini,
] {
match crate::services::provider::ProviderService::import_default_config(
&app_state,
app.clone(),
) {
Ok(_) => {
log::info!("✓ Imported default provider for {}", app.as_str());
}
Err(e) => {
log::debug!(
"○ No default provider to import for {}: {}",
app.as_str(),
e
);
}
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过
for app in [
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini,
] {
match crate::services::provider::ProviderService::import_default_config(
&app_state,
app.clone(),
) {
Ok(true) => {
log::info!("✓ Imported default provider for {}", app.as_str());
}
Ok(false) => {} // 已有供应商,静默跳过
Err(e) => {
log::debug!(
"○ No default provider to import for {}: {}",
app.as_str(),
e
);
}
}
}
// 3. 导入 MCP 服务器配置(表空时触发)
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
log::info!("MCP table empty, importing from live configurations...");
// 3. 导入 MCP 服务器配置
match crate::services::mcp::McpService::import_from_claude(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from Claude");
@@ -654,42 +681,28 @@ pub fn run() {
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
}
}
// 4. 导入提示词文件
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
// 4. 导入提示词文件(表空时触发)
if app_state.db.is_prompts_table_empty().unwrap_or(false) {
log::info!("Prompts table empty, importing from live configurations...");
for app in [
crate::app_config::AppType::Claude,
) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} prompt(s) from Claude");
}
Ok(_) => log::debug!("○ No Claude prompt file found to import"),
Err(e) => log::warn!("✗ Failed to import Claude prompt: {e}"),
}
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
crate::app_config::AppType::Codex,
) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} prompt(s) from Codex");
}
Ok(_) => log::debug!("○ No Codex prompt file found to import"),
Err(e) => log::warn!("✗ Failed to import Codex prompt: {e}"),
}
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
crate::app_config::AppType::Gemini,
) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} prompt(s) from Gemini");
] {
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
app.clone(),
) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} prompt(s) for {}", app.as_str());
}
Ok(_) => log::debug!("○ No prompt file found for {}", app.as_str()),
Err(e) => log::warn!("✗ Failed to import prompt for {}: {e}", app.as_str()),
}
Ok(_) => log::debug!("○ No Gemini prompt file found to import"),
Err(e) => log::warn!("✗ Failed to import Gemini prompt: {e}"),
}
log::info!("First-time import completed");
}
// 迁移旧的 app_config_dir 配置到 Store
@@ -705,10 +718,35 @@ pub fn run() {
// Linux 和 Windows 调试模式需要显式注册
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
{
if let Err(e) = app.deep_link().register_all() {
log::error!("✗ Failed to register deep link schemes: {}", e);
} else {
log::info!("✓ Deep link schemes registered (Linux/Windows)");
#[cfg(target_os = "linux")]
{
// Use Tauri's path API to get correct path (includes app identifier)
// tauri-plugin-deep-link writes to: ~/.local/share/com.ccswitch.desktop/applications/cc-switch-handler.desktop
// Only register if .desktop file doesn't exist to avoid overwriting user customizations
let should_register = app
.path()
.data_dir()
.map(|d| !d.join("applications/cc-switch-handler.desktop").exists())
.unwrap_or(true);
if should_register {
if let Err(e) = app.deep_link().register_all() {
log::error!("✗ Failed to register deep link schemes: {}", e);
} else {
log::info!("✓ Deep link schemes registered (Linux)");
}
} else {
log::info!("⊘ Deep link handler already exists, skipping registration");
}
}
#[cfg(all(debug_assertions, windows))]
{
if let Err(e) = app.deep_link().register_all() {
log::error!("✗ Failed to register deep link schemes: {}", e);
} else {
log::info!("✓ Deep link schemes registered (Windows debug)");
}
}
}
@@ -952,3 +990,67 @@ pub fn run() {
}
});
}
// ============================================================
// 迁移错误对话框辅助函数
// ============================================================
/// 检测是否为中文环境
fn is_chinese_locale() -> bool {
std::env::var("LANG")
.or_else(|_| std::env::var("LC_ALL"))
.or_else(|_| std::env::var("LC_MESSAGES"))
.map(|lang| lang.starts_with("zh"))
.unwrap_or(false)
}
/// 显示迁移错误对话框
/// 返回 true 表示用户选择重试,false 表示用户选择退出
fn show_migration_error_dialog(app: &tauri::AppHandle, error: &str) -> bool {
let title = if is_chinese_locale() {
"配置迁移失败"
} else {
"Migration Failed"
};
let message = if is_chinese_locale() {
format!(
"从旧版本迁移配置时发生错误:\n\n{error}\n\n\
您的数据尚未丢失,旧配置文件仍然保留。\n\
建议回退到旧版本 CC Switch 以保护数据。\n\n\
点击「重试」重新尝试迁移\n\
点击「退出」关闭程序(可回退版本后重新打开)"
)
} else {
format!(
"An error occurred while migrating configuration:\n\n{error}\n\n\
Your data is NOT lost - the old config file is still preserved.\n\
Consider rolling back to an older CC Switch version.\n\n\
Click 'Retry' to attempt migration again\n\
Click 'Exit' to close the program"
)
};
let retry_text = if is_chinese_locale() {
"重试"
} else {
"Retry"
};
let exit_text = if is_chinese_locale() {
"退出"
} else {
"Exit"
};
// 使用 blocking_show 同步等待用户响应
// OkCancelCustom: 第一个按钮(重试)返回 true,第二个按钮(退出)返回 false
app.dialog()
.message(&message)
.title(title)
.kind(MessageDialogKind::Error)
.buttons(MessageDialogButtons::OkCancelCustom(
retry_text.to_string(),
exit_text.to_string(),
))
.blocking_show()
}
+10
View File
@@ -2,5 +2,15 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
// 在 Linux 上设置 WebKit 环境变量以解决 DMA-BUF 渲染问题
// 某些 Linux 系统(如 Debian 13.2、Nvidia GPU)上 WebKitGTK 的 DMA-BUF 渲染器可能导致白屏/黑屏
// 参考: https://github.com/tauri-apps/tauri/issues/9394
#[cfg(target_os = "linux")]
{
if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
}
cc_switch_lib::run();
}
-48
View File
@@ -2,7 +2,6 @@ use super::provider::ProviderService;
use crate::app_config::{AppType, MultiAppConfig};
use crate::error::AppError;
use crate::provider::Provider;
use crate::store::AppState;
use chrono::Utc;
use serde_json::Value;
use std::fs;
@@ -84,53 +83,6 @@ impl ConfigService {
Ok(())
}
/// 将当前 config.json 拷贝到目标路径。
pub fn export_config_to_path(target_path: &Path) -> Result<(), AppError> {
let config_path = crate::config::get_app_config_path();
let config_content =
fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
fs::write(target_path, config_content).map_err(|e| AppError::io(target_path, e))
}
/// 从磁盘文件加载配置并写回 config.json,返回备份 ID 及新配置。
pub fn load_config_for_import(file_path: &Path) -> Result<(MultiAppConfig, String), AppError> {
let import_content =
fs::read_to_string(file_path).map_err(|e| AppError::io(file_path, e))?;
let new_config: MultiAppConfig =
serde_json::from_str(&import_content).map_err(|e| AppError::json(file_path, e))?;
let config_path = crate::config::get_app_config_path();
let backup_id = Self::create_backup(&config_path)?;
fs::write(&config_path, &import_content).map_err(|e| AppError::io(&config_path, e))?;
Ok((new_config, backup_id))
}
/// 将外部配置文件内容加载并写入应用状态。
/// TODO: 需要重构以使用数据库而不是 JSON 配置
pub fn import_config_from_path(
_file_path: &Path,
_state: &AppState,
) -> Result<String, AppError> {
// TODO: 实现基于数据库的导入逻辑
Err(AppError::Message(
"配置导入功能正在重构中,暂时不可用".to_string(),
))
/* 旧的实现,需要重构:
let (new_config, backup_id) = Self::load_config_for_import(file_path)?;
{
let mut guard = state.config.write().map_err(AppError::from)?;
*guard = new_config;
}
Ok(backup_id)
*/
}
/// 同步当前供应商到对应的 live 配置。
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
+6
View File
@@ -176,6 +176,12 @@ impl PromptService {
state: &AppState,
app: AppType,
) -> Result<usize, AppError> {
// 幂等性保护:该应用已有提示词则跳过
let existing = state.db.get_prompts(app.as_str())?;
if !existing.is_empty() {
return Ok(0);
}
let file_path = prompt_file_path(&app)?;
// 检查文件是否存在
+4 -149
View File
@@ -4,7 +4,6 @@
use crate::error::AppError;
use crate::provider::Provider;
use crate::settings;
/// Gemini authentication type enumeration
///
@@ -19,10 +18,6 @@ pub(crate) enum GeminiAuthType {
Generic,
}
// Authentication type constants
const PACKYCODE_SECURITY_SELECTED_TYPE: &str = "gemini-api-key";
const GOOGLE_OAUTH_SECURITY_SELECTED_TYPE: &str = "oauth-personal";
// Partner Promotion Key constants
const PACKYCODE_PARTNER_KEY: &str = "packycode";
const GOOGLE_OFFICIAL_PARTNER_KEY: &str = "google-official";
@@ -94,159 +89,22 @@ fn contains_packycode_keyword(value: &str) -> bool {
.any(|keyword| lower.contains(keyword))
}
/// Detect if provider is PackyCode Gemini (uses API Key authentication)
///
/// PackyCode is an official partner requiring special security configuration.
///
/// # Detection Rules (priority from high to low)
///
/// 1. **Partner Promotion Key** (most reliable):
/// - `provider.meta.partner_promotion_key == "packycode"`
///
/// 2. **Provider name**:
/// - Name contains "packycode", "packyapi" or "packy" (case-insensitive)
///
/// 3. **Website URL**:
/// - `provider.website_url` contains keywords
///
/// 4. **Base URL**:
/// - `settings_config.env.GOOGLE_GEMINI_BASE_URL` contains keywords
///
/// # Why multiple detection methods
///
/// - Users may manually create providers without `partner_promotion_key`
/// - Meta fields may be modified after copying from presets
/// - Ensure all PackyCode providers get correct security flags
#[allow(dead_code)]
pub(crate) fn is_packycode_gemini(provider: &Provider) -> bool {
// Strategy 1: Check partner_promotion_key (most reliable)
if provider
.meta
.as_ref()
.and_then(|meta| meta.partner_promotion_key.as_deref())
.is_some_and(|key| key.eq_ignore_ascii_case(PACKYCODE_PARTNER_KEY))
{
return true;
}
// Strategy 2: Check provider name
if contains_packycode_keyword(&provider.name) {
return true;
}
// Strategy 3: Check website URL
if let Some(site) = provider.website_url.as_deref() {
if contains_packycode_keyword(site) {
return true;
}
}
// Strategy 4: Check Base URL
if let Some(base_url) = provider
.settings_config
.pointer("/env/GOOGLE_GEMINI_BASE_URL")
.and_then(|v| v.as_str())
{
if contains_packycode_keyword(base_url) {
return true;
}
}
false
}
/// Detect if provider is Google Official Gemini (uses OAuth authentication)
///
/// Google Official Gemini uses OAuth personal authentication, no API Key needed.
///
/// # Detection Rules (priority from high to low)
///
/// 1. **Partner Promotion Key** (most reliable):
/// - `provider.meta.partner_promotion_key == "google-official"`
///
/// 2. **Provider name**:
/// - Name equals "google" (case-insensitive)
/// - Or name starts with "google " (e.g., "Google Official")
///
/// # OAuth vs API Key
///
/// - **OAuth mode**: `security.auth.selectedType = "oauth-personal"`
/// - User needs to login via browser with Google account
/// - No API Key needed in `.env` file
///
/// - **API Key mode**: `security.auth.selectedType = "gemini-api-key"`
/// - Used for third-party relay services (like PackyCode)
/// - Requires `GEMINI_API_KEY` in `.env` file
#[allow(dead_code)]
/// This is a convenience wrapper around `detect_gemini_auth_type`.
pub(crate) fn is_google_official_gemini(provider: &Provider) -> bool {
// Strategy 1: Check partner_promotion_key (most reliable)
if provider
.meta
.as_ref()
.and_then(|meta| meta.partner_promotion_key.as_deref())
.is_some_and(|key| key.eq_ignore_ascii_case(GOOGLE_OFFICIAL_PARTNER_KEY))
{
return true;
}
// Strategy 2: Check name matching (fallback)
let name_lower = provider.name.to_ascii_lowercase();
name_lower == "google" || name_lower.starts_with("google ")
}
/// Ensure PackyCode Gemini provider security flag is correctly set
///
/// PackyCode is an official partner using API Key authentication mode.
///
/// # Why write to two settings.json files
///
/// 1. **`~/.cc-switch/settings.json`** (application-level config):
/// - CC-Switch application global settings
/// - Ensures app knows current authentication type
/// - Used for UI display and other app logic
///
/// 2. **`~/.gemini/settings.json`** (Gemini client config):
/// - Configuration file read by Gemini CLI client
/// - Directly affects Gemini client authentication behavior
/// - Ensures Gemini uses correct authentication method to connect API
///
/// # Value set
///
/// ```json
/// {
/// "security": {
/// "auth": {
/// "selectedType": "gemini-api-key"
/// }
/// }
/// }
/// ```
///
/// # Error handling
///
/// If provider is not PackyCode, function returns `Ok(())` immediately without any operation.
pub(crate) fn ensure_packycode_security_flag(provider: &Provider) -> Result<(), AppError> {
if !is_packycode_gemini(provider) {
return Ok(());
}
// Write to application-level settings.json (~/.cc-switch/settings.json)
settings::ensure_security_auth_selected_type(PACKYCODE_SECURITY_SELECTED_TYPE)?;
// Write to Gemini directory settings.json (~/.gemini/settings.json)
use crate::gemini_config::write_packycode_settings;
write_packycode_settings()?;
Ok(())
detect_gemini_auth_type(provider) == GeminiAuthType::GoogleOfficial
}
/// Ensure Google Official Gemini provider security flag is correctly set (OAuth mode)
///
/// Google Official Gemini uses OAuth personal authentication, no API Key needed.
///
/// # Why write to two settings.json files
/// # What it does
///
/// Same as `ensure_packycode_security_flag`, need to configure both app-level and client-level settings.
/// Writes to **`~/.gemini/settings.json`** (Gemini client config).
///
/// # Value set
///
@@ -276,9 +134,6 @@ pub(crate) fn ensure_google_oauth_security_flag(provider: &Provider) -> Result<(
return Ok(());
}
// Write to application-level settings.json (~/.cc-switch/settings.json)
settings::ensure_security_auth_selected_type(GOOGLE_OAUTH_SECURITY_SELECTED_TYPE)?;
// Write to Gemini directory settings.json (~/.gemini/settings.json)
use crate::gemini_config::write_google_oauth_settings;
write_google_oauth_settings()?;
+69 -62
View File
@@ -15,8 +15,7 @@ use crate::services::mcp::McpService;
use crate::store::AppState;
use super::gemini_auth::{
detect_gemini_auth_type, ensure_google_oauth_security_flag, ensure_packycode_security_flag,
GeminiAuthType,
detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType,
};
use super::normalize_claude_models_in_value;
@@ -101,12 +100,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
write_json_file(&path, &provider.settings_config)?;
}
AppType::Codex => {
let obj = provider.settings_config.as_object().ok_or_else(|| {
AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string())
})?;
let auth = obj.get("auth").ok_or_else(|| {
AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string())
})?;
let obj = provider
.settings_config
.as_object()
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
let auth = obj
.get("auth")
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string())
})?;
@@ -114,51 +114,36 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
let auth_path = get_codex_auth_path();
write_json_file(&auth_path, auth)?;
let config_path = get_codex_config_path();
std::fs::write(&config_path, config_str)
.map_err(|e| AppError::io(&config_path, e))?;
std::fs::write(&config_path, config_str).map_err(|e| AppError::io(&config_path, e))?;
}
AppType::Gemini => {
use crate::gemini_config::{
get_gemini_settings_path, json_to_env, write_gemini_env_atomic,
};
// Extract env and config from provider settings
let env_value = provider.settings_config.get("env");
let config_value = provider.settings_config.get("config");
// Write env file
if let Some(env) = env_value {
let env_map = json_to_env(env)?;
write_gemini_env_atomic(&env_map)?;
}
// Write settings file
if let Some(config) = config_value {
let settings_path = get_gemini_settings_path();
write_json_file(&settings_path, config)?;
}
// Delegate to write_gemini_live which handles env file writing correctly
write_gemini_live(provider)?;
}
}
Ok(())
}
/// Sync current provider from database to live configuration
pub fn sync_current_from_db(state: &AppState) -> Result<(), AppError> {
/// Sync current provider to live configuration
///
/// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
let current_id = match state.db.get_current_provider(app_type.as_str())? {
Some(id) => id,
None => continue,
};
// Use validated effective current provider
let current_id =
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
Some(id) => id,
None => continue,
};
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
write_live_snapshot(&app_type, provider)?;
} else {
log::warn!(
"无法同步 live 配置: 当前供应商 {} ({}) 未找到",
current_id,
app_type.as_str()
);
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
}
// MCP sync
@@ -230,11 +215,14 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
}
/// Import default configuration from live files
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<(), AppError> {
///
/// Returns `Ok(true)` if a provider was actually imported,
/// `Ok(false)` if skipped (providers already exist for this app).
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
{
let providers = state.db.get_all_providers(app_type.as_str())?;
if !providers.is_empty() {
return Ok(());
return Ok(false); // 已有供应商,跳过
}
}
@@ -313,7 +301,7 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<(),
.db
.set_current_provider(app_type.as_str(), &provider.id)?;
Ok(())
Ok(true) // 真正导入了
}
/// Write Gemini live configuration with authentication handling
@@ -328,28 +316,44 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
let mut env_map = json_to_env(&provider.settings_config)?;
// Prepare config to write to ~/.gemini/settings.json (preserve existing file content when absent)
let mut config_to_write = if let Some(config_value) = provider.settings_config.get("config") {
if config_value.is_null() {
Some(json!({}))
} else if config_value.is_object() {
Some(config_value.clone())
} else {
// Prepare config to write to ~/.gemini/settings.json
// Behavior:
// - config is object: use it (merge with existing to preserve mcpServers etc.)
// - config is null or absent: preserve existing file content
let settings_path = get_gemini_settings_path();
let mut config_to_write: Option<Value> = None;
if let Some(config_value) = provider.settings_config.get("config") {
if config_value.is_object() {
// Merge with existing settings to preserve mcpServers and other fields
let mut merged = if settings_path.exists() {
read_json_file::<Value>(&settings_path).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
// Merge provider config into existing settings
if let (Some(merged_obj), Some(config_obj)) =
(merged.as_object_mut(), config_value.as_object())
{
for (k, v) in config_obj {
merged_obj.insert(k.clone(), v.clone());
}
}
config_to_write = Some(merged);
} else if !config_value.is_null() {
return Err(AppError::localized(
"gemini.validation.invalid_config",
"Gemini 配置格式错误: config 必须是对象或 null",
"Gemini config invalid: config must be an object or null",
));
}
} else {
None
};
// config is null: don't modify existing settings.json (preserve mcpServers etc.)
}
if config_to_write.is_none() {
let settings_path = get_gemini_settings_path();
if settings_path.exists() {
config_to_write = Some(read_json_file(&settings_path)?);
}
// If no config specified or config is null, preserve existing file
if config_to_write.is_none() && settings_path.exists() {
config_to_write = Some(read_json_file(&settings_path)?);
}
match auth_type {
@@ -371,14 +375,17 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
}
if let Some(config_value) = config_to_write {
let settings_path = get_gemini_settings_path();
write_json_file(&settings_path, &config_value)?;
}
// Set security.auth.selectedType based on auth type
// - Google Official: OAuth mode
// - All others: API Key mode
match auth_type {
GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,
GeminiAuthType::Packycode => ensure_packycode_security_flag(provider)?,
GeminiAuthType::Generic => {}
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
crate::gemini_config::write_packycode_settings()?;
}
}
Ok(())
+36 -33
View File
@@ -22,16 +22,12 @@ use crate::settings::CustomEndpoint;
use crate::store::AppState;
// Re-export sub-module functions for external access
pub use live::{import_default_config, read_live_settings, sync_current_from_db};
pub use live::{import_default_config, read_live_settings, sync_current_to_live};
// Internal re-exports (pub(crate))
pub(crate) use live::write_live_snapshot;
// Internal re-exports
use gemini_auth::{
detect_gemini_auth_type, ensure_google_oauth_security_flag, ensure_packycode_security_flag,
GeminiAuthType,
};
use live::write_gemini_live;
use usage::validate_usage_script;
@@ -98,10 +94,12 @@ impl ProviderService {
}
/// Get current provider ID
///
/// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
state
.db
.get_current_provider(app_type.as_str())
crate::settings::get_effective_current_provider(&state.db, &app_type)
.map(|opt| opt.unwrap_or_default())
}
@@ -139,9 +137,10 @@ impl ProviderService {
Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?;
// Check if this is current provider
let current_id = state.db.get_current_provider(app_type.as_str())?;
let is_current = current_id.as_deref() == Some(provider.id.as_str());
// Check if this is current provider (use effective current, not just DB)
let effective_current =
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
let is_current = effective_current.as_deref() == Some(provider.id.as_str());
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
@@ -156,13 +155,19 @@ impl ProviderService {
}
/// Delete a provider
///
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
let current = state.db.get_current_provider(app_type.as_str())?;
if current.as_deref() == Some(id) {
// Check both local settings and database
let local_current = crate::settings::get_current_provider(&app_type);
let db_current = state.db.get_current_provider(app_type.as_str())?;
if local_current.as_deref() == Some(id) || db_current.as_deref() == Some(id) {
return Err(AppError::Message(
"无法删除当前正在使用的供应商".to_string(),
));
}
state.db.delete_provider(app_type.as_str(), id)
}
@@ -171,9 +176,9 @@ impl ProviderService {
/// Switch flow:
/// 1. Validate target provider exists
/// 2. **Backfill mechanism**: Backfill current live config to current provider, protect user manual modifications
/// 3. Set new current provider
/// 4. Write target provider config to live files
/// 5. Gemini additional security flag handling
/// 3. Update local settings current_provider_xxx (device-level)
/// 4. Update database is_current (as default for new devices)
/// 5. Write target provider config to live files
/// 6. Sync MCP configuration
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// Check if provider exists
@@ -183,7 +188,10 @@ impl ProviderService {
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
// Backfill: Backfill current live config to current provider
if let Some(current_id) = state.db.get_current_provider(app_type.as_str())? {
// Use effective current provider (validated existence) to ensure backfill targets valid provider
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
if let Some(current_id) = current_id {
if current_id != id {
// Only backfill when switching to a different provider
if let Ok(live_config) = read_live_settings(app_type.clone()) {
@@ -196,35 +204,30 @@ impl ProviderService {
}
}
// Set current
// Update local settings (device-level, takes priority)
crate::settings::set_current_provider(&app_type, Some(id))?;
// Update database is_current (as default for new devices)
state.db.set_current_provider(app_type.as_str(), id)?;
// Sync to live
// Sync to live (write_gemini_live handles security flag internally for Gemini)
write_live_snapshot(&app_type, provider)?;
// Gemini needs additional security flag handling (PackyCode or Google OAuth)
if matches!(app_type, AppType::Gemini) {
let auth_type = detect_gemini_auth_type(provider);
match auth_type {
GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,
GeminiAuthType::Packycode => ensure_packycode_security_flag(provider)?,
GeminiAuthType::Generic => {}
}
}
// Sync MCP
McpService::sync_all_enabled(state)?;
Ok(())
}
/// Sync current provider from database to live configuration (re-export)
pub fn sync_current_from_db(state: &AppState) -> Result<(), AppError> {
sync_current_from_db(state)
/// Sync current provider to live configuration (re-export)
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
sync_current_to_live(state)
}
/// Import default configuration from live files (re-export)
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<(), AppError> {
///
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
import_default_config(state, app_type)
}
+1 -3
View File
@@ -168,9 +168,7 @@ pub(crate) fn validate_usage_script(script: &UsageScript) -> Result<(), AppError
if interval > 1440 {
return Err(AppError::localized(
"usage_script.interval_too_large",
format!(
"自动查询间隔不能超过 1440 分钟(24小时),当前值: {interval}"
),
format!("自动查询间隔不能超过 1440 分钟(24小时),当前值: {interval}"),
format!(
"Auto query interval cannot exceed 1440 minutes (24 hours), current: {interval}"
),
+159 -122
View File
@@ -34,9 +34,6 @@ pub struct Skill {
/// 分支名称
#[serde(rename = "repoBranch")]
pub repo_branch: Option<String>,
/// 技能所在的子目录路径 (可选, 如 "skills")
#[serde(rename = "skillsPath")]
pub skills_path: Option<String>,
}
/// 仓库配置
@@ -50,9 +47,6 @@ pub struct SkillRepo {
pub branch: String,
/// 是否启用
pub enabled: bool,
/// 技能所在的子目录路径 (可选, 如 "skills", "my-skills/subdir")
#[serde(rename = "skillsPath")]
pub skills_path: Option<String>,
}
/// 技能安装状态
@@ -84,21 +78,18 @@ impl Default for SkillStore {
name: "awesome-claude-skills".to_string(),
branch: "main".to_string(),
enabled: true,
skills_path: None, // 扫描根目录
},
SkillRepo {
owner: "anthropics".to_string(),
name: "skills".to_string(),
branch: "main".to_string(),
enabled: true,
skills_path: None, // 扫描根目录
},
SkillRepo {
owner: "cexll".to_string(),
name: "myclaude".to_string(),
branch: "master".to_string(),
enabled: true,
skills_path: Some("skills".to_string()), // 扫描 skills 子目录
},
],
}
@@ -194,76 +185,11 @@ impl SkillService {
})??;
let mut skills = Vec::new();
// 确定要扫描的目录路径
let scan_dir = if let Some(ref skills_path) = repo.skills_path {
// 如果指定了 skillsPath,则扫描该子目录
let subdir = temp_dir.join(skills_path.trim_matches('/'));
if !subdir.exists() {
log::warn!(
"仓库 {}/{} 中指定的技能路径 '{}' 不存在",
repo.owner,
repo.name,
skills_path
);
let _ = fs::remove_dir_all(&temp_dir);
return Ok(skills);
}
subdir
} else {
// 否则扫描仓库根目录
temp_dir.clone()
};
// 扫描仓库根目录(支持全仓库递归扫描)
let scan_dir = temp_dir.clone();
// 遍历目标目录
for entry in fs::read_dir(&scan_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
// 解析技能元数据
match self.parse_skill_metadata(&skill_md) {
Ok(meta) => {
// 安全地获取目录名
let Some(dir_name) = path.file_name() else {
log::warn!("Failed to get directory name from path: {path:?}");
continue;
};
let directory = dir_name.to_string_lossy().to_string();
// 构建 README URL(考虑 skillsPath
let readme_path = if let Some(ref skills_path) = repo.skills_path {
format!("{}/{}", skills_path.trim_matches('/'), directory)
} else {
directory.clone()
};
skills.push(Skill {
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
name: meta.name.unwrap_or_else(|| directory.clone()),
description: meta.description.unwrap_or_default(),
directory,
readme_url: Some(format!(
"https://github.com/{}/{}/tree/{}/{}",
repo.owner, repo.name, repo.branch, readme_path
)),
installed: false,
repo_owner: Some(repo.owner.clone()),
repo_name: Some(repo.name.clone()),
repo_branch: Some(repo.branch.clone()),
skills_path: repo.skills_path.clone(),
});
}
Err(e) => log::warn!("解析 {} 元数据失败: {}", skill_md.display(), e),
}
}
// 递归扫描目录查找所有技能
self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?;
// 清理临时目录
let _ = fs::remove_dir_all(&temp_dir);
@@ -271,6 +197,85 @@ impl SkillService {
Ok(skills)
}
/// 递归扫描目录查找 SKILL.md
///
/// 规则:
/// 1. 如果当前目录存在 SKILL.md,则识别为技能,停止扫描其子目录(子目录视为功能文件夹)
/// 2. 如果当前目录不存在 SKILL.md,则递归扫描所有子目录
fn scan_dir_recursive(
&self,
current_dir: &Path,
base_dir: &Path,
repo: &SkillRepo,
skills: &mut Vec<Skill>,
) -> Result<()> {
// 检查当前目录是否包含 SKILL.md
let skill_md = current_dir.join("SKILL.md");
if skill_md.exists() {
// 发现技能!获取相对路径作为目录名
let directory = if current_dir == base_dir {
// 根目录的 SKILL.md,使用仓库名
repo.name.clone()
} else {
// 子目录的 SKILL.md,使用相对路径
current_dir
.strip_prefix(base_dir)
.unwrap_or(current_dir)
.to_string_lossy()
.to_string()
};
if let Ok(skill) = self.build_skill_from_metadata(&skill_md, &directory, repo) {
skills.push(skill);
}
// 停止扫描此目录的子目录(同级目录都是功能文件夹)
return Ok(());
}
// 未发现 SKILL.md,继续递归扫描所有子目录
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
// 只处理目录
if path.is_dir() {
self.scan_dir_recursive(&path, base_dir, repo, skills)?;
}
}
Ok(())
}
/// 从 SKILL.md 构建技能对象
fn build_skill_from_metadata(
&self,
skill_md: &Path,
directory: &str,
repo: &SkillRepo,
) -> Result<Skill> {
let meta = self.parse_skill_metadata(skill_md)?;
// 构建 README URL
let readme_path = directory.to_string();
Ok(Skill {
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
name: meta.name.unwrap_or_else(|| directory.to_string()),
description: meta.description.unwrap_or_default(),
directory: directory.to_string(),
readme_url: Some(format!(
"https://github.com/{}/{}/tree/{}/{}",
repo.owner, repo.name, repo.branch, readme_path
)),
installed: false,
repo_owner: Some(repo.owner.clone()),
repo_name: Some(repo.name.clone()),
repo_branch: Some(repo.branch.clone()),
})
}
/// 解析技能元数据
fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> {
let content = fs::read_to_string(path)?;
@@ -302,25 +307,18 @@ impl SkillService {
return Ok(());
}
for entry in fs::read_dir(&self.install_dir)? {
let entry = entry?;
let path = entry.path();
// 收集所有本地技能
let mut local_skills = Vec::new();
self.scan_local_dir_recursive(&self.install_dir, &self.install_dir, &mut local_skills)?;
if !path.is_dir() {
continue;
}
// 处理找到的本地技能
for local_skill in local_skills {
let directory = &local_skill.directory;
// 安全地获取目录名
let Some(dir_name) = path.file_name() else {
log::warn!("Failed to get directory name from path: {path:?}");
continue;
};
let directory = dir_name.to_string_lossy().to_string();
// 更新已安装状态
// 更新已安装状态(匹配远程技能)
let mut found = false;
for skill in skills.iter_mut() {
if skill.directory.eq_ignore_ascii_case(&directory) {
if skill.directory.eq_ignore_ascii_case(directory) {
skill.installed = true;
found = true;
break;
@@ -329,23 +327,68 @@ impl SkillService {
// 添加本地独有的技能(仅当在仓库中未找到时)
if !found {
let skill_md = path.join("SKILL.md");
if skill_md.exists() {
if let Ok(meta) = self.parse_skill_metadata(&skill_md) {
skills.push(Skill {
key: format!("local:{directory}"),
name: meta.name.unwrap_or_else(|| directory.clone()),
description: meta.description.unwrap_or_default(),
directory: directory.clone(),
readme_url: None,
installed: true,
repo_owner: None,
repo_name: None,
repo_branch: None,
skills_path: None,
});
}
}
skills.push(local_skill);
}
}
Ok(())
}
/// 递归扫描本地目录查找 SKILL.md
fn scan_local_dir_recursive(
&self,
current_dir: &Path,
base_dir: &Path,
skills: &mut Vec<Skill>,
) -> Result<()> {
// 检查当前目录是否包含 SKILL.md
let skill_md = current_dir.join("SKILL.md");
if skill_md.exists() {
// 发现技能!获取相对路径作为目录名
let directory = if current_dir == base_dir {
// 如果是 install_dir 本身,使用最后一段路径名
current_dir
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
} else {
// 使用相对于 install_dir 的路径
current_dir
.strip_prefix(base_dir)
.unwrap_or(current_dir)
.to_string_lossy()
.to_string()
};
// 解析元数据并创建本地技能对象
if let Ok(meta) = self.parse_skill_metadata(&skill_md) {
skills.push(Skill {
key: format!("local:{directory}"),
name: meta.name.unwrap_or_else(|| directory.clone()),
description: meta.description.unwrap_or_default(),
directory: directory.clone(),
readme_url: None,
installed: true,
repo_owner: None,
repo_name: None,
repo_branch: None,
});
}
// 停止扫描此目录的子目录(同级目录都是功能文件夹)
return Ok(());
}
// 未发现 SKILL.md,继续递归扫描所有子目录
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
// 只处理目录
if path.is_dir() {
self.scan_local_dir_recursive(&path, base_dir, skills)?;
}
}
@@ -353,11 +396,13 @@ impl SkillService {
}
/// 去重技能列表
/// 使用完整的 key (owner/name:directory) 来区分不同仓库的同名技能
fn deduplicate_skills(skills: &mut Vec<Skill>) {
let mut seen = HashMap::new();
skills.retain(|skill| {
let key = skill.directory.to_lowercase();
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(key) {
// 使用完整 key 而非仅 directory,允许不同仓库的同名技能共存
let unique_key = skill.key.to_lowercase();
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) {
e.insert(true);
true
} else {
@@ -497,16 +542,8 @@ impl SkillService {
))
})??;
// 根据 skills_path 确定源目录路径
let source = if let Some(ref skills_path) = repo.skills_path {
// 如果指定了 skills_path,源路径为: temp_dir/skills_path/directory
temp_dir
.join(skills_path.trim_matches('/'))
.join(&directory)
} else {
// 否则源路径为: temp_dir/directory
temp_dir.join(&directory)
};
// 确定源目录路径(技能相对于仓库根目录的路径)
let source = temp_dir.join(&directory);
if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir);
+101 -119
View File
@@ -1,13 +1,12 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock, RwLock};
use std::sync::{OnceLock, RwLock};
use crate::database::Database;
use crate::app_config::AppType;
use crate::error::AppError;
/// 自定义端点配置
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomEndpoint {
@@ -17,24 +16,14 @@ pub struct CustomEndpoint {
pub last_used: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SecurityAuthSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SecuritySettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<SecurityAuthSettings>,
}
/// 应用设置结构,允许覆盖默认配置目录
/// 应用设置结构
///
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
/// 这确保了云同步场景下多设备可以独立运作。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppSettings {
// ===== 设备级 UI 设置 =====
#[serde(default = "default_show_in_tray")]
pub show_in_tray: bool,
#[serde(default = "default_minimize_to_tray_on_close")]
@@ -42,25 +31,30 @@ pub struct AppSettings {
/// 是否启用 Claude 插件联动
#[serde(default)]
pub enable_claude_plugin_integration: bool,
/// 是否开机自启
#[serde(default)]
pub launch_on_startup: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
// ===== 设备级目录覆盖 =====
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini_config_dir: Option<String>,
// ===== 当前供应商 ID(设备级)=====
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// 是否开机自启
#[serde(default)]
pub launch_on_startup: bool,
pub current_provider_claude: Option<String>,
/// 当前 Codex 供应商 ID(本地存储,优先于数据库 is_current)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub security: Option<SecuritySettings>,
/// Claude 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
/// Codex 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_codex: HashMap<String, CustomEndpoint>,
pub current_provider_codex: Option<String>,
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_gemini: Option<String>,
}
fn default_show_in_tray() -> bool {
@@ -77,14 +71,14 @@ impl Default for AppSettings {
show_in_tray: true,
minimize_to_tray_on_close: true,
enable_claude_plugin_integration: false,
launch_on_startup: false,
language: None,
claude_config_dir: None,
codex_config_dir: None,
gemini_config_dir: None,
language: None,
launch_on_startup: false,
security: None,
custom_endpoints_claude: HashMap::new(),
custom_endpoints_codex: HashMap::new(),
current_provider_claude: None,
current_provider_codex: None,
current_provider_gemini: None,
}
}
}
@@ -124,7 +118,7 @@ impl AppSettings {
.language
.as_ref()
.map(|s| s.trim())
.filter(|s| matches!(*s, "en" | "zh"))
.filter(|s| matches!(*s, "en" | "zh" | "ja"))
.map(|s| s.to_string());
}
@@ -169,60 +163,7 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
static SETTINGS_STORE: OnceLock<RwLock<AppSettings>> = OnceLock::new();
fn settings_store() -> &'static RwLock<AppSettings> {
SETTINGS_STORE.get_or_init(|| RwLock::new(load_initial_settings()))
}
static SETTINGS_DB: OnceLock<Arc<Database>> = OnceLock::new();
const APP_SETTINGS_KEY: &str = "app_settings";
pub fn bind_db(db: Arc<Database>) {
if SETTINGS_DB.set(db).is_err() {
return;
}
if let Some(store) = SETTINGS_STORE.get() {
let mut guard = store.write().expect("写入设置锁失败");
*guard = load_initial_settings();
}
}
fn load_initial_settings() -> AppSettings {
if let Some(db) = SETTINGS_DB.get() {
if let Some(from_db) = load_from_db(db.as_ref()) {
return from_db;
}
// 从文件迁移一次并写入数据库
let file_settings = AppSettings::load_from_file();
if let Err(e) = save_to_db(db.as_ref(), &file_settings) {
log::warn!("迁移设置到数据库失败,将继续使用内存副本: {e}");
}
return file_settings;
}
AppSettings::load_from_file()
}
fn load_from_db(db: &Database) -> Option<AppSettings> {
let raw = db.get_setting(APP_SETTINGS_KEY).ok()??;
match serde_json::from_str::<AppSettings>(&raw) {
Ok(mut settings) => {
settings.normalize_paths();
Some(settings)
}
Err(err) => {
log::warn!("解析数据库中 app_settings 失败: {err}");
None
}
}
}
fn save_to_db(db: &Database, settings: &AppSettings) -> Result<(), AppError> {
let mut normalized = settings.clone();
normalized.normalize_paths();
let json =
serde_json::to_string(&normalized).map_err(|e| AppError::JsonSerialize { source: e })?;
db.set_setting(APP_SETTINGS_KEY, &json)
SETTINGS_STORE.get_or_init(|| RwLock::new(AppSettings::load_from_file()))
}
fn resolve_override_path(raw: &str) -> PathBuf {
@@ -249,47 +190,22 @@ pub fn get_settings() -> AppSettings {
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
new_settings.normalize_paths();
if let Some(db) = SETTINGS_DB.get() {
save_to_db(db, &new_settings)?;
} else {
save_settings_file(&new_settings)?;
}
save_settings_file(&new_settings)?;
let mut guard = settings_store().write().expect("写入设置锁失败");
*guard = new_settings;
Ok(())
}
/// 从数据库重新加载设置到内存缓存
/// 用于导入配置等场景,确保内存缓存与数据库同步
/// 从文件重新加载设置到内存缓存
/// 用于导入配置等场景,确保内存缓存与文件同步
pub fn reload_settings() -> Result<(), AppError> {
let fresh_settings = load_initial_settings();
let fresh_settings = AppSettings::load_from_file();
let mut guard = settings_store().write().expect("写入设置锁失败");
*guard = fresh_settings;
Ok(())
}
pub fn ensure_security_auth_selected_type(selected_type: &str) -> Result<(), AppError> {
let mut settings = get_settings();
let current = settings
.security
.as_ref()
.and_then(|sec| sec.auth.as_ref())
.and_then(|auth| auth.selected_type.as_deref());
if current == Some(selected_type) {
return Ok(());
}
let mut security = settings.security.unwrap_or_default();
let mut auth = security.auth.unwrap_or_default();
auth.selected_type = Some(selected_type.to_string());
security.auth = Some(auth);
settings.security = Some(security);
update_settings(settings)
}
pub fn get_claude_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
@@ -313,3 +229,69 @@ pub fn get_gemini_override_dir() -> Option<PathBuf> {
.as_ref()
.map(|p| resolve_override_path(p))
}
// ===== 当前供应商管理函数 =====
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
///
/// 这是设备级别的设置,不随数据库同步。
/// 如果本地没有设置,调用者应该 fallback 到数据库的 `is_current` 字段。
pub fn get_current_provider(app_type: &AppType) -> Option<String> {
let settings = settings_store().read().ok()?;
match app_type {
AppType::Claude => settings.current_provider_claude.clone(),
AppType::Codex => settings.current_provider_codex.clone(),
AppType::Gemini => settings.current_provider_gemini.clone(),
}
}
/// 设置指定应用类型的当前供应商 ID(保存到本地 settings
///
/// 这是设备级别的设置,不随数据库同步。
/// 传入 `None` 会清除当前供应商设置。
pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppError> {
let mut settings = get_settings();
match app_type {
AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()),
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
}
update_settings(settings)
}
/// 获取有效的当前供应商 ID(验证存在性)
///
/// 逻辑:
/// 1. 从本地 settings 读取当前供应商 ID
/// 2. 验证该 ID 在数据库中存在
/// 3. 如果不存在则清理本地 settingsfallback 到数据库的 is_current
///
/// 这确保了返回的 ID 一定是有效的(在数据库中存在)。
/// 多设备云同步场景下,配置导入后本地 ID 可能失效,此函数会自动修复。
pub fn get_effective_current_provider(
db: &crate::database::Database,
app_type: &AppType,
) -> Result<Option<String>, AppError> {
// 1. 从本地 settings 读取
if let Some(local_id) = get_current_provider(app_type) {
// 2. 验证该 ID 在数据库中存在
let providers = db.get_all_providers(app_type.as_str())?;
if providers.contains_key(&local_id) {
// 存在,直接返回
return Ok(Some(local_id));
}
// 3. 不存在,清理本地 settings
log::warn!(
"本地 settings 中的供应商 {} ({}) 在数据库中不存在,将清理并 fallback 到数据库",
local_id,
app_type.as_str()
);
let _ = set_current_provider(app_type, None);
}
// Fallback 到数据库的 is_current
db.get_current_provider(app_type.as_str())
}
+15 -27
View File
@@ -3,13 +3,15 @@ use std::fs;
use std::path::PathBuf;
use cc_switch_lib::{
get_claude_settings_path, read_json_file, AppError, AppType, ConfigService,
MultiAppConfig, Provider, ProviderMeta,
get_claude_settings_path, read_json_file, AppError, AppType, ConfigService, MultiAppConfig,
Provider, ProviderMeta,
};
#[path = "support.rs"]
mod support;
use support::{create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
use support::{
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
};
#[test]
fn sync_claude_provider_writes_live_settings() {
@@ -843,21 +845,22 @@ fn sync_gemini_packycode_sets_security_selected_type() {
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing gemini live should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
gemini_settings.exists(),
"Gemini settings.json should exist at {}",
gemini_settings.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json");
let raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings.json");
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse gemini settings.json");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("gemini-api-key"),
"syncing PackyCode Gemini should enforce security.auth.selectedType"
"syncing PackyCode Gemini should enforce security.auth.selectedType in Gemini settings"
);
}
@@ -893,22 +896,7 @@ fn sync_gemini_google_official_sets_oauth_security() {
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing google official gemini should succeed");
let cc_settings = home.join(".cc-switch").join("settings.json");
assert!(
cc_settings.exists(),
"app settings should exist at {}",
cc_settings.display()
);
let cc_raw = std::fs::read_to_string(&cc_settings).expect("read .cc-switch settings");
let cc_value: serde_json::Value = serde_json::from_str(&cc_raw).expect("parse app settings");
assert_eq!(
cc_value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"syncing Google official should set oauth-personal in app settings"
);
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
@@ -923,7 +911,7 @@ fn sync_gemini_google_official_sets_oauth_security() {
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Gemini settings should also record oauth-personal"
"Gemini settings should record oauth-personal for Google Official"
);
}
+13 -12
View File
@@ -42,9 +42,13 @@ fn import_default_config_claude_persists_provider() {
.expect("import default config succeeds");
// 验证内存状态
let providers = state.db.get_all_providers(AppType::Claude.as_str())
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
let current_id = state.db.get_current_provider(AppType::Claude.as_str())
let current_id = state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
assert_eq!(current_id.as_deref(), Some("default"));
let default_provider = providers.get("default").expect("default provider");
@@ -87,7 +91,9 @@ fn import_default_config_without_live_file_returns_error() {
// 使用数据库架构,不再检查 config.json
// 失败的导入不应该向数据库写入任何供应商
let providers = state.db.get_all_providers(AppType::Claude.as_str())
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
assert!(
providers.is_empty(),
@@ -125,8 +131,7 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
"import should report inserted or normalized entries"
);
let servers = state.db.get_all_mcp_servers()
.expect("get all mcp servers");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers
.get("echo")
.expect("server imported into unified structure");
@@ -168,8 +173,7 @@ fn import_mcp_from_claude_invalid_json_preserves_state() {
}
// 使用数据库架构,检查 MCP 服务器未被写入
let servers = state.db.get_all_mcp_servers()
.expect("get all mcp servers");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
assert!(
servers.is_empty(),
"failed import should not persist any MCP servers to database"
@@ -224,11 +228,8 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
.expect("toggle_app should succeed");
let servers = state.db.get_all_mcp_servers()
.expect("get all mcp servers");
let entry = servers
.get("codex-server")
.expect("codex server exists");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers.get("codex-server").expect("codex server exists");
assert!(
entry.apps.codex,
"server should have Codex app enabled after toggle"
+34 -16
View File
@@ -7,8 +7,8 @@ use cc_switch_lib::{
#[path = "support.rs"]
mod support;
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
use std::collections::HashMap;
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
#[test]
fn switch_provider_updates_codex_live_and_state() {
@@ -104,16 +104,22 @@ command = "say"
"config.toml should contain synced MCP servers"
);
let current_id = app_state.db.get_current_provider(AppType::Codex.as_str())
let current_id = app_state
.db
.get_current_provider(AppType::Codex.as_str())
.expect("get current provider");
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let providers = app_state.db.get_all_providers(AppType::Codex.as_str())
let providers = app_state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("get all providers");
let new_provider = providers
.get("new-provider")
.expect("new provider exists");
let new_provider = providers.get("new-provider").expect("new provider exists");
let new_config_text = new_provider
.settings_config
.get("config")
@@ -165,7 +171,9 @@ fn switch_provider_missing_provider_returns_error() {
let err_str = err.to_string();
assert!(
err_str.contains("供应商不存在") || err_str.contains("Provider not found") || err_str.contains("missing-provider"),
err_str.contains("供应商不存在")
|| err_str.contains("Provider not found")
|| err_str.contains("missing-provider"),
"error message should mention missing provider, got: {err_str}"
);
}
@@ -241,11 +249,19 @@ fn switch_provider_updates_claude_live_and_state() {
"live settings.json should reflect new provider auth"
);
let current_id = app_state.db.get_current_provider(AppType::Claude.as_str())
let current_id = app_state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let providers = app_state.db.get_all_providers(AppType::Claude.as_str())
let providers = app_state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
let legacy_provider = providers
@@ -258,9 +274,7 @@ fn switch_provider_updates_claude_live_and_state() {
"previous provider should be backfilled with live config"
);
let new_provider = providers
.get("new-provider")
.expect("new provider exists");
let new_provider = providers.get("new-provider").expect("new provider exists");
assert_eq!(
new_provider
.settings_config
@@ -283,7 +297,9 @@ fn switch_provider_updates_claude_live_and_state() {
);
// 验证当前供应商已更新
let current_id = app_state.db.get_current_provider(AppType::Claude.as_str())
let current_id = app_state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
assert_eq!(
current_id.as_deref(),
@@ -328,7 +344,9 @@ fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {
other => panic!("expected config error, got {other:?}"),
}
let current_id = app_state.db.get_current_provider(AppType::Codex.as_str())
let current_id = app_state
.db
.get_current_provider(AppType::Codex.as_str())
.expect("get current provider");
// 切换失败后,由于数据库操作是先设置再验证,current 可能已被设为 "invalid"
// 但由于 live 配置写入失败,状态应该回滚
+60 -44
View File
@@ -1,13 +1,15 @@
use serde_json::json;
use cc_switch_lib::{
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType,
McpApps, McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService,
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType, McpApps,
McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService,
};
#[path = "support.rs"]
mod support;
use support::{create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
use support::{
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
};
fn sanitize_provider_name(name: &str) -> String {
name.chars()
@@ -69,7 +71,10 @@ command = "say"
}
// 使用新的统一 MCP 结构(v3.7.0+
let servers = initial_config.mcp.servers.get_or_insert_with(Default::default);
let servers = initial_config
.mcp
.servers
.get_or_insert_with(Default::default);
servers.insert(
"echo-server".into(),
McpServer {
@@ -111,16 +116,22 @@ command = "say"
"config.toml should contain synced MCP servers"
);
let current_id = state.db.get_current_provider(AppType::Codex.as_str())
let current_id = state
.db
.get_current_provider(AppType::Codex.as_str())
.expect("read current provider after switch");
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let providers = state.db.get_all_providers(AppType::Codex.as_str())
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("read providers after switch");
let new_provider = providers
.get("new-provider")
.expect("new provider exists");
let new_provider = providers.get("new-provider").expect("new provider exists");
let new_config_text = new_provider
.settings_config
.get("config")
@@ -185,15 +196,16 @@ fn switch_packycode_gemini_updates_security_selected_type() {
ProviderService::switch(&state, AppType::Gemini, "packy-gemini")
.expect("switching to PackyCode Gemini should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let settings_path = home.join(".gemini").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
"Gemini settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse settings.json after switch");
serde_json::from_str(&raw).expect("parse gemini settings.json after switch");
assert_eq!(
value
@@ -239,15 +251,16 @@ fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
ProviderService::switch(&state, AppType::Gemini, "packy-meta")
.expect("switching to partner meta provider should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let settings_path = home.join(".gemini").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
"Gemini settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse settings.json after switch");
serde_json::from_str(&raw).expect("parse gemini settings.json after switch");
assert_eq!(
value
@@ -292,23 +305,7 @@ fn switch_google_official_gemini_sets_oauth_security() {
ProviderService::switch(&state, AppType::Gemini, "google-official")
.expect("switching to Google official Gemini should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Google official Gemini should set oauth-personal selectedType in app settings"
);
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
@@ -324,7 +321,7 @@ fn switch_google_official_gemini_sets_oauth_security() {
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Gemini settings json should also reflect oauth-personal"
"Gemini settings json should reflect oauth-personal for Google Official"
);
}
@@ -399,11 +396,19 @@ fn provider_service_switch_claude_updates_live_and_state() {
"live settings.json should reflect new provider auth"
);
let providers = state.db.get_all_providers(AppType::Claude.as_str())
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
let current_id = state.db.get_current_provider(AppType::Claude.as_str())
let current_id = state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let legacy_provider = providers
.get("old-provider")
@@ -523,7 +528,9 @@ fn provider_service_delete_codex_removes_provider_and_files() {
ProviderService::delete(&app_state, AppType::Codex, "to-delete")
.expect("delete provider should succeed");
let providers = app_state.db.get_all_providers(AppType::Codex.as_str())
let providers = app_state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("get all providers");
assert!(
!providers.contains_key("to-delete"),
@@ -581,7 +588,9 @@ fn provider_service_delete_claude_removes_provider_files() {
ProviderService::delete(&app_state, AppType::Claude, "delete").expect("delete claude provider");
let providers = app_state.db.get_all_providers(AppType::Claude.as_str())
let providers = app_state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
assert!(
!providers.contains_key("delete"),
@@ -593,6 +602,10 @@ fn provider_service_delete_claude_removes_provider_files() {
#[test]
fn provider_service_delete_current_provider_returns_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
@@ -618,15 +631,18 @@ fn provider_service_delete_current_provider_returns_error() {
.expect_err("deleting current provider should fail");
match err {
AppError::Localized { zh, .. } => assert!(
zh.contains("不能删除当前正在使用的供应商") || zh.contains("无法删除当前正在使用的供应商"),
zh.contains("不能删除当前正在使用的供应商")
|| zh.contains("无法删除当前正在使用的供应商"),
"unexpected message: {zh}"
),
AppError::Config(msg) => assert!(
msg.contains("不能删除当前正在使用的供应商") || msg.contains("无法删除当前正在使用的供应商"),
msg.contains("不能删除当前正在使用的供应商")
|| msg.contains("无法删除当前正在使用的供应商"),
"unexpected message: {msg}"
),
AppError::Message(msg) => assert!(
msg.contains("不能删除当前正在使用的供应商") || msg.contains("无法删除当前正在使用的供应商"),
msg.contains("不能删除当前正在使用的供应商")
|| msg.contains("无法删除当前正在使用的供应商"),
"unexpected message: {msg}"
),
other => panic!("expected Config/Message error, got {other:?}"),
+7 -3
View File
@@ -40,8 +40,12 @@ export function ConfirmDialog({
}
}}
>
<DialogContent className="max-w-sm">
<DialogHeader className="space-y-3">
<DialogContent
className="max-w-sm"
zIndex="alert"
overlayClassName="bg-background/80"
>
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
<AlertTriangle className="h-5 w-5 text-destructive" />
{title}
@@ -50,7 +54,7 @@ export function ConfirmDialog({
{message}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
<Button variant="outline" onClick={onCancel}>
{cancelText || t("common.cancel")}
</Button>
+39 -147
View File
@@ -176,10 +176,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
() => {
const existingScript = provider.meta?.usage_script;
// 检测 NEW_API 模板(有 accessToken 或 userId
if (existingScript?.accessToken || existingScript?.userId) {
return TEMPLATE_KEYS.NEW_API;
}
return null;
// 检测 GENERAL 模板(有 apiKey 或 baseUrl
if (existingScript?.apiKey || existingScript?.baseUrl) {
return TEMPLATE_KEYS.GENERAL;
}
// 新配置或无凭证:默认使用 GENERAL(与默认代码模板一致)
return TEMPLATE_KEYS.GENERAL;
},
);
@@ -349,14 +355,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
footer={footer}
>
<div className="glass rounded-xl border border-white/10 px-6 py-4 flex items-center justify-between gap-4">
<div className="space-y-1">
<p className="text-sm font-medium leading-none text-foreground">
{t("usageScript.enableUsageQuery")}
</p>
<p className="text-xs text-muted-foreground">
{t("usageScript.autoQueryIntervalHint")}
</p>
</div>
<p className="text-base font-medium leading-none text-foreground">
{t("usageScript.enableUsageQuery")}
</p>
<Switch
checked={script.enabled}
onCheckedChange={(checked) =>
@@ -370,14 +371,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
<div className="space-y-6">
{/* 预设模板选择 */}
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label className="text-base font-medium">
{t("usageScript.presetTemplate")}
</Label>
<span className="text-xs text-muted-foreground">
{t("usageScript.variablesHint")}
</span>
</div>
<Label className="text-base font-medium">
{t("usageScript.presetTemplate")}
</Label>
<div className="flex gap-2 flex-wrap">
{Object.keys(PRESET_TEMPLATES).map((name) => {
const isSelected = selectedTemplate === name;
@@ -447,7 +443,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
<div className="space-y-2">
<Label htmlFor="usage-base-url">Base URL</Label>
<Label htmlFor="usage-base-url">
{t("usageScript.baseUrl")}
</Label>
<Input
id="usage-base-url"
type="text"
@@ -466,7 +464,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
{selectedTemplate === TEMPLATE_KEYS.NEW_API && (
<>
<div className="space-y-2">
<Label htmlFor="usage-newapi-base-url">Base URL</Label>
<Label htmlFor="usage-newapi-base-url">
{t("usageScript.baseUrl")}
</Label>
<Input
id="usage-newapi-base-url"
type="text"
@@ -545,141 +545,36 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
</div>
)}
</div>
{/* 脚本配置 */}
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<div className="flex items-center justify-between">
<h4 className="text-base font-medium text-foreground">
{t("usageScript.scriptConfig")}
</h4>
<p className="text-xs text-muted-foreground">
{t("usageScript.variablesHint")}
</p>
</div>
<div className="grid gap-4">
{/* 通用配置(始终显示) */}
<div className="grid gap-4 md:grid-cols-2 pt-4 border-t border-white/10">
{/* 超时时间 */}
<div className="space-y-2">
<Label htmlFor="usage-request-url">
{t("usageScript.requestUrl")}
<Label htmlFor="usage-timeout">
{t("usageScript.timeoutSeconds")}
</Label>
<Input
id="usage-request-url"
type="text"
value={script.request?.url || ""}
onChange={(e) => {
id="usage-timeout"
type="number"
min={0}
value={script.timeout ?? 10}
onChange={(e) =>
setScript({
...script,
request: { ...script.request, url: e.target.value },
});
}}
placeholder={t("usageScript.requestUrlPlaceholder")}
timeout: validateTimeout(e.target.value),
})
}
onBlur={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
className="border-white/10"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="usage-method">
{t("usageScript.method")}
</Label>
<Input
id="usage-method"
type="text"
value={script.request?.method || "GET"}
onChange={(e) => {
setScript({
...script,
request: {
...script.request,
method: e.target.value.toUpperCase(),
},
});
}}
placeholder="GET / POST"
className="border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-timeout">
{t("usageScript.timeoutSeconds")}
</Label>
<Input
id="usage-timeout"
type="number"
min={0}
value={script.timeout ?? 10}
onChange={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
onBlur={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
className="border-white/10"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="usage-headers">
{t("usageScript.headers")}
</Label>
<JsonEditor
id="usage-headers"
value={
script.request?.headers
? JSON.stringify(script.request.headers, null, 2)
: "{}"
}
onChange={(value) => {
try {
const parsed = JSON.parse(value || "{}");
setScript({
...script,
request: { ...script.request, headers: parsed },
});
} catch (error) {
console.error("Invalid headers JSON", error);
}
}}
height={180}
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-body">{t("usageScript.body")}</Label>
<JsonEditor
id="usage-body"
value={
script.request?.body
? JSON.stringify(script.request.body, null, 2)
: "{}"
}
onChange={(value) => {
try {
const parsed =
value?.trim() === "" ? undefined : JSON.parse(value);
setScript({
...script,
request: { ...script.request, body: parsed },
});
} catch (error) {
toast.error(
t("usageScript.invalidJson") || "Body 必须是合法 JSON",
);
}
}}
height={220}
/>
</div>
{/* 自动查询间隔 */}
<div className="space-y-2">
<Label htmlFor="usage-interval">
{t("usageScript.autoIntervalMinutes")}
@@ -708,9 +603,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
}
className="border-white/10"
/>
<p className="text-xs text-muted-foreground">
{t("usageScript.autoQueryIntervalHint")}
</p>
</div>
</div>
</div>
+5 -16
View File
@@ -30,22 +30,11 @@ export function SkillConfirmation({
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-muted-foreground">
{t("deeplink.skill.branch")}
</label>
<div className="mt-1 text-sm">{request.branch || "main"}</div>
</div>
{request.skillsPath && (
<div>
<label className="block text-sm font-medium text-muted-foreground">
{t("deeplink.skill.skillsPath")}
</label>
<div className="mt-1 text-sm">{request.skillsPath}</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-muted-foreground">
{t("deeplink.skill.branch")}
</label>
<div className="mt-1 text-sm">{request.branch || "main"}</div>
</div>
<div className="text-blue-600 dark:text-blue-400 text-sm bg-blue-50 dark:bg-blue-950/30 p-3 rounded border border-blue-200 dark:border-blue-800">
@@ -140,15 +140,24 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
setEntries((prev) => {
const map = new Map<string, EndpointEntry>();
// 先添加现有端点
// 先添加现有端点(来自预设,isCustom 可能为 false
prev.forEach((entry) => {
map.set(entry.url, entry);
});
// 添加从后端加载的自定义端点
// 合并从后端加载的自定义端点
// 关键:如果 URL 已存在(与预设重合),需要将 isCustom 更新为 true
// 因为它存在于数据库中,需要在 handleSave 时被正确识别
candidates.forEach((candidate) => {
const sanitized = normalizeEndpointUrl(candidate.url);
if (sanitized && !map.has(sanitized)) {
if (!sanitized) return;
const existing = map.get(sanitized);
if (existing) {
// URL 已存在,更新 isCustom 为 true(因为它在数据库中)
existing.isCustom = true;
} else {
// URL 不存在,添加新条目
map.set(sanitized, {
id: randomId(),
url: sanitized,
+94 -18
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, useCallback } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
@@ -326,11 +327,11 @@ export function ProviderForm({
configError: geminiConfigError,
handleGeminiApiKeyChange: originalHandleGeminiApiKeyChange,
handleGeminiBaseUrlChange: originalHandleGeminiBaseUrlChange,
handleGeminiModelChange: originalHandleGeminiModelChange,
handleGeminiEnvChange,
handleGeminiConfigChange,
resetGeminiConfig,
envStringToObj,
envObjToString,
} = useGeminiConfigState({
initialData: appId === "gemini" ? initialData : undefined,
});
@@ -368,6 +369,22 @@ export function ProviderForm({
[originalHandleGeminiBaseUrlChange, form],
);
const handleGeminiModelChange = useCallback(
(model: string) => {
originalHandleGeminiModelChange(model);
// 同步更新 settingsConfig
try {
const config = JSON.parse(form.watch("settingsConfig") || "{}");
if (!config.env) config.env = {};
config.env.GEMINI_MODEL = model.trim();
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[originalHandleGeminiModelChange, form],
);
// 使用 Gemini 通用配置 hook (仅 Gemini 模式)
const {
useCommonConfig: useGeminiCommonConfigFlag,
@@ -388,17 +405,82 @@ export function ProviderForm({
if (appId === "claude" && templateValueEntries.length > 0) {
const validation = validateTemplateValues();
if (!validation.isValid && validation.missingField) {
form.setError("settingsConfig", {
type: "manual",
message: t("providerForm.fillParameter", {
toast.error(
t("providerForm.fillParameter", {
label: validation.missingField.label,
defaultValue: `请填写 ${validation.missingField.label}`,
}),
});
);
return;
}
}
// 供应商名称必填校验
if (!values.name.trim()) {
toast.error(
t("providerForm.fillSupplierName", {
defaultValue: "请填写供应商名称",
}),
);
return;
}
// 非官方供应商必填校验:端点和 API Key
if (category !== "official") {
if (appId === "claude") {
if (!baseUrl.trim()) {
toast.error(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
return;
}
if (!apiKey.trim()) {
toast.error(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
}),
);
return;
}
} else if (appId === "codex") {
if (!codexBaseUrl.trim()) {
toast.error(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
return;
}
if (!codexApiKey.trim()) {
toast.error(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
}),
);
return;
}
} else if (appId === "gemini") {
if (!geminiBaseUrl.trim()) {
toast.error(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
return;
}
if (!geminiApiKey.trim()) {
toast.error(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
}),
);
return;
}
}
}
let settingsConfig: string;
// Codex: 组合 auth 和 config
@@ -616,6 +698,8 @@ export function ProviderForm({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify({ auth, config }, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
@@ -633,6 +717,8 @@ export function ProviderForm({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(preset.settingsConfig, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
@@ -647,6 +733,8 @@ export function ProviderForm({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
};
@@ -758,19 +846,7 @@ export function ProviderForm({
onCustomEndpointsChange={setDraftCustomEndpoints}
shouldShowModelField={true}
model={geminiModel}
onModelChange={(model) => {
// 同时更新 form.settingsConfig 和 geminiEnv
const config = JSON.parse(form.watch("settingsConfig") || "{}");
if (!config.env) config.env = {};
config.env.GEMINI_MODEL = model;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
// 同步更新 geminiEnv,确保提交时不丢失
const envObj = envStringToObj(geminiEnv);
envObj.GEMINI_MODEL = model.trim();
const newEnv = envObjToString(envObj);
handleGeminiEnvChange(newEnv);
}}
onModelChange={handleGeminiModelChange}
speedTestEndpoints={speedTestEndpoints}
/>
)}
@@ -22,18 +22,32 @@ export function useGeminiConfigState({
const [configError, setConfigError] = useState("");
// 将 JSON env 对象转换为 .env 格式字符串
// 保留所有环境变量,已知 key 优先显示
const envObjToString = useCallback(
(envObj: Record<string, unknown>): string => {
const priorityKeys = [
"GOOGLE_GEMINI_BASE_URL",
"GEMINI_API_KEY",
"GEMINI_MODEL",
];
const lines: string[] = [];
if (typeof envObj.GOOGLE_GEMINI_BASE_URL === "string") {
lines.push(`GOOGLE_GEMINI_BASE_URL=${envObj.GOOGLE_GEMINI_BASE_URL}`);
const addedKeys = new Set<string>();
// 先添加已知 key(按顺序)
for (const key of priorityKeys) {
if (typeof envObj[key] === "string" && envObj[key]) {
lines.push(`${key}=${envObj[key]}`);
addedKeys.add(key);
}
}
if (typeof envObj.GEMINI_API_KEY === "string") {
lines.push(`GEMINI_API_KEY=${envObj.GEMINI_API_KEY}`);
}
if (typeof envObj.GEMINI_MODEL === "string") {
lines.push(`GEMINI_MODEL=${envObj.GEMINI_MODEL}`);
// 再添加其他自定义 key(保留用户添加的环境变量)
for (const [key, value] of Object.entries(envObj)) {
if (!addedKeys.has(key) && typeof value === "string") {
lines.push(`${key}=${value}`);
}
}
return lines.join("\n");
},
[],
@@ -164,6 +178,20 @@ export function useGeminiConfigState({
[geminiEnv, envStringToObj, envObjToString, setGeminiEnv],
);
// 处理 Gemini Model 变化
const handleGeminiModelChange = useCallback(
(model: string) => {
const trimmed = model.trim();
setGeminiModel(trimmed);
const envObj = envStringToObj(geminiEnv);
envObj.GEMINI_MODEL = trimmed;
const newEnv = envObjToString(envObj);
setGeminiEnv(newEnv);
},
[geminiEnv, envStringToObj, envObjToString, setGeminiEnv],
);
// 处理 env 变化
const handleGeminiEnvChange = useCallback(
(value: string) => {
@@ -223,6 +251,7 @@ export function useGeminiConfigState({
setGeminiConfig,
handleGeminiApiKeyChange,
handleGeminiBaseUrlChange,
handleGeminiModelChange,
handleGeminiEnvChange,
handleGeminiConfigChange,
resetGeminiConfig,
+7 -2
View File
@@ -2,9 +2,11 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type LanguageOption = "zh" | "en" | "ja";
interface LanguageSettingsProps {
value: "zh" | "en";
onChange: (value: "zh" | "en") => void;
value: LanguageOption;
onChange: (value: LanguageOption) => void;
}
export function LanguageSettings({ value, onChange }: LanguageSettingsProps) {
@@ -25,6 +27,9 @@ export function LanguageSettings({ value, onChange }: LanguageSettingsProps) {
<LanguageButton active={value === "en"} onClick={() => onChange("en")}>
{t("settings.languageOptionEnglish")}
</LanguageButton>
<LanguageButton active={value === "ja"} onClick={() => onChange("ja")}>
{t("settings.languageOptionJapanese")}
</LanguageButton>
</div>
</section>
);
-16
View File
@@ -34,7 +34,6 @@ export function RepoManager({
const { t } = useTranslation();
const [repoUrl, setRepoUrl] = useState("");
const [branch, setBranch] = useState("");
const [skillsPath, setSkillsPath] = useState("");
const [error, setError] = useState("");
const getSkillCount = (repo: SkillRepo) =>
@@ -80,12 +79,10 @@ export function RepoManager({
name: parsed.name,
branch: branch || "main",
enabled: true,
skillsPath: skillsPath.trim() || undefined, // 仅在有值时传递
});
setRepoUrl("");
setBranch("");
setSkillsPath("");
} catch (e) {
setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
}
@@ -130,13 +127,6 @@ export function RepoManager({
onChange={(e) => setBranch(e.target.value)}
className="flex-1"
/>
<Input
id="skills-path"
placeholder={t("skills.repo.pathPlaceholder")}
value={skillsPath}
onChange={(e) => setSkillsPath(e.target.value)}
className="flex-1"
/>
<Button
onClick={handleAdd}
className="w-full sm:w-auto sm:px-4"
@@ -171,12 +161,6 @@ export function RepoManager({
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t("skills.repo.branch")}: {repo.branch || "main"}
{repo.skillsPath && (
<>
<span className="mx-2"></span>
{t("skills.repo.path")}: {repo.skillsPath}
</>
)}
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
{t("skills.repo.skillCount", {
count: getSkillCount(repo),
+11 -34
View File
@@ -26,7 +26,6 @@ export function RepoManagerPanel({
const { t } = useTranslation();
const [repoUrl, setRepoUrl] = useState("");
const [branch, setBranch] = useState("");
const [skillsPath, setSkillsPath] = useState("");
const [error, setError] = useState("");
const getSkillCount = (repo: SkillRepo) =>
@@ -67,12 +66,10 @@ export function RepoManagerPanel({
name: parsed.name,
branch: branch || "main",
enabled: true,
skillsPath: skillsPath.trim() || undefined,
});
setRepoUrl("");
setBranch("");
setSkillsPath("");
} catch (e) {
setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
}
@@ -110,31 +107,17 @@ export function RepoManagerPanel({
className="mt-2"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label htmlFor="branch" className="text-foreground">
{t("skills.repo.branch")}
</Label>
<Input
id="branch"
placeholder={t("skills.repo.branchPlaceholder")}
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="skills-path" className="text-foreground">
{t("skills.repo.path")}
</Label>
<Input
id="skills-path"
placeholder={t("skills.repo.pathPlaceholder")}
value={skillsPath}
onChange={(e) => setSkillsPath(e.target.value)}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="branch" className="text-foreground">
{t("skills.repo.branch")}
</Label>
<Input
id="branch"
placeholder={t("skills.repo.branchPlaceholder")}
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="mt-2"
/>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
@@ -174,12 +157,6 @@ export function RepoManagerPanel({
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t("skills.repo.branch")}: {repo.branch || "main"}
{repo.skillsPath && (
<>
<span className="mx-2"></span>
{t("skills.repo.path")}: {repo.skillsPath}
</>
)}
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
{t("skills.repo.skillCount", {
count: getSkillCount(repo),
+30 -3
View File
@@ -76,6 +76,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "deepseek",
iconColor: "#1E88E5",
},
{
name: "Zhipu GLM",
@@ -94,6 +96,8 @@ export const providerPresets: ProviderPreset[] = [
category: "cn_official",
isPartner: true, // 合作伙伴
partnerPromotionKey: "zhipu", // 促销信息 i18n key
icon: "zhipu",
iconColor: "#0F62FE",
},
{
name: "Z.ai GLM",
@@ -112,6 +116,8 @@ export const providerPresets: ProviderPreset[] = [
category: "cn_official",
isPartner: true, // 合作伙伴
partnerPromotionKey: "zhipu", // 促销信息 i18n key
icon: "zhipu",
iconColor: "#0F62FE",
},
{
name: "Qwen Coder",
@@ -128,6 +134,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "qwen",
iconColor: "#FF6A00",
},
{
name: "Kimi k2",
@@ -143,6 +151,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "kimi",
iconColor: "#6366F1",
},
{
name: "Kimi For Coding",
@@ -158,6 +168,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "kimi",
iconColor: "#6366F1",
},
{
name: "ModelScope",
@@ -173,6 +185,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "aggregator",
icon: "alibaba",
iconColor: "#FF6A00",
},
{
name: "KAT-Coder",
@@ -220,7 +234,7 @@ export const providerPresets: ProviderPreset[] = [
{
name: "MiniMax",
websiteUrl: "https://platform.minimaxi.com",
apiKeyUrl: "https://platform.minimaxi.com/user-center/basic-information",
apiKeyUrl: "https://platform.minimax.io/subscribe/coding-plan",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.minimaxi.com/anthropic",
@@ -234,6 +248,14 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax",
theme: {
backgroundColor: "#f64551",
textColor: "#FFFFFF",
},
icon: "minimax",
iconColor: "#FF6B6B",
},
{
name: "DouBaoSeed",
@@ -251,6 +273,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "doubao",
iconColor: "#3370FF",
},
{
name: "BaiLing",
@@ -266,6 +290,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "alibaba",
iconColor: "#FF6A00",
},
{
name: "AiHubMix",
@@ -290,11 +316,11 @@ export const providerPresets: ProviderPreset[] = [
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://www.dmxapi.cn",
ANTHROPIC_API_KEY: "",
ANTHROPIC_AUTH_TOKEN: "",
},
},
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
endpointCandidates: ["https://aihubmix.com", "https://api.aihubmix.com"],
endpointCandidates: ["https://www.dmxapi.cn", "https://api.dmxapi.cn"],
category: "aggregator",
},
{
@@ -315,5 +341,6 @@ export const providerPresets: ProviderPreset[] = [
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "packycode", // 促销信息 i18n key
icon: "packycode",
},
];
+8
View File
@@ -20,6 +20,9 @@ export interface CodexProviderPreset {
endpointCandidates?: string[];
// 新增:视觉主题配置
theme?: PresetTheme;
// 图标配置
icon?: string; // 图标名称
iconColor?: string; // 图标颜色
}
/**
@@ -71,6 +74,8 @@ export const codexProviderPresets: CodexProviderPreset[] = [
backgroundColor: "#1F2937", // gray-800
textColor: "#FFFFFF",
},
icon: "openai",
iconColor: "#00A67E",
},
{
name: "Azure OpenAI",
@@ -97,6 +102,8 @@ requires_openai_auth = true`,
backgroundColor: "#0078D4",
textColor: "#FFFFFF",
},
icon: "azure",
iconColor: "#0078D4",
},
{
name: "AiHubMix",
@@ -142,5 +149,6 @@ requires_openai_auth = true`,
],
isPartner: true, // 合作伙伴
partnerPromotionKey: "packycode", // 促销信息 i18n key
icon: "packycode",
},
];
+6
View File
@@ -25,6 +25,9 @@ export interface GeminiProviderPreset {
partnerPromotionKey?: string;
endpointCandidates?: string[];
theme?: GeminiPresetTheme;
// 图标配置
icon?: string; // 图标名称
iconColor?: string; // 图标颜色
}
export const geminiProviderPresets: GeminiProviderPreset[] = [
@@ -43,6 +46,8 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
backgroundColor: "#4285F4",
textColor: "#FFFFFF",
},
icon: "gemini",
iconColor: "#4285F4",
},
{
name: "PackyCode",
@@ -64,6 +69,7 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
"https://api-slb.packyapi.com",
"https://www.packyapi.com",
],
icon: "packycode",
},
{
name: "自定义",
+1 -1
View File
@@ -12,7 +12,7 @@ import {
} from "./useDirectorySettings";
import { useSettingsMetadata } from "./useSettingsMetadata";
type Language = "zh" | "en";
type Language = "zh" | "en" | "ja";
interface SaveResult {
requiresRestart: boolean;
+5 -4
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useSettingsQuery } from "@/lib/query";
import type { Settings } from "@/types";
type Language = "zh" | "en";
type Language = "zh" | "en" | "ja";
export type SettingsFormState = Omit<Settings, "language"> & {
language: Language;
@@ -11,7 +11,8 @@ export type SettingsFormState = Omit<Settings, "language"> & {
const normalizeLanguage = (lang?: string | null): Language => {
if (!lang) return "zh";
return lang === "en" ? "en" : "zh";
const normalized = lang.toLowerCase();
return normalized === "en" || normalized === "ja" ? normalized : "zh";
};
const sanitizeDir = (value?: string | null): string | undefined => {
@@ -51,8 +52,8 @@ export function useSettingsForm(): UseSettingsFormResult {
const readPersistedLanguage = useCallback((): Language => {
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("language");
if (stored === "en" || stored === "zh") {
return stored;
if (stored === "en" || stored === "zh" || stored === "ja") {
return stored as Language;
}
}
return normalizeLanguage(i18n.language);
+13 -3
View File
@@ -2,15 +2,18 @@ import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "./locales/en.json";
import ja from "./locales/ja.json";
import zh from "./locales/zh.json";
const DEFAULT_LANGUAGE: "zh" | "en" = "zh";
type Language = "zh" | "en" | "ja";
const getInitialLanguage = (): "zh" | "en" => {
const DEFAULT_LANGUAGE: Language = "zh";
const getInitialLanguage = (): Language => {
if (typeof window !== "undefined") {
try {
const stored = window.localStorage.getItem("language");
if (stored === "zh" || stored === "en") {
if (stored === "zh" || stored === "en" || stored === "ja") {
return stored;
}
} catch (error) {
@@ -28,6 +31,10 @@ const getInitialLanguage = (): "zh" | "en" => {
return "zh";
}
if (navigatorLang?.startsWith("ja")) {
return "ja";
}
if (navigatorLang?.startsWith("en")) {
return "en";
}
@@ -39,6 +46,9 @@ const resources = {
en: {
translation: en,
},
ja: {
translation: ja,
},
zh: {
translation: zh,
},
+7 -2
View File
@@ -165,6 +165,7 @@
"autoReload": "Data will refresh automatically in 2 seconds...",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"languageOptionJapanese": "日本語",
"windowBehavior": "Window Behavior",
"windowBehaviorHint": "Configure window minimize and Claude plugin integration policies.",
"launchOnStartup": "Launch on Startup",
@@ -261,7 +262,8 @@
"getApiKey": "Get API Key",
"partnerPromotion": {
"zhipu": "Zhipu GLM is an official partner of CC Switch. Use this link to top up and get a 10% discount",
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off"
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off",
"minimax": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)"
},
"parameterConfig": "Parameter Config - {{name}} *",
"mainModel": "Main Model (optional)",
@@ -275,6 +277,8 @@
"fillConfigContent": "Please fill in configuration content",
"fillParameter": "Please fill in {{label}}",
"fillTemplateValue": "Please fill in {{label}}",
"endpointRequired": "API endpoint is required for non-official providers",
"apiKeyRequired": "API Key is required for non-official providers",
"configJsonError": "Config JSON format error, please check syntax",
"authJsonRequired": "auth.json must be a JSON object",
"authJsonError": "auth.json format error, please check JSON syntax",
@@ -373,6 +377,7 @@
"templateGeneral": "General",
"templateNewAPI": "NewAPI",
"credentialsConfig": "Credentials",
"baseUrl": "Base URL",
"accessToken": "Access Token",
"accessTokenPlaceholder": "Generate in 'Security Settings'",
"userId": "User ID",
@@ -386,7 +391,7 @@
"timeoutHint": "Range: 2-30 seconds",
"timeoutMustBeInteger": "Timeout must be an integer, decimal part ignored",
"timeoutCannotBeNegative": "Timeout cannot be negative",
"autoIntervalMinutes": "Auto query interval (minutes)",
"autoIntervalMinutes": "Auto query interval (minutes, 0 to disable)",
"autoQueryInterval": "Auto Query Interval (minutes)",
"autoQueryIntervalHint": "0 to disable; recommend 5-60 minutes",
"intervalMustBeInteger": "Interval must be an integer, decimal part ignored",
+837
View File
@@ -0,0 +1,837 @@
{
"app": {
"title": "CC Switch",
"description": "Claude Code・Codex・Gemini CLI のためのオールインワンアシスタント"
},
"common": {
"add": "追加",
"edit": "編集",
"delete": "削除",
"save": "保存",
"saving": "保存中...",
"cancel": "キャンセル",
"confirm": "確認",
"close": "閉じる",
"done": "完了",
"settings": "設定",
"about": "バージョン情報",
"version": "バージョン",
"loading": "読み込み中...",
"success": "成功",
"error": "エラー",
"unknown": "不明",
"enterValidValue": "有効な値を入力してください",
"clear": "クリア",
"toggleTheme": "テーマを切り替え",
"format": "フォーマット",
"formatSuccess": "整形しました",
"formatError": "整形に失敗しました: {{error}}",
"copy": "コピー",
"view": "表示",
"back": "戻る"
},
"apiKeyInput": {
"placeholder": "API Key を入力",
"show": "API Key を表示",
"hide": "API Key を隠す"
},
"jsonEditor": {
"mustBeObject": "設定はオブジェクト形式の JSON で入力してください(配列や他の型は不可)",
"invalidJson": "JSON 形式が正しくありません"
},
"claudeConfig": {
"configLabel": "Claude Code settings.json (JSON) *",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "共通設定スニペットを編集",
"commonConfigHint": "「共通設定を書き込む」がオンのとき settings.json にマージされます",
"fullSettingsHint": "Claude Code の settings.json 全文"
},
"header": {
"viewOnGithub": "GitHub で見る",
"toggleDarkMode": "ダークモードに切り替え",
"toggleLightMode": "ライトモードに切り替え",
"addProvider": "プロバイダーを追加",
"switchToChinese": "中国語に切り替え",
"switchToEnglish": "英語に切り替え",
"enterEditMode": "編集モードに入る",
"exitEditMode": "編集モードを終了"
},
"provider": {
"noProviders": "まだプロバイダーがありません",
"noProvidersDescription": "右上の「プロバイダーを追加」を押して最初の API プロバイダーを登録してください",
"currentlyUsing": "現在使用中",
"enable": "有効化",
"inUse": "使用中",
"editProvider": "プロバイダーを編集",
"editProviderHint": "保存すると現在のプロバイダーにすぐ反映されます。",
"deleteProvider": "プロバイダーを削除",
"addNewProvider": "新しいプロバイダーを追加",
"addClaudeProvider": "Claude Code プロバイダーを追加",
"addCodexProvider": "Codex プロバイダーを追加",
"addGeminiProvider": "Gemini プロバイダーを追加",
"addProviderHint": "一覧にすばやく切り替えられるよう、ここに情報を入力してください。",
"editClaudeProvider": "Claude Code プロバイダーを編集",
"editCodexProvider": "Codex プロバイダーを編集",
"configError": "設定エラー",
"notConfigured": "公式サイト用に未設定",
"applyToClaudePlugin": "Claude プラグインに適用",
"removeFromClaudePlugin": "Claude プラグインから解除",
"dragToReorder": "ドラッグで並べ替え",
"dragHandle": "ドラッグで並べ替え",
"duplicate": "複製",
"sortUpdateFailed": "並び順の更新に失敗しました",
"configureUsage": "利用状況を設定",
"name": "プロバイダー名",
"namePlaceholder": "例: Claude Official",
"websiteUrl": "Web サイト URL",
"notes": "メモ",
"notesPlaceholder": "例: 会社用アカウント",
"configJson": "Config JSON",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfigButton": "共通設定を編集",
"configJsonHint": "Claude Code の設定をすべて入力してください",
"editCommonConfigTitle": "共通設定スニペットを編集",
"editCommonConfigHint": "共通設定スニペットは、この機能をオンにしたすべてのプロバイダーへマージされます",
"addProvider": "プロバイダーを追加",
"sortUpdated": "並び順を更新しました",
"usageSaved": "利用状況の設定を保存しました",
"usageSaveFailed": "利用状況設定の保存に失敗しました",
"geminiConfig": "Gemini 設定",
"geminiConfigHint": ".env 形式で Gemini を設定してください",
"form": {
"gemini": {
"model": "モデル",
"oauthTitle": "OAuth 認証モード",
"oauthHint": "Google 公式は OAuth 個人認証を使用するため API Key は不要です。初回利用時にブラウザが開きます。",
"apiKeyPlaceholder": "Gemini API Key を入力"
}
}
},
"notifications": {
"providerAdded": "プロバイダーを追加しました",
"providerSaved": "プロバイダー設定を保存しました",
"providerDeleted": "プロバイダーを削除しました",
"switchSuccess": "切り替え成功! {{appName}} ターミナルを再起動すると反映されます",
"switchFailedTitle": "切り替えに失敗しました",
"switchFailed": "切り替えに失敗しました: {{error}}",
"autoImported": "既存設定からデフォルトプロバイダーを自動作成しました",
"addFailed": "プロバイダーの追加に失敗しました: {{error}}",
"saveFailed": "保存に失敗しました: {{error}}",
"saveFailedGeneric": "保存に失敗しました。もう一度お試しください",
"appliedToClaudePlugin": "Claude プラグインに適用しました",
"removedFromClaudePlugin": "Claude プラグインから削除しました",
"syncClaudePluginFailed": "Claude プラグインとの同期に失敗しました",
"updateSuccess": "プロバイダーを更新しました",
"updateFailed": "プロバイダーの更新に失敗しました: {{error}}",
"deleteSuccess": "プロバイダーを削除しました",
"deleteFailed": "プロバイダーの削除に失敗しました: {{error}}",
"settingsSaved": "設定を保存しました",
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}"
},
"confirm": {
"deleteProvider": "プロバイダーを削除",
"deleteProviderMessage": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。"
},
"settings": {
"title": "設定",
"general": "一般",
"tabGeneral": "一般",
"tabAdvanced": "詳細",
"language": "言語",
"languageHint": "切り替えるとすぐにプレビューされ、保存後に永続化されます。",
"theme": "テーマ",
"themeHint": "アプリのテーマを選択します。すぐに反映されます。",
"themeLight": "ライト",
"themeDark": "ダーク",
"themeSystem": "システム",
"importExport": "SQL インポート/エクスポート",
"importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします。",
"exportConfig": "SQL バックアップをエクスポート",
"selectConfigFile": "SQL ファイルを選択",
"noFileSelected": "ファイルが選択されていません。",
"import": "インポート",
"importing": "インポート中...",
"importSuccess": "インポート成功!",
"importFailed": "インポート失敗",
"syncLiveFailed": "インポートしましたが、現在のプロバイダーへの同期に失敗しました。手動で再選択してください。",
"importPartialSuccess": "設定はインポートされましたが、現在のプロバイダーへの同期に失敗しました。",
"importPartialHint": "ライブ設定を更新するため、もう一度プロバイダーを選択してください。",
"configExported": "設定をエクスポートしました:",
"exportFailed": "エクスポートに失敗しました",
"selectFileFailed": "有効な SQL バックアップファイルを選択してください",
"configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります",
"backupId": "バックアップ ID",
"autoReload": "2 秒後に自動で再読み込みします...",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"languageOptionJapanese": "日本語",
"windowBehavior": "ウィンドウ動作",
"windowBehaviorHint": "最小化動作や Claude プラグイン連携を設定します。",
"launchOnStartup": "起動時に自動実行",
"launchOnStartupDescription": "システム起動時に CC Switch を自動起動します",
"autoLaunchFailed": "自動起動の設定に失敗しました",
"minimizeToTray": "閉じるときトレイへ最小化",
"minimizeToTrayDescription": "チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。",
"enableClaudePluginIntegration": "Claude Code 拡張に適用",
"enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します",
"configDirectoryOverride": "設定ディレクトリの上書き(詳細)",
"configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。",
"appConfigDir": "CC Switch 設定ディレクトリ",
"appConfigDirDescription": "CC Switch の保存場所をカスタマイズします(クラウド同期フォルダを指定すると設定を同期できます)",
"browsePlaceholderApp": "例: C:\\\\Users\\\\Administrator\\\\.cc-switch",
"claudeConfigDir": "Claude Code 設定ディレクトリ",
"claudeConfigDirDescription": "Claude の設定ディレクトリ(settings.json)を上書きし、claude.json(MCP)も同じ場所に置きます。",
"codexConfigDir": "Codex 設定ディレクトリ",
"codexConfigDirDescription": "Codex の設定ディレクトリを上書きします。",
"geminiConfigDir": "Gemini 設定ディレクトリ",
"geminiConfigDirDescription": "Gemini の設定ディレクトリ(.env)を上書きします。",
"browsePlaceholderClaude": "例: /home/<your-username>/.claude",
"browsePlaceholderCodex": "例: /home/<your-username>/.codex",
"browsePlaceholderGemini": "例: /home/<your-username>/.gemini",
"browseDirectory": "ディレクトリを選択",
"resetDefault": "デフォルトに戻す(保存後に反映)",
"checkForUpdates": "アップデートを確認",
"updateTo": "v{{version}} に更新",
"updating": "更新中...",
"checking": "確認中...",
"upToDate": "最新バージョンです",
"aboutHint": "バージョン情報と更新状況を表示します。",
"portableMode": "ポータブルモード: 更新は手動ダウンロードが必要です。",
"updateAvailable": "新しいバージョンがあります: {{version}}",
"updateFailed": "更新のインストールに失敗しました。ダウンロードページを開こうとしました。",
"checkUpdateFailed": "更新の確認に失敗しました。時間をおいて再試行してください。",
"openReleaseNotesFailed": "リリースノートの表示に失敗しました",
"releaseNotes": "リリースノート",
"viewReleaseNotes": "このバージョンのリリースノートを見る",
"viewCurrentReleaseNotes": "現在のバージョンのリリースノートを見る",
"importFailedError": "設定のインポートに失敗しました: {{message}}",
"exportFailedError": "設定のエクスポートに失敗しました:",
"restartRequired": "再起動が必要です",
"restartRequiredMessage": "CC Switch の設定ディレクトリを変更すると再起動が必要です。今すぐ再起動しますか?",
"restartNow": "今すぐ再起動",
"restartLater": "後で再起動",
"restartFailed": "アプリの再起動に失敗しました。手動で閉じて再度開いてください。",
"devModeRestartHint": "開発モードでは自動再起動をサポートしていません。手動で再起動してください。",
"saving": "保存中..."
},
"apps": {
"claude": "Claude Code",
"codex": "Codex",
"gemini": "Gemini"
},
"console": {
"providerSwitchReceived": "プロバイダー切り替えイベントを受信:",
"setupListenerFailed": "プロバイダー切り替えリスナーの設定に失敗:",
"updateProviderFailed": "プロバイダー更新に失敗:",
"autoImportFailed": "デフォルト設定の自動インポートに失敗:",
"openLinkFailed": "リンクを開けませんでした:",
"getVersionFailed": "バージョン情報の取得に失敗:",
"loadSettingsFailed": "設定の読み込みに失敗:",
"getConfigPathFailed": "設定パスの取得に失敗:",
"getConfigDirFailed": "設定ディレクトリの取得に失敗:",
"detectPortableFailed": "ポータブルモードの検出に失敗:",
"saveSettingsFailed": "設定の保存に失敗:",
"updateFailed": "更新に失敗:",
"checkUpdateFailed": "更新確認に失敗:",
"openConfigFolderFailed": "設定フォルダを開けませんでした:",
"selectConfigDirFailed": "設定ディレクトリの選択に失敗:",
"getDefaultConfigDirFailed": "デフォルト設定ディレクトリの取得に失敗:",
"openReleaseNotesFailed": "リリースノートを開けませんでした:"
},
"providerForm": {
"supplierName": "プロバイダー名",
"supplierNameRequired": "プロバイダー名 *",
"supplierNamePlaceholder": "例: Anthropic Official",
"websiteUrl": "Web サイト URL",
"websiteUrlPlaceholder": "https://example.com(任意)",
"apiEndpoint": "API エンドポイント",
"apiEndpointPlaceholder": "https://your-api-endpoint.com",
"codexApiEndpointPlaceholder": "https://your-api-endpoint.com/v1",
"manageAndTest": "管理・テスト",
"configContent": "設定内容",
"officialNoApiKey": "公式ログインは API Key 不要です。そのまま保存できます",
"codexOfficialNoApiKey": "公式は API Key 不要です。そのまま保存してください",
"codexApiKeyAutoFill": "ここに入力すれば auth.json も自動で埋まります",
"apiKeyAutoFill": "ここに入力すれば下の設定も自動で埋まります",
"cnOfficialApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"aggregatorApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"thirdPartyApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"customApiKeyHint": "💡 カスタム設定では必要な項目をすべて手動で入力してください",
"officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です",
"getApiKey": "API Key を取得",
"partnerPromotion": {
"zhipu": "Zhipu GLM は CC Switch の公式パートナーです。リンク経由でチャージすると 10% 割引",
"packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ",
"minimax": "MiniMax Coding Plan Black Friday、Starter が月額 $280% OFF"
},
"parameterConfig": "パラメーター設定 - {{name}} *",
"mainModel": "メインモデル(任意)",
"mainModelPlaceholder": "例: GLM-4.6",
"fastModel": "高速モデル(任意)",
"fastModelPlaceholder": "例: GLM-4.5-Air",
"modelHint": "💡 空欄ならプロバイダーのデフォルトモデルを使用します",
"apiHint": "💡 Claude API 互換サービスのエンドポイントを入力してください",
"codexApiHint": "💡 OpenAI Response 互換のサービスエンドポイントを入力してください",
"fillSupplierName": "プロバイダー名を入力してください",
"fillConfigContent": "設定内容を入力してください",
"fillParameter": "{{label}} を入力してください",
"fillTemplateValue": "{{label}} を入力してください",
"endpointRequired": "公式以外は API エンドポイントが必須です",
"apiKeyRequired": "公式以外は API Key が必須です",
"configJsonError": "Config JSON の形式が正しくありません。構文を確認してください",
"authJsonRequired": "auth.json は JSON オブジェクトで入力してください",
"authJsonError": "auth.json の形式が正しくありません。JSON を確認してください",
"fillAuthJson": "auth.json の設定を入力してください",
"fillApiKey": "OPENAI_API_KEY を入力してください",
"visitWebsite": "{{url}} を開く",
"anthropicModel": "メインモデル",
"anthropicSmallFastModel": "高速モデル",
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
"anthropicDefaultSonnetModel": "既定 Sonnet モデル",
"anthropicDefaultOpusModel": "既定 Opus モデル",
"modelPlaceholder": "",
"smallModelPlaceholder": "",
"haikuModelPlaceholder": "",
"modelHelper": "任意: 既定で使いたい Claude モデルを指定。空欄ならシステム既定を使用します。",
"categoryOfficial": "公式",
"categoryCnOfficial": "オープンソース公式",
"categoryAggregation": "アグリゲーター",
"categoryThirdParty": "サードパーティ"
},
"endpointTest": {
"title": "API エンドポイント管理",
"endpoints": "エンドポイント",
"autoSelect": "自動選択",
"testSpeed": "テスト",
"testing": "テスト中",
"addEndpointPlaceholder": "https://api.example.com",
"done": "完了",
"noEndpoints": "エンドポイントがありません",
"failed": "失敗",
"enterValidUrl": "有効な URL を入力してください",
"invalidUrlFormat": "URL 形式が正しくありません",
"onlyHttps": "HTTP/HTTPS のみサポートします",
"urlExists": "この URL はすでに存在します",
"saveFailed": "保存に失敗しました。もう一度お試しください",
"loadEndpointsFailed": "カスタムエンドポイントの読み込みに失敗:",
"addEndpointFailed": "カスタムエンドポイントの追加に失敗:",
"removeEndpointFailed": "カスタムエンドポイントの削除に失敗:",
"removeFailed": "削除に失敗しました: {{error}}",
"updateLastUsedFailed": "エンドポイントの最終使用時間の更新に失敗しました",
"pleaseAddEndpoint": "まずエンドポイントを追加してください",
"testUnavailable": "速度テストを実行できません",
"noResult": "結果がありません",
"testFailed": "速度テストに失敗しました: {{error}}",
"status": "ステータス: {{code}}"
},
"codexConfig": {
"authJson": "auth.json (JSON) *",
"authJsonPlaceholder": "{\n \"OPENAI_API_KEY\": \"sk-your-api-key-here\"\n}",
"authJsonHint": "Codex の auth.json 設定内容",
"configToml": "config.toml (TOML)",
"configTomlHint": "Codex の config.toml 設定内容",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "Codex 共通設定スニペットを編集",
"commonConfigHint": "「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます",
"apiUrlLabel": "API リクエスト URL"
},
"geminiConfig": {
"envFile": "環境変数 (.env)",
"envFileHint": ".env 形式で Gemini の環境変数を設定",
"configJson": "設定ファイル (config.json)",
"configJsonHint": "Gemini 拡張パラメーターを JSON 形式で設定(任意)",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "Gemini 共通設定スニペットを編集",
"commonConfigHint": "共通設定スニペットは、この機能をオンにしたすべての Gemini プロバイダーへマージされます"
},
"providerPreset": {
"label": "プロバイダータイプ",
"custom": "カスタム設定",
"other": "その他",
"hint": "プリセットを選んだ後でも、下のフィールドで調整できます。"
},
"usage": {
"queryFailed": "照会に失敗しました",
"refreshUsage": "利用状況を更新",
"planUsage": "プラン利用状況",
"invalid": "期限切れ",
"total": "合計:",
"used": "使用:",
"remaining": "残り:",
"justNow": "たった今",
"minutesAgo": "{{count}} 分前",
"hoursAgo": "{{count}} 時間前",
"daysAgo": "{{count}} 日前"
},
"usageScript": {
"title": "利用状況を設定",
"enableUsageQuery": "利用状況照会を有効にする",
"presetTemplate": "プリセットテンプレート",
"requestUrl": "リクエスト URL",
"requestUrlPlaceholder": "例: https://api.example.com",
"method": "HTTP メソッド",
"templateCustom": "カスタム",
"templateGeneral": "General",
"templateNewAPI": "NewAPI",
"credentialsConfig": "認証情報",
"baseUrl": "Base URL",
"accessToken": "Access Token",
"accessTokenPlaceholder": "「Security Settings」で生成",
"userId": "ユーザー ID",
"userIdPlaceholder": "例: 114514",
"defaultPlan": "デフォルトプラン",
"queryFailedMessage": "照会に失敗しました",
"queryScript": "照会スクリプト (JavaScript)",
"timeoutSeconds": "タイムアウト(秒)",
"headers": "ヘッダー",
"body": "ボディ",
"timeoutHint": "範囲: 2〜30 秒",
"timeoutMustBeInteger": "タイムアウトは整数で入力してください(小数は切り捨て)",
"timeoutCannotBeNegative": "タイムアウトは負の値にできません",
"autoIntervalMinutes": "自動照会間隔(分、0 で無効)",
"autoQueryInterval": "自動照会間隔(分)",
"autoQueryIntervalHint": "0 で無効。推奨 5〜60 分",
"intervalMustBeInteger": "間隔は整数で入力してください(小数は切り捨て)",
"intervalCannotBeNegative": "間隔は負の値にできません",
"intervalAdjusted": "間隔を {{value}} 分に調整しました",
"scriptHelp": "スクリプトの書き方:",
"configFormat": "設定の形式:",
"commentOptional": "任意",
"commentResponseIsJson": "response は API から返る JSON データです",
"extractorFormat": "抽出関数の返却形式(すべて任意):",
"tips": "💡 ヒント:",
"testing": "テスト中...",
"testScript": "スクリプトをテスト",
"format": "整形",
"saveConfig": "設定を保存",
"scriptEmpty": "スクリプト設定は空にできません",
"mustHaveReturn": "スクリプトには return 文が必要です",
"testSuccess": "テスト成功!",
"testFailed": "テストに失敗しました",
"formatSuccess": "整形に成功しました",
"formatFailed": "整形に失敗しました",
"variablesHint": "使用可能な変数: {{apiKey}}, {{baseUrl}} | extractor 関数には API 応答の JSON オブジェクトが渡されます",
"scriptConfig": "リクエスト設定",
"extractorCode": "抽出コード",
"extractorHint": "戻り値のオブジェクトに残り枠の項目を含めてください",
"fieldIsValid": "• isValid: Boolean。プランが有効かどうか",
"fieldInvalidMessage": "• invalidMessage: String。無効時の理由(isValid が false のとき表示)",
"fieldRemaining": "• remaining: Number。残り枠",
"fieldUnit": "• unit: String。単位(例: \"USD\"",
"fieldPlanName": "• planName: String。プラン名",
"fieldTotal": "• total: Number。総枠",
"fieldUsed": "• used: Number。使用量",
"fieldExtra": "• extra: String。自由記述の追加テキスト",
"tip1": "• 変数 {{apiKey}} と {{baseUrl}} は自動で置換されます",
"tip2": "• 抽出関数はサンドボックスで実行され、ES2020+ の構文を使えます",
"tip3": "• 全体を () で囲み、オブジェクトリテラル式にしてください"
},
"errors": {
"usage_query_failed": "利用状況の取得に失敗しました"
},
"presetSelector": {
"title": "設定タイプを選択",
"custom": "カスタム",
"customDescription": "手動で設定。完全な構成が必要",
"officialDescription": "公式ログイン。API Key 不要",
"presetDescription": "プリセットを使用。API Key だけ入力すれば OK"
},
"mcp": {
"title": "MCP 管理",
"claudeTitle": "Claude Code MCP 管理",
"codexTitle": "Codex MCP 管理",
"geminiTitle": "Gemini MCP 管理",
"unifiedPanel": {
"title": "MCP サーバー管理",
"addServer": "サーバーを追加",
"editServer": "サーバーを編集",
"deleteServer": "サーバーを削除",
"deleteConfirm": "サーバー「{{id}}」を削除しますか?この操作は元に戻せません。",
"noServers": "まだサーバーがありません",
"enabledApps": "有効なアプリ",
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
}
},
"userLevelPath": "ユーザーレベルの MCP パス",
"serverList": "サーバー一覧",
"loading": "読み込み中...",
"empty": "MCP サーバーがありません",
"emptyDescription": "右上のボタンから最初の MCP サーバーを追加してください",
"add": "MCP を追加",
"addServer": "MCP を追加",
"editServer": "MCP を編集",
"addClaudeServer": "Claude Code MCP を追加",
"editClaudeServer": "Claude Code MCP を編集",
"addCodexServer": "Codex MCP を追加",
"editCodexServer": "Codex MCP を編集",
"configPath": "設定パス",
"serverCount": "MCP サーバー: {{count}} 件",
"enabledCount": "{{count}} 件が有効",
"template": {
"fetch": "クイックテンプレート: mcp-fetch"
},
"form": {
"title": "MCP ID(ユニーク)",
"titlePlaceholder": "my-mcp-server",
"name": "表示名",
"namePlaceholder": "例: @modelcontextprotocol/server-time",
"enabledApps": "適用するアプリ",
"noAppsWarning": "少なくとも 1 つ選択してください",
"description": "説明",
"descriptionPlaceholder": "任意の説明",
"tags": "タグ(カンマ区切り)",
"tagsPlaceholder": "stdio, time, utility",
"homepage": "ホームページ",
"homepagePlaceholder": "https://example.com",
"docs": "ドキュメント",
"docsPlaceholder": "https://example.com/docs",
"additionalInfo": "追加情報",
"jsonConfig": "JSON 全設定",
"jsonConfigOrPrefix": "JSON 全設定、または",
"tomlConfigOrPrefix": "TOML 全設定、または",
"jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}",
"tomlConfig": "TOML 全設定",
"tomlPlaceholder": "type = \"stdio\"\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]",
"useWizard": "設定ウィザード",
"syncOtherSide": "{{target}} にも反映",
"syncOtherSideHint": "{{target}} に同じ設定を書き込みます。既存の同一 ID は上書きされます。",
"willOverwriteWarning": "{{target}} の既存設定を上書きします"
},
"wizard": {
"title": "MCP 設定ウィザード",
"hint": "MCP サーバーを素早く設定し JSON を自動生成します",
"type": "タイプ",
"typeStdio": "stdio",
"typeHttp": "http",
"typeSse": "sse",
"command": "コマンド",
"commandPlaceholder": "npx または uvx",
"args": "引数",
"argsPlaceholder": "arg1\narg2",
"env": "環境変数",
"envPlaceholder": "KEY1=value1\nKEY2=value2",
"url": "URL",
"urlPlaceholder": "https://api.example.com/mcp",
"urlRequired": "URL を入力してください",
"headers": "ヘッダー(任意)",
"headersPlaceholder": "Authorization: Bearer your_token_here\nContent-Type: application/json",
"preview": "設定プレビュー",
"apply": "設定を反映"
},
"id": "識別子(ユニーク)",
"type": "タイプ",
"command": "コマンド",
"validateCommand": "コマンドを検証",
"args": "引数",
"argsPlaceholder": "例: mcp-server-fetch --help",
"env": "環境変数(1 行に 1 件、KEY=VALUE",
"envPlaceholder": "FOO=bar\nHELLO=world",
"reset": "リセット",
"notice": {
"restartClaude": "書き込みました。Claude を再起動すると反映されます。"
},
"msg": {
"saved": "保存しました",
"deleted": "削除しました",
"enabled": "有効化しました",
"disabled": "無効化しました",
"templateAdded": "テンプレートを追加しました"
},
"error": {
"idRequired": "識別子を入力してください",
"idExists": "この識別子は既に存在します。別のものを選んでください。",
"jsonInvalid": "JSON 形式が無効です",
"tomlInvalid": "TOML 形式が無効です",
"commandRequired": "コマンドを入力してください",
"singleServerObjectRequired": "単一の MCP サーバーオブジェクトを貼り付けてください(トップレベルの mcpServers は不要)",
"saveFailed": "保存に失敗しました",
"deleteFailed": "削除に失敗しました"
},
"validation": {
"ok": "コマンドが見つかりました",
"fail": "コマンドが見つかりません"
},
"confirm": {
"deleteTitle": "MCP サーバーを削除",
"deleteMessage": "MCP サーバー「{{id}}」を削除してもよろしいですか?この操作は元に戻せません。"
},
"presets": {
"title": "MCP タイプを選択",
"enable": "有効化",
"enabled": "有効",
"installed": "インストール済み",
"docs": "ドキュメント",
"requiresEnv": "環境変数が必要",
"fetch": {
"name": "mcp-server-fetch",
"description": "汎用 HTTP リクエストツール。GET/POST などに対応し、API テストや Web データ取得に最適です"
},
"time": {
"name": "@modelcontextprotocol/server-time",
"description": "現在時刻、タイムゾーン変換、日付計算を提供する時間クエリツール"
},
"memory": {
"name": "@modelcontextprotocol/server-memory",
"description": "エンティティ・関係・観測を扱うナレッジグラフ型メモリ。会話の重要情報を AI に記憶させます"
},
"sequential-thinking": {
"name": "@modelcontextprotocol/server-sequential-thinking",
"description": "複雑な問題をステップに分解して深く考えるためのシーケンシャル思考ツール"
},
"context7": {
"name": "@upstash/context7-mcp",
"description": "最新のライブラリドキュメントとコード例を提供する Context7 ドキュメント検索ツール。キー設定で上限が拡張されます"
}
}
},
"prompts": {
"manage": "プロンプト",
"title": "{{appName}} プロンプト管理",
"claudeTitle": "Claude プロンプト管理",
"codexTitle": "Codex プロンプト管理",
"add": "プロンプトを追加",
"edit": "プロンプトを編集",
"addTitle": "{{appName}} プロンプトを追加",
"editTitle": "{{appName}} プロンプトを編集",
"import": "既存をインポート",
"count": "{{count}} 件のプロンプト",
"enabled": "有効",
"enable": "有効化",
"enabledName": "有効: {{name}}",
"noneEnabled": "有効なプロンプトがありません",
"currentFile": "現在の {{filename}} の内容",
"empty": "まだプロンプトがありません",
"emptyDescription": "上のボタンからプロンプトを追加またはインポートしてください",
"loading": "読み込み中...",
"name": "名前",
"namePlaceholder": "例: デフォルトプロジェクトプロンプト",
"description": "説明",
"descriptionPlaceholder": "任意の説明",
"content": "内容",
"contentPlaceholder": "# {{filename}}\n\nここにプロンプト内容を入力...",
"loadFailed": "プロンプトの読み込みに失敗しました",
"saveSuccess": "保存しました",
"saveFailed": "保存に失敗しました",
"deleteSuccess": "削除しました",
"deleteFailed": "削除に失敗しました",
"enableSuccess": "有効化しました",
"enableFailed": "有効化に失敗しました",
"disableSuccess": "無効化しました",
"disableFailed": "無効化に失敗しました",
"importSuccess": "インポートしました",
"importFailed": "インポートに失敗しました",
"confirm": {
"deleteTitle": "削除の確認",
"deleteMessage": "プロンプト「{{name}}」を削除してもよろしいですか?"
}
},
"env": {
"warning": {
"title": "競合する環境変数を検出しました",
"description": "設定を上書きする可能性のある環境変数を {{count}} 件見つけました"
},
"actions": {
"expand": "詳細を表示",
"collapse": "折りたたむ",
"selectAll": "すべて選択",
"clearSelection": "選択を解除",
"deleteSelected": "選択 {{count}} 件を削除",
"deleting": "削除中..."
},
"field": {
"value": "値",
"source": "ソース"
},
"source": {
"userRegistry": "ユーザー環境変数(レジストリ)",
"systemRegistry": "システム環境変数(レジストリ)",
"systemEnv": "システム環境変数"
},
"delete": {
"success": "環境変数を削除しました",
"error": "環境変数の削除に失敗しました"
},
"backup": {
"location": "バックアップ場所: {{path}}"
},
"confirm": {
"title": "環境変数を削除しますか?",
"message": "{{count}} 件の環境変数を削除してもよろしいですか?",
"backupNotice": "削除前に自動バックアップを作成します。後で復元できます。再起動またはターミナル再起動後に反映されます。",
"confirm": "削除を確認"
},
"error": {
"noSelection": "削除する環境変数を選択してください"
}
},
"skills": {
"manage": "Skills",
"title": "Claude スキル管理",
"description": "人気リポジトリから Claude Skills を探してインストールし、Claude Code/Codex を拡張",
"refresh": "更新",
"refreshing": "更新中...",
"repoManager": "リポジトリ管理",
"count": "{{count}} 個のスキル",
"empty": "スキルがありません",
"emptyDescription": "スキルリポジトリを追加して探索してください",
"addRepo": "スキルリポジトリを追加",
"loading": "読み込み中...",
"installed": "インストール済み",
"install": "インストール",
"installing": "インストール中...",
"uninstall": "アンインストール",
"uninstalling": "アンインストール中...",
"view": "表示",
"noDescription": "説明なし",
"loadFailed": "読み込みに失敗しました",
"installSuccess": "スキル {{name}} をインストールしました",
"installFailed": "インストールに失敗しました",
"uninstallSuccess": "スキル {{name}} をアンインストールしました",
"uninstallFailed": "アンインストールに失敗しました",
"error": {
"skillNotFound": "スキルが見つかりません: {{directory}}",
"missingRepoInfo": "リポジトリ情報(owner または name)が不足しています",
"downloadTimeout": "リポジトリ {{owner}}/{{name}} のダウンロードがタイムアウトしました({{timeout}} 秒)",
"downloadTimeoutHint": "ネットワークを確認するか、時間をおいて再試行してください",
"skillPathNotFound": "リポジトリ {{owner}}/{{name}} にスキルパス '{{path}}' がありません",
"skillDirNotFound": "スキルディレクトリが見つかりません: {{path}}",
"emptyArchive": "ダウンロードしたアーカイブが空です",
"downloadFailed": "ダウンロードに失敗しました: HTTP {{status}}",
"allBranchesFailed": "すべてのブランチで失敗しました。試行: {{branches}}",
"httpError": "HTTP エラー {{status}}",
"http403": "GitHub へのアクセスが制限されています(レート制限の可能性)",
"http404": "リポジトリまたはブランチが見つかりません。URL を確認してください",
"http429": "リクエストが多すぎます。時間をおいて再試行してください",
"parseMetadataFailed": "スキルメタデータの解析に失敗しました",
"getHomeDirFailed": "ユーザーのホームディレクトリを取得できません",
"networkError": "ネットワークエラー",
"fsError": "ファイルシステムエラー",
"unknownError": "不明なエラー",
"suggestion": {
"checkNetwork": "ネットワーク接続を確認してください",
"checkProxy": "HTTP プロキシの設定を検討してください",
"retryLater": "時間をおいて再試行してください",
"checkRepoUrl": "リポジトリ URL とブランチ名を確認してください",
"checkDiskSpace": "ディスク容量を確認してください",
"checkPermission": "ディレクトリの権限を確認してください"
}
},
"repo": {
"title": "スキルリポジトリを管理",
"description": "GitHub のスキルリポジトリソースを追加または削除します",
"url": "リポジトリ URL",
"urlPlaceholder": "owner/name または https://github.com/owner/name",
"branch": "ブランチ",
"branchPlaceholder": "main",
"path": "スキルパス",
"pathPlaceholder": "skills(任意。空欄はルート)",
"add": "リポジトリを追加",
"list": "追加済みリポジトリ",
"empty": "リポジトリがありません",
"invalidUrl": "リポジトリ URL の形式が無効です",
"addSuccess": "リポジトリ {{owner}}/{{name}} を追加しました。検出スキル: {{count}} 件",
"addFailed": "追加に失敗しました",
"removeSuccess": "リポジトリ {{owner}}/{{name}} を削除しました",
"removeFailed": "削除に失敗しました",
"skillCount": "{{count}} 件のスキルを検出"
},
"search": "スキルを検索",
"searchPlaceholder": "スキル名または説明で検索...",
"filter": {
"placeholder": "状態で絞り込み",
"all": "すべて",
"installed": "インストール済み",
"uninstalled": "未インストール"
},
"noResults": "一致するスキルが見つかりませんでした"
},
"deeplink": {
"confirmImport": "プロバイダーのインポートを確認",
"confirmImportDescription": "次の設定をディープリンクから CC Switch へインポートします",
"importPrompt": "プロンプトをインポート",
"importPromptDescription": "このシステムプロンプトをインポートするか確認してください",
"importMcp": "MCP サーバーをインポート",
"importMcpDescription": "これらの MCP サーバーをインポートするか確認してください",
"importSkill": "スキルリポジトリを追加",
"importSkillDescription": "このスキルリポジトリを追加するか確認してください",
"promptImportSuccess": "プロンプトをインポートしました",
"promptImportSuccessDescription": "インポートされたプロンプト: {{name}}",
"mcpImportSuccess": "MCP サーバーをインポートしました",
"mcpImportSuccessDescription": "{{count}} 件のサーバーをインポートしました",
"mcpPartialSuccess": "一部のみインポート成功",
"mcpPartialSuccessDescription": "成功: {{success}}、失敗: {{failed}}",
"skillImportSuccess": "スキルリポジトリを追加しました",
"skillImportSuccessDescription": "追加したリポジトリ: {{repo}}",
"app": "アプリ種別",
"providerName": "プロバイダー名",
"homepage": "ホームページ",
"endpoint": "API エンドポイント",
"apiKey": "API Key",
"icon": "アイコン",
"model": "モデル",
"haikuModel": "Haiku モデル",
"sonnetModel": "Sonnet モデル",
"opusModel": "Opus モデル",
"multiModel": "マルチモーダルモデル",
"notes": "メモ",
"import": "インポート",
"importing": "インポート中...",
"warning": "インポート前に内容を確認してください。後から一覧で編集・削除できます。",
"parseError": "ディープリンクの解析に失敗しました",
"importSuccess": "インポート成功",
"importSuccessDescription": "プロバイダー「{{name}}」をインポートしました",
"importError": "インポートに失敗しました",
"configSource": "設定ソース",
"configEmbedded": "埋め込み設定",
"configRemote": "リモート設定",
"configDetails": "設定の詳細",
"configUrl": "設定ファイル URL",
"configMergeError": "設定ファイルのマージに失敗しました",
"mcp": {
"title": "MCP サーバーを一括インポート",
"targetApps": "ターゲットアプリ",
"serverCount": "MCP サーバー({{count}} 件)",
"enabledWarning": "インポート後、指定したすべてのアプリに即座に書き込まれます"
},
"prompt": {
"title": "システムプロンプトをインポート",
"app": "アプリ",
"name": "名前",
"description": "説明",
"contentPreview": "内容プレビュー",
"enabledWarning": "インポート後すぐにこのプロンプトが有効になり、他は無効になります"
},
"skill": {
"title": "Claude スキルリポジトリを追加",
"repo": "GitHub リポジトリ",
"directory": "対象ディレクトリ",
"branch": "ブランチ",
"skillsPath": "スキルパス",
"hint": "この操作でスキルリポジトリが一覧に追加されます。",
"hintDetail": "追加後、スキル管理ページから個別のスキルをインストールできます。"
}
},
"iconPicker": {
"search": "アイコンを検索",
"searchPlaceholder": "アイコン名を入力...",
"noResults": "一致するアイコンが見つかりません",
"category": {
"aiProvider": "AI プロバイダー",
"cloud": "クラウドプラットフォーム",
"tool": "開発ツール",
"other": "その他"
}
},
"providerIcon": {
"label": "アイコン",
"colorLabel": "アイコンカラー",
"selectIcon": "アイコンを選択",
"preview": "プレビュー"
}
}
+8 -3
View File
@@ -165,6 +165,7 @@
"autoReload": "数据将在2秒后自动刷新...",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"languageOptionJapanese": "日本語",
"windowBehavior": "窗口行为",
"windowBehaviorHint": "配置窗口最小化与 Claude 插件联动策略。",
"launchOnStartup": "开机自启",
@@ -261,7 +262,8 @@
"getApiKey": "获取 API Key",
"partnerPromotion": {
"zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠",
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠"
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
"minimax": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)"
},
"parameterConfig": "参数配置 - {{name}} *",
"mainModel": "主模型 (可选)",
@@ -275,6 +277,8 @@
"fillConfigContent": "请填写配置内容",
"fillParameter": "请填写 {{label}}",
"fillTemplateValue": "请填写 {{label}}",
"endpointRequired": "非官方供应商请填写 API 端点",
"apiKeyRequired": "非官方供应商请填写 API Key",
"configJsonError": "配置JSON格式错误,请检查语法",
"authJsonRequired": "auth.json 必须是 JSON 对象",
"authJsonError": "auth.json 格式错误,请检查JSON语法",
@@ -373,7 +377,8 @@
"templateGeneral": "通用模板",
"templateNewAPI": "NewAPI",
"credentialsConfig": "凭证配置",
"accessToken": "访问令牌",
"baseUrl": "请求地址",
"accessToken": "访问令牌(在个人安全设置里获取)",
"accessTokenPlaceholder": "在'安全设置'里生成",
"userId": "用户 ID",
"userIdPlaceholder": "例如:114514",
@@ -386,7 +391,7 @@
"timeoutHint": "范围: 2-30 秒",
"timeoutMustBeInteger": "超时时间必须为整数,小数部分已忽略",
"timeoutCannotBeNegative": "超时时间不能为负数",
"autoIntervalMinutes": "自动查询间隔(分钟)",
"autoIntervalMinutes": "自动查询间隔(分钟0 表示不自动查询",
"autoQueryInterval": "自动查询间隔(分钟)",
"autoQueryIntervalHint": "0 表示不自动查询,建议 5-60 分钟",
"intervalMustBeInteger": "自动查询间隔必须为整数,小数部分已忽略",
+4 -5
View File
@@ -1,8 +1,7 @@
/* 引入 Tailwind v4 内建样式与工具 */
@import "tailwindcss";
/* 覆盖 Tailwind v4 默认的 dark 变体为"类选择器"模式 */
@custom-variant dark (&:where(.dark, .dark *));
/* Tailwind CSS v3 指令 */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* shadcn/ui 主题变量 - 蓝色主题 */
@layer base {
-1
View File
@@ -33,7 +33,6 @@ export interface DeepLinkImportRequest {
repo?: string;
directory?: string;
branch?: string;
skillsPath?: string;
// Config file fields
config?: string;
-2
View File
@@ -10,7 +10,6 @@ export interface Skill {
repoOwner?: string;
repoName?: string;
repoBranch?: string;
skillsPath?: string; // 技能所在的子目录路径,如 "skills"
}
export interface SkillRepo {
@@ -18,7 +17,6 @@ export interface SkillRepo {
name: string;
branch: string;
enabled: boolean;
skillsPath?: string; // 可选:技能所在的子目录路径,如 "skills"
}
export const skillsApi = {
+9 -1
View File
@@ -95,6 +95,13 @@ export const useUsageQuery = (
) => {
const { enabled = true, autoQueryInterval = 0 } = options || {};
// 计算 staleTime:如果有自动刷新间隔,使用该间隔;否则默认 5 分钟
// 这样可以避免切换 app 页面时重复触发查询
const staleTime =
autoQueryInterval > 0
? autoQueryInterval * 60 * 1000 // 与刷新间隔保持一致
: 5 * 60 * 1000; // 默认 5 分钟
const query = useQuery<UsageResult>({
queryKey: ["usage", providerId, appId],
queryFn: async () => usageApi.query(providerId, appId),
@@ -106,7 +113,8 @@ export const useUsageQuery = (
refetchIntervalInBackground: true, // 后台也继续定时查询
refetchOnWindowFocus: false,
retry: false,
staleTime: 0, // 使用缓存策略,确保 refetchInterval 准确执行
staleTime, // 使用动态计算的缓存时间
gcTime: 10 * 60 * 1000, // 缓存保留 10 分钟(组件卸载后)
});
return {
+1 -1
View File
@@ -36,7 +36,7 @@ function parseJsonError(error: unknown): string {
}
export const providerSchema = z.object({
name: z.string().min(1, "请填写供应商名称"),
name: z.string(), // 必填校验移至 handleSubmit 中用 toast 提示
websiteUrl: z.string().url("请输入有效的网址").optional().or(z.literal("")),
notes: z.string().optional(),
settingsConfig: z
+11 -3
View File
@@ -8,14 +8,22 @@ const directorySchema = z
.or(z.literal(""));
export const settingsSchema = z.object({
// 设备级 UI 设置
showInTray: z.boolean(),
minimizeToTrayOnClose: z.boolean(),
enableClaudePluginIntegration: z.boolean().optional(),
launchOnStartup: z.boolean().optional(),
language: z.enum(["en", "zh", "ja"]).optional(),
// 设备级目录覆盖
claudeConfigDir: directorySchema.nullable().optional(),
codexConfigDir: directorySchema.nullable().optional(),
language: z.enum(["en", "zh"]).optional(),
customEndpointsClaude: z.record(z.string(), z.unknown()).optional(),
customEndpointsCodex: z.record(z.string(), z.unknown()).optional(),
geminiConfigDir: directorySchema.nullable().optional(),
// 当前供应商 ID(设备级)
currentProviderClaude: z.string().optional(),
currentProviderCodex: z.string().optional(),
currentProviderGemini: z.string().optional(),
});
export type SettingsFormData = z.infer<typeof settingsSchema>;
+16 -14
View File
@@ -97,33 +97,35 @@ export interface ProviderMeta {
}
// 应用设置类型(用于设置对话框与 Tauri API)
// 存储在本地 ~/.cc-switch/settings.json,不随数据库同步
export interface Settings {
// ===== 设备级 UI 设置 =====
// 是否在系统托盘(macOS 菜单栏)显示图标
showInTray: boolean;
// 点击关闭按钮时是否最小化到托盘而不是关闭应用
minimizeToTrayOnClose: boolean;
// 启用 Claude 插件联动(写入 ~/.claude/config.json 的 primaryApiKey
enableClaudePluginIntegration?: boolean;
// 是否开机自启
launchOnStartup?: boolean;
// 首选语言(可选,默认中文)
language?: "en" | "zh" | "ja";
// ===== 设备级目录覆盖 =====
// 覆盖 Claude Code 配置目录(可选)
claudeConfigDir?: string;
// 覆盖 Codex 配置目录(可选)
codexConfigDir?: string;
// 覆盖 Gemini 配置目录(可选)
geminiConfigDir?: string;
// 首选语言(可选,默认中文)
language?: "en" | "zh";
// 是否开机自启
launchOnStartup?: boolean;
// Claude 自定义端点列表
customEndpointsClaude?: Record<string, CustomEndpoint>;
// Codex 自定义端点列表
customEndpointsCodex?: Record<string, CustomEndpoint>;
// 安全设置(兼容未来扩展)
security?: {
auth?: {
selectedType?: string;
};
};
// ===== 当前供应商 ID(设备级)=====
// 当前 Claude 供应商 ID(优先于数据库 is_current
currentProviderClaude?: string;
// 当前 Codex 供应商 ID(优先于数据库 is_current
currentProviderCodex?: string;
// 当前 Gemini 供应商 ID(优先于数据库 is_current
currentProviderGemini?: string;
}
// MCP 服务器连接参数(宽松:允许扩展字段)
+1
View File
@@ -4,6 +4,7 @@ export default {
"./src/index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: "selector",
theme: {
extend: {
colors: {
+23
View File
@@ -58,6 +58,29 @@ describe("useSettingsForm Hook", () => {
expect(changeLanguageSpy).toHaveBeenCalledWith("en");
});
it("should support japanese language preference from server data", async () => {
useSettingsQueryMock.mockReturnValue({
data: {
showInTray: true,
minimizeToTrayOnClose: true,
enableClaudePluginIntegration: false,
claudeConfigDir: "/Users/demo",
codexConfigDir: null,
language: "ja",
},
isLoading: false,
});
const { result } = renderHook(() => useSettingsForm());
await waitFor(() => {
expect(result.current.settings?.language).toBe("ja");
});
expect(result.current.initialLanguage).toBe("ja");
expect(changeLanguageSpy).toHaveBeenCalledWith("ja");
});
it("should prioritize reading language from local storage in readPersistedLanguage", () => {
useSettingsQueryMock.mockReturnValue({
data: null,
+1 -2
View File
@@ -1,11 +1,10 @@
import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
root: "src",
plugins: [react(), tailwindcss()],
plugins: [react()],
base: "./",
build: {
outDir: "../dist",