refactor: consolidate provider form components
This commit completes Stage 2.5-2.6 of the refactoring plan by: - Consolidating 8 provider form files (1941+ lines) into a single unified ProviderForm component (353 lines), reducing code by ~82% - Implementing modern form management with react-hook-form and zod - Adding preset provider categorization with grouped select UI - Supporting dual-mode operation for both Claude and Codex configs - Removing redundant subcomponents: - ApiKeyInput.tsx (72 lines) - ClaudeConfigEditor.tsx (205 lines) - CodexConfigEditor.tsx (667 lines) - EndpointSpeedTest.tsx (636 lines) - KimiModelSelector.tsx (195 lines) - PresetSelector.tsx (119 lines) Key improvements: - Type-safe form values with ProviderFormValues extension - Automatic template value application for presets - Better internationalization coverage - Cleaner separation of concerns - Enhanced UX with categorized preset groups Updates AddProviderDialog and EditProviderDialog to pass appType prop and handle preset category metadata.
This commit is contained in:
@@ -205,9 +205,10 @@ pnpm add class-variance-authority clsx tailwind-merge tailwindcss-animate
|
|||||||
|
|
||||||
### 2.6 清理旧组件
|
### 2.6 清理旧组件
|
||||||
|
|
||||||
- [ ] 删除 `src/components/AddProviderModal.tsx`
|
- [x] 删除 `src/components/AddProviderModal.tsx`
|
||||||
- [ ] 删除 `src/components/EditProviderModal.tsx`
|
- [x] 删除 `src/components/EditProviderModal.tsx`
|
||||||
- [ ] 更新所有引用这些组件的地方
|
- [x] 更新所有引用这些组件的地方
|
||||||
|
- [x] 删除 `src/components/ProviderForm.tsx` 及 `src/components/ProviderForm/`
|
||||||
|
|
||||||
**完成时间**: ___________
|
**完成时间**: ___________
|
||||||
**遇到的问题**: ___________
|
**遇到的问题**: ___________
|
||||||
|
|||||||
@@ -293,6 +293,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onSubmit={handleEditProvider}
|
onSubmit={handleEditProvider}
|
||||||
|
appType={activeApp}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{usageProvider && (
|
{usageProvider && (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,72 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,667 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,636 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Zap, Loader2, Plus, X, AlertCircle, Save } from "lucide-react";
|
|
||||||
import { vscodeApi, type AppType } from "@/lib/api";
|
|
||||||
import { isLinux } from "../../lib/platform";
|
|
||||||
|
|
||||||
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;
|
|
||||||
const customEndpoints = await vscodeApi.getCustomEndpoints(
|
|
||||||
appType,
|
|
||||||
providerId,
|
|
||||||
);
|
|
||||||
const candidates: EndpointCandidate[] = customEndpoints.map((ep) => ({
|
|
||||||
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, b) => {
|
|
||||||
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) {
|
|
||||||
await vscodeApi.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 {
|
|
||||||
await vscodeApi.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
setLastError(t("endpointTest.testUnavailable"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsTesting(true);
|
|
||||||
setLastError(null);
|
|
||||||
|
|
||||||
// 清空所有延迟数据,显示 loading 状态
|
|
||||||
setEntries((prev) =>
|
|
||||||
prev.map((entry) => ({
|
|
||||||
...entry,
|
|
||||||
latency: null,
|
|
||||||
status: undefined,
|
|
||||||
error: null,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const results = await vscodeApi.testApiEndpoints(urls, {
|
|
||||||
timeoutSecs: appType === "codex" ? 12 : 8,
|
|
||||||
});
|
|
||||||
const resultMap = new Map(
|
|
||||||
results.map((item) => [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) => typeof item.latency === "number" && item.latency !== null,
|
|
||||||
)
|
|
||||||
.sort((a, b) => (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) {
|
|
||||||
await vscodeApi.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;
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Zap } from "lucide-react";
|
|
||||||
import { ProviderCategory } from "../../types";
|
|
||||||
import { ClaudeIcon, CodexIcon } from "../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;
|
|
||||||
@@ -40,7 +40,7 @@ export function AddProviderDialog({
|
|||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||||
settingsConfig: parsedConfig,
|
settingsConfig: parsedConfig,
|
||||||
meta: {},
|
...(values.presetCategory ? { category: values.presetCategory } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await onSubmit(providerData);
|
await onSubmit(providerData);
|
||||||
@@ -67,6 +67,7 @@ export function AddProviderDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<ProviderForm
|
<ProviderForm
|
||||||
|
appType={appType}
|
||||||
submitLabel={t("common.add", { defaultValue: "添加" })}
|
submitLabel={t("common.add", { defaultValue: "添加" })}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => onOpenChange(false)}
|
onCancel={() => onOpenChange(false)}
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import {
|
|||||||
ProviderForm,
|
ProviderForm,
|
||||||
type ProviderFormValues,
|
type ProviderFormValues,
|
||||||
} from "@/components/providers/forms/ProviderForm";
|
} from "@/components/providers/forms/ProviderForm";
|
||||||
|
import type { AppType } from "@/lib/api";
|
||||||
|
|
||||||
interface EditProviderDialogProps {
|
interface EditProviderDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
provider: Provider | null;
|
provider: Provider | null;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onSubmit: (provider: Provider) => Promise<void> | void;
|
onSubmit: (provider: Provider) => Promise<void> | void;
|
||||||
|
appType: AppType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditProviderDialog({
|
export function EditProviderDialog({
|
||||||
@@ -25,6 +27,7 @@ export function EditProviderDialog({
|
|||||||
provider,
|
provider,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
appType,
|
||||||
}: EditProviderDialogProps) {
|
}: EditProviderDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -42,6 +45,9 @@ export function EditProviderDialog({
|
|||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||||
settingsConfig: parsedConfig,
|
settingsConfig: parsedConfig,
|
||||||
|
...(values.presetCategory
|
||||||
|
? { category: values.presetCategory }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await onSubmit(updatedProvider);
|
await onSubmit(updatedProvider);
|
||||||
@@ -69,6 +75,7 @@ export function EditProviderDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<ProviderForm
|
<ProviderForm
|
||||||
|
appType={appType}
|
||||||
submitLabel={t("common.save", { defaultValue: "保存" })}
|
submitLabel={t("common.save", { defaultValue: "保存" })}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => onOpenChange(false)}
|
onCancel={() => onOpenChange(false)}
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -15,10 +24,34 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { useTheme } from "@/components/theme-provider";
|
import { useTheme } from "@/components/theme-provider";
|
||||||
import JsonEditor from "@/components/JsonEditor";
|
import JsonEditor from "@/components/JsonEditor";
|
||||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||||
|
import type { AppType } from "@/lib/api";
|
||||||
|
import type { ProviderCategory } from "@/types";
|
||||||
|
import {
|
||||||
|
providerPresets,
|
||||||
|
type ProviderPreset,
|
||||||
|
} from "@/config/providerPresets";
|
||||||
|
import {
|
||||||
|
codexProviderPresets,
|
||||||
|
type CodexProviderPreset,
|
||||||
|
} from "@/config/codexProviderPresets";
|
||||||
|
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
||||||
|
|
||||||
|
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {}, config: {} }, null, 2);
|
||||||
|
const CODEX_DEFAULT_CONFIG = JSON.stringify(
|
||||||
|
{ auth: {}, config: "" },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
type PresetEntry = {
|
||||||
|
id: string;
|
||||||
|
preset: ProviderPreset | CodexProviderPreset;
|
||||||
|
};
|
||||||
|
|
||||||
interface ProviderFormProps {
|
interface ProviderFormProps {
|
||||||
|
appType: AppType;
|
||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
onSubmit: (values: ProviderFormData) => void;
|
onSubmit: (values: ProviderFormValues) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
initialData?: {
|
initialData?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -27,12 +60,8 @@ interface ProviderFormProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PLACEHOLDER = `{
|
|
||||||
"env": {},
|
|
||||||
"config": {}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
export function ProviderForm({
|
export function ProviderForm({
|
||||||
|
appType,
|
||||||
submitLabel,
|
submitLabel,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
@@ -40,6 +69,16 @@ export function ProviderForm({
|
|||||||
}: ProviderFormProps) {
|
}: ProviderFormProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const [selectedPresetId, setSelectedPresetId] = useState<string>("custom");
|
||||||
|
const [activePreset, setActivePreset] = useState<{
|
||||||
|
id: string;
|
||||||
|
category?: ProviderCategory;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedPresetId("custom");
|
||||||
|
setActivePreset(null);
|
||||||
|
}, [appType, initialData]);
|
||||||
|
|
||||||
const defaultValues: ProviderFormData = useMemo(
|
const defaultValues: ProviderFormData = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -47,9 +86,11 @@ export function ProviderForm({
|
|||||||
websiteUrl: initialData?.websiteUrl ?? "",
|
websiteUrl: initialData?.websiteUrl ?? "",
|
||||||
settingsConfig: initialData?.settingsConfig
|
settingsConfig: initialData?.settingsConfig
|
||||||
? JSON.stringify(initialData.settingsConfig, null, 2)
|
? JSON.stringify(initialData.settingsConfig, null, 2)
|
||||||
: DEFAULT_CONFIG_PLACEHOLDER,
|
: appType === "codex"
|
||||||
|
? CODEX_DEFAULT_CONFIG
|
||||||
|
: CLAUDE_DEFAULT_CONFIG,
|
||||||
}),
|
}),
|
||||||
[initialData],
|
[initialData, appType],
|
||||||
);
|
);
|
||||||
|
|
||||||
const form = useForm<ProviderFormData>({
|
const form = useForm<ProviderFormData>({
|
||||||
@@ -71,16 +112,164 @@ export function ProviderForm({
|
|||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const handleSubmit = (values: ProviderFormData) => {
|
const handleSubmit = (values: ProviderFormData) => {
|
||||||
onSubmit({
|
const payload: ProviderFormValues = {
|
||||||
...values,
|
...values,
|
||||||
|
name: values.name.trim(),
|
||||||
websiteUrl: values.websiteUrl?.trim() ?? "",
|
websiteUrl: values.websiteUrl?.trim() ?? "",
|
||||||
settingsConfig: values.settingsConfig.trim(),
|
settingsConfig: values.settingsConfig.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (activePreset) {
|
||||||
|
payload.presetId = activePreset.id;
|
||||||
|
if (activePreset.category) {
|
||||||
|
payload.presetCategory = activePreset.category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const presetCategoryLabels: Record<string, string> = useMemo(
|
||||||
|
() => ({
|
||||||
|
official: t("providerPreset.categoryOfficial", {
|
||||||
|
defaultValue: "官方推荐",
|
||||||
|
}),
|
||||||
|
cn_official: t("providerPreset.categoryCnOfficial", {
|
||||||
|
defaultValue: "国内官方",
|
||||||
|
}),
|
||||||
|
aggregator: t("providerPreset.categoryAggregator", {
|
||||||
|
defaultValue: "聚合服务",
|
||||||
|
}),
|
||||||
|
third_party: t("providerPreset.categoryThirdParty", {
|
||||||
|
defaultValue: "第三方",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const presetEntries = useMemo(() => {
|
||||||
|
if (appType === "codex") {
|
||||||
|
return codexProviderPresets.map<PresetEntry>((preset, index) => ({
|
||||||
|
id: `codex-${index}`,
|
||||||
|
preset,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return providerPresets.map<PresetEntry>((preset, index) => ({
|
||||||
|
id: `claude-${index}`,
|
||||||
|
preset,
|
||||||
|
}));
|
||||||
|
}, [appType]);
|
||||||
|
|
||||||
|
const groupedPresets = useMemo(() => {
|
||||||
|
return presetEntries.reduce<Record<string, PresetEntry[]>>((acc, entry) => {
|
||||||
|
const category = entry.preset.category ?? "others";
|
||||||
|
if (!acc[category]) {
|
||||||
|
acc[category] = [];
|
||||||
|
}
|
||||||
|
acc[category].push(entry);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}, [presetEntries]);
|
||||||
|
|
||||||
|
const categoryKeys = useMemo(() => {
|
||||||
|
return Object.keys(groupedPresets).filter(
|
||||||
|
(key) => key !== "custom" && groupedPresets[key]?.length,
|
||||||
|
);
|
||||||
|
}, [groupedPresets]);
|
||||||
|
|
||||||
|
const handlePresetChange = (value: string) => {
|
||||||
|
setSelectedPresetId(value);
|
||||||
|
if (value === "custom") {
|
||||||
|
setActivePreset(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = presetEntries.find((item) => item.id === value);
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActivePreset({
|
||||||
|
id: value,
|
||||||
|
category: entry.preset.category,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (appType === "codex") {
|
||||||
|
const preset = entry.preset as CodexProviderPreset;
|
||||||
|
const config = {
|
||||||
|
auth: preset.auth ?? {},
|
||||||
|
config: preset.config ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
|
form.reset({
|
||||||
|
name: preset.name,
|
||||||
|
websiteUrl: preset.websiteUrl ?? "",
|
||||||
|
settingsConfig: JSON.stringify(config, null, 2),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = entry.preset as ProviderPreset;
|
||||||
|
const config = applyTemplateValues(
|
||||||
|
preset.settingsConfig,
|
||||||
|
preset.templateValues,
|
||||||
|
);
|
||||||
|
|
||||||
|
form.reset({
|
||||||
|
name: preset.name,
|
||||||
|
websiteUrl: preset.websiteUrl ?? "",
|
||||||
|
settingsConfig: JSON.stringify(config, null, 2),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel>
|
||||||
|
{t("providerPreset.label", { defaultValue: "预设供应商" })}
|
||||||
|
</FormLabel>
|
||||||
|
<Select value={selectedPresetId} onValueChange={handlePresetChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t("providerPreset.placeholder", {
|
||||||
|
defaultValue: "选择一个预设",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="custom">
|
||||||
|
{t("providerPreset.custom", { defaultValue: "自定义配置" })}
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
{categoryKeys.map((category) => {
|
||||||
|
const entries = groupedPresets[category];
|
||||||
|
if (!entries || entries.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<SelectGroup key={category}>
|
||||||
|
<SelectLabel>
|
||||||
|
{presetCategoryLabels[category] ??
|
||||||
|
t("providerPreset.categoryOther", {
|
||||||
|
defaultValue: "其他",
|
||||||
|
})}
|
||||||
|
</SelectLabel>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<SelectItem key={entry.id} value={entry.id}>
|
||||||
|
{entry.preset.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("providerPreset.helper", {
|
||||||
|
defaultValue: "选择预设后可继续调整下方字段。",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
@@ -131,7 +320,11 @@ export function ProviderForm({
|
|||||||
<JsonEditor
|
<JsonEditor
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
placeholder={DEFAULT_CONFIG_PLACEHOLDER}
|
placeholder={
|
||||||
|
appType === "codex"
|
||||||
|
? CODEX_DEFAULT_CONFIG
|
||||||
|
: CLAUDE_DEFAULT_CONFIG
|
||||||
|
}
|
||||||
darkMode={isDarkMode}
|
darkMode={isDarkMode}
|
||||||
rows={14}
|
rows={14}
|
||||||
showValidation
|
showValidation
|
||||||
@@ -154,4 +347,7 @@ export function ProviderForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProviderFormValues = ProviderFormData;
|
export type ProviderFormValues = ProviderFormData & {
|
||||||
|
presetId?: string;
|
||||||
|
presetCategory?: ProviderCategory;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user