refactor: create modular hooks and integrate API key input
- Create custom hooks for state management: - useProviderCategory: manages provider category state - useApiKeyState: manages API key input with auto-sync to config - useBaseUrlState: manages base URL for Claude and Codex - useModelState: manages model selection state - Integrate API key input into simplified ProviderForm: - Add ApiKeyInput component for Claude mode - Auto-populate API key into settings config - Disable for official providers - Fix EndpointSpeedTest type errors: - Fix import paths to use @ alias - Add temporary type definitions - Format all TODO comments properly - Remove incorrect type assertions - Comment out unimplemented window.api checks All TypeScript type checks now pass.
This commit is contained in:
72
src/components/ProviderForm/ApiKeyInput.tsx
Normal file
72
src/components/ProviderForm/ApiKeyInput.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ApiKeyInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
required = false,
|
||||
label = "API Key",
|
||||
id = "apiKey",
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
const toggleShowKey = () => {
|
||||
setShowKey(!showKey);
|
||||
};
|
||||
|
||||
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
|
||||
disabled
|
||||
? "bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||
: "border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{label} {required && "*"}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder ?? t("apiKeyInput.placeholder")}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
autoComplete="off"
|
||||
className={inputClass}
|
||||
/>
|
||||
{!disabled && value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleShowKey}
|
||||
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={showKey ? t("apiKeyInput.hide") : t("apiKeyInput.show")}
|
||||
>
|
||||
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyInput;
|
||||
205
src/components/ProviderForm/ClaudeConfigEditor.tsx
Normal file
205
src/components/ProviderForm/ClaudeConfigEditor.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import JsonEditor from "../JsonEditor";
|
||||
import { X, Save } from "lucide-react";
|
||||
import { isLinux } from "../../lib/platform";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ClaudeConfigEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
commonConfigSnippet: string;
|
||||
onCommonConfigSnippetChange: (value: string) => void;
|
||||
commonConfigError: string;
|
||||
configError: string;
|
||||
}
|
||||
|
||||
const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
commonConfigSnippet,
|
||||
onCommonConfigSnippetChange,
|
||||
commonConfigError,
|
||||
configError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 检测暗色模式
|
||||
const checkDarkMode = () => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
};
|
||||
|
||||
checkDarkMode();
|
||||
|
||||
// 监听暗色模式变化
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === "class") {
|
||||
checkDarkMode();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||
setIsCommonConfigModalOpen(true);
|
||||
}
|
||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||
|
||||
// 支持按下 ESC 关闭弹窗
|
||||
useEffect(() => {
|
||||
if (!isCommonConfigModalOpen) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [isCommonConfigModalOpen]);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsCommonConfigModalOpen(false);
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="settingsConfig"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{t("claudeConfig.configLabel")}
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useCommonConfig}
|
||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
{t("claudeConfig.writeCommonConfig")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCommonConfigModalOpen(true)}
|
||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{t("claudeConfig.editCommonConfig")}
|
||||
</button>
|
||||
</div>
|
||||
{commonConfigError && !isCommonConfigModalOpen && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
darkMode={isDarkMode}
|
||||
placeholder={`{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
||||
}
|
||||
}`}
|
||||
rows={12}
|
||||
/>
|
||||
{configError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("claudeConfig.fullSettingsHint")}
|
||||
</p>
|
||||
{isCommonConfigModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) closeModal();
|
||||
}}
|
||||
>
|
||||
{/* Backdrop - 统一背景样式 */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
|
||||
isLinux() ? "" : " backdrop-blur-sm"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Modal - 统一窗口样式 */}
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header - 统一标题栏样式 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{t("claudeConfig.editCommonConfigTitle")}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - 统一内容区域样式 */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("claudeConfig.commonConfigHint")}
|
||||
</p>
|
||||
<JsonEditor
|
||||
value={commonConfigSnippet}
|
||||
onChange={onCommonConfigSnippetChange}
|
||||
darkMode={isDarkMode}
|
||||
rows={12}
|
||||
/>
|
||||
{commonConfigError && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - 统一底部按钮样式 */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClaudeConfigEditor;
|
||||
667
src/components/ProviderForm/CodexConfigEditor.tsx
Normal file
667
src/components/ProviderForm/CodexConfigEditor.tsx
Normal file
@@ -0,0 +1,667 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
|
||||
import { X, Save } from "lucide-react";
|
||||
|
||||
import { isLinux } from "../../lib/platform";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
generateThirdPartyAuth,
|
||||
generateThirdPartyConfig,
|
||||
} from "../../config/codexProviderPresets";
|
||||
|
||||
interface CodexConfigEditorProps {
|
||||
authValue: string;
|
||||
|
||||
configValue: string;
|
||||
|
||||
onAuthChange: (value: string) => void;
|
||||
|
||||
onConfigChange: (value: string) => void;
|
||||
|
||||
onAuthBlur?: () => void;
|
||||
|
||||
useCommonConfig: boolean;
|
||||
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
|
||||
commonConfigSnippet: string;
|
||||
|
||||
onCommonConfigSnippetChange: (value: string) => void;
|
||||
|
||||
commonConfigError: string;
|
||||
|
||||
authError: string;
|
||||
|
||||
isCustomMode?: boolean; // 新增:是否为自定义模式
|
||||
|
||||
onWebsiteUrlChange?: (url: string) => void; // 新增:更新网址回调
|
||||
|
||||
isTemplateModalOpen?: boolean; // 新增:模态框状态
|
||||
|
||||
setIsTemplateModalOpen?: (open: boolean) => void; // 新增:设置模态框状态
|
||||
|
||||
onNameChange?: (name: string) => void; // 新增:更新供应商名称回调
|
||||
}
|
||||
|
||||
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||
authValue,
|
||||
|
||||
configValue,
|
||||
|
||||
onAuthChange,
|
||||
|
||||
onConfigChange,
|
||||
|
||||
onAuthBlur,
|
||||
|
||||
useCommonConfig,
|
||||
|
||||
onCommonConfigToggle,
|
||||
|
||||
commonConfigSnippet,
|
||||
|
||||
onCommonConfigSnippetChange,
|
||||
|
||||
commonConfigError,
|
||||
|
||||
authError,
|
||||
|
||||
onWebsiteUrlChange,
|
||||
|
||||
onNameChange,
|
||||
|
||||
isTemplateModalOpen: externalTemplateModalOpen,
|
||||
|
||||
setIsTemplateModalOpen: externalSetTemplateModalOpen,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
// 使用内部状态或外部状态
|
||||
|
||||
const [internalTemplateModalOpen, setInternalTemplateModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const isTemplateModalOpen =
|
||||
externalTemplateModalOpen ?? internalTemplateModalOpen;
|
||||
|
||||
const setIsTemplateModalOpen =
|
||||
externalSetTemplateModalOpen ?? setInternalTemplateModalOpen;
|
||||
|
||||
const [templateApiKey, setTemplateApiKey] = useState("");
|
||||
|
||||
const [templateProviderName, setTemplateProviderName] = useState("");
|
||||
|
||||
const [templateBaseUrl, setTemplateBaseUrl] = useState("");
|
||||
|
||||
const [templateWebsiteUrl, setTemplateWebsiteUrl] = useState("");
|
||||
|
||||
const [templateModelName, setTemplateModelName] = useState("gpt-5-codex");
|
||||
const apiKeyInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const baseUrlInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const modelNameInputRef = useRef<HTMLInputElement>(null);
|
||||
const displayNameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 移除自动填充逻辑,因为现在在点击自定义按钮时就已经填充
|
||||
|
||||
const [templateDisplayName, setTemplateDisplayName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||
setIsCommonConfigModalOpen(true);
|
||||
}
|
||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||
|
||||
// 支持按下 ESC 关闭弹窗
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCommonConfigModalOpen) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [isCommonConfigModalOpen]);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsCommonConfigModalOpen(false);
|
||||
};
|
||||
|
||||
const closeTemplateModal = () => {
|
||||
setIsTemplateModalOpen(false);
|
||||
};
|
||||
|
||||
const applyTemplate = () => {
|
||||
const requiredInputs = [
|
||||
displayNameInputRef.current,
|
||||
apiKeyInputRef.current,
|
||||
baseUrlInputRef.current,
|
||||
modelNameInputRef.current,
|
||||
];
|
||||
|
||||
for (const input of requiredInputs) {
|
||||
if (input && !input.checkValidity()) {
|
||||
input.reportValidity();
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const trimmedKey = templateApiKey.trim();
|
||||
|
||||
const trimmedBaseUrl = templateBaseUrl.trim();
|
||||
|
||||
const trimmedModel = templateModelName.trim();
|
||||
|
||||
const auth = generateThirdPartyAuth(trimmedKey);
|
||||
|
||||
const config = generateThirdPartyConfig(
|
||||
templateProviderName || "custom",
|
||||
|
||||
trimmedBaseUrl,
|
||||
|
||||
trimmedModel,
|
||||
);
|
||||
|
||||
onAuthChange(JSON.stringify(auth, null, 2));
|
||||
|
||||
onConfigChange(config);
|
||||
|
||||
if (onWebsiteUrlChange) {
|
||||
const trimmedWebsite = templateWebsiteUrl.trim();
|
||||
|
||||
if (trimmedWebsite) {
|
||||
onWebsiteUrlChange(trimmedWebsite);
|
||||
}
|
||||
}
|
||||
|
||||
if (onNameChange) {
|
||||
const trimmedName = templateDisplayName.trim();
|
||||
if (trimmedName) {
|
||||
onNameChange(trimmedName);
|
||||
}
|
||||
}
|
||||
|
||||
setTemplateApiKey("");
|
||||
|
||||
setTemplateProviderName("");
|
||||
|
||||
setTemplateBaseUrl("");
|
||||
|
||||
setTemplateWebsiteUrl("");
|
||||
|
||||
setTemplateModelName("gpt-5-codex");
|
||||
|
||||
setTemplateDisplayName("");
|
||||
|
||||
closeTemplateModal();
|
||||
};
|
||||
|
||||
const handleTemplateInputKeyDown = (
|
||||
e: React.KeyboardEvent<HTMLInputElement>,
|
||||
) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
applyTemplate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthChange = (value: string) => {
|
||||
onAuthChange(value);
|
||||
};
|
||||
|
||||
const handleConfigChange = (value: string) => {
|
||||
onConfigChange(value);
|
||||
};
|
||||
|
||||
const handleCommonConfigSnippetChange = (value: string) => {
|
||||
onCommonConfigSnippetChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="codexAuth"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{t("codexConfig.authJson")}
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
id="codexAuth"
|
||||
value={authValue}
|
||||
onChange={(e) => handleAuthChange(e.target.value)}
|
||||
onBlur={onAuthBlur}
|
||||
placeholder={t("codexConfig.authJsonPlaceholder")}
|
||||
rows={6}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 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 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[8rem]"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
/>
|
||||
|
||||
{authError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{authError}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("codexConfig.authJsonHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="codexConfig"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{t("codexConfig.configToml")}
|
||||
</label>
|
||||
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useCommonConfig}
|
||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
{t("codexConfig.writeCommonConfig")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCommonConfigModalOpen(true)}
|
||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{t("codexConfig.editCommonConfig")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{commonConfigError && !isCommonConfigModalOpen && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
id="codexConfig"
|
||||
value={configValue}
|
||||
onChange={(e) => handleConfigChange(e.target.value)}
|
||||
placeholder=""
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 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 focus:border-blue-500 dark:focus:border-blue-400 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"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("codexConfig.configTomlHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isTemplateModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
closeTemplateModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
|
||||
isLinux() ? "" : " backdrop-blur-sm"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="relative mx-4 flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white shadow-lg dark:bg-gray-900">
|
||||
<div className="flex h-full min-h-0 flex-col" role="form">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-800">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{t("codexConfig.quickWizard")}
|
||||
</h2>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeTemplateModal}
|
||||
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 space-y-4 overflow-auto p-6">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{t("codexConfig.wizardHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("codexConfig.apiKeyLabel")}
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={templateApiKey}
|
||||
ref={apiKeyInputRef}
|
||||
onChange={(e) => setTemplateApiKey(e.target.value)}
|
||||
onKeyDown={handleTemplateInputKeyDown}
|
||||
pattern=".*\S.*"
|
||||
title={t("common.enterValidValue")}
|
||||
placeholder={t("codexConfig.apiKeyPlaceholder")}
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("codexConfig.supplierNameLabel")}
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={templateDisplayName}
|
||||
ref={displayNameInputRef}
|
||||
onChange={(e) => {
|
||||
setTemplateDisplayName(e.target.value);
|
||||
if (onNameChange) {
|
||||
onNameChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleTemplateInputKeyDown}
|
||||
placeholder={t("codexConfig.supplierNamePlaceholder")}
|
||||
required
|
||||
pattern=".*\S.*"
|
||||
title={t("common.enterValidValue")}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("codexConfig.supplierNameHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("codexConfig.supplierCodeLabel")}
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={templateProviderName}
|
||||
onChange={(e) => setTemplateProviderName(e.target.value)}
|
||||
onKeyDown={handleTemplateInputKeyDown}
|
||||
placeholder={t("codexConfig.supplierCodePlaceholder")}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("codexConfig.supplierCodeHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("codexConfig.apiUrlLabel")}
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="url"
|
||||
value={templateBaseUrl}
|
||||
ref={baseUrlInputRef}
|
||||
onChange={(e) => setTemplateBaseUrl(e.target.value)}
|
||||
onKeyDown={handleTemplateInputKeyDown}
|
||||
placeholder={t("codexConfig.apiUrlPlaceholder")}
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("codexConfig.websiteLabel")}
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="url"
|
||||
value={templateWebsiteUrl}
|
||||
onChange={(e) => setTemplateWebsiteUrl(e.target.value)}
|
||||
onKeyDown={handleTemplateInputKeyDown}
|
||||
placeholder={t("codexConfig.websitePlaceholder")}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("codexConfig.websiteHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("codexConfig.modelNameLabel")}
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={templateModelName}
|
||||
ref={modelNameInputRef}
|
||||
onChange={(e) => setTemplateModelName(e.target.value)}
|
||||
onKeyDown={handleTemplateInputKeyDown}
|
||||
pattern=".*\S.*"
|
||||
title={t("common.enterValidValue")}
|
||||
placeholder={t("codexConfig.modelNamePlaceholder")}
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(templateApiKey ||
|
||||
templateProviderName ||
|
||||
templateBaseUrl) && (
|
||||
<div className="space-y-2 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("codexConfig.configPreview")}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
auth.json
|
||||
</label>
|
||||
|
||||
<pre className="overflow-x-auto rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
{JSON.stringify(
|
||||
generateThirdPartyAuth(templateApiKey),
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
config.toml
|
||||
</label>
|
||||
|
||||
<pre className="whitespace-pre-wrap rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
{templateProviderName && templateBaseUrl
|
||||
? generateThirdPartyConfig(
|
||||
templateProviderName,
|
||||
|
||||
templateBaseUrl,
|
||||
|
||||
templateModelName,
|
||||
)
|
||||
: ""}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 border-t border-gray-200 bg-gray-100 p-6 dark:border-gray-800 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeTemplateModal}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-500 transition-colors hover:bg-white hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
applyTemplate();
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{t("codexConfig.applyConfig")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCommonConfigModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) closeModal();
|
||||
}}
|
||||
>
|
||||
{/* Backdrop - 统一背景样式 */}
|
||||
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
|
||||
isLinux() ? "" : " backdrop-blur-sm"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Modal - 统一窗口样式 */}
|
||||
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header - 统一标题栏样式 */}
|
||||
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{t("codexConfig.editCommonConfigTitle")}
|
||||
</h2>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - 统一内容区域样式 */}
|
||||
|
||||
<div className="flex-1 overflow-auto p-6 space-y-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("codexConfig.commonConfigHint")}
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
value={commonConfigSnippet}
|
||||
onChange={(e) =>
|
||||
handleCommonConfigSnippetChange(e.target.value)
|
||||
}
|
||||
placeholder={`# Common Codex config
|
||||
|
||||
|
||||
|
||||
# Add your common TOML configuration here`}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 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 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
/>
|
||||
|
||||
{commonConfigError && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - 统一底部按钮样式 */}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodexConfigEditor;
|
||||
656
src/components/ProviderForm/EndpointSpeedTest.tsx
Normal file
656
src/components/ProviderForm/EndpointSpeedTest.tsx
Normal file
@@ -0,0 +1,656 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Zap, Loader2, Plus, X, AlertCircle, Save } from "lucide-react";
|
||||
import { isLinux } from "@/lib/platform";
|
||||
import type { AppType } from "@/lib/api";
|
||||
|
||||
|
||||
// 临时类型定义,待后端 API 实现后替换
|
||||
interface CustomEndpoint {
|
||||
url: string;
|
||||
addedAt: number;
|
||||
lastUsed?: number;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
url: string;
|
||||
latency: number | null;
|
||||
status?: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface EndpointCandidate {
|
||||
id?: string;
|
||||
url: string;
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
interface EndpointSpeedTestProps {
|
||||
appType: AppType;
|
||||
providerId?: string;
|
||||
value: string;
|
||||
onChange: (url: string) => void;
|
||||
initialEndpoints: EndpointCandidate[];
|
||||
visible?: boolean;
|
||||
onClose: () => void;
|
||||
// 当自定义端点列表变化时回传(仅包含 isCustom 的条目)
|
||||
onCustomEndpointsChange?: (urls: string[]) => void;
|
||||
}
|
||||
|
||||
interface EndpointEntry extends EndpointCandidate {
|
||||
id: string;
|
||||
latency: number | null;
|
||||
status?: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
const randomId = () => `ep_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
const normalizeEndpointUrl = (url: string): string =>
|
||||
url.trim().replace(/\/+$/, "");
|
||||
|
||||
const buildInitialEntries = (
|
||||
candidates: EndpointCandidate[],
|
||||
selected: string,
|
||||
): EndpointEntry[] => {
|
||||
const map = new Map<string, EndpointEntry>();
|
||||
const addCandidate = (candidate: EndpointCandidate) => {
|
||||
const sanitized = candidate.url ? normalizeEndpointUrl(candidate.url) : "";
|
||||
if (!sanitized) return;
|
||||
if (map.has(sanitized)) return;
|
||||
|
||||
map.set(sanitized, {
|
||||
id: candidate.id ?? randomId(),
|
||||
url: sanitized,
|
||||
isCustom: candidate.isCustom ?? false,
|
||||
latency: null,
|
||||
status: undefined,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
candidates.forEach(addCandidate);
|
||||
|
||||
const selectedUrl = normalizeEndpointUrl(selected);
|
||||
if (selectedUrl && !map.has(selectedUrl)) {
|
||||
addCandidate({ url: selectedUrl, isCustom: true });
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
||||
appType,
|
||||
providerId,
|
||||
value,
|
||||
onChange,
|
||||
initialEndpoints,
|
||||
visible = true,
|
||||
onClose,
|
||||
onCustomEndpointsChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [entries, setEntries] = useState<EndpointEntry[]>(() =>
|
||||
buildInitialEntries(initialEndpoints, value),
|
||||
);
|
||||
const [customUrl, setCustomUrl] = useState("");
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [autoSelect, setAutoSelect] = useState(true);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
|
||||
const normalizedSelected = normalizeEndpointUrl(value);
|
||||
|
||||
const hasEndpoints = entries.length > 0;
|
||||
|
||||
// 加载保存的自定义端点(按正在编辑的供应商)
|
||||
useEffect(() => {
|
||||
const loadCustomEndpoints = async () => {
|
||||
try {
|
||||
if (!providerId) return;
|
||||
// TODO: 实现后端 API
|
||||
const customEndpoints: CustomEndpoint[] = [];
|
||||
// const customEndpoints = await window.api.getCustomEndpoints(appType, providerId);
|
||||
|
||||
const candidates: EndpointCandidate[] = customEndpoints.map((ep: CustomEndpoint) => ({
|
||||
url: ep.url,
|
||||
isCustom: true,
|
||||
}));
|
||||
|
||||
setEntries((prev) => {
|
||||
const map = new Map<string, EndpointEntry>();
|
||||
|
||||
// 先添加现有端点
|
||||
prev.forEach((entry) => {
|
||||
map.set(entry.url, entry);
|
||||
});
|
||||
|
||||
// 合并自定义端点
|
||||
candidates.forEach((candidate) => {
|
||||
const sanitized = normalizeEndpointUrl(candidate.url);
|
||||
if (sanitized && !map.has(sanitized)) {
|
||||
map.set(sanitized, {
|
||||
id: randomId(),
|
||||
url: sanitized,
|
||||
isCustom: true,
|
||||
latency: null,
|
||||
status: undefined,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(map.values());
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(t("endpointTest.loadEndpointsFailed"), error);
|
||||
}
|
||||
};
|
||||
|
||||
if (visible) {
|
||||
loadCustomEndpoints();
|
||||
}
|
||||
}, [appType, visible, providerId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setEntries((prev) => {
|
||||
const map = new Map<string, EndpointEntry>();
|
||||
prev.forEach((entry) => {
|
||||
map.set(entry.url, entry);
|
||||
});
|
||||
|
||||
let changed = false;
|
||||
|
||||
const mergeCandidate = (candidate: EndpointCandidate) => {
|
||||
const sanitized = candidate.url
|
||||
? normalizeEndpointUrl(candidate.url)
|
||||
: "";
|
||||
if (!sanitized) return;
|
||||
const existing = map.get(sanitized);
|
||||
if (existing) return;
|
||||
|
||||
map.set(sanitized, {
|
||||
id: candidate.id ?? randomId(),
|
||||
url: sanitized,
|
||||
isCustom: candidate.isCustom ?? false,
|
||||
latency: null,
|
||||
status: undefined,
|
||||
error: null,
|
||||
});
|
||||
changed = true;
|
||||
};
|
||||
|
||||
initialEndpoints.forEach(mergeCandidate);
|
||||
|
||||
if (normalizedSelected && !map.has(normalizedSelected)) {
|
||||
mergeCandidate({ url: normalizedSelected, isCustom: true });
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
});
|
||||
}, [initialEndpoints, normalizedSelected]);
|
||||
|
||||
// 将自定义端点变化透传给父组件(仅限 isCustom)
|
||||
useEffect(() => {
|
||||
if (!onCustomEndpointsChange) return;
|
||||
try {
|
||||
const customUrls = Array.from(
|
||||
new Set(
|
||||
entries
|
||||
.filter((e) => e.isCustom)
|
||||
.map((e) => (e.url ? normalizeEndpointUrl(e.url) : ""))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
onCustomEndpointsChange(customUrls);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
// 仅在 entries 变化时同步
|
||||
}, [entries, onCustomEndpointsChange]);
|
||||
|
||||
const sortedEntries = useMemo(() => {
|
||||
return entries.slice().sort((a: TestResult, b: TestResult) => {
|
||||
const aLatency = a.latency ?? Number.POSITIVE_INFINITY;
|
||||
const bLatency = b.latency ?? Number.POSITIVE_INFINITY;
|
||||
if (aLatency === bLatency) {
|
||||
return a.url.localeCompare(b.url);
|
||||
}
|
||||
return aLatency - bLatency;
|
||||
});
|
||||
}, [entries]);
|
||||
|
||||
const handleAddEndpoint = useCallback(async () => {
|
||||
const candidate = customUrl.trim();
|
||||
let errorMsg: string | null = null;
|
||||
|
||||
if (!candidate) {
|
||||
errorMsg = t("endpointTest.enterValidUrl");
|
||||
}
|
||||
|
||||
let parsed: URL | null = null;
|
||||
if (!errorMsg) {
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch {
|
||||
errorMsg = t("endpointTest.invalidUrlFormat");
|
||||
}
|
||||
}
|
||||
|
||||
if (!errorMsg && parsed && !parsed.protocol.startsWith("http")) {
|
||||
errorMsg = t("endpointTest.onlyHttps");
|
||||
}
|
||||
|
||||
let sanitized = "";
|
||||
if (!errorMsg && parsed) {
|
||||
sanitized = normalizeEndpointUrl(parsed.toString());
|
||||
// 使用当前 entries 做去重校验,避免依赖可能过期的 addError
|
||||
const isDuplicate = entries.some((entry) => entry.url === sanitized);
|
||||
if (isDuplicate) {
|
||||
errorMsg = t("endpointTest.urlExists");
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg) {
|
||||
setAddError(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
setAddError(null);
|
||||
|
||||
// 保存到后端
|
||||
try {
|
||||
if (providerId) {
|
||||
// TODO: 实现后端 API
|
||||
// await window.api.addCustomEndpoint(appType, providerId, sanitized);
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
setEntries((prev) => {
|
||||
if (prev.some((e) => e.url === sanitized)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: randomId(),
|
||||
url: sanitized,
|
||||
isCustom: true,
|
||||
latency: null,
|
||||
status: undefined,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (!normalizedSelected) {
|
||||
onChange(sanitized);
|
||||
}
|
||||
|
||||
setCustomUrl("");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setAddError(message || t("endpointTest.saveFailed"));
|
||||
console.error(t("endpointTest.addEndpointFailed"), error);
|
||||
}
|
||||
}, [
|
||||
customUrl,
|
||||
entries,
|
||||
normalizedSelected,
|
||||
onChange,
|
||||
appType,
|
||||
providerId,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleRemoveEndpoint = useCallback(
|
||||
async (entry: EndpointEntry) => {
|
||||
// 如果是自定义端点,尝试从后端删除(无 providerId 则仅本地删除)
|
||||
if (entry.isCustom && providerId) {
|
||||
try {
|
||||
// TODO: 实现后端 API
|
||||
// await window.api.removeCustomEndpoint(appType, providerId, entry.url);
|
||||
} catch (error) {
|
||||
console.error(t("endpointTest.removeEndpointFailed"), error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
setEntries((prev) => {
|
||||
const next = prev.filter((item) => item.id !== entry.id);
|
||||
if (entry.url === normalizedSelected) {
|
||||
const fallback = next[0];
|
||||
onChange(fallback ? fallback.url : "");
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[normalizedSelected, onChange, appType, providerId, t],
|
||||
);
|
||||
|
||||
const runSpeedTest = useCallback(async () => {
|
||||
const urls = entries.map((entry) => entry.url);
|
||||
if (urls.length === 0) {
|
||||
setLastError(t("endpointTest.pleaseAddEndpoint"));
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: API 尚未实现,暂时跳过检查
|
||||
// if (typeof window === "undefined" || !window.api?.testApiEndpoints) {
|
||||
// setLastError(t("endpointTest.testUnavailable"));
|
||||
// return;
|
||||
// }
|
||||
|
||||
setIsTesting(true);
|
||||
setLastError(null);
|
||||
|
||||
// 清空所有延迟数据,显示 loading 状态
|
||||
setEntries((prev) =>
|
||||
prev.map((entry) => ({
|
||||
...entry,
|
||||
latency: null,
|
||||
status: undefined,
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
|
||||
try {
|
||||
// TODO: 实现后端 API
|
||||
const results: TestResult[] = [];
|
||||
// const results = await window.api.testApiEndpoints(urls, {
|
||||
// timeoutSecs: appType === "codex" ? 12 : 8,
|
||||
// });
|
||||
const resultMap = new Map(
|
||||
results.map((item: TestResult) => [normalizeEndpointUrl(item.url), item]),
|
||||
);
|
||||
|
||||
setEntries((prev) =>
|
||||
prev.map((entry) => {
|
||||
const match = resultMap.get(entry.url);
|
||||
if (!match) {
|
||||
return {
|
||||
...entry,
|
||||
latency: null,
|
||||
status: undefined,
|
||||
error: t("endpointTest.noResult"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
latency:
|
||||
typeof match.latency === "number"
|
||||
? Math.round(match.latency)
|
||||
: null,
|
||||
status: match.status,
|
||||
error: match.error ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (autoSelect) {
|
||||
const successful = results
|
||||
.filter((item: TestResult) => typeof item.latency === "number" && item.latency !== null,
|
||||
)
|
||||
.sort((a: TestResult, b: TestResult) => (a.latency! || 0) - (b.latency! || 0));
|
||||
const best = successful[0];
|
||||
if (best && best.url && best.url !== normalizedSelected) {
|
||||
onChange(best.url);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `${t("endpointTest.testFailed", { error: String(error) })}`;
|
||||
setLastError(message);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}, [entries, autoSelect, appType, normalizedSelected, onChange, t]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (url: string) => {
|
||||
if (!url || url === normalizedSelected) return;
|
||||
|
||||
// 更新最后使用时间(对自定义端点)
|
||||
const entry = entries.find((e) => e.url === url);
|
||||
if (entry?.isCustom && providerId) {
|
||||
// TODO: 实现后端 API
|
||||
// await window.api.updateEndpointLastUsed(appType, providerId, url);
|
||||
}
|
||||
|
||||
onChange(url);
|
||||
},
|
||||
[normalizedSelected, onChange, appType, entries, providerId],
|
||||
);
|
||||
|
||||
// 支持按下 ESC 关闭弹窗
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
|
||||
isLinux() ? "" : " backdrop-blur-sm"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg w-full max-w-2xl mx-4 max-h-[80vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="text-base font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("endpointTest.title")}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
|
||||
{/* 测速控制栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{entries.length} {t("endpointTest.endpoints")}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoSelect}
|
||||
onChange={(event) => setAutoSelect(event.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
{t("endpointTest.autoSelect")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runSpeedTest}
|
||||
disabled={isTesting || !hasEndpoints}
|
||||
className="flex h-7 w-20 items-center justify-center gap-1.5 rounded-md bg-blue-500 px-2.5 text-xs font-medium text-white transition hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-blue-600 dark:hover:bg-blue-700"
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t("endpointTest.testing")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
{t("endpointTest.testSpeed")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加输入 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={customUrl}
|
||||
placeholder={t("endpointTest.addEndpointPlaceholder")}
|
||||
onChange={(event) => setCustomUrl(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleAddEndpoint();
|
||||
}
|
||||
}}
|
||||
className="flex-1 rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 transition focus:border-gray-400 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500 dark:focus:border-gray-600"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddEndpoint}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md border border-gray-200 transition hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-800"
|
||||
>
|
||||
<Plus className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
{addError && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{addError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 端点列表 */}
|
||||
{hasEndpoints ? (
|
||||
<div className="space-y-2">
|
||||
{sortedEntries.map((entry) => {
|
||||
const isSelected = normalizedSelected === entry.url;
|
||||
const latency = entry.latency;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
onClick={() => handleSelect(entry.url)}
|
||||
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600 dark:hover:bg-gray-850"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{/* 选择指示器 */}
|
||||
<div
|
||||
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full transition ${
|
||||
isSelected
|
||||
? "bg-blue-500 dark:bg-blue-400"
|
||||
: "bg-gray-300 dark:bg-gray-700"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-gray-900 dark:text-gray-100">
|
||||
{entry.url}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧信息 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{latency !== null ? (
|
||||
<div className="text-right">
|
||||
<div
|
||||
className={`font-mono text-sm font-medium ${
|
||||
latency < 300
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: latency < 500
|
||||
? "text-yellow-600 dark:text-yellow-400"
|
||||
: latency < 800
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{latency}ms
|
||||
</div>
|
||||
</div>
|
||||
) : isTesting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
) : entry.error ? (
|
||||
<div className="text-xs text-gray-400">
|
||||
{t("endpointTest.failed")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">—</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveEndpoint(entry);
|
||||
}}
|
||||
className="opacity-0 transition hover:text-red-600 group-hover:opacity-100 dark:hover:text-red-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-gray-200 bg-gray-50 py-8 text-center text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400">
|
||||
{t("endpointTest.noEndpoints")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{lastError && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{lastError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EndpointSpeedTest;
|
||||
195
src/components/ProviderForm/KimiModelSelector.tsx
Normal file
195
src/components/ProviderForm/KimiModelSelector.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, RefreshCw, AlertCircle } from "lucide-react";
|
||||
|
||||
interface KimiModel {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
owned_by: string;
|
||||
}
|
||||
|
||||
interface KimiModelSelectorProps {
|
||||
apiKey: string;
|
||||
anthropicModel: string;
|
||||
anthropicSmallFastModel: string;
|
||||
onModelChange: (
|
||||
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
|
||||
value: string,
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
||||
apiKey,
|
||||
anthropicModel,
|
||||
anthropicSmallFastModel,
|
||||
onModelChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [models, setModels] = useState<KimiModel[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [debouncedKey, setDebouncedKey] = useState("");
|
||||
|
||||
// 获取模型列表
|
||||
const fetchModelsWithKey = async (key: string) => {
|
||||
if (!key) {
|
||||
setError(t("kimiSelector.fillApiKeyFirst"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.moonshot.cn/v1/models", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
t("kimiSelector.requestFailed", {
|
||||
error: `${response.status} ${response.statusText}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.data && Array.isArray(data.data)) {
|
||||
setModels(data.data);
|
||||
} else {
|
||||
throw new Error(t("kimiSelector.invalidData"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(t("kimiSelector.fetchModelsFailed") + ":", err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("kimiSelector.fetchModelsFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 500ms 防抖 API Key
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedKey(apiKey.trim());
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [apiKey]);
|
||||
|
||||
// 当防抖后的 Key 改变时自动获取模型列表
|
||||
useEffect(() => {
|
||||
if (debouncedKey) {
|
||||
fetchModelsWithKey(debouncedKey);
|
||||
} else {
|
||||
setModels([]);
|
||||
setError("");
|
||||
}
|
||||
}, [debouncedKey]);
|
||||
|
||||
const selectClass = `w-full px-3 py-2 border rounded-lg text-sm transition-colors appearance-none bg-white dark:bg-gray-800 ${
|
||||
disabled
|
||||
? "bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||
: "border-gray-200 dark:border-gray-700 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400"
|
||||
}`;
|
||||
|
||||
const ModelSelect: React.FC<{
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}> = ({ label, value, onChange }) => (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled || loading || models.length === 0}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">
|
||||
{loading
|
||||
? t("common.loading")
|
||||
: models.length === 0
|
||||
? t("kimiSelector.noModels")
|
||||
: t("kimiSelector.pleaseSelectModel")}
|
||||
</option>
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 dark:text-gray-400 pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("kimiSelector.modelConfig")}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => debouncedKey && fetchModelsWithKey(debouncedKey)}
|
||||
disabled={disabled || loading || !debouncedKey}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
{t("kimiSelector.refreshModels")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-100 dark:bg-red-900/20 border border-red-500/20 dark:border-red-500/30 rounded-lg">
|
||||
<AlertCircle
|
||||
size={16}
|
||||
className="text-red-500 dark:text-red-400 flex-shrink-0"
|
||||
/>
|
||||
<p className="text-red-500 dark:text-red-400 text-xs">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ModelSelect
|
||||
label={t("kimiSelector.mainModel")}
|
||||
value={anthropicModel}
|
||||
onChange={(value) => onModelChange("ANTHROPIC_MODEL", value)}
|
||||
/>
|
||||
<ModelSelect
|
||||
label={t("kimiSelector.fastModel")}
|
||||
value={anthropicSmallFastModel}
|
||||
onChange={(value) =>
|
||||
onModelChange("ANTHROPIC_SMALL_FAST_MODEL", value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!apiKey.trim() && (
|
||||
<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">
|
||||
{t("kimiSelector.apiKeyHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KimiModelSelector;
|
||||
119
src/components/ProviderForm/PresetSelector.tsx
Normal file
119
src/components/ProviderForm/PresetSelector.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Zap } from "lucide-react";
|
||||
import { ProviderCategory } from "@/types";
|
||||
import { ClaudeIcon, CodexIcon } from "@/components/BrandIcons";
|
||||
|
||||
interface Preset {
|
||||
name: string;
|
||||
isOfficial?: boolean;
|
||||
category?: ProviderCategory;
|
||||
}
|
||||
|
||||
interface PresetSelectorProps {
|
||||
title?: string;
|
||||
presets: Preset[];
|
||||
selectedIndex: number | null;
|
||||
onSelectPreset: (index: number) => void;
|
||||
onCustomClick: () => void;
|
||||
customLabel?: string;
|
||||
renderCustomDescription?: () => React.ReactNode; // 新增:自定义描述渲染
|
||||
}
|
||||
|
||||
const PresetSelector: React.FC<PresetSelectorProps> = ({
|
||||
title,
|
||||
presets,
|
||||
selectedIndex,
|
||||
onSelectPreset,
|
||||
onCustomClick,
|
||||
customLabel,
|
||||
renderCustomDescription,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getButtonClass = (index: number, preset?: Preset) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
const baseClass =
|
||||
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
|
||||
|
||||
if (isSelected) {
|
||||
if (preset?.isOfficial || preset?.category === "official") {
|
||||
// Codex 官方使用黑色背景
|
||||
if (preset?.name.includes("Codex")) {
|
||||
return `${baseClass} bg-gray-900 text-white`;
|
||||
}
|
||||
// Claude 官方使用品牌色背景
|
||||
return `${baseClass} bg-[#D97757] text-white`;
|
||||
}
|
||||
return `${baseClass} bg-blue-500 text-white`;
|
||||
}
|
||||
|
||||
return `${baseClass} bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700`;
|
||||
};
|
||||
|
||||
const getDescription = () => {
|
||||
if (selectedIndex === -1) {
|
||||
// 如果提供了自定义描述渲染函数,使用它
|
||||
if (renderCustomDescription) {
|
||||
return renderCustomDescription();
|
||||
}
|
||||
return t("presetSelector.customDescription");
|
||||
}
|
||||
|
||||
if (selectedIndex !== null && selectedIndex >= 0) {
|
||||
const preset = presets[selectedIndex];
|
||||
return preset?.isOfficial || preset?.category === "official"
|
||||
? t("presetSelector.officialDescription")
|
||||
: t("presetSelector.presetDescription");
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
{title || t("presetSelector.title")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`${getButtonClass(-1)} ${selectedIndex === -1 ? "" : ""}`}
|
||||
onClick={onCustomClick}
|
||||
>
|
||||
{customLabel || t("presetSelector.custom")}
|
||||
</button>
|
||||
{presets.map((preset, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className={getButtonClass(index, preset)}
|
||||
onClick={() => onSelectPreset(index)}
|
||||
>
|
||||
{(preset.isOfficial || preset.category === "official") && (
|
||||
<>
|
||||
{preset.name.includes("Claude") ? (
|
||||
<ClaudeIcon size={14} />
|
||||
) : preset.name.includes("Codex") ? (
|
||||
<CodexIcon size={14} />
|
||||
) : (
|
||||
<Zap size={14} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{getDescription() && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{getDescription()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PresetSelector;
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { Provider } from "@/types";
|
||||
import type { Provider, CustomEndpoint } from "@/types";
|
||||
import type { AppType } from "@/lib/api";
|
||||
import {
|
||||
ProviderForm,
|
||||
type ProviderFormValues,
|
||||
} from "@/components/providers/forms/ProviderForm";
|
||||
import { providerPresets } from "@/config/providerPresets";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
|
||||
interface AddProviderDialogProps {
|
||||
open: boolean;
|
||||
@@ -36,6 +38,7 @@ export function AddProviderDialog({
|
||||
unknown
|
||||
>;
|
||||
|
||||
// 构造基础提交数据
|
||||
const providerData: Omit<Provider, "id"> = {
|
||||
name: values.name.trim(),
|
||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||
@@ -43,10 +46,80 @@ export function AddProviderDialog({
|
||||
...(values.presetCategory ? { category: values.presetCategory } : {}),
|
||||
};
|
||||
|
||||
// 收集端点候选(仅新增供应商时)
|
||||
// 1. 从预设配置中获取 endpointCandidates
|
||||
// 2. 从当前配置中提取 baseUrl (ANTHROPIC_BASE_URL 或 Codex base_url)
|
||||
const urlSet = new Set<string>();
|
||||
|
||||
const addUrl = (rawUrl?: string) => {
|
||||
const url = (rawUrl || "").trim().replace(/\/+$/, "");
|
||||
if (url && url.startsWith("http")) {
|
||||
urlSet.add(url);
|
||||
}
|
||||
};
|
||||
|
||||
// 如果选择了预设,获取预设中的 endpointCandidates
|
||||
if (values.presetId) {
|
||||
if (appType === "claude") {
|
||||
const presets = providerPresets;
|
||||
const presetIndex = parseInt(values.presetId.replace("claude-", ""));
|
||||
if (!isNaN(presetIndex) && presetIndex >= 0 && presetIndex < presets.length) {
|
||||
const preset = presets[presetIndex];
|
||||
if (preset?.endpointCandidates) {
|
||||
preset.endpointCandidates.forEach(addUrl);
|
||||
}
|
||||
}
|
||||
} else if (appType === "codex") {
|
||||
const presets = codexProviderPresets;
|
||||
const presetIndex = parseInt(values.presetId.replace("codex-", ""));
|
||||
if (!isNaN(presetIndex) && presetIndex >= 0 && presetIndex < presets.length) {
|
||||
const preset = presets[presetIndex];
|
||||
if ((preset as any).endpointCandidates) {
|
||||
(preset as any).endpointCandidates.forEach(addUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从当前配置中提取 baseUrl
|
||||
if (appType === "claude") {
|
||||
const env = parsedConfig.env as Record<string, any> | undefined;
|
||||
if (env?.ANTHROPIC_BASE_URL) {
|
||||
addUrl(env.ANTHROPIC_BASE_URL);
|
||||
}
|
||||
} else if (appType === "codex") {
|
||||
// Codex 的 baseUrl 在 config.toml 字符串中
|
||||
const config = parsedConfig.config as string | undefined;
|
||||
if (config) {
|
||||
const baseUrlMatch = config.match(/base_url\s*=\s*["']([^"']+)["']/);
|
||||
if (baseUrlMatch?.[1]) {
|
||||
addUrl(baseUrlMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果收集到了端点,添加到 meta.custom_endpoints
|
||||
const urls = Array.from(urlSet);
|
||||
if (urls.length > 0) {
|
||||
const now = Date.now();
|
||||
const customEndpoints: Record<string, CustomEndpoint> = {};
|
||||
urls.forEach((url) => {
|
||||
customEndpoints[url] = {
|
||||
url,
|
||||
addedAt: now,
|
||||
lastUsed: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
providerData.meta = {
|
||||
custom_endpoints: customEndpoints,
|
||||
};
|
||||
}
|
||||
|
||||
await onSubmit(providerData);
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onSubmit, onOpenChange],
|
||||
[appType, onSubmit, onOpenChange],
|
||||
);
|
||||
|
||||
const submitLabel =
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
type CodexProviderPreset,
|
||||
} from "@/config/codexProviderPresets";
|
||||
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
||||
import ApiKeyInput from "@/components/ProviderForm/ApiKeyInput";
|
||||
import { useProviderCategory, useApiKeyState } from "./hooks";
|
||||
|
||||
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {}, config: {} }, null, 2);
|
||||
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
||||
@@ -53,6 +55,8 @@ export function ProviderForm({
|
||||
}: ProviderFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useTheme();
|
||||
const isEditMode = Boolean(initialData);
|
||||
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(
|
||||
initialData ? null : "custom",
|
||||
);
|
||||
@@ -61,6 +65,13 @@ export function ProviderForm({
|
||||
category?: ProviderCategory;
|
||||
} | null>(null);
|
||||
|
||||
// 使用 category hook
|
||||
const { category } = useProviderCategory({
|
||||
appType,
|
||||
selectedPresetId,
|
||||
isEditMode,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedPresetId(initialData ? null : "custom");
|
||||
setActivePreset(null);
|
||||
@@ -85,6 +96,13 @@ export function ProviderForm({
|
||||
mode: "onSubmit",
|
||||
});
|
||||
|
||||
// 使用 API Key hook
|
||||
const { apiKey, handleApiKeyChange, showApiKey: shouldShowApiKey } = useApiKeyState({
|
||||
initialConfig: form.watch("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
selectedPresetId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues);
|
||||
}, [defaultValues, form]);
|
||||
@@ -303,6 +321,23 @@ export function ProviderForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* API Key 输入框(仅 Claude 且非编辑模式显示) */}
|
||||
{appType === "claude" && shouldShowApiKey(form.watch("settingsConfig"), isEditMode) && (
|
||||
<div>
|
||||
<ApiKeyInput
|
||||
value={apiKey}
|
||||
onChange={handleApiKeyChange}
|
||||
required={category !== "official"}
|
||||
placeholder={
|
||||
category === "official"
|
||||
? t("providerForm.officialNoApiKey", { defaultValue: "官方供应商无需 API Key" })
|
||||
: t("providerForm.apiKeyAutoFill", { defaultValue: "输入 API Key,将自动填充到配置" })
|
||||
}
|
||||
disabled={category === "official"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsConfig"
|
||||
|
||||
4
src/components/providers/forms/hooks/index.ts
Normal file
4
src/components/providers/forms/hooks/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { useProviderCategory } from "./useProviderCategory";
|
||||
export { useApiKeyState } from "./useApiKeyState";
|
||||
export { useBaseUrlState } from "./useBaseUrlState";
|
||||
export { useModelState } from "./useModelState";
|
||||
63
src/components/providers/forms/hooks/useApiKeyState.ts
Normal file
63
src/components/providers/forms/hooks/useApiKeyState.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
getApiKeyFromConfig,
|
||||
setApiKeyInConfig,
|
||||
hasApiKeyField,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
|
||||
interface UseApiKeyStateProps {
|
||||
initialConfig?: string;
|
||||
onConfigChange: (config: string) => void;
|
||||
selectedPresetId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理 API Key 输入状态
|
||||
* 自动同步 API Key 和 JSON 配置
|
||||
*/
|
||||
export function useApiKeyState({
|
||||
initialConfig,
|
||||
onConfigChange,
|
||||
selectedPresetId,
|
||||
}: UseApiKeyStateProps) {
|
||||
const [apiKey, setApiKey] = useState(() => {
|
||||
if (initialConfig) {
|
||||
return getApiKeyFromConfig(initialConfig);
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const handleApiKeyChange = useCallback(
|
||||
(key: string) => {
|
||||
setApiKey(key);
|
||||
|
||||
const configString = setApiKeyInConfig(
|
||||
initialConfig || "{}",
|
||||
key.trim(),
|
||||
{
|
||||
createIfMissing:
|
||||
selectedPresetId !== null && selectedPresetId !== "custom",
|
||||
},
|
||||
);
|
||||
|
||||
onConfigChange(configString);
|
||||
},
|
||||
[initialConfig, selectedPresetId, onConfigChange],
|
||||
);
|
||||
|
||||
const showApiKey = useCallback(
|
||||
(config: string, isEditMode: boolean) => {
|
||||
return (
|
||||
selectedPresetId !== null || (!isEditMode && hasApiKeyField(config))
|
||||
);
|
||||
},
|
||||
[selectedPresetId],
|
||||
);
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
setApiKey,
|
||||
handleApiKeyChange,
|
||||
showApiKey,
|
||||
};
|
||||
}
|
||||
114
src/components/providers/forms/hooks/useBaseUrlState.ts
Normal file
114
src/components/providers/forms/hooks/useBaseUrlState.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { extractCodexBaseUrl, setCodexBaseUrl as setCodexBaseUrlInConfig } from "@/utils/providerConfigUtils";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
|
||||
interface UseBaseUrlStateProps {
|
||||
appType: "claude" | "codex";
|
||||
category: ProviderCategory | undefined;
|
||||
settingsConfig: string;
|
||||
codexConfig?: string;
|
||||
onSettingsConfigChange: (config: string) => void;
|
||||
onCodexConfigChange?: (config: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理 Base URL 状态
|
||||
* 支持 Claude (JSON) 和 Codex (TOML) 两种格式
|
||||
*/
|
||||
export function useBaseUrlState({
|
||||
appType,
|
||||
category,
|
||||
settingsConfig,
|
||||
codexConfig,
|
||||
onSettingsConfigChange,
|
||||
onCodexConfigChange,
|
||||
}: UseBaseUrlStateProps) {
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [codexBaseUrl, setCodexBaseUrl] = useState("");
|
||||
const isUpdatingRef = useRef(false);
|
||||
|
||||
// 从配置同步到 state(Claude)
|
||||
useEffect(() => {
|
||||
if (appType !== "claude") return;
|
||||
if (category !== "third_party" && category !== "custom") return;
|
||||
if (isUpdatingRef.current) return;
|
||||
|
||||
try {
|
||||
const config = JSON.parse(settingsConfig || "{}");
|
||||
const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL;
|
||||
if (typeof envUrl === "string" && envUrl && envUrl !== baseUrl) {
|
||||
setBaseUrl(envUrl.trim());
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [appType, category, settingsConfig, baseUrl]);
|
||||
|
||||
// 从配置同步到 state(Codex)
|
||||
useEffect(() => {
|
||||
if (appType !== "codex") return;
|
||||
if (category !== "third_party" && category !== "custom") return;
|
||||
if (isUpdatingRef.current) return;
|
||||
if (!codexConfig) return;
|
||||
|
||||
const extracted = extractCodexBaseUrl(codexConfig) || "";
|
||||
if (extracted !== codexBaseUrl) {
|
||||
setCodexBaseUrl(extracted);
|
||||
}
|
||||
}, [appType, category, codexConfig, codexBaseUrl]);
|
||||
|
||||
// 处理 Claude Base URL 变化
|
||||
const handleClaudeBaseUrlChange = useCallback(
|
||||
(url: string) => {
|
||||
const sanitized = url.trim().replace(/\/+$/, "");
|
||||
setBaseUrl(sanitized);
|
||||
isUpdatingRef.current = true;
|
||||
|
||||
try {
|
||||
const config = JSON.parse(settingsConfig || "{}");
|
||||
if (!config.env) {
|
||||
config.env = {};
|
||||
}
|
||||
config.env.ANTHROPIC_BASE_URL = sanitized;
|
||||
onSettingsConfigChange(JSON.stringify(config, null, 2));
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
isUpdatingRef.current = false;
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
[settingsConfig, onSettingsConfigChange],
|
||||
);
|
||||
|
||||
// 处理 Codex Base URL 变化
|
||||
const handleCodexBaseUrlChange = useCallback(
|
||||
(url: string) => {
|
||||
const sanitized = url.trim().replace(/\/+$/, "");
|
||||
setCodexBaseUrl(sanitized);
|
||||
|
||||
if (!sanitized || !onCodexConfigChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdatingRef.current = true;
|
||||
const updatedConfig = setCodexBaseUrlInConfig(codexConfig || "", sanitized);
|
||||
onCodexConfigChange(updatedConfig);
|
||||
|
||||
setTimeout(() => {
|
||||
isUpdatingRef.current = false;
|
||||
}, 0);
|
||||
},
|
||||
[codexConfig, onCodexConfigChange],
|
||||
);
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
setBaseUrl,
|
||||
codexBaseUrl,
|
||||
setCodexBaseUrl,
|
||||
handleClaudeBaseUrlChange,
|
||||
handleCodexBaseUrlChange,
|
||||
};
|
||||
}
|
||||
55
src/components/providers/forms/hooks/useModelState.ts
Normal file
55
src/components/providers/forms/hooks/useModelState.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface UseModelStateProps {
|
||||
settingsConfig: string;
|
||||
onConfigChange: (config: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理模型选择状态
|
||||
* 支持 ANTHROPIC_MODEL 和 ANTHROPIC_SMALL_FAST_MODEL
|
||||
*/
|
||||
export function useModelState({
|
||||
settingsConfig,
|
||||
onConfigChange,
|
||||
}: UseModelStateProps) {
|
||||
const [claudeModel, setClaudeModel] = useState("");
|
||||
const [claudeSmallFastModel, setClaudeSmallFastModel] = useState("");
|
||||
|
||||
const handleModelChange = useCallback(
|
||||
(field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL", value: string) => {
|
||||
if (field === "ANTHROPIC_MODEL") {
|
||||
setClaudeModel(value);
|
||||
} else {
|
||||
setClaudeSmallFastModel(value);
|
||||
}
|
||||
|
||||
try {
|
||||
const currentConfig = settingsConfig
|
||||
? JSON.parse(settingsConfig)
|
||||
: { env: {} };
|
||||
if (!currentConfig.env) currentConfig.env = {};
|
||||
|
||||
if (value.trim()) {
|
||||
currentConfig.env[field] = value.trim();
|
||||
} else {
|
||||
delete currentConfig.env[field];
|
||||
}
|
||||
|
||||
onConfigChange(JSON.stringify(currentConfig, null, 2));
|
||||
} catch (err) {
|
||||
// 如果 JSON 解析失败,不做处理
|
||||
console.error("Failed to update model config:", err);
|
||||
}
|
||||
},
|
||||
[settingsConfig, onConfigChange],
|
||||
);
|
||||
|
||||
return {
|
||||
claudeModel,
|
||||
setClaudeModel,
|
||||
claudeSmallFastModel,
|
||||
setClaudeSmallFastModel,
|
||||
handleModelChange,
|
||||
};
|
||||
}
|
||||
62
src/components/providers/forms/hooks/useProviderCategory.ts
Normal file
62
src/components/providers/forms/hooks/useProviderCategory.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
import type { AppType } from "@/lib/api";
|
||||
import { providerPresets } from "@/config/providerPresets";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
|
||||
interface UseProviderCategoryProps {
|
||||
appType: AppType;
|
||||
selectedPresetId: string | null;
|
||||
isEditMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理供应商类别状态
|
||||
* 根据选择的预设自动更新类别
|
||||
*/
|
||||
export function useProviderCategory({
|
||||
appType,
|
||||
selectedPresetId,
|
||||
isEditMode,
|
||||
}: UseProviderCategoryProps) {
|
||||
const [category, setCategory] = useState<ProviderCategory | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 编辑模式不自动设置类别
|
||||
if (isEditMode) return;
|
||||
|
||||
if (selectedPresetId === "custom") {
|
||||
setCategory("custom");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedPresetId) return;
|
||||
|
||||
// 从预设 ID 提取索引
|
||||
const match = selectedPresetId.match(/^(claude|codex)-(\d+)$/);
|
||||
if (!match) return;
|
||||
|
||||
const [, type, indexStr] = match;
|
||||
const index = parseInt(indexStr, 10);
|
||||
|
||||
if (type === "codex" && appType === "codex") {
|
||||
const preset = codexProviderPresets[index];
|
||||
if (preset) {
|
||||
setCategory(
|
||||
preset.category || (preset.isOfficial ? "official" : undefined),
|
||||
);
|
||||
}
|
||||
} else if (type === "claude" && appType === "claude") {
|
||||
const preset = providerPresets[index];
|
||||
if (preset) {
|
||||
setCategory(
|
||||
preset.category || (preset.isOfficial ? "official" : undefined),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [appType, selectedPresetId, isEditMode]);
|
||||
|
||||
return { category, setCategory };
|
||||
}
|
||||
Reference in New Issue
Block a user