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:
@@ -1,5 +1,5 @@
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { ClaudeIcon, CodexIcon } from "./BrandIcons";
|
||||
import { ClaudeIcon, CodexIcon, GeminiIcon } from "./BrandIcons";
|
||||
|
||||
interface AppSwitcherProps {
|
||||
activeApp: AppId;
|
||||
@@ -46,6 +46,26 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
||||
<CodexIcon size={16} />
|
||||
<span>Codex</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSwitch("gemini")}
|
||||
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
activeApp === "gemini"
|
||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
|
||||
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
||||
}`}
|
||||
>
|
||||
<GeminiIcon
|
||||
size={16}
|
||||
className={
|
||||
activeApp === "gemini"
|
||||
? "text-[#4285F4] dark:text-[#4285F4] transition-colors duration-200"
|
||||
: "text-gray-500 dark:text-gray-400 group-hover:text-[#4285F4] dark:group-hover:text-[#4285F4] transition-colors duration-200"
|
||||
}
|
||||
/>
|
||||
<span>Gemini</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,3 +32,18 @@ export function CodexIcon({ size = 16, className = "" }: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GeminiIcon({ size = 16, className = "" }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 1024 1024"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M471.04 824.32Q512 918.4 512 1024q0-106.24.93-199.68.96-93.44 110.08-162.56t162.56-108.8Q918.4 512 1024 512q-106.24 0-199.68-39.68a524.8 524.8 0 0 1-162.56-110.08 524.8 524.8 0 0 1-110.08-162.56Q512 106.24 512 0q0 106.24-40.96 199.68-39.68 93.44-108.8 162.56a524.8 524.8 0 0 1-162.56 110.08Q106.24 512 0 512q106.24 0 199.68 40.96 93.44 39.68 162.56 108.8t108.8 162.56" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -267,7 +267,8 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
|
||||
// 判断是否应该显示凭证配置区域
|
||||
const shouldShowCredentialsConfig =
|
||||
selectedTemplate === TEMPLATE_KEYS.GENERAL || selectedTemplate === TEMPLATE_KEYS.NEW_API;
|
||||
selectedTemplate === TEMPLATE_KEYS.GENERAL ||
|
||||
selectedTemplate === TEMPLATE_KEYS.NEW_API;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
@@ -334,9 +335,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-api-key">
|
||||
API Key
|
||||
</Label>
|
||||
<Label htmlFor="usage-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="usage-api-key"
|
||||
@@ -353,18 +352,24 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||
aria-label={showApiKey ? t("apiKeyInput.hide") : t("apiKeyInput.show")}
|
||||
aria-label={
|
||||
showApiKey
|
||||
? t("apiKeyInput.hide")
|
||||
: t("apiKeyInput.show")
|
||||
}
|
||||
>
|
||||
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
{showApiKey ? (
|
||||
<EyeOff size={16} />
|
||||
) : (
|
||||
<Eye size={16} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-base-url">
|
||||
Base URL
|
||||
</Label>
|
||||
<Label htmlFor="usage-base-url">Base URL</Label>
|
||||
<Input
|
||||
id="usage-base-url"
|
||||
type="text"
|
||||
@@ -383,9 +388,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
{selectedTemplate === TEMPLATE_KEYS.NEW_API && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-newapi-base-url">
|
||||
Base URL
|
||||
</Label>
|
||||
<Label htmlFor="usage-newapi-base-url">Base URL</Label>
|
||||
<Input
|
||||
id="usage-newapi-base-url"
|
||||
type="text"
|
||||
@@ -408,19 +411,34 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
type={showAccessToken ? "text" : "password"}
|
||||
value={script.accessToken || ""}
|
||||
onChange={(e) =>
|
||||
setScript({ ...script, accessToken: e.target.value })
|
||||
setScript({
|
||||
...script,
|
||||
accessToken: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t("usageScript.accessTokenPlaceholder")}
|
||||
placeholder={t(
|
||||
"usageScript.accessTokenPlaceholder",
|
||||
)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{script.accessToken && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAccessToken(!showAccessToken)}
|
||||
onClick={() =>
|
||||
setShowAccessToken(!showAccessToken)
|
||||
}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||
aria-label={showAccessToken ? t("apiKeyInput.hide") : t("apiKeyInput.show")}
|
||||
aria-label={
|
||||
showAccessToken
|
||||
? t("apiKeyInput.hide")
|
||||
: t("apiKeyInput.show")
|
||||
}
|
||||
>
|
||||
{showAccessToken ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
{showAccessToken ? (
|
||||
<EyeOff size={16} />
|
||||
) : (
|
||||
<Eye size={16} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -448,9 +466,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
|
||||
{/* 脚本编辑器 */}
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("usageScript.queryScript")}
|
||||
</Label>
|
||||
<Label className="mb-2">{t("usageScript.queryScript")}</Label>
|
||||
<JsonEditor
|
||||
value={script.code}
|
||||
onChange={(code) => setScript({ ...script, code })}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/components/providers/forms/ProviderForm";
|
||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||
|
||||
interface AddProviderDialogProps {
|
||||
open: boolean;
|
||||
@@ -96,6 +97,21 @@ export function AddProviderDialog({
|
||||
preset.endpointCandidates.forEach(addUrl);
|
||||
}
|
||||
}
|
||||
} else if (appId === "gemini") {
|
||||
const presets = geminiProviderPresets;
|
||||
const presetIndex = parseInt(
|
||||
values.presetId.replace("gemini-", ""),
|
||||
);
|
||||
if (
|
||||
!isNaN(presetIndex) &&
|
||||
presetIndex >= 0 &&
|
||||
presetIndex < presets.length
|
||||
) {
|
||||
const preset = presets[presetIndex];
|
||||
if (Array.isArray(preset.endpointCandidates)) {
|
||||
preset.endpointCandidates.forEach(addUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +130,11 @@ export function AddProviderDialog({
|
||||
addUrl(baseUrlMatch[1]);
|
||||
}
|
||||
}
|
||||
} else if (appId === "gemini") {
|
||||
const env = parsedConfig.env as Record<string, any> | undefined;
|
||||
if (env?.GOOGLE_GEMINI_BASE_URL) {
|
||||
addUrl(env.GOOGLE_GEMINI_BASE_URL);
|
||||
}
|
||||
}
|
||||
|
||||
const urls = Array.from(urlSet);
|
||||
@@ -144,7 +165,9 @@ export function AddProviderDialog({
|
||||
const submitLabel =
|
||||
appId === "claude"
|
||||
? t("provider.addClaudeProvider")
|
||||
: t("provider.addCodexProvider");
|
||||
: appId === "codex"
|
||||
? t("provider.addCodexProvider")
|
||||
: t("provider.addGeminiProvider");
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
||||
@@ -40,7 +40,9 @@ const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||
const config = provider.settingsConfig;
|
||||
|
||||
if (config && typeof config === "object") {
|
||||
const envBase = (config as Record<string, any>)?.env?.ANTHROPIC_BASE_URL;
|
||||
const envBase =
|
||||
(config as Record<string, any>)?.env?.ANTHROPIC_BASE_URL ||
|
||||
(config as Record<string, any>)?.env?.GOOGLE_GEMINI_BASE_URL;
|
||||
if (typeof envBase === "string" && envBase.trim()) {
|
||||
return envBase;
|
||||
}
|
||||
@@ -147,6 +149,17 @@ export function ProviderCard({
|
||||
<h3 className="text-base font-semibold leading-none">
|
||||
{provider.name}
|
||||
</h3>
|
||||
{provider.category === "third_party" &&
|
||||
provider.meta?.isPartner && (
|
||||
<span
|
||||
className="text-yellow-500 dark:text-yellow-400"
|
||||
title={t("provider.officialPartner", {
|
||||
defaultValue: "官方合作伙伴",
|
||||
})}
|
||||
>
|
||||
⭐
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full bg-green-500/10 px-2 py-0.5 text-xs font-medium text-green-500 dark:text-green-400 transition-opacity duration-200",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
83
src/config/geminiProviderPresets.ts
Normal file
83
src/config/geminiProviderPresets.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { ProviderCategory } from "@/types";
|
||||
|
||||
export interface GeminiProviderPreset {
|
||||
name: string;
|
||||
websiteUrl: string;
|
||||
apiKeyUrl?: string;
|
||||
settingsConfig: object;
|
||||
baseURL?: string;
|
||||
model?: string;
|
||||
description?: string;
|
||||
category?: ProviderCategory;
|
||||
isPartner?: boolean;
|
||||
partnerPromotionKey?: string;
|
||||
endpointCandidates?: string[];
|
||||
}
|
||||
|
||||
export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
{
|
||||
name: "Google",
|
||||
websiteUrl: "https://ai.google.dev/",
|
||||
apiKeyUrl: "https://aistudio.google.com/apikey",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GEMINI_MODEL: "gemini-2.5-pro",
|
||||
},
|
||||
},
|
||||
description: "Google 官方 Gemini API (OAuth)",
|
||||
category: "official",
|
||||
partnerPromotionKey: "google-official",
|
||||
model: "gemini-2.5-pro",
|
||||
},
|
||||
{
|
||||
name: "PackyCode",
|
||||
websiteUrl: "https://www.packyapi.com",
|
||||
apiKeyUrl: "https://www.packyapi.com/register?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://www.packyapi.com",
|
||||
GEMINI_MODEL: "gemini-2.5-pro",
|
||||
},
|
||||
},
|
||||
baseURL: "https://www.packyapi.com",
|
||||
model: "gemini-2.5-pro",
|
||||
description: "PackyCode",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "packycode",
|
||||
endpointCandidates: [
|
||||
"https://api-slb.packyapi.com",
|
||||
"https://www.packyapi.com",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "自定义",
|
||||
websiteUrl: "",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "",
|
||||
GEMINI_MODEL: "gemini-2.5-pro",
|
||||
},
|
||||
},
|
||||
model: "gemini-2.5-pro",
|
||||
description: "自定义 Gemini API 端点",
|
||||
category: "custom",
|
||||
},
|
||||
];
|
||||
|
||||
export function getGeminiPresetByName(
|
||||
name: string,
|
||||
): GeminiProviderPreset | undefined {
|
||||
return geminiProviderPresets.find((preset) => preset.name === name);
|
||||
}
|
||||
|
||||
export function getGeminiPresetByUrl(
|
||||
url: string,
|
||||
): GeminiProviderPreset | undefined {
|
||||
if (!url) return undefined;
|
||||
return geminiProviderPresets.find(
|
||||
(preset) =>
|
||||
preset.baseURL &&
|
||||
url.toLowerCase().includes(preset.baseURL.toLowerCase()),
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,7 @@
|
||||
"addNewProvider": "Add New Provider",
|
||||
"addClaudeProvider": "Add Claude Code Provider",
|
||||
"addCodexProvider": "Add Codex Provider",
|
||||
"addGeminiProvider": "Add Gemini Provider",
|
||||
"addProviderHint": "Fill in the information to quickly switch providers in the list.",
|
||||
"editClaudeProvider": "Edit Claude Code Provider",
|
||||
"editCodexProvider": "Edit Codex Provider",
|
||||
@@ -91,7 +92,17 @@
|
||||
"addProvider": "Add Provider",
|
||||
"sortUpdated": "Sort order updated",
|
||||
"usageSaved": "Usage query configuration saved",
|
||||
"usageSaveFailed": "Failed to save usage query configuration"
|
||||
"usageSaveFailed": "Failed to save usage query configuration",
|
||||
"geminiConfig": "Gemini Configuration",
|
||||
"geminiConfigHint": "Use .env format to configure Gemini",
|
||||
"form": {
|
||||
"gemini": {
|
||||
"model": "Model",
|
||||
"oauthTitle": "OAuth Authentication Mode",
|
||||
"oauthHint": "Google official uses OAuth personal authentication, no need to fill in API Key. The browser will automatically open for login on first use.",
|
||||
"apiKeyPlaceholder": "Enter Gemini API Key"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"providerAdded": "Provider added",
|
||||
@@ -195,7 +206,8 @@
|
||||
},
|
||||
"apps": {
|
||||
"claude": "Claude Code",
|
||||
"codex": "Codex"
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
},
|
||||
"console": {
|
||||
"providerSwitchReceived": "Received provider switch event:",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"addNewProvider": "添加新供应商",
|
||||
"addClaudeProvider": "添加 Claude Code 供应商",
|
||||
"addCodexProvider": "添加 Codex 供应商",
|
||||
"addGeminiProvider": "添加 Gemini 供应商",
|
||||
"addProviderHint": "填写信息后即可在列表中快速切换供应商。",
|
||||
"editClaudeProvider": "编辑 Claude Code 供应商",
|
||||
"editCodexProvider": "编辑 Codex 供应商",
|
||||
@@ -91,7 +92,17 @@
|
||||
"addProvider": "添加供应商",
|
||||
"sortUpdated": "排序已更新",
|
||||
"usageSaved": "用量查询配置已保存",
|
||||
"usageSaveFailed": "用量查询配置保存失败"
|
||||
"usageSaveFailed": "用量查询配置保存失败",
|
||||
"geminiConfig": "Gemini 配置",
|
||||
"geminiConfigHint": "使用 .env 格式配置 Gemini",
|
||||
"form": {
|
||||
"gemini": {
|
||||
"model": "模型",
|
||||
"oauthTitle": "OAuth 认证模式",
|
||||
"oauthHint": "Google 官方使用 OAuth 个人认证,无需填写 API Key。首次使用时会自动打开浏览器进行登录。",
|
||||
"apiKeyPlaceholder": "请输入 Gemini API Key"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"providerAdded": "供应商已添加",
|
||||
@@ -195,7 +206,8 @@
|
||||
},
|
||||
"apps": {
|
||||
"claude": "Claude Code",
|
||||
"codex": "Codex"
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
},
|
||||
"console": {
|
||||
"providerSwitchReceived": "收到供应商切换事件:",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// 前端统一使用 AppId 作为应用标识(与后端命令参数 `app` 一致)
|
||||
export type AppId = "claude" | "codex";
|
||||
export type AppId = "claude" | "codex" | "gemini"; // 新增 gemini
|
||||
|
||||
10
src/types.ts
10
src/types.ts
@@ -77,6 +77,10 @@ export interface ProviderMeta {
|
||||
custom_endpoints?: Record<string, CustomEndpoint>;
|
||||
// 用量查询脚本配置
|
||||
usage_script?: UsageScript;
|
||||
// 是否为官方合作伙伴
|
||||
isPartner?: boolean;
|
||||
// 合作伙伴促销 key(用于后端识别 PackyCode 等)
|
||||
partnerPromotionKey?: string;
|
||||
}
|
||||
|
||||
// 应用设置类型(用于设置对话框与 Tauri API)
|
||||
@@ -97,6 +101,12 @@ export interface Settings {
|
||||
customEndpointsClaude?: Record<string, CustomEndpoint>;
|
||||
// Codex 自定义端点列表
|
||||
customEndpointsCodex?: Record<string, CustomEndpoint>;
|
||||
// 安全设置(兼容未来扩展)
|
||||
security?: {
|
||||
auth?: {
|
||||
selectedType?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// MCP 服务器连接参数(宽松:允许扩展字段)
|
||||
|
||||
@@ -165,12 +165,32 @@ export const hasCommonConfigSnippet = (
|
||||
}
|
||||
};
|
||||
|
||||
// 读取配置中的 API Key(优先 ANTHROPIC_AUTH_TOKEN,其次 ANTHROPIC_API_KEY)
|
||||
export const getApiKeyFromConfig = (jsonString: string): string => {
|
||||
// 读取配置中的 API Key(支持 Claude, Codex, Gemini)
|
||||
export const getApiKeyFromConfig = (
|
||||
jsonString: string,
|
||||
appType?: string,
|
||||
): string => {
|
||||
try {
|
||||
const config = JSON.parse(jsonString);
|
||||
const token = config?.env?.ANTHROPIC_AUTH_TOKEN;
|
||||
const apiKey = config?.env?.ANTHROPIC_API_KEY;
|
||||
const env = config?.env;
|
||||
|
||||
if (!env) return "";
|
||||
|
||||
// Gemini API Key
|
||||
if (appType === "gemini") {
|
||||
const geminiKey = env.GEMINI_API_KEY;
|
||||
return typeof geminiKey === "string" ? geminiKey : "";
|
||||
}
|
||||
|
||||
// Codex API Key
|
||||
if (appType === "codex") {
|
||||
const codexKey = env.CODEX_API_KEY;
|
||||
return typeof codexKey === "string" ? codexKey : "";
|
||||
}
|
||||
|
||||
// Claude API Key (优先 ANTHROPIC_AUTH_TOKEN,其次 ANTHROPIC_API_KEY)
|
||||
const token = env.ANTHROPIC_AUTH_TOKEN;
|
||||
const apiKey = env.ANTHROPIC_API_KEY;
|
||||
const value =
|
||||
typeof token === "string"
|
||||
? token
|
||||
@@ -229,10 +249,22 @@ export const applyTemplateValues = (
|
||||
};
|
||||
|
||||
// 判断配置中是否存在 API Key 字段
|
||||
export const hasApiKeyField = (jsonString: string): boolean => {
|
||||
export const hasApiKeyField = (
|
||||
jsonString: string,
|
||||
appType?: string,
|
||||
): boolean => {
|
||||
try {
|
||||
const config = JSON.parse(jsonString);
|
||||
const env = config?.env ?? {};
|
||||
|
||||
if (appType === "gemini") {
|
||||
return Object.prototype.hasOwnProperty.call(env, "GEMINI_API_KEY");
|
||||
}
|
||||
|
||||
if (appType === "codex") {
|
||||
return Object.prototype.hasOwnProperty.call(env, "CODEX_API_KEY");
|
||||
}
|
||||
|
||||
return (
|
||||
Object.prototype.hasOwnProperty.call(env, "ANTHROPIC_AUTH_TOKEN") ||
|
||||
Object.prototype.hasOwnProperty.call(env, "ANTHROPIC_API_KEY")
|
||||
@@ -246,9 +278,9 @@ export const hasApiKeyField = (jsonString: string): boolean => {
|
||||
export const setApiKeyInConfig = (
|
||||
jsonString: string,
|
||||
apiKey: string,
|
||||
options: { createIfMissing?: boolean } = {},
|
||||
options: { createIfMissing?: boolean; appType?: string } = {},
|
||||
): string => {
|
||||
const { createIfMissing = false } = options;
|
||||
const { createIfMissing = false, appType } = options;
|
||||
try {
|
||||
const config = JSON.parse(jsonString);
|
||||
if (!config.env) {
|
||||
@@ -256,7 +288,32 @@ export const setApiKeyInConfig = (
|
||||
config.env = {};
|
||||
}
|
||||
const env = config.env as Record<string, any>;
|
||||
// 优先写入已存在的字段;若两者均不存在且允许创建,则默认创建 AUTH_TOKEN 字段
|
||||
|
||||
// Gemini API Key
|
||||
if (appType === "gemini") {
|
||||
if ("GEMINI_API_KEY" in env) {
|
||||
env.GEMINI_API_KEY = apiKey;
|
||||
} else if (createIfMissing) {
|
||||
env.GEMINI_API_KEY = apiKey;
|
||||
} else {
|
||||
return jsonString;
|
||||
}
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
// Codex API Key
|
||||
if (appType === "codex") {
|
||||
if ("CODEX_API_KEY" in env) {
|
||||
env.CODEX_API_KEY = apiKey;
|
||||
} else if (createIfMissing) {
|
||||
env.CODEX_API_KEY = apiKey;
|
||||
} else {
|
||||
return jsonString;
|
||||
}
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
// Claude API Key (优先写入已存在的字段;若两者均不存在且允许创建,则默认创建 AUTH_TOKEN 字段)
|
||||
if ("ANTHROPIC_AUTH_TOKEN" in env) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
} else if ("ANTHROPIC_API_KEY" in env) {
|
||||
|
||||
@@ -6,15 +6,17 @@
|
||||
*/
|
||||
export const normalizeQuotes = (text: string): string => {
|
||||
if (!text) return text;
|
||||
return text
|
||||
// 双引号族 → "
|
||||
.replace(/[“”„‟"]/g, '"')
|
||||
// 单引号族 → '
|
||||
.replace(/[‘’']/g, "'");
|
||||
return (
|
||||
text
|
||||
// 双引号族 → "
|
||||
.replace(/[“”„‟"]/g, '"')
|
||||
// 单引号族 → '
|
||||
.replace(/[‘’']/g, "'")
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 专用于 TOML 文本的归一化;目前等同于 normalizeQuotes,后续可扩展(如空白、行尾等)。
|
||||
*/
|
||||
export const normalizeTomlText = (text: string): string => normalizeQuotes(text);
|
||||
|
||||
export const normalizeTomlText = (text: string): string =>
|
||||
normalizeQuotes(text);
|
||||
|
||||
Reference in New Issue
Block a user