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

@@ -18,7 +18,7 @@ function App() {
path: string; path: string;
} | null>(null); } | null>(null);
const [editingProviderId, setEditingProviderId] = useState<string | null>( const [editingProviderId, setEditingProviderId] = useState<string | null>(
null null,
); );
const [notification, setNotification] = useState<{ const [notification, setNotification] = useState<{
message: string; message: string;
@@ -37,7 +37,7 @@ function App() {
const showNotification = ( const showNotification = (
message: string, message: string,
type: "success" | "error", type: "success" | "error",
duration = 3000 duration = 3000,
) => { ) => {
// 清除之前的定时器 // 清除之前的定时器
if (timeoutRef.current) { if (timeoutRef.current) {
@@ -182,7 +182,7 @@ function App() {
showNotification( showNotification(
`切换成功!请重启 ${appName} 终端以生效`, `切换成功!请重启 ${appName} 终端以生效`,
"success", "success",
2000 2000,
); );
// 更新托盘菜单 // 更新托盘菜单
await window.api.updateTrayMenu(); await window.api.updateTrayMenu();

View File

@@ -10,8 +10,12 @@ import {
} from "../utils/providerConfigUtils"; } from "../utils/providerConfigUtils";
import { providerPresets } from "../config/providerPresets"; import { providerPresets } from "../config/providerPresets";
import { codexProviderPresets } from "../config/codexProviderPresets"; import { codexProviderPresets } from "../config/codexProviderPresets";
import JsonEditor from "./JsonEditor"; import PresetSelector from "./ProviderForm/PresetSelector";
import { X, AlertCircle, Save, Zap } from "lucide-react"; 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 { interface ProviderFormProps {
appType?: AppType; appType?: AppType;
@@ -49,7 +53,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const [codexApiKey, setCodexApiKey] = useState(""); const [codexApiKey, setCodexApiKey] = useState("");
// -1 表示自定义null 表示未选择,>= 0 表示预设索引 // -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedCodexPreset, setSelectedCodexPreset] = useState<number | null>( const [selectedCodexPreset, setSelectedCodexPreset] = useState<number | null>(
showPresets && isCodex ? -1 : null showPresets && isCodex ? -1 : null,
); );
// 初始化 Codex 配置 // 初始化 Codex 配置
@@ -70,20 +74,42 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
} }
} }
}, [isCodex, initialData]); }, [isCodex, initialData]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [disableCoAuthored, setDisableCoAuthored] = useState(false); const [disableCoAuthored, setDisableCoAuthored] = useState(false);
// -1 表示自定义null 表示未选择,>= 0 表示预设索引 // -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedPreset, setSelectedPreset] = useState<number | null>( const [selectedPreset, setSelectedPreset] = useState<number | null>(
showPresets ? -1 : null showPresets ? -1 : null,
); );
const [apiKey, setApiKey] = useState(""); const [apiKey, setApiKey] = useState("");
// Kimi 模型选择状态
const [kimiAnthropicModel, setKimiAnthropicModel] = useState("");
const [kimiAnthropicSmallFastModel, setKimiAnthropicSmallFastModel] =
useState("");
// 初始化时检查禁用签名状态 // 初始化时检查禁用签名状态
useEffect(() => { useEffect(() => {
if (initialData) { if (initialData) {
const configString = JSON.stringify(initialData.settingsConfig, null, 2); const configString = JSON.stringify(initialData.settingsConfig, null, 2);
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString); const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
setDisableCoAuthored(hasCoAuthoredDisabled); 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]); }, [initialData]);
@@ -155,7 +181,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}; };
const handleChange = ( const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => { ) => {
const { name, value } = e.target; const { name, value } = e.target;
@@ -188,7 +214,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 更新JSON配置 // 更新JSON配置
const updatedConfig = updateCoAuthoredSetting( const updatedConfig = updateCoAuthoredSetting(
formData.settingsConfig, formData.settingsConfig,
checked checked,
); );
setFormData({ setFormData({
...formData, ...formData,
@@ -214,6 +240,24 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 同步选择框状态 // 同步选择框状态
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString); const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
setDisableCoAuthored(hasCoAuthoredDisabled); 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(""); setApiKey("");
setDisableCoAuthored(false); setDisableCoAuthored(false);
setKimiAnthropicModel("");
setKimiAnthropicSmallFastModel("");
}; };
// Codex: 应用预设 // Codex: 应用预设
const applyCodexPreset = ( const applyCodexPreset = (
preset: (typeof codexProviderPresets)[0], preset: (typeof codexProviderPresets)[0],
index: number index: number,
) => { ) => {
const authString = JSON.stringify(preset.auth || {}, null, 2); const authString = JSON.stringify(preset.auth || {}, null, 2);
setCodexAuth(authString); setCodexAuth(authString);
setCodexConfig(preset.config || ""); setCodexConfig(preset.config || "");
setFormData({ setFormData((prev) => ({
...prev,
name: preset.name, name: preset.name,
websiteUrl: preset.websiteUrl, websiteUrl: preset.websiteUrl,
settingsConfig: formData.settingsConfig, }));
});
setSelectedCodexPreset(index); setSelectedCodexPreset(index);
@@ -269,7 +315,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const configString = setApiKeyInConfig( const configString = setApiKeyInConfig(
formData.settingsConfig, formData.settingsConfig,
key.trim(), key.trim(),
{ createIfMissing: selectedPreset !== null && selectedPreset !== -1 } { createIfMissing: selectedPreset !== null && selectedPreset !== -1 },
); );
// 更新表单配置 // 更新表单配置
@@ -307,6 +353,23 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
selectedPreset >= 0 && selectedPreset >= 0 &&
providerPresets[selectedPreset]?.isOfficial === true; 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 与官方标记 // Codex: 控制显示 API Key 与官方标记
const getCodexAuthApiKey = (authString: string): string => { const getCodexAuthApiKey = (authString: string): string => {
try { try {
@@ -316,20 +379,49 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
return ""; return "";
} }
}; };
// 自定义模式(-1)不显示独立的 API Key 输入框 // 自定义模式(-1)不显示独立的 API Key 输入框
const showCodexApiKey = const showCodexApiKey =
(selectedCodexPreset !== null && selectedCodexPreset !== -1) || (selectedCodexPreset !== null && selectedCodexPreset !== -1) ||
(!showPresets && getCodexAuthApiKey(codexAuth) !== ""); (!showPresets && getCodexAuthApiKey(codexAuth) !== "");
const isCodexOfficialPreset = const isCodexOfficialPreset =
selectedCodexPreset !== null && selectedCodexPreset !== null &&
selectedCodexPreset >= 0 && selectedCodexPreset >= 0 &&
codexProviderPresets[selectedCodexPreset]?.isOfficial === true; 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编辑模式 // 初始时从配置中同步 API Key编辑模式
useEffect(() => { useEffect(() => {
if (initialData) { if (initialData) {
const parsedKey = getApiKeyFromConfig( const parsedKey = getApiKeyFromConfig(
JSON.stringify(initialData.settingsConfig) JSON.stringify(initialData.settingsConfig),
); );
if (parsedKey) setApiKey(parsedKey); if (parsedKey) setApiKey(parsedKey);
} }
@@ -390,107 +482,25 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
)} )}
{showPresets && !isCodex && ( {showPresets && !isCodex && (
<div className="space-y-4"> <PresetSelector
<div> presets={providerPresets}
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-3"> selectedIndex={selectedPreset}
onSelectPreset={(index) =>
</label> applyPreset(providerPresets[index], index)
<div className="flex flex-wrap gap-2"> }
<button onCustomClick={handleCustomClick}
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>
)} )}
{showPresets && isCodex && ( {showPresets && isCodex && (
<div className="space-y-4"> <PresetSelector
<div> presets={codexProviderPresets}
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-3"> selectedIndex={selectedCodexPreset}
onSelectPreset={(index) =>
</label> applyCodexPreset(codexProviderPresets[index], index)
<div className="flex flex-wrap gap-2"> }
<button onCustomClick={handleCodexCustomClick}
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>
)} )}
<div className="space-y-2"> <div className="space-y-2">
@@ -514,47 +524,36 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
</div> </div>
{!isCodex && showApiKey && ( {!isCodex && showApiKey && (
<div className="space-y-2"> <ApiKeyInput
<label
htmlFor="apiKey"
className="block text-sm font-medium text-[var(--color-text-primary)]"
>
API Key *
</label>
<input
type="text"
id="apiKey"
value={apiKey} value={apiKey}
onChange={(e) => handleApiKeyChange(e.target.value)} onChange={handleApiKeyChange}
placeholder={ placeholder={
isOfficialPreset isOfficialPreset
? "官方登录无需填写 API Key直接保存即可" ? "官方登录无需填写 API Key直接保存即可"
: shouldShowKimiSelector
? "sk-xxx-api-key-here (填写后可获取模型列表)"
: "只需要填这里,下方配置会自动填充" : "只需要填这里,下方配置会自动填充"
} }
disabled={isOfficialPreset} 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> )}
{!isCodex && shouldShowKimiSelector && apiKey.trim() && (
<KimiModelSelector
apiKey={apiKey}
anthropicModel={kimiAnthropicModel}
anthropicSmallFastModel={kimiAnthropicSmallFastModel}
onModelChange={handleKimiModelChange}
disabled={isOfficialPreset}
/>
)} )}
{isCodex && showCodexApiKey && ( {isCodex && showCodexApiKey && (
<div className="space-y-2"> <ApiKeyInput
<label
htmlFor="codexApiKey"
className="block text-sm font-medium text-[var(--color-text-primary)]"
>
API Key *
</label>
<input
type="text"
id="codexApiKey" id="codexApiKey"
label="API Key"
value={codexApiKey} value={codexApiKey}
onChange={(e) => handleCodexApiKeyChange(e.target.value)} onChange={handleCodexApiKeyChange}
placeholder={ placeholder={
isCodexOfficialPreset isCodexOfficialPreset
? "官方无需填写 API Key直接保存即可" ? "官方无需填写 API Key直接保存即可"
@@ -566,14 +565,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
selectedCodexPreset >= 0 && selectedCodexPreset >= 0 &&
!isCodexOfficialPreset !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>
)} )}
<div className="space-y-2"> <div className="space-y-2">
@@ -597,23 +589,14 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
{/* Claude 或 Codex 的配置部分 */} {/* Claude 或 Codex 的配置部分 */}
{isCodex ? ( {isCodex ? (
// Codex: 双编辑器 <CodexConfigEditor
<div className="space-y-6"> authValue={codexAuth}
<div className="space-y-2"> configValue={codexConfig}
<label onAuthChange={setCodexAuth}
htmlFor="codexAuth" onConfigChange={setCodexConfig}
className="block text-sm font-medium text-[var(--color-text-primary)]" onAuthBlur={() => {
>
auth.json (JSON) *
</label>
<textarea
id="codexAuth"
value={codexAuth}
onChange={(e) => {
const value = e.target.value;
setCodexAuth(value);
try { try {
const auth = JSON.parse(value || "{}"); const auth = JSON.parse(codexAuth || "{}");
const key = const key =
typeof auth.OPENAI_API_KEY === "string" typeof auth.OPENAI_API_KEY === "string"
? auth.OPENAI_API_KEY ? auth.OPENAI_API_KEY
@@ -623,77 +606,18 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// ignore // 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: 原有的单编辑器 <ClaudeConfigEditor
<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} value={formData.settingsConfig}
onChange={(value) => onChange={(value) =>
handleChange({ handleChange({
target: { name: "settingsConfig", value }, target: { name: "settingsConfig", value },
} as React.ChangeEvent<HTMLTextAreaElement>) } as React.ChangeEvent<HTMLTextAreaElement>)
} }
placeholder={`{ disableCoAuthored={disableCoAuthored}
"env": { onCoAuthoredToggle={handleCoAuthoredToggle}
"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>
)} )}
</div> </div>

View File

@@ -0,0 +1,70 @@
import React, { useState } from "react";
import { Eye, EyeOff } from "lucide-react";
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 = "请输入API Key",
disabled = false,
required = false,
label = "API Key",
id = "apiKey",
}) => {
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-[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)]"
}`;
return (
<div className="space-y-2">
<label
htmlFor={id}
className="block text-sm font-medium text-[var(--color-text-primary)]"
>
{label} {required && "*"}
</label>
<div className="relative">
<input
type={showKey ? "text" : "password"}
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={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-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
aria-label={showKey ? "隐藏API Key" : "显示API Key"}
>
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
)}
</div>
</div>
);
};
export default ApiKeyInput;

View File

@@ -0,0 +1,54 @@
import React from "react";
import JsonEditor from "../JsonEditor";
interface ClaudeConfigEditorProps {
value: string;
onChange: (value: string) => void;
disableCoAuthored: boolean;
onCoAuthoredToggle: (checked: boolean) => void;
}
const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
value,
onChange,
disableCoAuthored,
onCoAuthoredToggle,
}) => {
return (
<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) => onCoAuthoredToggle(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={value}
onChange={onChange}
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>
);
};
export default ClaudeConfigEditor;

View File

@@ -0,0 +1,67 @@
import React from "react";
interface CodexConfigEditorProps {
authValue: string;
configValue: string;
onAuthChange: (value: string) => void;
onConfigChange: (value: string) => void;
onAuthBlur?: () => void;
}
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
authValue,
configValue,
onAuthChange,
onConfigChange,
onAuthBlur,
}) => {
return (
<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={authValue}
onChange={(e) => onAuthChange(e.target.value)}
onBlur={onAuthBlur}
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={configValue}
onChange={(e) => onConfigChange(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>
);
};
export default CodexConfigEditor;

View File

@@ -0,0 +1,185 @@
import React, { useState, useEffect } from "react";
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 [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("请先填写 API Key");
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(`请求失败: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.data && Array.isArray(data.data)) {
setModels(data.data);
} else {
throw new Error("返回数据格式错误");
}
} catch (err) {
console.error("获取模型列表失败:", err);
setError(err instanceof Error ? err.message : "获取模型列表失败");
} 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 ${
disabled
? "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)]"
}`;
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-[var(--color-text-primary)]">
{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
? "加载中..."
: models.length === 0
? "暂无模型"
: "请选择模型"}
</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-[var(--color-text-secondary)] 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-[var(--color-text-primary)]">
</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-[var(--color-text-secondary)] hover:text-[var(--color-primary)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-[var(--color-error-light)] border border-[var(--color-error)]/20 rounded-lg">
<AlertCircle
size={16}
className="text-[var(--color-error)] flex-shrink-0"
/>
<p className="text-[var(--color-error)] text-xs">{error}</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ModelSelect
label="主模型 (ANTHROPIC_MODEL)"
value={anthropicModel}
onChange={(value) => onModelChange("ANTHROPIC_MODEL", value)}
/>
<ModelSelect
label="快速模型 (ANTHROPIC_SMALL_FAST_MODEL)"
value={anthropicSmallFastModel}
onChange={(value) =>
onModelChange("ANTHROPIC_SMALL_FAST_MODEL", value)
}
/>
</div>
{!apiKey.trim() && (
<div className="p-3 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg">
<p className="text-xs text-[var(--color-text-secondary)]">
📝 API Keysk-xxx-api-key-here
</p>
</div>
)}
</div>
);
};
export default KimiModelSelector;

View File

@@ -0,0 +1,91 @@
import React from "react";
import { Zap } from "lucide-react";
interface Preset {
name: string;
isOfficial?: boolean;
}
interface PresetSelectorProps {
title?: string;
presets: Preset[];
selectedIndex: number | null;
onSelectPreset: (index: number) => void;
onCustomClick: () => void;
customLabel?: string;
}
const PresetSelector: React.FC<PresetSelectorProps> = ({
title = "选择配置类型",
presets,
selectedIndex,
onSelectPreset,
onCustomClick,
customLabel = "自定义",
}) => {
const getButtonClass = (index: number, isOfficial?: boolean) => {
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) {
return isOfficial
? `${baseClass} bg-[var(--color-warning)] text-white`
: `${baseClass} bg-[var(--color-primary)] text-white`;
}
return `${baseClass} bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:bg-[var(--color-border)]`;
};
const getDescription = () => {
if (selectedIndex === -1) {
return "手动配置供应商,需要填写完整的配置信息";
}
if (selectedIndex !== null && selectedIndex >= 0) {
const preset = presets[selectedIndex];
return preset?.isOfficial
? "Claude 官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key";
}
return null;
};
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-3">
{title}
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
className={getButtonClass(-1)}
onClick={onCustomClick}
>
{customLabel}
</button>
{presets.map((preset, index) => (
<button
key={index}
type="button"
className={getButtonClass(index, preset.isOfficial)}
onClick={() => onSelectPreset(index)}
>
{preset.isOfficial && <Zap size={14} />}
{preset.name}
</button>
))}
</div>
</div>
{getDescription() && (
<p className="text-sm text-[var(--color-text-secondary)]">
{getDescription()}
</p>
)}
</div>
);
};
export default PresetSelector;

View File

@@ -23,7 +23,7 @@ export const providerPresets: ProviderPreset[] = [
settingsConfig: { settingsConfig: {
env: { env: {
ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic", ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here", ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "deepseek-chat", ANTHROPIC_MODEL: "deepseek-chat",
ANTHROPIC_SMALL_FAST_MODEL: "deepseek-chat", ANTHROPIC_SMALL_FAST_MODEL: "deepseek-chat",
}, },
@@ -35,7 +35,7 @@ export const providerPresets: ProviderPreset[] = [
settingsConfig: { settingsConfig: {
env: { env: {
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic", ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here", ANTHROPIC_AUTH_TOKEN: "",
}, },
}, },
}, },
@@ -46,7 +46,7 @@ export const providerPresets: ProviderPreset[] = [
env: { env: {
ANTHROPIC_BASE_URL: ANTHROPIC_BASE_URL:
"https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy", "https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here", ANTHROPIC_AUTH_TOKEN: "",
}, },
}, },
}, },
@@ -56,7 +56,7 @@ export const providerPresets: ProviderPreset[] = [
settingsConfig: { settingsConfig: {
env: { env: {
ANTHROPIC_BASE_URL: "https://api.moonshot.cn/anthropic", ANTHROPIC_BASE_URL: "https://api.moonshot.cn/anthropic",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here", ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "kimi-k2-turbo-preview", ANTHROPIC_MODEL: "kimi-k2-turbo-preview",
ANTHROPIC_SMALL_FAST_MODEL: "kimi-k2-turbo-preview", ANTHROPIC_SMALL_FAST_MODEL: "kimi-k2-turbo-preview",
}, },
@@ -66,11 +66,11 @@ export const providerPresets: ProviderPreset[] = [
name: "魔搭", name: "魔搭",
websiteUrl: "https://modelscope.cn", websiteUrl: "https://modelscope.cn",
settingsConfig: { settingsConfig: {
"env": { env: {
ANTHROPIC_AUTH_TOKEN: "ms-your-api-key-here", ANTHROPIC_AUTH_TOKEN: "ms-your-api-key",
ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn", ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn",
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.5", ANTHROPIC_MODEL: "ZhipuAI/GLM-4.5",
ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.5" ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.5",
}, },
}, },
}, },
@@ -80,7 +80,7 @@ export const providerPresets: ProviderPreset[] = [
settingsConfig: { settingsConfig: {
env: { env: {
ANTHROPIC_BASE_URL: "https://api.packycode.com", ANTHROPIC_BASE_URL: "https://api.packycode.com",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here", ANTHROPIC_AUTH_TOKEN: "",
}, },
}, },
}, },

View File

@@ -53,7 +53,7 @@ export const tauriAPI = {
// 更新供应商 // 更新供应商
updateProvider: async ( updateProvider: async (
provider: Provider, provider: Provider,
app?: AppType app?: AppType,
): Promise<boolean> => { ): Promise<boolean> => {
try { try {
return await invoke("update_provider", { provider, app_type: app, app }); return await invoke("update_provider", { provider, app_type: app, app });
@@ -76,7 +76,7 @@ export const tauriAPI = {
// 切换供应商 // 切换供应商
switchProvider: async ( switchProvider: async (
providerId: string, providerId: string,
app?: AppType app?: AppType,
): Promise<boolean> => { ): Promise<boolean> => {
try { try {
return await invoke("switch_provider", { return await invoke("switch_provider", {
@@ -92,7 +92,7 @@ export const tauriAPI = {
// 导入当前配置为默认供应商 // 导入当前配置为默认供应商
importCurrentConfigAsDefault: async ( importCurrentConfigAsDefault: async (
app?: AppType app?: AppType,
): Promise<ImportResult> => { ): Promise<ImportResult> => {
try { try {
const success = await invoke<boolean>("import_default_config", { const success = await invoke<boolean>("import_default_config", {
@@ -180,7 +180,7 @@ export const tauriAPI = {
// 监听供应商切换事件 // 监听供应商切换事件
onProviderSwitched: async ( onProviderSwitched: async (
callback: (data: { appType: string; providerId: string }) => void callback: (data: { appType: string; providerId: string }) => void,
): Promise<UnlistenFn> => { ): Promise<UnlistenFn> => {
return await listen("provider-switched", (event) => { return await listen("provider-switched", (event) => {
callback(event.payload as { appType: string; providerId: string }); callback(event.payload as { appType: string; providerId: string });

View File

@@ -33,7 +33,6 @@ export const checkCoAuthoredSetting = (jsonString: string): boolean => {
} }
}; };
// 读取配置中的 API Keyenv.ANTHROPIC_AUTH_TOKEN // 读取配置中的 API Keyenv.ANTHROPIC_AUTH_TOKEN
export const getApiKeyFromConfig = (jsonString: string): string => { export const getApiKeyFromConfig = (jsonString: string): string => {
try { try {

2
src/vite-env.d.ts vendored
View File

@@ -33,7 +33,7 @@ declare global {
openExternal: (url: string) => Promise<void>; openExternal: (url: string) => Promise<void>;
updateTrayMenu: () => Promise<boolean>; updateTrayMenu: () => Promise<boolean>;
onProviderSwitched: ( onProviderSwitched: (
callback: (data: { appType: string; providerId: string }) => void callback: (data: { appType: string; providerId: string }) => void,
) => Promise<UnlistenFn>; ) => Promise<UnlistenFn>;
}; };
platform: { platform: {