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

@@ -3,10 +3,11 @@ import type { AppId } from "@/lib/api";
import type { ProviderCategory } from "@/types";
import type { ProviderPreset } from "@/config/claudeProviderPresets";
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
type PresetEntry = {
id: string;
preset: ProviderPreset | CodexProviderPreset;
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset;
};
interface UseApiKeyLinkProps {
@@ -73,11 +74,9 @@ export function useApiKeyLink({
return {
shouldShowApiKeyLink:
appId === "claude"
appId === "claude" || appId === "codex" || appId === "gemini"
? shouldShowApiKeyLink
: appId === "codex"
? shouldShowApiKeyLink
: false,
: false,
websiteUrl: getWebsiteUrl,
isPartner,
partnerPromotionKey,

View File

@@ -11,6 +11,7 @@ interface UseApiKeyStateProps {
onConfigChange: (config: string) => void;
selectedPresetId: string | null;
category?: ProviderCategory;
appType?: string;
}
/**
@@ -22,10 +23,11 @@ export function useApiKeyState({
onConfigChange,
selectedPresetId,
category,
appType,
}: UseApiKeyStateProps) {
const [apiKey, setApiKey] = useState(() => {
if (initialConfig) {
return getApiKeyFromConfig(initialConfig);
return getApiKeyFromConfig(initialConfig, appType);
}
return "";
});
@@ -38,7 +40,7 @@ export function useApiKeyState({
initialConfig || "{}",
key.trim(),
{
// 最佳实践:仅在"新增模式"且"非官方类别"时补齐缺失字段
// 最佳实践:仅在新增模式”且“非官方类别时补齐缺失字段
// - 新增模式selectedPresetId !== null
// - 非官方类别category !== undefined && category !== "official"
// - 官方类别不创建字段UI 也会禁用输入框)
@@ -47,21 +49,23 @@ export function useApiKeyState({
selectedPresetId !== null &&
category !== undefined &&
category !== "official",
appType,
},
);
onConfigChange(configString);
},
[initialConfig, selectedPresetId, category, onConfigChange],
[initialConfig, selectedPresetId, category, appType, onConfigChange],
);
const showApiKey = useCallback(
(config: string, isEditMode: boolean) => {
return (
selectedPresetId !== null || (isEditMode && hasApiKeyField(config))
selectedPresetId !== null ||
(isEditMode && hasApiKeyField(config, appType))
);
},
[selectedPresetId],
[selectedPresetId, appType],
);
return {

View File

@@ -6,7 +6,7 @@ import {
import type { ProviderCategory } from "@/types";
interface UseBaseUrlStateProps {
appType: "claude" | "codex";
appType: "claude" | "codex" | "gemini";
category: ProviderCategory | undefined;
settingsConfig: string;
codexConfig?: string;
@@ -28,6 +28,7 @@ export function useBaseUrlState({
}: UseBaseUrlStateProps) {
const [baseUrl, setBaseUrl] = useState("");
const [codexBaseUrl, setCodexBaseUrl] = useState("");
const [geminiBaseUrl, setGeminiBaseUrl] = useState("");
const isUpdatingRef = useRef(false);
// 从配置同步到 stateClaude
@@ -62,6 +63,27 @@ export function useBaseUrlState({
}
}, [appType, category, codexConfig, codexBaseUrl]);
// 从Claude配置同步到 stateGemini
useEffect(() => {
if (appType !== "gemini") return;
// 只有 official 类别不显示 Base URL 输入框,其他类别都需要回填
if (category === "official") return;
if (isUpdatingRef.current) return;
try {
const config = JSON.parse(settingsConfig || "{}");
const envUrl: unknown = config?.env?.GOOGLE_GEMINI_BASE_URL;
const nextUrl =
typeof envUrl === "string" ? envUrl.trim().replace(/\/+$/, "") : "";
if (nextUrl !== geminiBaseUrl) {
setGeminiBaseUrl(nextUrl);
setBaseUrl(nextUrl); // 也更新 baseUrl 用于 UI
}
} catch {
// ignore
}
}, [appType, category, settingsConfig, geminiBaseUrl]);
// 处理 Claude Base URL 变化
const handleClaudeBaseUrlChange = useCallback(
(url: string) => {
@@ -111,12 +133,41 @@ export function useBaseUrlState({
[codexConfig, onCodexConfigChange],
);
// 处理 Gemini Base URL 变化
const handleGeminiBaseUrlChange = useCallback(
(url: string) => {
const sanitized = url.trim().replace(/\/+$/, "");
setGeminiBaseUrl(sanitized);
setBaseUrl(sanitized); // 也更新 baseUrl 用于 UI
isUpdatingRef.current = true;
try {
const config = JSON.parse(settingsConfig || "{}");
if (!config.env) {
config.env = {};
}
config.env.GOOGLE_GEMINI_BASE_URL = sanitized;
onSettingsConfigChange(JSON.stringify(config, null, 2));
} catch {
// ignore
} finally {
setTimeout(() => {
isUpdatingRef.current = false;
}, 0);
}
},
[settingsConfig, onSettingsConfigChange],
);
return {
baseUrl,
setBaseUrl,
codexBaseUrl,
setCodexBaseUrl,
geminiBaseUrl,
setGeminiBaseUrl,
handleClaudeBaseUrlChange,
handleCodexBaseUrlChange,
handleGeminiBaseUrlChange,
};
}

View File

@@ -3,6 +3,7 @@ import type { ProviderCategory } from "@/types";
import type { AppId } from "@/lib/api";
import { providerPresets } from "@/config/claudeProviderPresets";
import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
interface UseProviderCategoryProps {
appId: AppId;
@@ -41,7 +42,7 @@ export function useProviderCategory({
if (!selectedPresetId) return;
// 从预设 ID 提取索引
const match = selectedPresetId.match(/^(claude|codex)-(\d+)$/);
const match = selectedPresetId.match(/^(claude|codex|gemini)-(\d+)$/);
if (!match) return;
const [, type, indexStr] = match;
@@ -61,6 +62,11 @@ export function useProviderCategory({
preset.category || (preset.isOfficial ? "official" : undefined),
);
}
} else if (type === "gemini" && appId === "gemini") {
const preset = geminiProviderPresets[index];
if (preset) {
setCategory(preset.category || undefined);
}
}
}, [appId, selectedPresetId, isEditMode, initialCategory]);

View File

@@ -39,7 +39,8 @@ export function useSpeedTestEndpoints({
initialData,
}: UseSpeedTestEndpointsProps) {
const claudeEndpoints = useMemo<EndpointCandidate[]>(() => {
if (appId !== "claude") return [];
// Reuse this branch for Claude and Gemini (non-Codex)
if (appId !== "claude" && appId !== "gemini") return [];
const map = new Map<string, EndpointCandidate>();
// 所有端点都标记为 isCustom: true给用户完全的管理自由
@@ -66,26 +67,37 @@ export function useSpeedTestEndpoints({
// 3. 编辑模式:初始数据中的 URL
if (initialData && typeof initialData.settingsConfig === "object") {
const configEnv = initialData.settingsConfig as {
env?: { ANTHROPIC_BASE_URL?: string };
env?: { ANTHROPIC_BASE_URL?: string; GOOGLE_GEMINI_BASE_URL?: string };
};
const envUrl = configEnv.env?.ANTHROPIC_BASE_URL;
if (typeof envUrl === "string") {
add(envUrl);
}
const envUrls = [
configEnv.env?.ANTHROPIC_BASE_URL,
configEnv.env?.GOOGLE_GEMINI_BASE_URL,
];
envUrls.forEach((u) => {
if (typeof u === "string") add(u);
});
}
// 4. 预设中的 endpointCandidates也允许用户删除
if (selectedPresetId && selectedPresetId !== "custom") {
const entry = presetEntries.find((item) => item.id === selectedPresetId);
if (entry) {
const preset = entry.preset as ProviderPreset;
// 添加预设自己的 baseUrl
const presetEnv = preset.settingsConfig as {
env?: { ANTHROPIC_BASE_URL?: string };
const preset = entry.preset as ProviderPreset & {
settingsConfig?: { env?: { GOOGLE_GEMINI_BASE_URL?: string } };
endpointCandidates?: string[];
};
if (presetEnv.env?.ANTHROPIC_BASE_URL) {
add(presetEnv.env.ANTHROPIC_BASE_URL);
}
// 添加预设自己的 baseUrl兼容 Claude/Gemini
const presetEnv = preset.settingsConfig as {
env?: {
ANTHROPIC_BASE_URL?: string;
GOOGLE_GEMINI_BASE_URL?: string;
};
};
const presetUrls = [
presetEnv?.env?.ANTHROPIC_BASE_URL,
presetEnv?.env?.GOOGLE_GEMINI_BASE_URL,
];
presetUrls.forEach((u) => add(u));
// 添加预设的候选端点
if (preset.endpointCandidates) {
preset.endpointCandidates.forEach((url) => add(url));