refactor(forms): streamline imports and improve state handling for API keys

This commit is contained in:
LTbinglingfeng
2026-06-01 00:22:09 +08:00
parent 7c18e4c937
commit 75da30311a
6 changed files with 91 additions and 200 deletions
@@ -2,11 +2,7 @@ import { useEffect, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapsible } from '@/components/ui/Collapsible';
import { IconPlus, IconX } from '@/components/ui/icons';
import type {
AmpcodeConfig,
AmpcodeModelMapping,
AmpcodeUpstreamApiKeyMapping,
} from '@/types';
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping } from '@/types';
import type { ProviderResource } from '../../types';
import styles from './sharedForm.module.scss';
@@ -34,7 +30,7 @@ function buildState(config?: AmpcodeConfig | null): AmpcodeFormState {
: [emptyModelMapping()];
return {
upstreamUrl: safe.upstreamUrl ?? '',
upstreamApiKey: safe.upstreamApiKey ?? '',
upstreamApiKey: '',
forceModelMappings: safe.forceModelMappings === true,
upstreamMappings,
modelMappings,
@@ -66,9 +62,7 @@ export function AmpcodeForm({
const fid = useId();
const initialConfig = (resource?.raw as AmpcodeConfig | undefined) ?? {};
const [form, setForm] = useState<AmpcodeFormState>(() => buildState(initialConfig));
const [initialFormSignature] = useState<string>(() =>
JSON.stringify(buildState(initialConfig))
);
const [initialFormSignature] = useState<string>(() => JSON.stringify(buildState(initialConfig)));
const [error, setError] = useState<string | null>(null);
const isDirty = useMemo(
@@ -109,7 +103,8 @@ export function AmpcodeForm({
const next: AmpcodeConfig = {
upstreamUrl: form.upstreamUrl.trim() || undefined,
upstreamApiKey: form.upstreamApiKey.trim() || undefined,
upstreamApiKey:
form.upstreamApiKey.trim() || initialConfig.upstreamApiKey?.trim() || undefined,
upstreamApiKeys: upstreamApiKeys.length ? upstreamApiKeys : undefined,
modelMappings: modelMappings.length ? modelMappings : undefined,
forceModelMappings: form.forceModelMappings,
@@ -149,9 +144,7 @@ export function AmpcodeForm({
className={styles.input}
type="password"
value={form.upstreamApiKey}
onChange={(e) =>
setForm((s) => ({ ...s, upstreamApiKey: e.target.value }))
}
onChange={(e) => setForm((s) => ({ ...s, upstreamApiKey: e.target.value }))}
autoComplete="new-password"
data-1p-ignore="true"
data-lpignore="true"
@@ -165,9 +158,7 @@ export function AmpcodeForm({
className={styles.checkboxBox}
checked={form.forceModelMappings}
disabled={mutating}
onChange={(e) =>
setForm((s) => ({ ...s, forceModelMappings: e.target.checked }))
}
onChange={(e) => setForm((s) => ({ ...s, forceModelMappings: e.target.checked }))}
/>
<span className={styles.checkboxText}>
<span>{t('providersPage.ampcode.forceModelMappings')}</span>
@@ -197,9 +188,7 @@ export function AmpcodeForm({
</button>
</div>
<div className={styles.field}>
<label className={styles.label}>
{t('providersPage.ampcode.upstreamApiKey')}
</label>
<label className={styles.label}>{t('providersPage.ampcode.upstreamApiKey')}</label>
<input
className={styles.input}
value={m.upstreamApiKey}
@@ -259,10 +248,7 @@ export function AmpcodeForm({
<Collapsible label={t('providersPage.ampcode.modelMappingsSection')}>
<div className={styles.entriesList}>
{form.modelMappings.map((m, idx) => (
<div
key={idx}
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 8 }}
>
<div key={idx} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 8 }}>
<input
className={styles.input}
placeholder="from"
@@ -13,11 +13,7 @@ import {
import { Collapsible } from '@/components/ui/Collapsible';
import { Select } from '@/components/ui/Select';
import { hasDisableAllModelsRule } from '@/components/providers/utils';
import type {
GeminiKeyConfig,
OpenAIProviderConfig,
ProviderKeyConfig,
} from '@/types';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import type { ModelInfo } from '@/utils/models';
import { PROVIDER_DESCRIPTORS } from '../../descriptors';
import type {
@@ -79,13 +75,9 @@ function buildInitialForm(
excludedModelsText: '',
websockets: brand === 'codex' ? false : undefined,
cloak:
brand === 'claude'
? { mode: '', strictMode: false, sensitiveWordsText: '' }
: undefined,
testModel:
brand === 'openaiCompatibility' || brand === 'claude' ? '' : undefined,
apiKeyEntries:
brand === 'openaiCompatibility' ? [emptyApiKeyEntry()] : undefined,
brand === 'claude' ? { mode: '', strictMode: false, sensitiveWordsText: '' } : undefined,
testModel: brand === 'openaiCompatibility' || brand === 'claude' ? '' : undefined,
apiKeyEntries: brand === 'openaiCompatibility' ? [emptyApiKeyEntry()] : undefined,
};
}
@@ -115,7 +107,8 @@ function buildInitialForm(
testModel: cfg.testModel ?? '',
apiKeyEntries: cfg.apiKeyEntries?.length
? cfg.apiKeyEntries.map((entry) => ({
apiKey: entry.apiKey,
apiKey: '',
existingApiKey: entry.apiKey,
proxyUrl: entry.proxyUrl ?? '',
authIndex: entry.authIndex,
}))
@@ -150,15 +143,13 @@ function buildInitialForm(
? Object.entries(cfg.headers).map(([k, v]) => ({ key: k, value: String(v) }))
: [emptyHeader()],
excludedModelsText: excludedList.join('\n'),
websockets:
brand === 'codex' ? (cfg as ProviderKeyConfig).websockets === true : undefined,
websockets: brand === 'codex' ? (cfg as ProviderKeyConfig).websockets === true : undefined,
cloak:
brand === 'claude'
? {
mode: (cfg as ProviderKeyConfig).cloak?.mode ?? '',
strictMode: (cfg as ProviderKeyConfig).cloak?.strictMode === true,
sensitiveWordsText:
(cfg as ProviderKeyConfig).cloak?.sensitiveWords?.join('\n') ?? '',
sensitiveWordsText: (cfg as ProviderKeyConfig).cloak?.sensitiveWords?.join('\n') ?? '',
}
: undefined,
testModel: brand === 'claude' ? '' : undefined,
@@ -241,9 +232,7 @@ export function BaseProviderForm({
const fallbackAuthIndex = useMemo(() => {
if (mode !== 'edit' || !resource) return '';
return (
(resource.raw as { authIndex?: string } | undefined)?.authIndex ?? ''
);
return (resource.raw as { authIndex?: string } | undefined)?.authIndex ?? '';
}, [mode, resource]);
const connectivityMessages = useMemo<ConnectivityErrorMessages>(
@@ -252,8 +241,7 @@ export function BaseProviderForm({
endpointInvalid: t('providersPage.connectivity.endpointInvalid'),
apiKeyRequired: t('providersPage.connectivity.apiKeyRequired'),
modelRequired: t('providersPage.connectivity.modelRequired'),
timeout: (seconds: number) =>
t('providersPage.connectivity.timeout', { seconds }),
timeout: (seconds: number) => t('providersPage.connectivity.timeout', { seconds }),
requestFailed: t('providersPage.connectivity.requestFailed'),
}),
[t]
@@ -307,9 +295,7 @@ export function BaseProviderForm({
const autoLabel = firstName
? t('providersPage.form.testModelAutoWith', { name: firstName })
: t('providersPage.form.testModelAutoEmpty');
const opts: Array<{ value: string; label: string }> = [
{ value: '', label: autoLabel },
];
const opts: Array<{ value: string; label: string }> = [{ value: '', label: autoLabel }];
names.forEach((n) => opts.push({ value: n, label: n }));
const tm = (form.testModel ?? '').trim();
if (tm && !seen.has(tm)) {
@@ -389,11 +375,7 @@ export function BaseProviderForm({
if (descriptor.supportsName && !form.name.trim()) {
return t('providersPage.form.validation.nameRequired');
}
if (
descriptor.supportsApiKey &&
mode === 'create' &&
!form.apiKey.trim()
) {
if (descriptor.supportsApiKey && mode === 'create' && !form.apiKey.trim()) {
return t('providersPage.form.validation.apiKeyRequired');
}
if (descriptor.baseUrlRequired && !form.baseUrl.trim()) {
@@ -436,9 +418,7 @@ export function BaseProviderForm({
);
const apiKeyEntries = useMemo(
() =>
form.apiKeyEntries && form.apiKeyEntries.length
? form.apiKeyEntries
: [emptyApiKeyEntry()],
form.apiKeyEntries && form.apiKeyEntries.length ? form.apiKeyEntries : [emptyApiKeyEntry()],
[form.apiKeyEntries]
);
@@ -519,11 +499,7 @@ export function BaseProviderForm({
: t('providersPage.form.showApiKey')
}
>
{showSingleApiKey ? (
<IconEyeOff size={16} />
) : (
<IconEye size={16} />
)}
{showSingleApiKey ? <IconEyeOff size={16} /> : <IconEye size={16} />}
</button>
</div>
</div>
@@ -647,9 +623,7 @@ export function BaseProviderForm({
</div>
) : null}
{brand === 'claude' && connectivity.claudeStatus.state === 'error' ? (
<div className={styles.connectivityError}>
{connectivity.claudeStatus.message}
</div>
<div className={styles.connectivityError}>{connectivity.claudeStatus.message}</div>
) : null}
</div>
) : null}
@@ -690,7 +664,9 @@ export function BaseProviderForm({
{descriptor.supportsApiKeyEntries && form.apiKeyEntries ? (
<Collapsible
label={t('providersPage.form.apiKeyEntriesSection')}
hint={`${apiKeyEntries.filter((e) => e.apiKey.trim()).length}`}
hint={`${
apiKeyEntries.filter((e) => e.apiKey.trim() || e.existingApiKey?.trim()).length
}`}
defaultOpen
>
<div className={styles.entriesList}>
@@ -700,9 +676,7 @@ export function BaseProviderForm({
type="button"
className={styles.addBtn}
disabled={mutating}
onClick={() =>
updateField('apiKeyEntries', [...apiKeyEntries, emptyApiKeyEntry()])
}
onClick={() => updateField('apiKeyEntries', [...apiKeyEntries, emptyApiKeyEntry()])}
>
<IconPlus size={12} />
<span>{t('providersPage.form.addApiKeyEntry')}</span>
@@ -731,9 +705,7 @@ export function BaseProviderForm({
return (
<div key={realIdx} className={styles.entryCard}>
<div className={styles.entryCardHeader}>
<span>
{t('providersPage.form.apiKeyEntry', { index: realIdx + 1 })}
</span>
<span>{t('providersPage.form.apiKeyEntry', { index: realIdx + 1 })}</span>
<div className={styles.entryCardHeaderRight}>
<ConnectivityStatusIcon state={status.state} />
<button
@@ -760,9 +732,7 @@ export function BaseProviderForm({
</div>
</div>
<div className={styles.field}>
<label className={styles.label}>
{t('providersPage.form.apiKey')}
</label>
<label className={styles.label}>{t('providersPage.form.apiKey')}</label>
<div className={styles.passwordField}>
<input
className={styles.passwordInput}
@@ -781,7 +751,11 @@ export function BaseProviderForm({
data-lpignore="true"
data-bwignore="true"
disabled={mutating}
placeholder={t('providersPage.form.apiKeyCreatePlaceholder')}
placeholder={
entry.existingApiKey
? t('providersPage.form.apiKeyEditPlaceholder')
: t('providersPage.form.apiKeyCreatePlaceholder')
}
/>
<button
type="button"
@@ -808,9 +782,7 @@ export function BaseProviderForm({
</div>
</div>
<div className={styles.field}>
<label className={styles.label}>
{t('providersPage.form.proxyUrl')}
</label>
<label className={styles.label}>{t('providersPage.form.proxyUrl')}</label>
<input
className={styles.input}
value={entry.proxyUrl}
@@ -827,9 +799,7 @@ export function BaseProviderForm({
/>
</div>
{status.state === 'error' ? (
<div className={styles.connectivityError}>
{status.message}
</div>
<div className={styles.connectivityError}>{status.message}</div>
) : null}
</div>
);
@@ -853,9 +823,7 @@ export function BaseProviderForm({
onChange={(e) =>
updateField(
'headers',
headersList.map((it, i) =>
i === idx ? { ...it, key: e.target.value } : it
)
headersList.map((it, i) => (i === idx ? { ...it, key: e.target.value } : it))
)
}
disabled={mutating}
@@ -945,9 +913,7 @@ export function BaseProviderForm({
onChange={(e) =>
updateField(
'models',
modelsList.map((it, i) =>
i === idx ? { ...it, name: e.target.value } : it
)
modelsList.map((it, i) => (i === idx ? { ...it, name: e.target.value } : it))
)
}
disabled={mutating}
@@ -959,9 +925,7 @@ export function BaseProviderForm({
onChange={(e) =>
updateField(
'models',
modelsList.map((it, i) =>
i === idx ? { ...it, alias: e.target.value } : it
)
modelsList.map((it, i) => (i === idx ? { ...it, alias: e.target.value } : it))
)
}
disabled={mutating}
@@ -997,9 +961,7 @@ export function BaseProviderForm({
{descriptor.supportsExcludedModels ? (
<Collapsible label={t('providersPage.form.excludedSection')}>
<div className={styles.field}>
<span className={styles.labelHint}>
{t('providersPage.form.excludedHint')}
</span>
<span className={styles.labelHint}>{t('providersPage.form.excludedHint')}</span>
<textarea
className={styles.textarea}
rows={4}
@@ -1016,9 +978,7 @@ export function BaseProviderForm({
<Collapsible label={t('providersPage.form.cloakSection')}>
<div className={styles.section}>
<div className={styles.field}>
<label className={styles.label}>
{t('providersPage.form.cloakMode')}
</label>
<label className={styles.label}>{t('providersPage.form.cloakMode')}</label>
<input
className={styles.input}
value={form.cloak.mode}
@@ -1040,16 +1000,12 @@ export function BaseProviderForm({
</span>
</label>
<div className={styles.field}>
<label className={styles.label}>
{t('providersPage.form.cloakSensitiveWords')}
</label>
<label className={styles.label}>{t('providersPage.form.cloakSensitiveWords')}</label>
<textarea
className={styles.textarea}
rows={3}
value={form.cloak.sensitiveWordsText}
onChange={(e) =>
updateCloak('sensitiveWordsText', e.target.value)
}
onChange={(e) => updateCloak('sensitiveWordsText', e.target.value)}
disabled={mutating}
/>
</div>
@@ -5,11 +5,7 @@ import {
buildOpenAIChatCompletionsEndpoint,
} from '@/components/providers/utils';
import { buildHeaderObject, hasHeader } from '@/utils/headers';
import type {
ApiKeyEntryInput,
ModelEntryInput,
ProviderBrand,
} from '../../types';
import type { ApiKeyEntryInput, ModelEntryInput, ProviderBrand } from '../../types';
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
@@ -29,10 +25,7 @@ const errorMessage = (err: unknown): string => {
return '';
};
const pickModel = (
testModel: string | undefined,
models: ModelEntryInput[]
): string => {
const pickModel = (testModel: string | undefined, models: ModelEntryInput[]): string => {
const trimmed = (testModel ?? '').trim();
if (trimmed) return trimmed;
for (const m of models) {
@@ -43,9 +36,7 @@ const pickModel = (
};
const resolveBearerToken = (headers: Record<string, string>): string => {
const auth = Object.entries(headers).find(
([k]) => k.toLowerCase() === 'authorization'
)?.[1];
const auth = Object.entries(headers).find(([k]) => k.toLowerCase() === 'authorization')?.[1];
if (!auth) return '';
const match = String(auth).match(/^Bearer\s+(.+)$/i);
return match ? match[1].trim() : '';
@@ -99,21 +90,21 @@ export function useConnectivityTest(
const entriesCount = apiKeyEntries?.length ?? 0;
const [openaiStatuses, setOpenaiStatuses] = useState<ConnectivityStatus[]>(
() => Array.from({ length: entriesCount }, () => IDLE)
const [openaiStatuses, setOpenaiStatuses] = useState<ConnectivityStatus[]>(() =>
Array.from({ length: entriesCount }, () => IDLE)
);
const [claudeStatus, setClaudeStatus] = useState<ConnectivityStatus>(IDLE);
const [inFlight, setInFlight] = useState(0);
const entrySignatures = useMemo(
() =>
(apiKeyEntries ?? []).map(
(entry) =>
[
entry.apiKey ?? '',
entry.authIndex ?? '',
entry.proxyUrl ?? '',
].join('||')
(apiKeyEntries ?? []).map((entry) =>
[
entry.apiKey ?? '',
entry.existingApiKey ?? '',
entry.authIndex ?? '',
entry.proxyUrl ?? '',
].join('||')
),
[apiKeyEntries]
);
@@ -153,16 +144,13 @@ export function useConnectivityTest(
setClaudeStatus(IDLE);
}, [signature]);
const updateOpenaiStatus = useCallback(
(idx: number, value: ConnectivityStatus) => {
setOpenaiStatuses((prev) => {
const next = [...prev];
next[idx] = value;
return next;
});
},
[]
);
const updateOpenaiStatus = useCallback((idx: number, value: ConnectivityStatus) => {
setOpenaiStatuses((prev) => {
const next = [...prev];
next[idx] = value;
return next;
});
}, []);
const runOpenAIKey = useCallback(
async (idx: number): Promise<boolean> => {
@@ -185,7 +173,7 @@ export function useConnectivityTest(
return false;
}
const entry = apiKeyEntries?.[idx];
const entryKey = (entry?.apiKey ?? '').trim();
const entryKey = (entry?.apiKey ?? '').trim() || (entry?.existingApiKey ?? '').trim();
const resolvedAuthIndex =
(entry?.authIndex ?? '').trim() || (authIndex ?? '').trim() || undefined;
if (!entryKey && !resolvedAuthIndex) {
@@ -356,17 +344,7 @@ export function useConnectivityTest(
} finally {
setInFlight((n) => n - 1);
}
}, [
apiKey,
authIndex,
baseUrl,
brand,
fallbackApiKey,
formHeaders,
messages,
models,
testModel,
]);
}, [apiKey, authIndex, baseUrl, brand, fallbackApiKey, formHeaders, messages, models, testModel]);
return {
openaiStatuses,
@@ -40,18 +40,8 @@ export interface UseModelDiscoveryResult {
reset: () => void;
}
export function useModelDiscovery(
args: UseModelDiscoveryArgs
): UseModelDiscoveryResult {
const {
brand,
baseUrl,
formHeaders,
apiKeyEntries,
apiKey,
fallbackApiKey,
authIndex,
} = args;
export function useModelDiscovery(args: UseModelDiscoveryArgs): UseModelDiscoveryResult {
const { brand, baseUrl, formHeaders, apiKeyEntries, apiKey, fallbackApiKey, authIndex } = args;
const available = isModelDiscoveryBrand(brand);
const [loading, setLoading] = useState(false);
@@ -92,12 +82,13 @@ export function useModelDiscovery(
resolvedAuthIndex
);
} else if (brand === 'openaiCompatibility') {
const firstEntry = (apiKeyEntries ?? []).find((e) =>
(e.apiKey ?? '').trim() || (e.authIndex ?? '').trim()
const firstEntry = (apiKeyEntries ?? []).find(
(e) =>
(e.apiKey ?? '').trim() || (e.existingApiKey ?? '').trim() || (e.authIndex ?? '').trim()
);
const entryKey = (firstEntry?.apiKey ?? '').trim();
const entryAuthIndex =
(firstEntry?.authIndex ?? '').trim() || resolvedAuthIndex;
const entryKey =
(firstEntry?.apiKey ?? '').trim() || (firstEntry?.existingApiKey ?? '').trim();
const entryAuthIndex = (firstEntry?.authIndex ?? '').trim() || resolvedAuthIndex;
try {
next = await modelsApi.fetchModelsViaApiCall(
baseUrl,
@@ -125,16 +116,7 @@ export function useModelDiscovery(
} finally {
setLoading(false);
}
}, [
available,
apiKey,
apiKeyEntries,
authIndex,
baseUrl,
brand,
fallbackApiKey,
formHeaders,
]);
}, [available, apiKey, apiKeyEntries, authIndex, baseUrl, brand, fallbackApiKey, formHeaders]);
const reset = useCallback(() => {
setModels([]);
@@ -144,11 +126,9 @@ export function useModelDiscovery(
}, []);
const inputSignature = useMemo(() => {
const headerSig = formHeaders
.map((h) => `${h.key}:${h.value}`)
.join('|');
const headerSig = formHeaders.map((h) => `${h.key}:${h.value}`).join('|');
const entriesSig = (apiKeyEntries ?? [])
.map((e) => `${e.apiKey ?? ''}::${e.authIndex ?? ''}`)
.map((e) => `${e.apiKey ?? ''}::${e.existingApiKey ?? ''}::${e.authIndex ?? ''}`)
.join('|');
return [
baseUrl,
+1
View File
@@ -90,6 +90,7 @@ export interface ModelEntryInput {
export interface ApiKeyEntryInput {
apiKey: string;
existingApiKey?: string;
proxyUrl: string;
authIndex?: string;
}
+14 -24
View File
@@ -1,8 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ampcodeApi,
providersApi,
} from '@/services/api';
import { ampcodeApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore } from '@/stores';
import {
withDisableAllModelsRule,
@@ -22,10 +19,7 @@ import {
openaiToResource,
vertexToResource,
} from './adapters';
import {
PROVIDER_BRAND_ORDER,
PROVIDER_PATHS,
} from './descriptors';
import { PROVIDER_BRAND_ORDER, PROVIDER_PATHS } from './descriptors';
import type {
ProviderBrand,
ProviderEntryFormInput,
@@ -49,14 +43,8 @@ export interface UseProviderWorkbenchResult {
snapshot: ProviderSnapshot | null;
refetch: () => Promise<void>;
createProvider: (
brand: ProviderBrand,
input: ProviderEntryFormInput
) => Promise<void>;
updateProvider: (
resource: ProviderResource,
input: ProviderEntryFormInput
) => Promise<void>;
createProvider: (brand: ProviderBrand, input: ProviderEntryFormInput) => Promise<void>;
updateProvider: (resource: ProviderResource, input: ProviderEntryFormInput) => Promise<void>;
deleteProvider: (resource: ProviderResource) => Promise<void>;
toggleDisabled: (resource: ProviderResource, disabled: boolean) => Promise<void>;
saveAmpcode: (config: AmpcodeConfig) => Promise<void>;
@@ -157,11 +145,15 @@ const buildOpenAIConfig = (
.filter((m) => m.name);
const apiKeyEntries =
input.apiKeyEntries
?.map((entry) => ({
apiKey: entry.apiKey.trim(),
proxyUrl: entry.proxyUrl.trim() || undefined,
authIndex: entry.authIndex?.trim() || undefined,
}))
?.map((entry, index) => {
const fallbackApiKey =
entry.existingApiKey?.trim() || existing?.apiKeyEntries?.[index]?.apiKey?.trim() || '';
return {
apiKey: entry.apiKey.trim() || fallbackApiKey,
proxyUrl: entry.proxyUrl.trim() || undefined,
authIndex: entry.authIndex?.trim() || undefined,
};
})
.filter((entry) => entry.apiKey) ?? [];
return {
@@ -169,9 +161,7 @@ const buildOpenAIConfig = (
name: input.name.trim(),
baseUrl: input.baseUrl.trim(),
prefix: input.prefix.trim() || undefined,
apiKeyEntries: apiKeyEntries.length
? apiKeyEntries
: existing?.apiKeyEntries ?? [],
apiKeyEntries: apiKeyEntries.length ? apiKeyEntries : (existing?.apiKeyEntries ?? []),
disabled: input.disabled,
headers: Object.keys(headers).length ? headers : undefined,
models: models.length ? models : undefined,