feat: refactor ProviderForm component with new subcomponents (#13)

* feat: refactor ProviderForm component with new subcomponents

- Introduced PresetSelector, ApiKeyInput, ClaudeConfigEditor, and CodexConfigEditor for improved modularity and readability.
- Simplified preset selection logic for both Claude and Codex configurations.
- Enhanced API Key input handling with dedicated components for better user experience.
- Removed redundant code and improved state management in the ProviderForm component.

* feat: add Kimi model selection to ProviderForm component

- Introduced KimiModelSelector for enhanced model configuration options.
- Implemented state management for Kimi model selection, including initialization and updates based on preset selection.
- Improved user experience by conditionally displaying the Kimi model selector based on the selected preset.
- Refactored related logic to ensure proper handling of Kimi-specific settings in the ProviderForm.

* feat: enhance API Key input and model selection in ProviderForm

- Added toggle functionality to show/hide API Key in ApiKeyInput component for improved user experience.
- Updated placeholder text in ProviderForm to provide clearer instructions based on the selected preset.
- Enhanced KimiModelSelector to display a more informative message when API Key is not provided.
- Refactored provider presets to remove hardcoded API Key values for better security practices.

* fix(kimi): optimize debounce implementation in model selector

- Fix initial state: use empty string instead of apiKey.trim()
- Refactor fetchModels to fetchModelsWithKey with explicit key parameter
- Ensure consistent behavior between auto-fetch and manual refresh
- Eliminate mental overhead from optional parameter fallback logic

* fix(api-key): remove custom masking logic, use native password input

- Remove getDisplayValue function with custom star masking
- Use native browser password input behavior for better UX consistency
- Simplify component logic while maintaining show/hide toggle functionality

* chore: format code with prettier

- Apply consistent code formatting across all TypeScript files
- Fix indentation and spacing according to project style guide

---------

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
TinsFox
2025-09-06 23:13:01 +08:00
committed by GitHub
parent 5af476d376
commit 7346fcde2c
11 changed files with 670 additions and 280 deletions

View File

@@ -10,8 +10,12 @@ import {
} from "../utils/providerConfigUtils";
import { providerPresets } from "../config/providerPresets";
import { codexProviderPresets } from "../config/codexProviderPresets";
import JsonEditor from "./JsonEditor";
import { X, AlertCircle, Save, Zap } from "lucide-react";
import PresetSelector from "./ProviderForm/PresetSelector";
import ApiKeyInput from "./ProviderForm/ApiKeyInput";
import ClaudeConfigEditor from "./ProviderForm/ClaudeConfigEditor";
import CodexConfigEditor from "./ProviderForm/CodexConfigEditor";
import KimiModelSelector from "./ProviderForm/KimiModelSelector";
import { X, AlertCircle, Save } from "lucide-react";
interface ProviderFormProps {
appType?: AppType;
@@ -49,7 +53,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const [codexApiKey, setCodexApiKey] = useState("");
// -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedCodexPreset, setSelectedCodexPreset] = useState<number | null>(
showPresets && isCodex ? -1 : null
showPresets && isCodex ? -1 : null,
);
// 初始化 Codex 配置
@@ -70,20 +74,42 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}
}
}, [isCodex, initialData]);
const [error, setError] = useState("");
const [disableCoAuthored, setDisableCoAuthored] = useState(false);
// -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedPreset, setSelectedPreset] = useState<number | null>(
showPresets ? -1 : null
showPresets ? -1 : null,
);
const [apiKey, setApiKey] = useState("");
// Kimi 模型选择状态
const [kimiAnthropicModel, setKimiAnthropicModel] = useState("");
const [kimiAnthropicSmallFastModel, setKimiAnthropicSmallFastModel] =
useState("");
// 初始化时检查禁用签名状态
useEffect(() => {
if (initialData) {
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
setDisableCoAuthored(hasCoAuthoredDisabled);
// 初始化 Kimi 模型选择(编辑模式)
if (
initialData.settingsConfig &&
typeof initialData.settingsConfig === "object"
) {
const config = initialData.settingsConfig as {
env?: Record<string, any>;
};
if (config.env) {
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
setKimiAnthropicSmallFastModel(
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
);
}
}
}
}, [initialData]);
@@ -155,7 +181,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { name, value } = e.target;
@@ -188,7 +214,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 更新JSON配置
const updatedConfig = updateCoAuthoredSetting(
formData.settingsConfig,
checked
checked,
);
setFormData({
...formData,
@@ -214,6 +240,24 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 同步选择框状态
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
setDisableCoAuthored(hasCoAuthoredDisabled);
// 如果是 Kimi 预设,初始化模型选择
if (
preset.name?.includes("Kimi") &&
preset.settingsConfig &&
typeof preset.settingsConfig === "object"
) {
const config = preset.settingsConfig as { env?: Record<string, any> };
if (config.env) {
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
setKimiAnthropicSmallFastModel(
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
);
}
} else {
setKimiAnthropicModel("");
setKimiAnthropicSmallFastModel("");
}
};
// 处理点击自定义按钮
@@ -226,22 +270,24 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
});
setApiKey("");
setDisableCoAuthored(false);
setKimiAnthropicModel("");
setKimiAnthropicSmallFastModel("");
};
// Codex: 应用预设
const applyCodexPreset = (
preset: (typeof codexProviderPresets)[0],
index: number
index: number,
) => {
const authString = JSON.stringify(preset.auth || {}, null, 2);
setCodexAuth(authString);
setCodexConfig(preset.config || "");
setFormData({
setFormData((prev) => ({
...prev,
name: preset.name,
websiteUrl: preset.websiteUrl,
settingsConfig: formData.settingsConfig,
});
}));
setSelectedCodexPreset(index);
@@ -269,7 +315,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const configString = setApiKeyInConfig(
formData.settingsConfig,
key.trim(),
{ createIfMissing: selectedPreset !== null && selectedPreset !== -1 }
{ createIfMissing: selectedPreset !== null && selectedPreset !== -1 },
);
// 更新表单配置
@@ -307,6 +353,23 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
selectedPreset >= 0 &&
providerPresets[selectedPreset]?.isOfficial === true;
// 判断当前选中的预设是否是 Kimi
const isKimiPreset =
selectedPreset !== null &&
selectedPreset >= 0 &&
providerPresets[selectedPreset]?.name?.includes("Kimi");
// 判断当前编辑的是否是 Kimi 提供商(通过名称或配置判断)
const isEditingKimi =
initialData &&
(formData.name.includes("Kimi") ||
formData.name.includes("kimi") ||
(formData.settingsConfig.includes("api.moonshot.cn") &&
formData.settingsConfig.includes("ANTHROPIC_MODEL")));
// 综合判断是否应该显示 Kimi 模型选择器
const shouldShowKimiSelector = isKimiPreset || isEditingKimi;
// Codex: 控制显示 API Key 与官方标记
const getCodexAuthApiKey = (authString: string): string => {
try {
@@ -316,20 +379,49 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
return "";
}
};
// 自定义模式(-1)不显示独立的 API Key 输入框
const showCodexApiKey =
(selectedCodexPreset !== null && selectedCodexPreset !== -1) ||
(!showPresets && getCodexAuthApiKey(codexAuth) !== "");
const isCodexOfficialPreset =
selectedCodexPreset !== null &&
selectedCodexPreset >= 0 &&
codexProviderPresets[selectedCodexPreset]?.isOfficial === true;
// Kimi 模型选择处理函数
const handleKimiModelChange = (
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
value: string,
) => {
if (field === "ANTHROPIC_MODEL") {
setKimiAnthropicModel(value);
} else {
setKimiAnthropicSmallFastModel(value);
}
// 更新配置 JSON
try {
const currentConfig = JSON.parse(formData.settingsConfig || "{}");
if (!currentConfig.env) currentConfig.env = {};
currentConfig.env[field] = value;
const updatedConfigString = JSON.stringify(currentConfig, null, 2);
setFormData((prev) => ({
...prev,
settingsConfig: updatedConfigString,
}));
} catch (err) {
console.error("更新 Kimi 模型配置失败:", err);
}
};
// 初始时从配置中同步 API Key编辑模式
useEffect(() => {
if (initialData) {
const parsedKey = getApiKeyFromConfig(
JSON.stringify(initialData.settingsConfig)
JSON.stringify(initialData.settingsConfig),
);
if (parsedKey) setApiKey(parsedKey);
}
@@ -390,107 +482,25 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
)}
{showPresets && !isCodex && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-3">
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPreset === -1
? "bg-[var(--color-primary)] text-white"
: "bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:bg-[var(--color-border)]"
}`}
onClick={handleCustomClick}
>
</button>
{providerPresets.map((preset, index) => (
<button
key={index}
type="button"
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPreset === index
? preset.isOfficial
? "bg-[var(--color-warning)] text-white"
: "bg-[var(--color-primary)] text-white"
: "bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:bg-[var(--color-border)]"
}`}
onClick={() => applyPreset(preset, index)}
>
{preset.isOfficial && <Zap size={14} />}
{preset.name}
</button>
))}
</div>
</div>
{selectedPreset === -1 && (
<p className="text-sm text-[var(--color-text-secondary)]">
</p>
)}
{selectedPreset !== -1 && selectedPreset !== null && (
<p className="text-sm text-[var(--color-text-secondary)]">
{isOfficialPreset
? "Claude 官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key"}
</p>
)}
</div>
<PresetSelector
presets={providerPresets}
selectedIndex={selectedPreset}
onSelectPreset={(index) =>
applyPreset(providerPresets[index], index)
}
onCustomClick={handleCustomClick}
/>
)}
{showPresets && isCodex && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-3">
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedCodexPreset === -1
? "bg-[var(--color-primary)] text-white"
: "bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:bg-[var(--color-border)]"
}`}
onClick={handleCodexCustomClick}
>
</button>
{codexProviderPresets.map((preset, index) => (
<button
key={index}
type="button"
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedCodexPreset === index
? preset.isOfficial
? "bg-[var(--color-warning)] text-white"
: "bg-[var(--color-primary)] text-white"
: "bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:bg-[var(--color-border)]"
}`}
onClick={() => applyCodexPreset(preset, index)}
>
{preset.isOfficial && <Zap size={14} />}
{preset.name}
</button>
))}
</div>
</div>
{selectedCodexPreset === -1 && (
<p className="text-sm text-[var(--color-text-secondary)]">
</p>
)}
{selectedCodexPreset !== -1 && selectedCodexPreset !== null && (
<p className="text-sm text-[var(--color-text-secondary)]">
{isCodexOfficialPreset
? "Codex 官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key"}
</p>
)}
</div>
<PresetSelector
presets={codexProviderPresets}
selectedIndex={selectedCodexPreset}
onSelectPreset={(index) =>
applyCodexPreset(codexProviderPresets[index], index)
}
onCustomClick={handleCodexCustomClick}
/>
)}
<div className="space-y-2">
@@ -514,66 +524,48 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
</div>
{!isCodex && showApiKey && (
<div className="space-y-2">
<label
htmlFor="apiKey"
className="block text-sm font-medium text-[var(--color-text-primary)]"
>
API Key *
</label>
<input
type="text"
id="apiKey"
value={apiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder={
isOfficialPreset
? "官方登录无需填写 API Key直接保存即可"
<ApiKeyInput
value={apiKey}
onChange={handleApiKeyChange}
placeholder={
isOfficialPreset
? "官方登录无需填写 API Key直接保存即可"
: shouldShowKimiSelector
? "sk-xxx-api-key-here (填写后可获取模型列表)"
: "只需要填这里,下方配置会自动填充"
}
disabled={isOfficialPreset}
autoComplete="off"
className={`w-full px-3 py-2 border rounded-lg text-sm transition-colors ${
isOfficialPreset
? "bg-[var(--color-bg-tertiary)] border-[var(--color-border)] text-[var(--color-text-tertiary)] cursor-not-allowed"
: "border-[var(--color-border)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
}`}
/>
</div>
}
disabled={isOfficialPreset}
/>
)}
{!isCodex && shouldShowKimiSelector && apiKey.trim() && (
<KimiModelSelector
apiKey={apiKey}
anthropicModel={kimiAnthropicModel}
anthropicSmallFastModel={kimiAnthropicSmallFastModel}
onModelChange={handleKimiModelChange}
disabled={isOfficialPreset}
/>
)}
{isCodex && showCodexApiKey && (
<div className="space-y-2">
<label
htmlFor="codexApiKey"
className="block text-sm font-medium text-[var(--color-text-primary)]"
>
API Key *
</label>
<input
type="text"
id="codexApiKey"
value={codexApiKey}
onChange={(e) => handleCodexApiKeyChange(e.target.value)}
placeholder={
isCodexOfficialPreset
? "官方无需填写 API Key直接保存即可"
: "只需要填这里,下方 auth.json 会自动填充"
}
disabled={isCodexOfficialPreset}
required={
selectedCodexPreset !== null &&
selectedCodexPreset >= 0 &&
!isCodexOfficialPreset
}
autoComplete="off"
className={`w-full px-3 py-2 border rounded-lg text-sm transition-colors ${
isCodexOfficialPreset
? "bg-[var(--color-bg-tertiary)] border-[var(--color-border)] text-[var(--color-text-tertiary)] cursor-not-allowed"
: "border-[var(--color-border)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
}`}
/>
</div>
<ApiKeyInput
id="codexApiKey"
label="API Key"
value={codexApiKey}
onChange={handleCodexApiKeyChange}
placeholder={
isCodexOfficialPreset
? "官方无需填写 API Key直接保存即可"
: "只需要填这里,下方 auth.json 会自动填充"
}
disabled={isCodexOfficialPreset}
required={
selectedCodexPreset !== null &&
selectedCodexPreset >= 0 &&
!isCodexOfficialPreset
}
/>
)}
<div className="space-y-2">
@@ -597,103 +589,35 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
{/* Claude 或 Codex 的配置部分 */}
{isCodex ? (
// Codex: 双编辑器
<div className="space-y-6">
<div className="space-y-2">
<label
htmlFor="codexAuth"
className="block text-sm font-medium text-[var(--color-text-primary)]"
>
auth.json (JSON) *
</label>
<textarea
id="codexAuth"
value={codexAuth}
onChange={(e) => {
const value = e.target.value;
setCodexAuth(value);
try {
const auth = JSON.parse(value || "{}");
const key =
typeof auth.OPENAI_API_KEY === "string"
? auth.OPENAI_API_KEY
: "";
setCodexApiKey(key);
} catch {
// ignore
}
}}
placeholder={`{
"OPENAI_API_KEY": "sk-your-api-key-here"
}`}
rows={6}
required
className="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)] transition-colors resize-y min-h-[8rem]"
/>
<p className="text-xs text-[var(--color-text-secondary)]">
Codex auth.json
</p>
</div>
<div className="space-y-2">
<label
htmlFor="codexConfig"
className="block text-sm font-medium text-[var(--color-text-primary)]"
>
config.toml (TOML)
</label>
<textarea
id="codexConfig"
value={codexConfig}
onChange={(e) => setCodexConfig(e.target.value)}
placeholder=""
rows={8}
className="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)] transition-colors resize-y min-h-[10rem]"
/>
<p className="text-xs text-[var(--color-text-secondary)]">
Codex config.toml
</p>
</div>
</div>
) : (
// Claude: 原有的单编辑器
<div className="space-y-2">
<div className="flex items-center justify-between">
<label
htmlFor="settingsConfig"
className="block text-sm font-medium text-[var(--color-text-primary)]"
>
Claude Code (JSON) *
</label>
<label className="inline-flex items-center gap-2 text-sm text-[var(--color-text-secondary)] cursor-pointer">
<input
type="checkbox"
checked={disableCoAuthored}
onChange={(e) => handleCoAuthoredToggle(e.target.checked)}
className="w-4 h-4 text-[var(--color-primary)] bg-white border-[var(--color-border)] rounded focus:ring-[var(--color-primary)] focus:ring-2"
/>
Claude Code
</label>
</div>
<JsonEditor
value={formData.settingsConfig}
onChange={(value) =>
handleChange({
target: { name: "settingsConfig", value },
} as React.ChangeEvent<HTMLTextAreaElement>)
<CodexConfigEditor
authValue={codexAuth}
configValue={codexConfig}
onAuthChange={setCodexAuth}
onConfigChange={setCodexConfig}
onAuthBlur={() => {
try {
const auth = JSON.parse(codexAuth || "{}");
const key =
typeof auth.OPENAI_API_KEY === "string"
? auth.OPENAI_API_KEY
: "";
setCodexApiKey(key);
} catch {
// ignore
}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-your-api-key-here"
}
}`}
rows={12}
/>
<p className="text-xs text-[var(--color-text-secondary)]">
Claude Code settings.json
</p>
</div>
}}
/>
) : (
<ClaudeConfigEditor
value={formData.settingsConfig}
onChange={(value) =>
handleChange({
target: { name: "settingsConfig", value },
} as React.ChangeEvent<HTMLTextAreaElement>)
}
disableCoAuthored={disableCoAuthored}
onCoAuthoredToggle={handleCoAuthoredToggle}
/>
)}
</div>