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:
@@ -146,11 +146,6 @@ export function CommonConfigEditor({
|
||||
<Wand2 className="w-3.5 h-3.5" />
|
||||
{t("common.format", { defaultValue: "格式化" })}
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("claudeConfig.fullSettingsHint", {
|
||||
defaultValue: "请填写完整的 Claude Code 配置",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { CustomEndpoint, EndpointCandidate } from "@/types";
|
||||
const ENDPOINT_TIMEOUT_SECS = {
|
||||
codex: 12,
|
||||
claude: 8,
|
||||
gemini: 8, // 新增 gemini
|
||||
} as const;
|
||||
|
||||
interface TestResult {
|
||||
|
||||
139
src/components/providers/forms/GeminiConfigEditor.tsx
Normal file
139
src/components/providers/forms/GeminiConfigEditor.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Wand2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface GeminiConfigEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function GeminiConfigEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: GeminiConfigEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 将 JSON 格式转换为 .env 格式显示
|
||||
const jsonToEnv = (jsonString: string): string => {
|
||||
try {
|
||||
const config = JSON.parse(jsonString);
|
||||
const env = config?.env || {};
|
||||
|
||||
const lines: string[] = [];
|
||||
if (env.GOOGLE_GEMINI_BASE_URL) {
|
||||
lines.push(`GOOGLE_GEMINI_BASE_URL=${env.GOOGLE_GEMINI_BASE_URL}`);
|
||||
}
|
||||
if (env.GEMINI_API_KEY) {
|
||||
lines.push(`GEMINI_API_KEY=${env.GEMINI_API_KEY}`);
|
||||
}
|
||||
if (env.GEMINI_MODEL) {
|
||||
lines.push(`GEMINI_MODEL=${env.GEMINI_MODEL}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
// 将 .env 格式转换为 JSON 格式保存
|
||||
const envToJson = (envString: string): string => {
|
||||
try {
|
||||
const lines = envString.split("\n");
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) return;
|
||||
|
||||
const equalIndex = trimmed.indexOf("=");
|
||||
if (equalIndex > 0) {
|
||||
const key = trimmed.substring(0, equalIndex).trim();
|
||||
const value = trimmed.substring(equalIndex + 1).trim();
|
||||
env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.stringify({ env }, null, 2);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const displayValue = jsonToEnv(value);
|
||||
|
||||
const handleChange = (envString: string) => {
|
||||
const jsonString = envToJson(envString);
|
||||
onChange(jsonString);
|
||||
};
|
||||
|
||||
const handleFormat = () => {
|
||||
if (!value.trim()) return;
|
||||
|
||||
try {
|
||||
// 重新格式化
|
||||
const envString = jsonToEnv(value);
|
||||
const formatted = envString
|
||||
.split("\n")
|
||||
.filter((l) => l.trim())
|
||||
.join("\n");
|
||||
const jsonString = envToJson(formatted);
|
||||
onChange(jsonString);
|
||||
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
t("common.formatError", {
|
||||
defaultValue: "格式化失败:{{error}}",
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="geminiConfig">
|
||||
{t("provider.geminiConfig", { defaultValue: "Gemini 配置" })}
|
||||
</Label>
|
||||
</div>
|
||||
<textarea
|
||||
id="geminiConfig"
|
||||
value={displayValue}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
|
||||
GEMINI_API_KEY=sk-your-api-key-here
|
||||
GEMINI_MODEL=gemini-2.5-pro`}
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[10rem]"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFormat}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Wand2 className="w-3.5 h-3.5" />
|
||||
{t("common.format", { defaultValue: "格式化" })}
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("provider.geminiConfigHint", {
|
||||
defaultValue: "使用 .env 格式配置 Gemini",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
src/components/providers/forms/GeminiFormFields.tsx
Normal file
150
src/components/providers/forms/GeminiFormFields.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Info } from "lucide-react";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { ApiKeySection, EndpointField } from "./shared";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
|
||||
interface EndpointCandidate {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface GeminiFormFieldsProps {
|
||||
providerId?: string;
|
||||
// API Key
|
||||
shouldShowApiKey: boolean;
|
||||
apiKey: string;
|
||||
onApiKeyChange: (key: string) => void;
|
||||
category?: ProviderCategory;
|
||||
shouldShowApiKeyLink: boolean;
|
||||
websiteUrl: string;
|
||||
isPartner?: boolean;
|
||||
partnerPromotionKey?: string;
|
||||
|
||||
// Base URL
|
||||
shouldShowSpeedTest: boolean;
|
||||
baseUrl: string;
|
||||
onBaseUrlChange: (url: string) => void;
|
||||
isEndpointModalOpen: boolean;
|
||||
onEndpointModalToggle: (open: boolean) => void;
|
||||
onCustomEndpointsChange: (endpoints: string[]) => void;
|
||||
|
||||
// Model
|
||||
shouldShowModelField: boolean;
|
||||
model: string;
|
||||
onModelChange: (value: string) => void;
|
||||
|
||||
// Speed Test Endpoints
|
||||
speedTestEndpoints: EndpointCandidate[];
|
||||
}
|
||||
|
||||
export function GeminiFormFields({
|
||||
providerId,
|
||||
shouldShowApiKey,
|
||||
apiKey,
|
||||
onApiKeyChange,
|
||||
category,
|
||||
shouldShowApiKeyLink,
|
||||
websiteUrl,
|
||||
isPartner,
|
||||
partnerPromotionKey,
|
||||
shouldShowSpeedTest,
|
||||
baseUrl,
|
||||
onBaseUrlChange,
|
||||
isEndpointModalOpen,
|
||||
onEndpointModalToggle,
|
||||
onCustomEndpointsChange,
|
||||
shouldShowModelField,
|
||||
model,
|
||||
onModelChange,
|
||||
speedTestEndpoints,
|
||||
}: GeminiFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 检测是否为 Google 官方(使用 OAuth)
|
||||
const isGoogleOfficial =
|
||||
partnerPromotionKey?.toLowerCase() === "google-official";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Google OAuth 提示 */}
|
||||
{isGoogleOfficial && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950">
|
||||
<div className="flex gap-3">
|
||||
<Info className="h-5 w-5 flex-shrink-0 text-blue-600 dark:text-blue-400" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
{t("provider.form.gemini.oauthTitle", {
|
||||
defaultValue: "OAuth 认证模式",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
{t("provider.form.gemini.oauthHint", {
|
||||
defaultValue:
|
||||
"Google 官方使用 OAuth 个人认证,无需填写 API Key。首次使用时会自动打开浏览器进行登录。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key 输入框 */}
|
||||
{shouldShowApiKey && !isGoogleOfficial && (
|
||||
<ApiKeySection
|
||||
value={apiKey}
|
||||
onChange={onApiKeyChange}
|
||||
category={category}
|
||||
shouldShowLink={shouldShowApiKeyLink}
|
||||
websiteUrl={websiteUrl}
|
||||
isPartner={isPartner}
|
||||
partnerPromotionKey={partnerPromotionKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Base URL 输入框(统一使用与 Codex 相同的样式与交互) */}
|
||||
{shouldShowSpeedTest && (
|
||||
<EndpointField
|
||||
id="baseUrl"
|
||||
label={t("providerForm.apiEndpoint", { defaultValue: "API 端点" })}
|
||||
value={baseUrl}
|
||||
onChange={onBaseUrlChange}
|
||||
placeholder={t("providerForm.apiEndpointPlaceholder", {
|
||||
defaultValue: "https://your-api-endpoint.com/",
|
||||
})}
|
||||
onManageClick={() => onEndpointModalToggle(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Model 输入框 */}
|
||||
{shouldShowModelField && (
|
||||
<div>
|
||||
<FormLabel htmlFor="gemini-model">
|
||||
{t("provider.form.gemini.model", { defaultValue: "模型" })}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="gemini-model"
|
||||
value={model}
|
||||
onChange={(e) => onModelChange(e.target.value)}
|
||||
placeholder="gemini-2.5-pro"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 端点测速弹窗 */}
|
||||
{shouldShowSpeedTest && isEndpointModalOpen && (
|
||||
<EndpointSpeedTest
|
||||
appId="gemini"
|
||||
providerId={providerId}
|
||||
value={baseUrl}
|
||||
onChange={onBaseUrlChange}
|
||||
initialEndpoints={speedTestEndpoints}
|
||||
visible={isEndpointModalOpen}
|
||||
onClose={() => onEndpointModalToggle(false)}
|
||||
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -15,14 +15,20 @@ import {
|
||||
codexProviderPresets,
|
||||
type CodexProviderPreset,
|
||||
} from "@/config/codexProviderPresets";
|
||||
import {
|
||||
geminiProviderPresets,
|
||||
type GeminiProviderPreset,
|
||||
} from "@/config/geminiProviderPresets";
|
||||
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||
import CodexConfigEditor from "./CodexConfigEditor";
|
||||
import { CommonConfigEditor } from "./CommonConfigEditor";
|
||||
import { GeminiConfigEditor } from "./GeminiConfigEditor";
|
||||
import { ProviderPresetSelector } from "./ProviderPresetSelector";
|
||||
import { BasicFormFields } from "./BasicFormFields";
|
||||
import { ClaudeFormFields } from "./ClaudeFormFields";
|
||||
import { CodexFormFields } from "./CodexFormFields";
|
||||
import { GeminiFormFields } from "./GeminiFormFields";
|
||||
import {
|
||||
useProviderCategory,
|
||||
useApiKeyState,
|
||||
@@ -39,10 +45,21 @@ import {
|
||||
|
||||
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
|
||||
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
||||
const GEMINI_DEFAULT_CONFIG = JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "",
|
||||
GEMINI_API_KEY: "",
|
||||
GEMINI_MODEL: "gemini-2.5-pro",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
type PresetEntry = {
|
||||
id: string;
|
||||
preset: ProviderPreset | CodexProviderPreset;
|
||||
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset;
|
||||
};
|
||||
|
||||
interface ProviderFormProps {
|
||||
@@ -80,6 +97,7 @@ export function ProviderForm({
|
||||
id: string;
|
||||
category?: ProviderCategory;
|
||||
isPartner?: boolean;
|
||||
partnerPromotionKey?: string;
|
||||
} | null>(null);
|
||||
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
|
||||
|
||||
@@ -123,7 +141,9 @@ export function ProviderForm({
|
||||
? JSON.stringify(initialData.settingsConfig, null, 2)
|
||||
: appId === "codex"
|
||||
? CODEX_DEFAULT_CONFIG
|
||||
: CLAUDE_DEFAULT_CONFIG,
|
||||
: appId === "gemini"
|
||||
? GEMINI_DEFAULT_CONFIG
|
||||
: CLAUDE_DEFAULT_CONFIG,
|
||||
}),
|
||||
[initialData, appId],
|
||||
);
|
||||
@@ -144,19 +164,22 @@ export function ProviderForm({
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
selectedPresetId,
|
||||
category,
|
||||
appType: appId,
|
||||
});
|
||||
|
||||
// 使用 Base URL hook (仅 Claude 模式)
|
||||
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
|
||||
appType: appId,
|
||||
category,
|
||||
settingsConfig: form.watch("settingsConfig"),
|
||||
codexConfig: "",
|
||||
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
onCodexConfigChange: () => {
|
||||
// Codex 使用 useCodexConfigState 管理 Base URL
|
||||
},
|
||||
});
|
||||
// 使用 Base URL hook (Claude, Codex, Gemini)
|
||||
const { baseUrl, handleClaudeBaseUrlChange, handleGeminiBaseUrlChange } =
|
||||
useBaseUrlState({
|
||||
appType: appId,
|
||||
category,
|
||||
settingsConfig: form.watch("settingsConfig"),
|
||||
codexConfig: "",
|
||||
onSettingsConfigChange: (config) =>
|
||||
form.setValue("settingsConfig", config),
|
||||
onCodexConfigChange: () => {
|
||||
/* noop */
|
||||
},
|
||||
});
|
||||
|
||||
// 使用 Model hook(新:主模型 + Haiku/Sonnet/Opus 默认模型)
|
||||
const {
|
||||
@@ -230,6 +253,11 @@ export function ProviderForm({
|
||||
id: `codex-${index}`,
|
||||
preset,
|
||||
}));
|
||||
} else if (appId === "gemini") {
|
||||
return geminiProviderPresets.map<PresetEntry>((preset, index) => ({
|
||||
id: `gemini-${index}`,
|
||||
preset,
|
||||
}));
|
||||
}
|
||||
return providerPresets.map<PresetEntry>((preset, index) => ({
|
||||
id: `claude-${index}`,
|
||||
@@ -366,11 +394,26 @@ export function ProviderForm({
|
||||
hadEndpoints && draftCustomEndpoints.length === 0;
|
||||
|
||||
// 如果用户明确清空了端点,传递空对象(而不是 null)让后端知道要删除
|
||||
const mergedMeta = needsClearEndpoints
|
||||
let mergedMeta = needsClearEndpoints
|
||||
? mergeProviderMeta(initialData?.meta, {})
|
||||
: mergeProviderMeta(initialData?.meta, customEndpointsToSave);
|
||||
|
||||
if (mergedMeta) {
|
||||
// 添加合作伙伴标识与促销 key
|
||||
if (activePreset?.isPartner) {
|
||||
mergedMeta = {
|
||||
...(mergedMeta ?? {}),
|
||||
isPartner: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (activePreset?.partnerPromotionKey) {
|
||||
mergedMeta = {
|
||||
...(mergedMeta ?? {}),
|
||||
partnerPromotionKey: activePreset.partnerPromotionKey,
|
||||
};
|
||||
}
|
||||
|
||||
if (mergedMeta !== undefined) {
|
||||
payload.meta = mergedMeta;
|
||||
}
|
||||
|
||||
@@ -425,6 +468,20 @@ export function ProviderForm({
|
||||
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||||
});
|
||||
|
||||
// 使用 API Key 链接 hook (Gemini)
|
||||
const {
|
||||
shouldShowApiKeyLink: shouldShowGeminiApiKeyLink,
|
||||
websiteUrl: geminiWebsiteUrl,
|
||||
isPartner: isGeminiPartner,
|
||||
partnerPromotionKey: geminiPartnerPromotionKey,
|
||||
} = useApiKeyLink({
|
||||
appId: "gemini",
|
||||
category,
|
||||
selectedPresetId,
|
||||
presetEntries,
|
||||
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||||
});
|
||||
|
||||
// 使用端点测速候选 hook
|
||||
const speedTestEndpoints = useSpeedTestEndpoints({
|
||||
appId,
|
||||
@@ -457,6 +514,7 @@ export function ProviderForm({
|
||||
id: value,
|
||||
category: entry.preset.category,
|
||||
isPartner: entry.preset.isPartner,
|
||||
partnerPromotionKey: entry.preset.partnerPromotionKey,
|
||||
});
|
||||
|
||||
if (appId === "codex") {
|
||||
@@ -476,6 +534,16 @@ export function ProviderForm({
|
||||
return;
|
||||
}
|
||||
|
||||
if (appId === "gemini") {
|
||||
const preset = entry.preset as GeminiProviderPreset;
|
||||
form.reset({
|
||||
name: preset.name,
|
||||
websiteUrl: preset.websiteUrl ?? "",
|
||||
settingsConfig: JSON.stringify(preset.settingsConfig, null, 2),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = entry.preset as ProviderPreset;
|
||||
const config = applyTemplateValues(
|
||||
preset.settingsConfig,
|
||||
@@ -573,7 +641,45 @@ export function ProviderForm({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 配置编辑器:Claude 使用通用配置编辑器,Codex 使用专用编辑器 */}
|
||||
{/* Gemini 专属字段 */}
|
||||
{appId === "gemini" && (
|
||||
<GeminiFormFields
|
||||
providerId={providerId}
|
||||
shouldShowApiKey={shouldShowApiKey(
|
||||
form.watch("settingsConfig"),
|
||||
isEditMode,
|
||||
)}
|
||||
apiKey={apiKey}
|
||||
onApiKeyChange={handleApiKeyChange}
|
||||
category={category}
|
||||
shouldShowApiKeyLink={shouldShowGeminiApiKeyLink}
|
||||
websiteUrl={geminiWebsiteUrl}
|
||||
isPartner={isGeminiPartner}
|
||||
partnerPromotionKey={geminiPartnerPromotionKey}
|
||||
shouldShowSpeedTest={shouldShowSpeedTest}
|
||||
baseUrl={baseUrl}
|
||||
onBaseUrlChange={handleGeminiBaseUrlChange}
|
||||
isEndpointModalOpen={isEndpointModalOpen}
|
||||
onEndpointModalToggle={setIsEndpointModalOpen}
|
||||
onCustomEndpointsChange={setDraftCustomEndpoints}
|
||||
shouldShowModelField={true}
|
||||
model={
|
||||
form.watch("settingsConfig")
|
||||
? JSON.parse(form.watch("settingsConfig") || "{}")?.env
|
||||
?.GEMINI_MODEL || ""
|
||||
: ""
|
||||
}
|
||||
onModelChange={(model) => {
|
||||
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));
|
||||
}}
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */}
|
||||
{appId === "codex" ? (
|
||||
<>
|
||||
<CodexConfigEditor
|
||||
@@ -604,6 +710,23 @@ export function ProviderForm({
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : appId === "gemini" ? (
|
||||
<>
|
||||
<GeminiConfigEditor
|
||||
value={form.watch("settingsConfig")}
|
||||
onChange={(value) => form.setValue("settingsConfig", value)}
|
||||
/>
|
||||
{/* 配置验证错误显示 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsConfig"
|
||||
render={() => (
|
||||
<FormItem className="space-y-0">
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CommonConfigEditor
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 从配置同步到 state(Claude)
|
||||
@@ -62,6 +63,27 @@ export function useBaseUrlState({
|
||||
}
|
||||
}, [appType, category, codexConfig, codexBaseUrl]);
|
||||
|
||||
// 从Claude配置同步到 state(Gemini)
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -9,7 +9,7 @@ interface EndpointFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
hint: string;
|
||||
hint?: string;
|
||||
showManageButton?: boolean;
|
||||
onManageClick?: () => void;
|
||||
manageButtonLabel?: string;
|
||||
@@ -55,9 +55,11 @@ export function EndpointField({
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">{hint}</p>
|
||||
</div>
|
||||
{hint ? (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">{hint}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user