feat: add model configuration support and fix Gemini deeplink bug (#251)
* feat(providers): add notes field for provider management - Add notes field to Provider model (backend and frontend) - Display notes with higher priority than URL in provider card - Style notes as non-clickable text to differentiate from URLs - Add notes input field in provider form - Add i18n support (zh/en) for notes field * chore: format code and clean up unused props - Run cargo fmt on Rust backend code - Format TypeScript imports and code style - Remove unused appId prop from ProviderPresetSelector - Clean up unused variables in tests - Integrate notes field handling in provider dialogs * feat(deeplink): implement ccswitch:// protocol for provider import Add deep link support to enable one-click provider configuration import via ccswitch:// URLs. Backend: - Implement URL parsing and validation (src-tauri/src/deeplink.rs) - Add Tauri commands for parse and import (src-tauri/src/commands/deeplink.rs) - Register ccswitch:// protocol in macOS Info.plist - Add comprehensive unit tests (src-tauri/tests/deeplink_import.rs) Frontend: - Create confirmation dialog with security review UI (src/components/DeepLinkImportDialog.tsx) - Add API wrapper (src/lib/api/deeplink.ts) - Integrate event listeners in App.tsx Configuration: - Update Tauri config for deep link handling - Add i18n support for Chinese and English - Include test page for deep link validation (deeplink-test.html) Files: 15 changed, 1312 insertions(+) * chore(deeplink): integrate deep link handling into app lifecycle Wire up deep link infrastructure with app initialization and event handling. Backend Integration: - Register deep link module and commands in mod.rs - Add URL handling in app setup (src-tauri/src/lib.rs:handle_deeplink_url) - Handle deep links from single instance callback (Windows/Linux CLI) - Handle deep links from macOS system events - Add tauri-plugin-deep-link dependency (Cargo.toml) Frontend Integration: - Listen for deeplink-import/deeplink-error events in App.tsx - Update DeepLinkImportDialog component imports Configuration: - Enable deep link plugin in tauri.conf.json - Update Cargo.lock for new dependencies Localization: - Add Chinese translations for deep link UI (zh.json) - Add English translations for deep link UI (en.json) Files: 9 changed, 359 insertions(+), 18 deletions(-) * refactor(deeplink): enhance Codex provider template generation Align deep link import with UI preset generation logic by: - Adding complete config.toml template matching frontend defaults - Generating safe provider name from sanitized input - Including model_provider, reasoning_effort, and wire_api settings - Removing minimal template that only contained base_url - Cleaning up deprecated test file deeplink-test.html * style: fix clippy uninlined_format_args warnings Apply clippy --fix to use inline format arguments in: - src/mcp.rs (8 fixes) - src/services/env_manager.rs (10 fixes) * style: apply code formatting and cleanup - Format TypeScript files with Prettier (App.tsx, EnvWarningBanner.tsx, formatters.ts) - Organize Rust imports and module order alphabetically - Add newline at end of JSON files (en.json, zh.json) - Update Cargo.lock for dependency changes * feat: add model name configuration support for Codex and fix Gemini model handling - Add visual model name input field for Codex providers - Add model name extraction and update utilities in providerConfigUtils - Implement model name state management in useCodexConfigState hook - Add conditional model field rendering in CodexFormFields (non-official only) - Integrate model name sync with TOML config in ProviderForm - Fix Gemini deeplink model injection bug - Correct environment variable name from GOOGLE_GEMINI_MODEL to GEMINI_MODEL - Add test cases for Gemini model injection (with/without model) - All tests passing (9/9) - Fix Gemini model field binding in edit mode - Add geminiModel state to useGeminiConfigState hook - Extract model value during initialization and reset - Sync model field with geminiEnv state to prevent data loss on submit - Fix missing model value display when editing Gemini providers Changes: - 6 files changed, 245 insertions(+), 13 deletions(-)
This commit is contained in:
204
src/components/DeepLinkImportDialog.tsx
Normal file
204
src/components/DeepLinkImportDialog.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface DeeplinkError {
|
||||
url: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export function DeepLinkImportDialog() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [request, setRequest] = useState<DeepLinkImportRequest | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for deep link import events
|
||||
const unlistenImport = listen<DeepLinkImportRequest>(
|
||||
"deeplink-import",
|
||||
(event) => {
|
||||
console.log("Deep link import event received:", event.payload);
|
||||
setRequest(event.payload);
|
||||
setIsOpen(true);
|
||||
},
|
||||
);
|
||||
|
||||
// Listen for deep link error events
|
||||
const unlistenError = listen<DeeplinkError>("deeplink-error", (event) => {
|
||||
console.error("Deep link error:", event.payload);
|
||||
toast.error(t("deeplink.parseError"), {
|
||||
description: event.payload.error,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlistenImport.then((fn) => fn());
|
||||
unlistenError.then((fn) => fn());
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!request) return;
|
||||
|
||||
setIsImporting(true);
|
||||
|
||||
try {
|
||||
await deeplinkApi.importFromDeeplink(request);
|
||||
|
||||
// Invalidate provider queries to refresh the list
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["providers", request.app],
|
||||
});
|
||||
|
||||
toast.success(t("deeplink.importSuccess"), {
|
||||
description: t("deeplink.importSuccessDescription", {
|
||||
name: request.name,
|
||||
}),
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
setRequest(null);
|
||||
} catch (error) {
|
||||
console.error("Failed to import provider from deep link:", error);
|
||||
toast.error(t("deeplink.importError"), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsOpen(false);
|
||||
setRequest(null);
|
||||
};
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
// Mask API key for display (show first 4 chars + ***)
|
||||
const maskedApiKey =
|
||||
request.apiKey.length > 4
|
||||
? `${request.apiKey.substring(0, 4)}${"*".repeat(20)}`
|
||||
: "****";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
{/* 标题显式左对齐,避免默认居中样式影响 */}
|
||||
<DialogHeader className="text-left sm:text-left">
|
||||
<DialogTitle>{t("deeplink.confirmImport")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("deeplink.confirmImportDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 主体内容整体右移,略大于标题内边距,让内容看起来不贴边 */}
|
||||
<div className="space-y-4 px-8 py-4">
|
||||
{/* App Type */}
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.app")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm font-medium capitalize">
|
||||
{request.app}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider Name */}
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.providerName")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm font-medium">{request.name}</div>
|
||||
</div>
|
||||
|
||||
{/* Homepage */}
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.homepage")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm break-all text-blue-600 dark:text-blue-400">
|
||||
{request.homepage}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Endpoint */}
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.endpoint")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm break-all">
|
||||
{request.endpoint}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key (masked) */}
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.apiKey")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm font-mono text-muted-foreground">
|
||||
{maskedApiKey}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model (if present) */}
|
||||
{request.model && (
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.model")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm font-mono">
|
||||
{request.model}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes (if present) */}
|
||||
{request.notes && (
|
||||
<div className="grid grid-cols-3 items-start gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.notes")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground">
|
||||
{request.notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning */}
|
||||
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
|
||||
{t("deeplink.warning")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isImporting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={isImporting}>
|
||||
{isImporting ? t("deeplink.importing") : t("deeplink.import")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
7
src/components/env/EnvWarningBanner.tsx
vendored
7
src/components/env/EnvWarningBanner.tsx
vendored
@@ -198,7 +198,8 @@ export function EnvWarningBanner({
|
||||
{t("env.field.value")}: {conflict.varValue}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
{t("env.field.source")}: {getSourceDescription(conflict)}
|
||||
{t("env.field.source")}:{" "}
|
||||
{getSourceDescription(conflict)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,7 +248,9 @@ export function EnvWarningBanner({
|
||||
{t("env.confirm.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-2">
|
||||
<p>{t("env.confirm.message", { count: selectedConflicts.size })}</p>
|
||||
<p>
|
||||
{t("env.confirm.message", { count: selectedConflicts.size })}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("env.confirm.backupNotice")}
|
||||
</p>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Save, Plus, AlertCircle, ChevronDown, ChevronUp, Wand2 } from "lucide-react";
|
||||
import {
|
||||
Save,
|
||||
Plus,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
|
||||
@@ -80,7 +80,9 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
initialServer,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [wizardType, setWizardType] = useState<"stdio" | "http" | "sse">("stdio");
|
||||
const [wizardType, setWizardType] = useState<"stdio" | "http" | "sse">(
|
||||
"stdio",
|
||||
);
|
||||
const [wizardTitle, setWizardTitle] = useState("");
|
||||
// stdio 字段
|
||||
const [wizardCommand, setWizardCommand] = useState("");
|
||||
|
||||
@@ -76,10 +76,7 @@ export function useMcpValidation() {
|
||||
if (typ === "stdio" && !(obj as any)?.command?.trim()) {
|
||||
return t("mcp.error.commandRequired");
|
||||
}
|
||||
if (
|
||||
(typ === "http" || typ === "sse") &&
|
||||
!(obj as any)?.url?.trim()
|
||||
) {
|
||||
if ((typ === "http" || typ === "sse") && !(obj as any)?.url?.trim()) {
|
||||
return t("mcp.wizard.urlRequired");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export function AddProviderDialog({
|
||||
// 构造基础提交数据
|
||||
const providerData: Omit<Provider, "id"> = {
|
||||
name: values.name.trim(),
|
||||
notes: values.notes?.trim() || undefined,
|
||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||
settingsConfig: parsedConfig,
|
||||
...(values.presetCategory ? { category: values.presetCategory } : {}),
|
||||
|
||||
@@ -93,6 +93,7 @@ export function EditProviderDialog({
|
||||
const updatedProvider: Provider = {
|
||||
...provider,
|
||||
name: values.name.trim(),
|
||||
notes: values.notes?.trim() || undefined,
|
||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||
settingsConfig: parsedConfig,
|
||||
...(values.presetCategory ? { category: values.presetCategory } : {}),
|
||||
@@ -129,6 +130,7 @@ export function EditProviderDialog({
|
||||
onCancel={() => onOpenChange(false)}
|
||||
initialData={{
|
||||
name: provider.name,
|
||||
notes: provider.notes,
|
||||
websiteUrl: provider.websiteUrl,
|
||||
// 若读取到实时配置则优先使用
|
||||
settingsConfig: initialSettingsConfig,
|
||||
|
||||
@@ -33,10 +33,17 @@ interface ProviderCardProps {
|
||||
}
|
||||
|
||||
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||
// 优先级 1: 备注
|
||||
if (provider.notes?.trim()) {
|
||||
return provider.notes.trim();
|
||||
}
|
||||
|
||||
// 优先级 2: 官网地址
|
||||
if (provider.websiteUrl) {
|
||||
return provider.websiteUrl;
|
||||
}
|
||||
|
||||
// 优先级 3: 从配置中提取请求地址
|
||||
const config = provider.settingsConfig;
|
||||
|
||||
if (config && typeof config === "object") {
|
||||
@@ -83,10 +90,24 @@ export function ProviderCard({
|
||||
return extractApiUrl(provider, fallbackUrlText);
|
||||
}, [provider, fallbackUrlText]);
|
||||
|
||||
// 判断是否为可点击的 URL(备注不可点击)
|
||||
const isClickableUrl = useMemo(() => {
|
||||
// 如果有备注,则不可点击
|
||||
if (provider.notes?.trim()) {
|
||||
return false;
|
||||
}
|
||||
// 如果显示的是回退文本,也不可点击
|
||||
if (displayUrl === fallbackUrlText) {
|
||||
return false;
|
||||
}
|
||||
// 其他情况(官网地址或请求地址)可点击
|
||||
return true;
|
||||
}, [provider.notes, displayUrl, fallbackUrlText]);
|
||||
|
||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||
|
||||
const handleOpenWebsite = () => {
|
||||
if (!displayUrl || displayUrl === fallbackUrlText) {
|
||||
if (!isClickableUrl) {
|
||||
return;
|
||||
}
|
||||
onOpenWebsite(displayUrl);
|
||||
@@ -174,8 +195,14 @@ export function ProviderCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenWebsite}
|
||||
className="inline-flex items-center text-sm text-blue-500 transition-colors hover:underline dark:text-blue-400 max-w-[280px]"
|
||||
className={cn(
|
||||
"inline-flex items-center text-sm max-w-[280px]",
|
||||
isClickableUrl
|
||||
? "text-blue-500 transition-colors hover:underline dark:text-blue-400 cursor-pointer"
|
||||
: "text-muted-foreground cursor-default",
|
||||
)}
|
||||
title={displayUrl}
|
||||
disabled={!isClickableUrl}
|
||||
>
|
||||
<span className="truncate">{displayUrl}</span>
|
||||
</button>
|
||||
|
||||
@@ -46,6 +46,20 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("provider.notes")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={t("provider.notesPlaceholder")} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@ interface CodexFormFieldsProps {
|
||||
onEndpointModalToggle: (open: boolean) => void;
|
||||
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
||||
|
||||
// Model Name
|
||||
shouldShowModelField?: boolean;
|
||||
modelName?: string;
|
||||
onModelNameChange?: (model: string) => void;
|
||||
|
||||
// Speed Test Endpoints
|
||||
speedTestEndpoints: EndpointCandidate[];
|
||||
}
|
||||
@@ -45,6 +50,9 @@ export function CodexFormFields({
|
||||
isEndpointModalOpen,
|
||||
onEndpointModalToggle,
|
||||
onCustomEndpointsChange,
|
||||
shouldShowModelField = true,
|
||||
modelName = "",
|
||||
onModelNameChange,
|
||||
speedTestEndpoints,
|
||||
}: CodexFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -85,6 +93,33 @@ export function CodexFormFields({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Codex Model Name 输入框 */}
|
||||
{shouldShowModelField && onModelNameChange && (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="codexModelName"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{t("codexConfig.modelName", { defaultValue: "模型名称" })}
|
||||
</label>
|
||||
<input
|
||||
id="codexModelName"
|
||||
type="text"
|
||||
value={modelName}
|
||||
onChange={(e) => onModelNameChange(e.target.value)}
|
||||
placeholder={t("codexConfig.modelNamePlaceholder", {
|
||||
defaultValue: "例如: gpt-5-codex",
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("codexConfig.modelNameHint", {
|
||||
defaultValue: "指定使用的模型,将自动更新到 config.toml 中",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 端点测速弹窗 - Codex */}
|
||||
{shouldShowSpeedTest && isEndpointModalOpen && (
|
||||
<EndpointSpeedTest
|
||||
|
||||
@@ -74,6 +74,7 @@ interface ProviderFormProps {
|
||||
initialData?: {
|
||||
name?: string;
|
||||
websiteUrl?: string;
|
||||
notes?: string;
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
category?: ProviderCategory;
|
||||
meta?: ProviderMeta;
|
||||
@@ -138,6 +139,7 @@ export function ProviderForm({
|
||||
() => ({
|
||||
name: initialData?.name ?? "",
|
||||
websiteUrl: initialData?.websiteUrl ?? "",
|
||||
notes: initialData?.notes ?? "",
|
||||
settingsConfig: initialData?.settingsConfig
|
||||
? JSON.stringify(initialData.settingsConfig, null, 2)
|
||||
: appId === "codex"
|
||||
@@ -200,10 +202,12 @@ export function ProviderForm({
|
||||
codexConfig,
|
||||
codexApiKey,
|
||||
codexBaseUrl,
|
||||
codexModelName,
|
||||
codexAuthError,
|
||||
setCodexAuth,
|
||||
handleCodexApiKeyChange,
|
||||
handleCodexBaseUrlChange,
|
||||
handleCodexModelNameChange,
|
||||
handleCodexConfigChange: originalHandleCodexConfigChange,
|
||||
resetCodexConfig,
|
||||
} = useCodexConfigState({ initialData });
|
||||
@@ -313,12 +317,14 @@ export function ProviderForm({
|
||||
const {
|
||||
geminiEnv,
|
||||
geminiConfig,
|
||||
geminiModel,
|
||||
envError,
|
||||
configError: geminiConfigError,
|
||||
handleGeminiEnvChange,
|
||||
handleGeminiConfigChange,
|
||||
resetGeminiConfig,
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
} = useGeminiConfigState({
|
||||
initialData: appId === "gemini" ? initialData : undefined,
|
||||
});
|
||||
@@ -621,7 +627,6 @@ export function ProviderForm({
|
||||
presetCategoryLabels={presetCategoryLabels}
|
||||
onPresetChange={handlePresetChange}
|
||||
category={category}
|
||||
appId={appId}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -684,6 +689,9 @@ export function ProviderForm({
|
||||
onCustomEndpointsChange={
|
||||
isEditMode ? undefined : setDraftCustomEndpoints
|
||||
}
|
||||
shouldShowModelField={category !== "official"}
|
||||
modelName={codexModelName}
|
||||
onModelNameChange={handleCodexModelNameChange}
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
/>
|
||||
)}
|
||||
@@ -710,17 +718,19 @@ export function ProviderForm({
|
||||
onEndpointModalToggle={setIsEndpointModalOpen}
|
||||
onCustomEndpointsChange={setDraftCustomEndpoints}
|
||||
shouldShowModelField={true}
|
||||
model={
|
||||
form.watch("settingsConfig")
|
||||
? JSON.parse(form.watch("settingsConfig") || "{}")?.env
|
||||
?.GEMINI_MODEL || ""
|
||||
: ""
|
||||
}
|
||||
model={geminiModel}
|
||||
onModelChange={(model) => {
|
||||
// 同时更新 form.settingsConfig 和 geminiEnv
|
||||
const config = JSON.parse(form.watch("settingsConfig") || "{}");
|
||||
if (!config.env) config.env = {};
|
||||
config.env.GEMINI_MODEL = model;
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
|
||||
// 同步更新 geminiEnv,确保提交时不丢失
|
||||
const envObj = envStringToObj(geminiEnv);
|
||||
envObj.GEMINI_MODEL = model.trim();
|
||||
const newEnv = envObjToString(envObj);
|
||||
handleGeminiEnvChange(newEnv);
|
||||
}}
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
|
||||
type PresetEntry = {
|
||||
id: string;
|
||||
@@ -20,7 +19,6 @@ interface ProviderPresetSelectorProps {
|
||||
presetCategoryLabels: Record<string, string>;
|
||||
onPresetChange: (value: string) => void;
|
||||
category?: ProviderCategory; // 当前选中的分类
|
||||
appId?: AppId;
|
||||
}
|
||||
|
||||
export function ProviderPresetSelector({
|
||||
@@ -30,7 +28,6 @@ export function ProviderPresetSelector({
|
||||
presetCategoryLabels,
|
||||
onPresetChange,
|
||||
category,
|
||||
appId,
|
||||
}: ProviderPresetSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
extractCodexBaseUrl,
|
||||
setCodexBaseUrl as setCodexBaseUrlInConfig,
|
||||
extractCodexModelName,
|
||||
setCodexModelName as setCodexModelNameInConfig,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { normalizeTomlText } from "@/utils/textNormalization";
|
||||
|
||||
@@ -20,9 +22,11 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
const [codexConfig, setCodexConfigState] = useState("");
|
||||
const [codexApiKey, setCodexApiKey] = useState("");
|
||||
const [codexBaseUrl, setCodexBaseUrl] = useState("");
|
||||
const [codexModelName, setCodexModelName] = useState("");
|
||||
const [codexAuthError, setCodexAuthError] = useState("");
|
||||
|
||||
const isUpdatingCodexBaseUrlRef = useRef(false);
|
||||
const isUpdatingCodexModelNameRef = useRef(false);
|
||||
|
||||
// 初始化 Codex 配置(编辑模式)
|
||||
useEffect(() => {
|
||||
@@ -47,6 +51,12 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
setCodexBaseUrl(initialBaseUrl);
|
||||
}
|
||||
|
||||
// 提取 Model Name
|
||||
const initialModelName = extractCodexModelName(configStr);
|
||||
if (initialModelName) {
|
||||
setCodexModelName(initialModelName);
|
||||
}
|
||||
|
||||
// 提取 API Key
|
||||
try {
|
||||
if (auth && typeof auth.OPENAI_API_KEY === "string") {
|
||||
@@ -69,6 +79,17 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
}
|
||||
}, [codexConfig, codexBaseUrl]);
|
||||
|
||||
// 与 TOML 配置保持模型名称同步
|
||||
useEffect(() => {
|
||||
if (isUpdatingCodexModelNameRef.current) {
|
||||
return;
|
||||
}
|
||||
const extracted = extractCodexModelName(codexConfig) || "";
|
||||
if (extracted !== codexModelName) {
|
||||
setCodexModelName(extracted);
|
||||
}
|
||||
}, [codexConfig, codexModelName]);
|
||||
|
||||
// 获取 API Key(从 auth JSON)
|
||||
const getCodexAuthApiKey = useCallback((authString: string): string => {
|
||||
try {
|
||||
@@ -157,7 +178,26 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
[setCodexConfig],
|
||||
);
|
||||
|
||||
// 处理 config 变化(同步 Base URL)
|
||||
// 处理 Codex Model Name 变化
|
||||
const handleCodexModelNameChange = useCallback(
|
||||
(modelName: string) => {
|
||||
const trimmed = modelName.trim();
|
||||
setCodexModelName(trimmed);
|
||||
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdatingCodexModelNameRef.current = true;
|
||||
setCodexConfig((prev) => setCodexModelNameInConfig(prev, trimmed));
|
||||
setTimeout(() => {
|
||||
isUpdatingCodexModelNameRef.current = false;
|
||||
}, 0);
|
||||
},
|
||||
[setCodexConfig],
|
||||
);
|
||||
|
||||
// 处理 config 变化(同步 Base URL 和 Model Name)
|
||||
const handleCodexConfigChange = useCallback(
|
||||
(value: string) => {
|
||||
// 归一化中文/全角/弯引号,避免 TOML 解析报错
|
||||
@@ -170,8 +210,15 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
setCodexBaseUrl(extracted);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUpdatingCodexModelNameRef.current) {
|
||||
const extractedModel = extractCodexModelName(normalized) || "";
|
||||
if (extractedModel !== codexModelName) {
|
||||
setCodexModelName(extractedModel);
|
||||
}
|
||||
}
|
||||
},
|
||||
[setCodexConfig, codexBaseUrl],
|
||||
[setCodexConfig, codexBaseUrl, codexModelName],
|
||||
);
|
||||
|
||||
// 重置配置(用于预设切换)
|
||||
@@ -186,6 +233,13 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
setCodexBaseUrl(baseUrl);
|
||||
}
|
||||
|
||||
const modelName = extractCodexModelName(config);
|
||||
if (modelName) {
|
||||
setCodexModelName(modelName);
|
||||
} else {
|
||||
setCodexModelName("");
|
||||
}
|
||||
|
||||
// 提取 API Key
|
||||
try {
|
||||
if (auth && typeof auth.OPENAI_API_KEY === "string") {
|
||||
@@ -205,11 +259,13 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
codexConfig,
|
||||
codexApiKey,
|
||||
codexBaseUrl,
|
||||
codexModelName,
|
||||
codexAuthError,
|
||||
setCodexAuth,
|
||||
setCodexConfig,
|
||||
handleCodexApiKeyChange,
|
||||
handleCodexBaseUrlChange,
|
||||
handleCodexModelNameChange,
|
||||
handleCodexConfigChange,
|
||||
resetCodexConfig,
|
||||
getCodexAuthApiKey,
|
||||
|
||||
@@ -17,6 +17,7 @@ export function useGeminiConfigState({
|
||||
const [geminiConfig, setGeminiConfigState] = useState("");
|
||||
const [geminiApiKey, setGeminiApiKey] = useState("");
|
||||
const [geminiBaseUrl, setGeminiBaseUrl] = useState("");
|
||||
const [geminiModel, setGeminiModel] = useState("");
|
||||
const [envError, setEnvError] = useState("");
|
||||
const [configError, setConfigError] = useState("");
|
||||
|
||||
@@ -72,21 +73,25 @@ export function useGeminiConfigState({
|
||||
const configObj = (config as any).config || {};
|
||||
setGeminiConfigState(JSON.stringify(configObj, null, 2));
|
||||
|
||||
// 提取 API Key 和 Base URL
|
||||
// 提取 API Key、Base URL 和 Model
|
||||
if (typeof env.GEMINI_API_KEY === "string") {
|
||||
setGeminiApiKey(env.GEMINI_API_KEY);
|
||||
}
|
||||
if (typeof env.GOOGLE_GEMINI_BASE_URL === "string") {
|
||||
setGeminiBaseUrl(env.GOOGLE_GEMINI_BASE_URL);
|
||||
}
|
||||
if (typeof env.GEMINI_MODEL === "string") {
|
||||
setGeminiModel(env.GEMINI_MODEL);
|
||||
}
|
||||
}
|
||||
}, [initialData, envObjToString]);
|
||||
|
||||
// 从 geminiEnv 中提取并同步 API Key 和 Base URL
|
||||
// 从 geminiEnv 中提取并同步 API Key、Base URL 和 Model
|
||||
useEffect(() => {
|
||||
const envObj = envStringToObj(geminiEnv);
|
||||
const extractedKey = envObj.GEMINI_API_KEY || "";
|
||||
const extractedBaseUrl = envObj.GOOGLE_GEMINI_BASE_URL || "";
|
||||
const extractedModel = envObj.GEMINI_MODEL || "";
|
||||
|
||||
if (extractedKey !== geminiApiKey) {
|
||||
setGeminiApiKey(extractedKey);
|
||||
@@ -94,7 +99,10 @@ export function useGeminiConfigState({
|
||||
if (extractedBaseUrl !== geminiBaseUrl) {
|
||||
setGeminiBaseUrl(extractedBaseUrl);
|
||||
}
|
||||
}, [geminiEnv, envStringToObj]);
|
||||
if (extractedModel !== geminiModel) {
|
||||
setGeminiModel(extractedModel);
|
||||
}
|
||||
}, [geminiEnv, envStringToObj, geminiApiKey, geminiBaseUrl, geminiModel]);
|
||||
|
||||
// 验证 Gemini Config JSON
|
||||
const validateGeminiConfig = useCallback((value: string): string => {
|
||||
@@ -181,7 +189,7 @@ export function useGeminiConfigState({
|
||||
setGeminiEnv(envString);
|
||||
setGeminiConfig(configString);
|
||||
|
||||
// 提取 API Key 和 Base URL
|
||||
// 提取 API Key、Base URL 和 Model
|
||||
if (typeof env.GEMINI_API_KEY === "string") {
|
||||
setGeminiApiKey(env.GEMINI_API_KEY);
|
||||
} else {
|
||||
@@ -193,6 +201,12 @@ export function useGeminiConfigState({
|
||||
} else {
|
||||
setGeminiBaseUrl("");
|
||||
}
|
||||
|
||||
if (typeof env.GEMINI_MODEL === "string") {
|
||||
setGeminiModel(env.GEMINI_MODEL);
|
||||
} else {
|
||||
setGeminiModel("");
|
||||
}
|
||||
},
|
||||
[envObjToString, setGeminiEnv, setGeminiConfig],
|
||||
);
|
||||
@@ -202,6 +216,7 @@ export function useGeminiConfigState({
|
||||
geminiConfig,
|
||||
geminiApiKey,
|
||||
geminiBaseUrl,
|
||||
geminiModel,
|
||||
envError,
|
||||
configError,
|
||||
setGeminiEnv,
|
||||
|
||||
Reference in New Issue
Block a user