Compare commits

..

1 Commits

Author SHA1 Message Date
YoVinchen cfe3343f6c fix(provider): include icon fields when duplicating provider 2025-11-25 23:16:03 +08:00
62 changed files with 1438 additions and 2675 deletions
+3 -4
View File
@@ -4,7 +4,7 @@
1. **安装依赖**:添加了 `react-i18next``i18next`
2. **配置国际化**:在 `src/i18n/` 目录下创建了配置文件
3. **翻译文件**:创建了英文、中文、日文翻译文件
3. **翻译文件**:创建了英文和中文翻译文件
4. **组件更新**:替换了主要组件中的硬编码文案
5. **语言切换器**:添加了语言切换按钮
@@ -16,7 +16,6 @@ src/
│ ├── index.ts # 国际化配置文件
│ └── locales/
│ ├── en.json # 英文翻译
│ ├── ja.json # 日文翻译
│ └── zh.json # 中文翻译
├── components/
│ └── LanguageSwitcher.tsx # 语言切换组件
@@ -25,7 +24,7 @@ src/
## 默认语言设置
- **默认语言**文 (zh)(无首选时根据浏览器/系统语言选择 zh/en/ja)
- **默认语言**文 (en)
- **回退语言**:英文 (en)
## 使用方式
@@ -58,7 +57,7 @@ src/
## 测试功能
应用已添加了语言切换按钮,支持中文、英文、日文三种语言切换,验证国际化功能是否正常工作。
应用已添加了语言切换按钮(地球图标),点击可以在中英文之间切换,验证国际化功能是否正常工作。
## 已更新的组件
+3 -4
View File
@@ -32,10 +32,7 @@
"prettier": "^3.6.2",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vitest": "^2.0.5",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17"
"vitest": "^2.0.5"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.4",
@@ -59,6 +56,7 @@
"@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",
@@ -78,6 +76,7 @@
"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"
+255 -578
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
-7
View File
@@ -7,14 +7,7 @@ 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_to_live(&app_state) {
if let Err(err) = ProviderService::sync_current_from_db(&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_to_live(&app_state)?;
ProviderService::sync_current_from_db(&app_state)?;
Ok::<_, AppError>(json!({
"success": true,
"message": "Live configuration synchronized"
+5 -3
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<bool, AppError> {
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<(), 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<bool, AppError> {
) -> Result<(), AppError> {
import_default_config_internal(state, app_type)
}
@@ -102,7 +102,9 @@ 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_err(Into::into)
import_default_config_internal(&state, app_type)
.map(|_| true)
.map_err(Into::into)
}
/// 查询供应商用量
+1
View File
@@ -90,6 +90,7 @@ pub async fn install_skill(
.clone()
.unwrap_or_else(|| "main".to_string()),
enabled: true,
skills_path: skill.skills_path.clone(), // 使用技能记录的 skills_path
};
service
+37 -73
View File
@@ -122,98 +122,62 @@ 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 existing: Option<bool> = tx
// 检查是否存在保留 is_current
let is_current: 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),
)
.ok();
.unwrap_or(false);
let is_update = existing.is_some();
let is_current = existing.unwrap_or(false);
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()))?;
if is_update {
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
// 同步 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 {
tx.execute(
"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,
],
"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()))?;
} 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()))?;
+4 -5
View File
@@ -58,9 +58,7 @@ 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 FROM skill_repos ORDER BY owner ASC, name ASC",
)
.prepare("SELECT owner, name, branch, enabled, skills_path FROM skill_repos ORDER BY owner ASC, name ASC")
.map_err(|e| AppError::Database(e.to_string()))?;
let repo_iter = stmt
@@ -70,6 +68,7 @@ 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()))?;
@@ -85,8 +84,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) VALUES (?1, ?2, ?3, ?4)",
params![repo.owner, repo.name, repo.branch, repo.enabled],
"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],
).map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
+11 -13
View File
@@ -153,14 +153,12 @@ 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)",
@@ -176,9 +174,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")?;
@@ -202,8 +200,8 @@ impl Database {
for repo in &config.skills.repos {
tx.execute(
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)",
params![repo.owner, repo.name, repo.branch, repo.enabled],
"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],
).map_err(|e| AppError::Database(format!("Migrate skill repo failed: {e}")))?;
}
+17 -10
View File
@@ -115,21 +115,28 @@ impl Database {
Ok(db)
}
/// 检查 MCP 服务器表是否为空
pub fn is_mcp_table_empty(&self) -> Result<bool, AppError> {
/// 检查数据库是否为空(需要首次导入)
///
/// 通过检查是否有任何 MCP 服务器、提示词、Skills 仓库或供应商来判断
pub fn is_empty_for_first_import(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
let mcp_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)
}
/// 检查提示词表是否为空
pub fn is_prompts_table_empty(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
let prompt_count: i64 = conn
.query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count == 0)
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)
}
}
+13 -11
View File
@@ -104,6 +104,7 @@ 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)
)",
[],
@@ -225,14 +226,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",
"branch",
"TEXT NOT NULL DEFAULT 'main'",
"enabled",
"BOOLEAN NOT NULL DEFAULT 1",
)?;
Self::add_column_if_missing(conn, "skill_repos", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
// 注意: skills_path 字段已被移除,因为现在支持全仓库递归扫描
Self::add_column_if_missing(conn, "skill_repos", "skills_path", "TEXT")?;
Ok(())
}
@@ -246,7 +247,9 @@ 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, [])
@@ -258,7 +261,10 @@ 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},仅允许字母、数字和下划线"
)));
@@ -286,11 +292,7 @@ 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, "列名")?;
+10 -4
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,7 +168,10 @@ 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");
@@ -178,7 +181,10 @@ 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,8 +100,12 @@ 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,6 +143,7 @@ fn parse_provider_deeplink(
repo: None,
directory: None,
branch: None,
skills_path: None,
config,
config_format,
config_url,
@@ -203,6 +204,7 @@ fn parse_prompt_deeplink(
repo: None,
directory: None,
branch: None,
skills_path: None,
config: None,
config_format: None,
config_url: None,
@@ -260,6 +262,7 @@ fn parse_mcp_deeplink(
repo: None,
directory: None,
branch: None,
skills_path: None,
config_url: None,
})
}
@@ -284,6 +287,10 @@ 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,
@@ -291,6 +298,7 @@ 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,6 +40,7 @@ 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
+8 -2
View File
@@ -142,6 +142,7 @@ fn test_build_gemini_provider_with_model() {
repo: None,
directory: None,
branch: None,
skills_path: None,
content: None,
description: None,
enabled: None,
@@ -188,6 +189,7 @@ fn test_build_gemini_provider_without_model() {
repo: None,
directory: None,
branch: None,
skills_path: None,
content: None,
description: None,
enabled: None,
@@ -229,6 +231,7 @@ fn test_parse_and_merge_config_claude() {
repo: None,
directory: None,
branch: None,
skills_path: None,
content: None,
description: None,
enabled: None,
@@ -272,6 +275,7 @@ fn test_parse_and_merge_config_url_override() {
repo: None,
directory: None,
branch: None,
skills_path: None,
content: None,
description: None,
enabled: None,
@@ -303,7 +307,8 @@ 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");
@@ -368,11 +373,12 @@ 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";
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev&skills_path=src";
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");
}
+1 -19
View File
@@ -49,11 +49,6 @@ 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() {
@@ -61,25 +56,12 @@ pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>
}
let root = read_json_value(&path)?;
let mut servers: std::collections::HashMap<String, Value> = root
let servers = 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)
}
+103 -205
View File
@@ -43,7 +43,6 @@ 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::{
@@ -69,11 +68,6 @@ 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: " (无供应商,请在主界面添加)",
@@ -226,11 +220,10 @@ 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)?;
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
let current_id =
crate::settings::get_effective_current_provider(&app_state.db, &section.app_type)?
.unwrap_or_default();
let current_id = app_state
.db
.get_current_provider(app_type_str)?
.unwrap_or_default();
let manager = crate::provider::ProviderManager {
providers,
@@ -553,111 +546,91 @@ 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));
}
};
// 如果有预加载的配置,执行迁移
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}");
// 静默迁移:检测到旧版 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},将从现有配置导入");
} else {
log::info!("配置已归档为 config.json.migrated");
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");
}
}
}
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
});
// 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}"),
}
if need_first_import {
// 数据库为空,尝试从用户现有的配置文件导入数据并初始化默认配置
log::info!(
"Empty database detected, importing existing configurations and initializing defaults..."
);
// 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());
// 1. 初始化默认 Skills 仓库(3个
match app_state.db.init_default_skill_repos() {
Ok(count) if count > 0 => {
log::info!("✓ Initialized {count} default skill repositories");
}
Ok(false) => {} // 已有供应商,静默跳过
Err(e) => {
log::debug!(
"○ No default provider to import for {}: {}",
app.as_str(),
e
);
Ok(_) => log::debug!("No default skill repositories to initialize"),
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
);
}
}
}
}
// 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");
@@ -681,28 +654,42 @@ pub fn run() {
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
}
}
// 4. 导入提示词文件(表空时触发)
if app_state.db.is_prompts_table_empty().unwrap_or(false) {
log::info!("Prompts table empty, importing from live configurations...");
for app in [
// 4. 导入提示词文件
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::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(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");
}
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
@@ -718,35 +705,10 @@ pub fn run() {
// Linux 和 Windows 调试模式需要显式注册
#[cfg(any(target_os = "linux", all(debug_assertions, 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)");
}
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)");
}
}
@@ -990,67 +952,3 @@ 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,15 +2,5 @@
#![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,6 +2,7 @@ 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;
@@ -83,6 +84,53 @@ 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,12 +176,6 @@ 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)?;
// 检查文件是否存在
+149 -4
View File
@@ -4,6 +4,7 @@
use crate::error::AppError;
use crate::provider::Provider;
use crate::settings;
/// Gemini authentication type enumeration
///
@@ -18,6 +19,10 @@ 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";
@@ -89,22 +94,159 @@ 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.
///
/// This is a convenience wrapper around `detect_gemini_auth_type`.
/// # 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)]
pub(crate) fn is_google_official_gemini(provider: &Provider) -> bool {
detect_gemini_auth_type(provider) == GeminiAuthType::GoogleOfficial
// 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(())
}
/// Ensure Google Official Gemini provider security flag is correctly set (OAuth mode)
///
/// Google Official Gemini uses OAuth personal authentication, no API Key needed.
///
/// # What it does
/// # Why write to two settings.json files
///
/// Writes to **`~/.gemini/settings.json`** (Gemini client config).
/// Same as `ensure_packycode_security_flag`, need to configure both app-level and client-level settings.
///
/// # Value set
///
@@ -134,6 +276,9 @@ 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()?;
+62 -69
View File
@@ -15,7 +15,8 @@ use crate::services::mcp::McpService;
use crate::store::AppState;
use super::gemini_auth::{
detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType,
detect_gemini_auth_type, ensure_google_oauth_security_flag, ensure_packycode_security_flag,
GeminiAuthType,
};
use super::normalize_claude_models_in_value;
@@ -100,13 +101,12 @@ 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,36 +114,51 @@ 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 => {
// Delegate to write_gemini_live which handles env file writing correctly
write_gemini_live(provider)?;
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)?;
}
}
}
Ok(())
}
/// Sync current provider to live configuration
///
/// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
/// Sync current provider from database to live configuration
pub fn sync_current_from_db(state: &AppState) -> Result<(), AppError> {
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
// 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 current_id = match state.db.get_current_provider(app_type.as_str())? {
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
@@ -215,14 +230,11 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
}
/// Import default configuration from live files
///
/// 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> {
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<(), AppError> {
{
let providers = state.db.get_all_providers(app_type.as_str())?;
if !providers.is_empty() {
return Ok(false); // 已有供应商,跳过
return Ok(());
}
}
@@ -301,7 +313,7 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
.db
.set_current_provider(app_type.as_str(), &provider.id)?;
Ok(true) // 真正导入了
Ok(())
}
/// Write Gemini live configuration with authentication handling
@@ -316,44 +328,28 @@ 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
// 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() {
// 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 {
return Err(AppError::localized(
"gemini.validation.invalid_config",
"Gemini 配置格式错误: config 必须是对象或 null",
"Gemini config invalid: config must be an object or null",
));
}
// config is null: don't modify existing settings.json (preserve mcpServers etc.)
}
} else {
None
};
// 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)?);
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)?);
}
}
match auth_type {
@@ -375,17 +371,14 @@ 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 | GeminiAuthType::Generic => {
crate::gemini_config::write_packycode_settings()?;
}
GeminiAuthType::Packycode => ensure_packycode_security_flag(provider)?,
GeminiAuthType::Generic => {}
}
Ok(())
+33 -36
View File
@@ -22,12 +22,16 @@ use crate::settings::CustomEndpoint;
use crate::store::AppState;
// Re-export sub-module functions for external access
pub use live::{import_default_config, read_live_settings, sync_current_to_live};
pub use live::{import_default_config, read_live_settings, sync_current_from_db};
// 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;
@@ -94,12 +98,10 @@ impl ProviderService {
}
/// Get current provider ID
///
/// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
crate::settings::get_effective_current_provider(&state.db, &app_type)
state
.db
.get_current_provider(app_type.as_str())
.map(|opt| opt.unwrap_or_default())
}
@@ -137,10 +139,9 @@ impl ProviderService {
Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?;
// Check if this is current provider (use effective current, not just DB)
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());
// 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());
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
@@ -155,19 +156,13 @@ impl ProviderService {
}
/// Delete a provider
///
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// 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) {
let current = state.db.get_current_provider(app_type.as_str())?;
if current.as_deref() == Some(id) {
return Err(AppError::Message(
"无法删除当前正在使用的供应商".to_string(),
));
}
state.db.delete_provider(app_type.as_str(), id)
}
@@ -176,9 +171,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. 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
/// 3. Set new current provider
/// 4. Write target provider config to live files
/// 5. Gemini additional security flag handling
/// 6. Sync MCP configuration
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// Check if provider exists
@@ -188,10 +183,7 @@ impl ProviderService {
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
// Backfill: Backfill current live config to current provider
// 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 let Some(current_id) = state.db.get_current_provider(app_type.as_str())? {
if current_id != id {
// Only backfill when switching to a different provider
if let Ok(live_config) = read_live_settings(app_type.clone()) {
@@ -204,30 +196,35 @@ impl ProviderService {
}
}
// 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)
// Set current
state.db.set_current_provider(app_type.as_str(), id)?;
// Sync to live (write_gemini_live handles security flag internally for Gemini)
// Sync to live
write_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 to live configuration (re-export)
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
sync_current_to_live(state)
/// 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)
}
/// Import default configuration from live files (re-export)
///
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<(), AppError> {
import_default_config(state, app_type)
}
+3 -1
View File
@@ -168,7 +168,9 @@ 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}"
),
+122 -159
View File
@@ -34,6 +34,9 @@ pub struct Skill {
/// 分支名称
#[serde(rename = "repoBranch")]
pub repo_branch: Option<String>,
/// 技能所在的子目录路径 (可选, 如 "skills")
#[serde(rename = "skillsPath")]
pub skills_path: Option<String>,
}
/// 仓库配置
@@ -47,6 +50,9 @@ pub struct SkillRepo {
pub branch: String,
/// 是否启用
pub enabled: bool,
/// 技能所在的子目录路径 (可选, 如 "skills", "my-skills/subdir")
#[serde(rename = "skillsPath")]
pub skills_path: Option<String>,
}
/// 技能安装状态
@@ -78,18 +84,21 @@ 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 子目录
},
],
}
@@ -185,11 +194,76 @@ impl SkillService {
})??;
let mut skills = Vec::new();
// 扫描仓库根目录(支持全仓库递归扫描)
let scan_dir = temp_dir.clone();
// 确定要扫描的目录路径
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()
};
// 递归扫描目录查找所有技能
self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?;
// 遍历目标目录
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),
}
}
// 清理临时目录
let _ = fs::remove_dir_all(&temp_dir);
@@ -197,85 +271,6 @@ 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)?;
@@ -307,18 +302,25 @@ impl SkillService {
return Ok(());
}
// 收集所有本地技能
let mut local_skills = Vec::new();
self.scan_local_dir_recursive(&self.install_dir, &self.install_dir, &mut local_skills)?;
for entry in fs::read_dir(&self.install_dir)? {
let entry = entry?;
let path = entry.path();
// 处理找到的本地技能
for local_skill in local_skills {
let directory = &local_skill.directory;
if !path.is_dir() {
continue;
}
// 更新已安装状态(匹配远程技能)
// 安全地获取目录名
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;
@@ -327,68 +329,23 @@ impl SkillService {
// 添加本地独有的技能(仅当在仓库中未找到时)
if !found {
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)?;
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,
});
}
}
}
}
@@ -396,13 +353,11 @@ impl SkillService {
}
/// 去重技能列表
/// 使用完整的 key (owner/name:directory) 来区分不同仓库的同名技能
fn deduplicate_skills(skills: &mut Vec<Skill>) {
let mut seen = HashMap::new();
skills.retain(|skill| {
// 使用完整 key 而非仅 directory,允许不同仓库的同名技能共存
let unique_key = skill.key.to_lowercase();
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) {
let key = skill.directory.to_lowercase();
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(key) {
e.insert(true);
true
} else {
@@ -542,8 +497,16 @@ impl SkillService {
))
})??;
// 确定源目录路径(技能相对于仓库根目录的路径)
let source = temp_dir.join(&directory);
// 根据 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)
};
if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir);
+119 -101
View File
@@ -1,12 +1,13 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
use std::sync::{Arc, OnceLock, RwLock};
use crate::app_config::AppType;
use crate::database::Database;
use crate::error::AppError;
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints
/// 自定义端点配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomEndpoint {
@@ -16,14 +17,24 @@ pub struct CustomEndpoint {
pub last_used: Option<i64>,
}
/// 应用设置结构
///
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
/// 这确保了云同步场景下多设备可以独立运作。
#[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>,
}
/// 应用设置结构,允许覆盖默认配置目录
#[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")]
@@ -31,30 +42,25 @@ 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 current_provider_claude: Option<String>,
/// 当前 Codex 供应商 ID(本地存储,优先于数据库 is_current)
pub language: Option<String>,
/// 是否开机自启
#[serde(default)]
pub launch_on_startup: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_codex: Option<String>,
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_gemini: Option<String>,
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>,
}
fn default_show_in_tray() -> bool {
@@ -71,14 +77,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,
current_provider_claude: None,
current_provider_codex: None,
current_provider_gemini: None,
language: None,
launch_on_startup: false,
security: None,
custom_endpoints_claude: HashMap::new(),
custom_endpoints_codex: HashMap::new(),
}
}
}
@@ -118,7 +124,7 @@ impl AppSettings {
.language
.as_ref()
.map(|s| s.trim())
.filter(|s| matches!(*s, "en" | "zh" | "ja"))
.filter(|s| matches!(*s, "en" | "zh"))
.map(|s| s.to_string());
}
@@ -163,7 +169,60 @@ 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(AppSettings::load_from_file()))
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)
}
fn resolve_override_path(raw: &str) -> PathBuf {
@@ -190,22 +249,47 @@ pub fn get_settings() -> AppSettings {
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
new_settings.normalize_paths();
save_settings_file(&new_settings)?;
if let Some(db) = SETTINGS_DB.get() {
save_to_db(db, &new_settings)?;
} else {
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 = AppSettings::load_from_file();
let fresh_settings = load_initial_settings();
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
@@ -229,69 +313,3 @@ 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())
}
+27 -15
View File
@@ -3,15 +3,13 @@ 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() {
@@ -845,22 +843,21 @@ fn sync_gemini_packycode_sets_security_selected_type() {
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing gemini live should succeed");
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let gemini_settings = home.join(".gemini").join("settings.json");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
gemini_settings.exists(),
"Gemini settings.json should exist at {}",
gemini_settings.display()
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
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");
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("gemini-api-key"),
"syncing PackyCode Gemini should enforce security.auth.selectedType in Gemini settings"
"syncing PackyCode Gemini should enforce security.auth.selectedType"
);
}
@@ -896,7 +893,22 @@ fn sync_gemini_google_official_sets_oauth_security() {
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing google official gemini should succeed");
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
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"
);
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
@@ -911,7 +923,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 record oauth-personal for Google Official"
"Gemini settings should also record oauth-personal"
);
}
+12 -13
View File
@@ -42,13 +42,9 @@ 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");
@@ -91,9 +87,7 @@ 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(),
@@ -131,7 +125,8 @@ 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");
@@ -173,7 +168,8 @@ 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"
@@ -228,8 +224,11 @@ 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"
+16 -34
View File
@@ -7,8 +7,8 @@ use cc_switch_lib::{
#[path = "support.rs"]
mod support;
use std::collections::HashMap;
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
use std::collections::HashMap;
#[test]
fn switch_provider_updates_codex_live_and_state() {
@@ -104,22 +104,16 @@ 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")
@@ -171,9 +165,7 @@ 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}"
);
}
@@ -249,19 +241,11 @@ 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
@@ -274,7 +258,9 @@ 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
@@ -297,9 +283,7 @@ 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(),
@@ -344,9 +328,7 @@ 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 配置写入失败,状态应该回滚
+44 -60
View File
@@ -1,15 +1,13 @@
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()
@@ -71,10 +69,7 @@ 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 {
@@ -116,22 +111,16 @@ 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")
@@ -196,16 +185,15 @@ fn switch_packycode_gemini_updates_security_selected_type() {
ProviderService::switch(&state, AppType::Gemini, "packy-gemini")
.expect("switching to PackyCode Gemini should succeed");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let settings_path = home.join(".gemini").join("settings.json");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"Gemini settings.json should exist at {}",
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
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 gemini settings.json after switch");
serde_json::from_str(&raw).expect("parse settings.json after switch");
assert_eq!(
value
@@ -251,16 +239,15 @@ 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");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let settings_path = home.join(".gemini").join("settings.json");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"Gemini settings.json should exist at {}",
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
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 gemini settings.json after switch");
serde_json::from_str(&raw).expect("parse settings.json after switch");
assert_eq!(
value
@@ -305,7 +292,23 @@ fn switch_google_official_gemini_sets_oauth_security() {
ProviderService::switch(&state, AppType::Gemini, "google-official")
.expect("switching to Google official Gemini should succeed");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
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"
);
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
@@ -321,7 +324,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 reflect oauth-personal for Google Official"
"Gemini settings json should also reflect oauth-personal"
);
}
@@ -396,19 +399,11 @@ 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")
@@ -528,9 +523,7 @@ 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"),
@@ -588,9 +581,7 @@ 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"),
@@ -602,10 +593,6 @@ 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
@@ -631,18 +618,15 @@ 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:?}"),
+26 -29
View File
@@ -6,7 +6,7 @@ import {
Plus,
Settings,
ArrowLeft,
// Bot, // TODO: Agents 功能开发中,暂时不需要
Bot,
Book,
Wrench,
Server,
@@ -24,7 +24,6 @@ import {
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions";
import { extractErrorMessage } from "@/utils/errorUtils";
import { cn } from "@/lib/utils";
import { AppSwitcher } from "@/components/AppSwitcher";
import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
@@ -405,6 +404,7 @@ function App() {
>
CC Switch
</a>
<div className="h-5 w-[1px] bg-black/10 dark:bg-white/15" />
<Button
variant="ghost"
size="icon"
@@ -470,34 +470,9 @@ function App() {
<>
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="h-8 w-[1px] bg-black/10 dark:bg-white/10 mx-1" />
<div className="glass p-1 rounded-xl flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
isClaudeApp
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("skills.manage")}
>
<Wrench className="h-4 w-4 flex-shrink-0" />
</Button>
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
{/* {isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("agents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="Agents"
>
<Bot className="h-4 w-4" />
</Button>
)} */}
<Button
variant="ghost"
size="sm"
@@ -507,6 +482,17 @@ function App() {
>
<Book className="h-4 w-4" />
</Button>
{isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("skills.manage")}
>
<Wrench className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
@@ -516,6 +502,17 @@ function App() {
>
<Server className="h-4 w-4" />
</Button>
{isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("agents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="Agents"
>
<Bot className="h-4 w-4" />
</Button>
)}
</div>
<Button
+3 -7
View File
@@ -40,12 +40,8 @@ export function ConfirmDialog({
}
}}
>
<DialogContent
className="max-w-sm"
zIndex="alert"
overlayClassName="bg-background/80"
>
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogContent className="max-w-sm">
<DialogHeader className="space-y-3">
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
<AlertTriangle className="h-5 w-5 text-destructive" />
{title}
@@ -54,7 +50,7 @@ export function ConfirmDialog({
{message}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
<DialogFooter className="flex gap-2 sm:justify-end">
<Button variant="outline" onClick={onCancel}>
{cancelText || t("common.cancel")}
</Button>
+147 -39
View File
@@ -176,16 +176,10 @@ 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;
}
// 检测 GENERAL 模板(有 apiKey 或 baseUrl
if (existingScript?.apiKey || existingScript?.baseUrl) {
return TEMPLATE_KEYS.GENERAL;
}
// 新配置或无凭证:默认使用 GENERAL(与默认代码模板一致)
return TEMPLATE_KEYS.GENERAL;
return null;
},
);
@@ -355,9 +349,14 @@ 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">
<p className="text-base font-medium leading-none text-foreground">
{t("usageScript.enableUsageQuery")}
</p>
<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>
<Switch
checked={script.enabled}
onCheckedChange={(checked) =>
@@ -371,9 +370,14 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
<div className="space-y-6">
{/* 预设模板选择 */}
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<Label className="text-base font-medium">
{t("usageScript.presetTemplate")}
</Label>
<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>
<div className="flex gap-2 flex-wrap">
{Object.keys(PRESET_TEMPLATES).map((name) => {
const isSelected = selectedTemplate === name;
@@ -443,9 +447,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
<div className="space-y-2">
<Label htmlFor="usage-base-url">
{t("usageScript.baseUrl")}
</Label>
<Label htmlFor="usage-base-url">Base URL</Label>
<Input
id="usage-base-url"
type="text"
@@ -464,9 +466,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
{selectedTemplate === TEMPLATE_KEYS.NEW_API && (
<>
<div className="space-y-2">
<Label htmlFor="usage-newapi-base-url">
{t("usageScript.baseUrl")}
</Label>
<Label htmlFor="usage-newapi-base-url">Base URL</Label>
<Input
id="usage-newapi-base-url"
type="text"
@@ -545,36 +545,141 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
</div>
)}
</div>
{/* 通用配置(始终显示) */}
<div className="grid gap-4 md:grid-cols-2 pt-4 border-t border-white/10">
{/* 超时时间 */}
{/* 脚本配置 */}
<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="space-y-2">
<Label htmlFor="usage-timeout">
{t("usageScript.timeoutSeconds")}
<Label htmlFor="usage-request-url">
{t("usageScript.requestUrl")}
</Label>
<Input
id="usage-timeout"
type="number"
min={0}
value={script.timeout ?? 10}
onChange={(e) =>
id="usage-request-url"
type="text"
value={script.request?.url || ""}
onChange={(e) => {
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
onBlur={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
request: { ...script.request, url: e.target.value },
});
}}
placeholder={t("usageScript.requestUrlPlaceholder")}
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")}
@@ -603,6 +708,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
}
className="border-white/10"
/>
<p className="text-xs text-muted-foreground">
{t("usageScript.autoQueryIntervalHint")}
</p>
</div>
</div>
</div>
+16 -5
View File
@@ -30,11 +30,22 @@ export function SkillConfirmation({
</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 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>
<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,24 +140,15 @@ 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) return;
const existing = map.get(sanitized);
if (existing) {
// URL 已存在,更新 isCustom 为 true(因为它在数据库中)
existing.isCustom = true;
} else {
// URL 不存在,添加新条目
if (sanitized && !map.has(sanitized)) {
map.set(sanitized, {
id: randomId(),
url: sanitized,
+18 -94
View File
@@ -2,7 +2,6 @@ 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";
@@ -327,11 +326,11 @@ export function ProviderForm({
configError: geminiConfigError,
handleGeminiApiKeyChange: originalHandleGeminiApiKeyChange,
handleGeminiBaseUrlChange: originalHandleGeminiBaseUrlChange,
handleGeminiModelChange: originalHandleGeminiModelChange,
handleGeminiEnvChange,
handleGeminiConfigChange,
resetGeminiConfig,
envStringToObj,
envObjToString,
} = useGeminiConfigState({
initialData: appId === "gemini" ? initialData : undefined,
});
@@ -369,22 +368,6 @@ 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,
@@ -405,82 +388,17 @@ export function ProviderForm({
if (appId === "claude" && templateValueEntries.length > 0) {
const validation = validateTemplateValues();
if (!validation.isValid && validation.missingField) {
toast.error(
t("providerForm.fillParameter", {
form.setError("settingsConfig", {
type: "manual",
message: 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
@@ -698,8 +616,6 @@ export function ProviderForm({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify({ auth, config }, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
@@ -717,8 +633,6 @@ export function ProviderForm({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(preset.settingsConfig, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
@@ -733,8 +647,6 @@ export function ProviderForm({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
};
@@ -846,7 +758,19 @@ export function ProviderForm({
onCustomEndpointsChange={setDraftCustomEndpoints}
shouldShowModelField={true}
model={geminiModel}
onModelChange={handleGeminiModelChange}
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);
}}
speedTestEndpoints={speedTestEndpoints}
/>
)}
@@ -22,32 +22,18 @@ 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[] = [];
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.GOOGLE_GEMINI_BASE_URL === "string") {
lines.push(`GOOGLE_GEMINI_BASE_URL=${envObj.GOOGLE_GEMINI_BASE_URL}`);
}
// 再添加其他自定义 key(保留用户添加的环境变量)
for (const [key, value] of Object.entries(envObj)) {
if (!addedKeys.has(key) && typeof value === "string") {
lines.push(`${key}=${value}`);
}
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}`);
}
return lines.join("\n");
},
[],
@@ -178,20 +164,6 @@ 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) => {
@@ -251,7 +223,6 @@ export function useGeminiConfigState({
setGeminiConfig,
handleGeminiApiKeyChange,
handleGeminiBaseUrlChange,
handleGeminiModelChange,
handleGeminiEnvChange,
handleGeminiConfigChange,
resetGeminiConfig,
+2 -7
View File
@@ -2,11 +2,9 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type LanguageOption = "zh" | "en" | "ja";
interface LanguageSettingsProps {
value: LanguageOption;
onChange: (value: LanguageOption) => void;
value: "zh" | "en";
onChange: (value: "zh" | "en") => void;
}
export function LanguageSettings({ value, onChange }: LanguageSettingsProps) {
@@ -27,9 +25,6 @@ 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,6 +34,7 @@ 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) =>
@@ -79,10 +80,12 @@ 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"));
}
@@ -127,6 +130,13 @@ 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"
@@ -161,6 +171,12 @@ 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),
+34 -11
View File
@@ -26,6 +26,7 @@ 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) =>
@@ -66,10 +67,12 @@ 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"));
}
@@ -107,17 +110,31 @@ export function RepoManagerPanel({
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 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>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
@@ -157,6 +174,12 @@ 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),
+3 -30
View File
@@ -76,8 +76,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "deepseek",
iconColor: "#1E88E5",
},
{
name: "Zhipu GLM",
@@ -96,8 +94,6 @@ export const providerPresets: ProviderPreset[] = [
category: "cn_official",
isPartner: true, // 合作伙伴
partnerPromotionKey: "zhipu", // 促销信息 i18n key
icon: "zhipu",
iconColor: "#0F62FE",
},
{
name: "Z.ai GLM",
@@ -116,8 +112,6 @@ export const providerPresets: ProviderPreset[] = [
category: "cn_official",
isPartner: true, // 合作伙伴
partnerPromotionKey: "zhipu", // 促销信息 i18n key
icon: "zhipu",
iconColor: "#0F62FE",
},
{
name: "Qwen Coder",
@@ -134,8 +128,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "qwen",
iconColor: "#FF6A00",
},
{
name: "Kimi k2",
@@ -151,8 +143,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "kimi",
iconColor: "#6366F1",
},
{
name: "Kimi For Coding",
@@ -168,8 +158,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "kimi",
iconColor: "#6366F1",
},
{
name: "ModelScope",
@@ -185,8 +173,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "aggregator",
icon: "alibaba",
iconColor: "#FF6A00",
},
{
name: "KAT-Coder",
@@ -234,7 +220,7 @@ export const providerPresets: ProviderPreset[] = [
{
name: "MiniMax",
websiteUrl: "https://platform.minimaxi.com",
apiKeyUrl: "https://platform.minimax.io/subscribe/coding-plan",
apiKeyUrl: "https://platform.minimaxi.com/user-center/basic-information",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.minimaxi.com/anthropic",
@@ -248,14 +234,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax",
theme: {
backgroundColor: "#f64551",
textColor: "#FFFFFF",
},
icon: "minimax",
iconColor: "#FF6B6B",
},
{
name: "DouBaoSeed",
@@ -273,8 +251,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "doubao",
iconColor: "#3370FF",
},
{
name: "BaiLing",
@@ -290,8 +266,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "alibaba",
iconColor: "#FF6A00",
},
{
name: "AiHubMix",
@@ -316,11 +290,11 @@ export const providerPresets: ProviderPreset[] = [
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://www.dmxapi.cn",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_API_KEY: "",
},
},
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
endpointCandidates: ["https://www.dmxapi.cn", "https://api.dmxapi.cn"],
endpointCandidates: ["https://aihubmix.com", "https://api.aihubmix.com"],
category: "aggregator",
},
{
@@ -341,6 +315,5 @@ export const providerPresets: ProviderPreset[] = [
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "packycode", // 促销信息 i18n key
icon: "packycode",
},
];
-8
View File
@@ -20,9 +20,6 @@ export interface CodexProviderPreset {
endpointCandidates?: string[];
// 新增:视觉主题配置
theme?: PresetTheme;
// 图标配置
icon?: string; // 图标名称
iconColor?: string; // 图标颜色
}
/**
@@ -74,8 +71,6 @@ export const codexProviderPresets: CodexProviderPreset[] = [
backgroundColor: "#1F2937", // gray-800
textColor: "#FFFFFF",
},
icon: "openai",
iconColor: "#00A67E",
},
{
name: "Azure OpenAI",
@@ -102,8 +97,6 @@ requires_openai_auth = true`,
backgroundColor: "#0078D4",
textColor: "#FFFFFF",
},
icon: "azure",
iconColor: "#0078D4",
},
{
name: "AiHubMix",
@@ -149,6 +142,5 @@ requires_openai_auth = true`,
],
isPartner: true, // 合作伙伴
partnerPromotionKey: "packycode", // 促销信息 i18n key
icon: "packycode",
},
];
-6
View File
@@ -25,9 +25,6 @@ export interface GeminiProviderPreset {
partnerPromotionKey?: string;
endpointCandidates?: string[];
theme?: GeminiPresetTheme;
// 图标配置
icon?: string; // 图标名称
iconColor?: string; // 图标颜色
}
export const geminiProviderPresets: GeminiProviderPreset[] = [
@@ -46,8 +43,6 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
backgroundColor: "#4285F4",
textColor: "#FFFFFF",
},
icon: "gemini",
iconColor: "#4285F4",
},
{
name: "PackyCode",
@@ -69,7 +64,6 @@ 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" | "ja";
type Language = "zh" | "en";
interface SaveResult {
requiresRestart: boolean;
+4 -5
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" | "ja";
type Language = "zh" | "en";
export type SettingsFormState = Omit<Settings, "language"> & {
language: Language;
@@ -11,8 +11,7 @@ export type SettingsFormState = Omit<Settings, "language"> & {
const normalizeLanguage = (lang?: string | null): Language => {
if (!lang) return "zh";
const normalized = lang.toLowerCase();
return normalized === "en" || normalized === "ja" ? normalized : "zh";
return lang === "en" ? "en" : "zh";
};
const sanitizeDir = (value?: string | null): string | undefined => {
@@ -52,8 +51,8 @@ export function useSettingsForm(): UseSettingsFormResult {
const readPersistedLanguage = useCallback((): Language => {
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("language");
if (stored === "en" || stored === "zh" || stored === "ja") {
return stored as Language;
if (stored === "en" || stored === "zh") {
return stored;
}
}
return normalizeLanguage(i18n.language);
+3 -13
View File
@@ -2,18 +2,15 @@ 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";
type Language = "zh" | "en" | "ja";
const DEFAULT_LANGUAGE: "zh" | "en" = "zh";
const DEFAULT_LANGUAGE: Language = "zh";
const getInitialLanguage = (): Language => {
const getInitialLanguage = (): "zh" | "en" => {
if (typeof window !== "undefined") {
try {
const stored = window.localStorage.getItem("language");
if (stored === "zh" || stored === "en" || stored === "ja") {
if (stored === "zh" || stored === "en") {
return stored;
}
} catch (error) {
@@ -31,10 +28,6 @@ const getInitialLanguage = (): Language => {
return "zh";
}
if (navigatorLang?.startsWith("ja")) {
return "ja";
}
if (navigatorLang?.startsWith("en")) {
return "en";
}
@@ -46,9 +39,6 @@ const resources = {
en: {
translation: en,
},
ja: {
translation: ja,
},
zh: {
translation: zh,
},
+2 -7
View File
@@ -165,7 +165,6 @@
"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",
@@ -262,8 +261,7 @@
"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",
"minimax": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% 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"
},
"parameterConfig": "Parameter Config - {{name}} *",
"mainModel": "Main Model (optional)",
@@ -277,8 +275,6 @@
"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",
@@ -377,7 +373,6 @@
"templateGeneral": "General",
"templateNewAPI": "NewAPI",
"credentialsConfig": "Credentials",
"baseUrl": "Base URL",
"accessToken": "Access Token",
"accessTokenPlaceholder": "Generate in 'Security Settings'",
"userId": "User ID",
@@ -391,7 +386,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, 0 to disable)",
"autoIntervalMinutes": "Auto query interval (minutes)",
"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
@@ -1,837 +0,0 @@
{
"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": "プレビュー"
}
}
+3 -8
View File
@@ -165,7 +165,6 @@
"autoReload": "数据将在2秒后自动刷新...",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"languageOptionJapanese": "日本語",
"windowBehavior": "窗口行为",
"windowBehaviorHint": "配置窗口最小化与 Claude 插件联动策略。",
"launchOnStartup": "开机自启",
@@ -262,8 +261,7 @@
"getApiKey": "获取 API Key",
"partnerPromotion": {
"zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠",
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
"minimax": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)"
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠"
},
"parameterConfig": "参数配置 - {{name}} *",
"mainModel": "主模型 (可选)",
@@ -277,8 +275,6 @@
"fillConfigContent": "请填写配置内容",
"fillParameter": "请填写 {{label}}",
"fillTemplateValue": "请填写 {{label}}",
"endpointRequired": "非官方供应商请填写 API 端点",
"apiKeyRequired": "非官方供应商请填写 API Key",
"configJsonError": "配置JSON格式错误,请检查语法",
"authJsonRequired": "auth.json 必须是 JSON 对象",
"authJsonError": "auth.json 格式错误,请检查JSON语法",
@@ -377,8 +373,7 @@
"templateGeneral": "通用模板",
"templateNewAPI": "NewAPI",
"credentialsConfig": "凭证配置",
"baseUrl": "请求地址",
"accessToken": "访问令牌(在个人安全设置里获取)",
"accessToken": "访问令牌",
"accessTokenPlaceholder": "在'安全设置'里生成",
"userId": "用户 ID",
"userIdPlaceholder": "例如:114514",
@@ -391,7 +386,7 @@
"timeoutHint": "范围: 2-30 秒",
"timeoutMustBeInteger": "超时时间必须为整数,小数部分已忽略",
"timeoutCannotBeNegative": "超时时间不能为负数",
"autoIntervalMinutes": "自动查询间隔(分钟0 表示不自动查询",
"autoIntervalMinutes": "自动查询间隔(分钟)",
"autoQueryInterval": "自动查询间隔(分钟)",
"autoQueryIntervalHint": "0 表示不自动查询,建议 5-60 分钟",
"intervalMustBeInteger": "自动查询间隔必须为整数,小数部分已忽略",
+5 -4
View File
@@ -1,7 +1,8 @@
/* Tailwind CSS v3 指令 */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 引入 Tailwind v4 内建样式与工具 */
@import "tailwindcss";
/* 覆盖 Tailwind v4 默认的 dark 变体为"类选择器"模式 */
@custom-variant dark (&:where(.dark, .dark *));
/* shadcn/ui 主题变量 - 蓝色主题 */
@layer base {
+1
View File
@@ -33,6 +33,7 @@ export interface DeepLinkImportRequest {
repo?: string;
directory?: string;
branch?: string;
skillsPath?: string;
// Config file fields
config?: string;
+2
View File
@@ -10,6 +10,7 @@ export interface Skill {
repoOwner?: string;
repoName?: string;
repoBranch?: string;
skillsPath?: string; // 技能所在的子目录路径,如 "skills"
}
export interface SkillRepo {
@@ -17,6 +18,7 @@ export interface SkillRepo {
name: string;
branch: string;
enabled: boolean;
skillsPath?: string; // 可选:技能所在的子目录路径,如 "skills"
}
export const skillsApi = {
+1 -9
View File
@@ -95,13 +95,6 @@ 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),
@@ -113,8 +106,7 @@ export const useUsageQuery = (
refetchIntervalInBackground: true, // 后台也继续定时查询
refetchOnWindowFocus: false,
retry: false,
staleTime, // 使用动态计算的缓存时间
gcTime: 10 * 60 * 1000, // 缓存保留 10 分钟(组件卸载后)
staleTime: 0, // 使用缓存策略,确保 refetchInterval 准确执行
});
return {
+1 -1
View File
@@ -36,7 +36,7 @@ function parseJsonError(error: unknown): string {
}
export const providerSchema = z.object({
name: z.string(), // 必填校验移至 handleSubmit 中用 toast 提示
name: z.string().min(1, "请填写供应商名称"),
websiteUrl: z.string().url("请输入有效的网址").optional().or(z.literal("")),
notes: z.string().optional(),
settingsConfig: z
+3 -11
View File
@@ -8,22 +8,14 @@ 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(),
geminiConfigDir: directorySchema.nullable().optional(),
// 当前供应商 ID(设备级)
currentProviderClaude: z.string().optional(),
currentProviderCodex: z.string().optional(),
currentProviderGemini: z.string().optional(),
language: z.enum(["en", "zh"]).optional(),
customEndpointsClaude: z.record(z.string(), z.unknown()).optional(),
customEndpointsCodex: z.record(z.string(), z.unknown()).optional(),
});
export type SettingsFormData = z.infer<typeof settingsSchema>;
+14 -16
View File
@@ -97,35 +97,33 @@ 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;
// ===== 当前供应商 ID(设备级)=====
// 当前 Claude 供应商 ID(优先于数据库 is_current
currentProviderClaude?: string;
// 当前 Codex 供应商 ID(优先于数据库 is_current
currentProviderCodex?: string;
// 当前 Gemini 供应商 ID(优先于数据库 is_current
currentProviderGemini?: string;
// 首选语言(可选,默认中文)
language?: "en" | "zh";
// 是否开机自启
launchOnStartup?: boolean;
// Claude 自定义端点列表
customEndpointsClaude?: Record<string, CustomEndpoint>;
// Codex 自定义端点列表
customEndpointsCodex?: Record<string, CustomEndpoint>;
// 安全设置(兼容未来扩展)
security?: {
auth?: {
selectedType?: string;
};
};
}
// MCP 服务器连接参数(宽松:允许扩展字段)
-1
View File
@@ -4,7 +4,6 @@ export default {
"./src/index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: "selector",
theme: {
extend: {
colors: {
-23
View File
@@ -58,29 +58,6 @@ 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,
+2 -1
View File
@@ -1,10 +1,11 @@
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()],
plugins: [react(), tailwindcss()],
base: "./",
build: {
outDir: "../dist",