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,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import type { Provider } from "@/types";
|
import type { Provider, CustomEndpoint } from "@/types";
|
||||||
import type { AppType } from "@/lib/api";
|
import type { AppType } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
ProviderForm,
|
ProviderForm,
|
||||||
type ProviderFormValues,
|
type ProviderFormValues,
|
||||||
} from "@/components/providers/forms/ProviderForm";
|
} from "@/components/providers/forms/ProviderForm";
|
||||||
|
import { providerPresets } from "@/config/providerPresets";
|
||||||
|
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||||
|
|
||||||
interface AddProviderDialogProps {
|
interface AddProviderDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -36,6 +38,7 @@ export function AddProviderDialog({
|
|||||||
unknown
|
unknown
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
// 构造基础提交数据
|
||||||
const providerData: Omit<Provider, "id"> = {
|
const providerData: Omit<Provider, "id"> = {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||||
@@ -43,10 +46,80 @@ export function AddProviderDialog({
|
|||||||
...(values.presetCategory ? { category: values.presetCategory } : {}),
|
...(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);
|
await onSubmit(providerData);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
[onSubmit, onOpenChange],
|
[appType, onSubmit, onOpenChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitLabel =
|
const submitLabel =
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
type CodexProviderPreset,
|
type CodexProviderPreset,
|
||||||
} from "@/config/codexProviderPresets";
|
} from "@/config/codexProviderPresets";
|
||||||
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
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 CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {}, config: {} }, null, 2);
|
||||||
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
||||||
@@ -53,6 +55,8 @@ export function ProviderForm({
|
|||||||
}: ProviderFormProps) {
|
}: ProviderFormProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const isEditMode = Boolean(initialData);
|
||||||
|
|
||||||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(
|
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(
|
||||||
initialData ? null : "custom",
|
initialData ? null : "custom",
|
||||||
);
|
);
|
||||||
@@ -61,6 +65,13 @@ export function ProviderForm({
|
|||||||
category?: ProviderCategory;
|
category?: ProviderCategory;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// 使用 category hook
|
||||||
|
const { category } = useProviderCategory({
|
||||||
|
appType,
|
||||||
|
selectedPresetId,
|
||||||
|
isEditMode,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedPresetId(initialData ? null : "custom");
|
setSelectedPresetId(initialData ? null : "custom");
|
||||||
setActivePreset(null);
|
setActivePreset(null);
|
||||||
@@ -85,6 +96,13 @@ export function ProviderForm({
|
|||||||
mode: "onSubmit",
|
mode: "onSubmit",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 使用 API Key hook
|
||||||
|
const { apiKey, handleApiKeyChange, showApiKey: shouldShowApiKey } = useApiKeyState({
|
||||||
|
initialConfig: form.watch("settingsConfig"),
|
||||||
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
|
selectedPresetId,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset(defaultValues);
|
form.reset(defaultValues);
|
||||||
}, [defaultValues, form]);
|
}, [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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="settingsConfig"
|
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