feat(gemini): add Gemini provider integration (#202)

* feat(gemini): add Gemini provider integration

- Add gemini_config.rs module for .env file parsing
- Extend AppType enum to support Gemini
- Implement GeminiConfigEditor and GeminiFormFields components
- Add GeminiIcon with standardized 1024x1024 viewBox
- Add Gemini provider presets configuration
- Update i18n translations for Gemini support
- Extend ProviderService and McpService for Gemini

* fix(gemini): resolve TypeScript errors, add i18n support, and fix MCP logic

**Critical Fixes:**
- Fix TS2741 errors in tests/msw/state.ts by adding missing Gemini type definitions
- Fix ProviderCard.extractApiUrl to support GOOGLE_GEMINI_BASE_URL display
- Add missing apps.gemini i18n keys (zh/en) for proper app name display
- Fix MCP service Gemini cross-app duplication logic to prevent self-copy

**Technical Details:**
- tests/msw/state.ts: Add gemini default providers, current ID, and MCP config
- ProviderCard.tsx: Check both ANTHROPIC_BASE_URL and GOOGLE_GEMINI_BASE_URL
- services/mcp.rs: Skip Gemini in sync_other_side logic with unreachable!() guards
- Run pnpm format to auto-fix code style issues

**Verification:**
-  pnpm typecheck passes
-  pnpm format completed

* feat(gemini): enhance authentication and config parsing

- Add strict and lenient .env parsing modes
- Implement PackyCode partner authentication detection
- Support Google OAuth official authentication
- Auto-configure security.auth.selectedType for PackyCode
- Add comprehensive test coverage for all auth types
- Update i18n for OAuth hints and Gemini config

---------

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
YoVinchen
2025-11-12 10:47:34 +08:00
committed by GitHub
parent 32a2ba5ef6
commit 8a05e7bd3d
46 changed files with 2522 additions and 276 deletions

View File

@@ -55,8 +55,7 @@ fn validate_mcp_entry(entry: &Value) -> Result<(), AppError> {
if let Some(val) = obj.get(key) {
if !val.is_string() {
return Err(AppError::McpValidation(format!(
"MCP 服务器 {} 必须为字符串",
key
"MCP 服务器 {key} 必须为字符串"
)));
}
}
@@ -138,9 +137,7 @@ fn normalize_server_keys(map: &mut HashMap<String, Value>) -> usize {
}
if map.contains_key(&new_key) {
log::warn!(
"MCP 条目 '{}' 的内部 id '{}' 与现有键冲突,回退为原键",
old_key,
new_key
"MCP 条目 '{old_key}' 的内部 id '{new_key}' 与现有键冲突,回退为原键"
);
if let Some(value) = map.get_mut(&old_key) {
if let Some(obj) = value.as_object_mut() {
@@ -161,7 +158,7 @@ fn normalize_server_keys(map: &mut HashMap<String, Value>) -> usize {
if let Some(obj) = value.as_object_mut() {
obj.insert("id".into(), json!(new_key.clone()));
}
log::info!("MCP 条目键名已自动修复: '{}' -> '{}'", old_key, new_key);
log::info!("MCP 条目键名已自动修复: '{old_key}' -> '{new_key}'");
map.insert(new_key, value);
change_count += 1;
}
@@ -208,7 +205,7 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
out.insert(id.clone(), spec);
}
Err(err) => {
log::warn!("跳过无效的 MCP 条目 '{}': {}", id, err);
log::warn!("跳过无效的 MCP 条目 '{id}': {err}");
}
}
}
@@ -223,7 +220,7 @@ pub fn get_servers_snapshot_for(
let mut snapshot = config.mcp_for(app).servers.clone();
snapshot.retain(|id, value| {
let Some(obj) = value.as_object_mut() else {
log::warn!("跳过无效的 MCP 条目 '{}': 必须为 JSON 对象", id);
log::warn!("跳过无效的 MCP 条目 '{id}': 必须为 JSON 对象");
return false;
};
@@ -232,7 +229,7 @@ pub fn get_servers_snapshot_for(
match validate_mcp_entry(value) {
Ok(()) => true,
Err(err) => {
log::error!("config.json 中存在无效的 MCP 条目 '{}': {}", id, err);
log::error!("config.json 中存在无效的 MCP 条目 '{id}': {err}");
false
}
}
@@ -262,8 +259,7 @@ pub fn upsert_in_config_for(
};
if existing_id_str != id {
return Err(AppError::McpValidation(format!(
"MCP 服务器条目中的 id '{}' 与参数 id '{}' 不一致",
existing_id_str, id
"MCP 服务器条目中的 id '{existing_id_str}' 与参数 id '{id}' 不一致"
)));
}
} else {
@@ -332,7 +328,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
let Some(text) = text_opt else { return Ok(0) };
let mut changed = normalize_servers_for(config, &AppType::Claude);
let v: Value = serde_json::from_str(&text)
.map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {}", e)))?;
.map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {e}")))?;
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
return Ok(changed);
};
@@ -359,7 +355,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
Entry::Occupied(mut occ) => {
let value = occ.get_mut();
let Some(existing) = value.as_object_mut() else {
log::warn!("MCP 条目 '{}' 不是 JSON 对象,覆盖为导入数据", id);
log::warn!("MCP 条目 '{id}' 不是 JSON 对象,覆盖为导入数据");
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
@@ -380,12 +376,12 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
modified = true;
}
if existing.get("server").is_none() {
log::warn!("MCP 条目 '{}' 缺少 server 字段,覆盖为导入数据", id);
log::warn!("MCP 条目 '{id}' 缺少 server 字段,覆盖为导入数据");
existing.insert(String::from("server"), spec.clone());
modified = true;
}
if existing.get("id").is_none() {
log::warn!("MCP 条目 '{}' 缺少 id 字段,自动填充", id);
log::warn!("MCP 条目 '{id}' 缺少 id 字段,自动填充");
existing.insert(String::from("id"), json!(id));
modified = true;
}
@@ -409,7 +405,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
let mut changed_total = normalize_servers_for(config, &AppType::Codex);
let root: toml::Table = toml::from_str(&text)
.map_err(|e| AppError::McpValidation(format!("解析 ~/.codex/config.toml 失败: {}", e)))?;
.map_err(|e| AppError::McpValidation(format!("解析 ~/.codex/config.toml 失败: {e}")))?;
// helper处理一组 servers 表
let mut import_servers_tbl = |servers_tbl: &toml::value::Table| {
@@ -484,7 +480,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
// 校验
if let Err(e) = validate_server_spec(&spec_v) {
log::warn!("跳过无效 Codex MCP 项 '{}': {}", id, e);
log::warn!("跳过无效 Codex MCP 项 '{id}': {e}");
continue;
}
@@ -507,7 +503,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
Entry::Occupied(mut occ) => {
let value = occ.get_mut();
let Some(existing) = value.as_object_mut() else {
log::warn!("MCP 条目 '{}' 不是 JSON 对象,覆盖为导入数据", id);
log::warn!("MCP 条目 '{id}' 不是 JSON 对象,覆盖为导入数据");
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
@@ -528,12 +524,12 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
modified = true;
}
if existing.get("server").is_none() {
log::warn!("MCP 条目 '{}' 缺少 server 字段,覆盖为导入数据", id);
log::warn!("MCP 条目 '{id}' 缺少 server 字段,覆盖为导入数据");
existing.insert(String::from("server"), spec_v.clone());
modified = true;
}
if existing.get("id").is_none() {
log::warn!("MCP 条目 '{}' 缺少 id 字段,自动填充", id);
log::warn!("MCP 条目 '{id}' 缺少 id 字段,自动填充");
existing.insert(String::from("id"), json!(id));
modified = true;
}
@@ -587,7 +583,7 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
} else {
base_text
.parse::<DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 config.toml 失败: {}", e)))?
.map_err(|e| AppError::McpValidation(format!("解析 config.toml 失败: {e}")))?
};
enum Target {