diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index a5532fa..70f07b3 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { Check, @@ -79,6 +79,13 @@ type ProviderRow = { remark: string; }; +type ProviderSaveResult = + | { saved: true } + | { saved: false; error: string; target: 'form' | 'models' }; + +const requestErrorMessage = (error: unknown) => + error instanceof Error ? error.message : String(error); + export type ProviderDraft = { name: string; apiKey: string; @@ -758,12 +765,14 @@ export function ApiAccessPage() { ); const openCreate = () => { + setError(''); setEditingRow(null); setDialogDraft(createProviderDraft(activeCategory)); setDialogOpen(true); }; const openEdit = (row: ProviderRow) => { + setError(''); setEditingRow(row); const draft = draftFromRow(row); setDialogDraft(activeCategory === 'deepseek' @@ -772,7 +781,7 @@ export function ApiAccessPage() { setDialogOpen(true); }; - const saveProvider = async (nextDraft: ProviderDraft): Promise => { + const saveProvider = async (nextDraft: ProviderDraft): Promise => { const definition = activeDefinition; const preparedDraft = applyProviderRemarkIdentity( activeCategory, @@ -790,18 +799,18 @@ export function ApiAccessPage() { || (remarkRequired && !preparedDraft.remark.trim()) || (baseUrlRequired && !preparedDraft.baseUrl.trim()) ) { - setError( - definition.openAi + return { + saved: false, + target: 'form', + error: definition.openAi ? t('apiAccess.error.requiredAll') : baseUrlRequired ? t('apiAccess.error.requiredBaseKey') : t('apiAccess.error.requiredKey'), - ); - return false; + }; } if (Array.from(preparedDraft.remark.trim()).length > 80 || /[\u0000-\u001f\u007f]/.test(preparedDraft.remark)) { - setError(t('apiAccess.error.remarkInvalid')); - return false; + return { saved: false, target: 'form', error: t('apiAccess.error.remarkInvalid') }; } let baseUrl = preparedDraft.baseUrl.trim(); let providerHeaders: Record = {}; @@ -810,23 +819,31 @@ export function ApiAccessPage() { if (baseUrlRequired && !baseUrl) throw new Error(t('apiAccess.error.baseRequired', { provider: definition.label })); providerHeaders = parseProviderHeaders(preparedDraft.headersText ?? ''); } catch (requestError) { - setError(String(requestError)); - return false; + return { saved: false, target: 'form', error: requestErrorMessage(requestError) }; } setBusy(true); setError(''); try { let draftToSave = { ...preparedDraftForSave, baseUrl }; if (definition.openAi && draftToSave.models.length === 0) { - const fetchedModels = await fetchModels( - 'openai', - baseUrl, - parsedApiKeys[0], - editingRow?.authIndex, - providerHeaders, - ); - if (fetchedModels.length === 0) { - throw new Error(t('apiAccess.error.noModels')); + let fetchedModels: ModelOption[]; + try { + fetchedModels = await fetchModels( + 'openai', + baseUrl, + parsedApiKeys[0], + editingRow?.authIndex, + providerHeaders, + ); + if (fetchedModels.length === 0) { + throw new Error(t('apiAccess.error.noModels')); + } + } catch (requestError) { + return { + saved: false, + target: 'models', + error: requestErrorMessage(requestError), + }; } draftToSave = applyProviderPreset(activeCategory, { ...draftToSave, @@ -883,10 +900,9 @@ export function ApiAccessPage() { }); setNotice(editingRow ? t('apiAccess.notice.updated') : t('apiAccess.notice.added')); await loadProviders(); - return true; + return { saved: true }; } catch (requestError) { - setError(String(requestError)); - return false; + return { saved: false, target: 'form', error: requestErrorMessage(requestError) }; } finally { setBusy(false); } @@ -1312,7 +1328,7 @@ type ApiProviderDialogProps = { initialDraft: ProviderDraft; busy: boolean; onClose: () => void; - onSave: (draft: ProviderDraft) => Promise; + onSave: (draft: ProviderDraft) => Promise; }; function ApiProviderDialog({ @@ -1329,6 +1345,7 @@ function ApiProviderDialog({ const [draft, setDraft] = useState(initialDraft); const [modelLoading, setModelLoading] = useState(false); const [modelError, setModelError] = useState(''); + const [formError, setFormError] = useState(''); const [discoveredModels, setDiscoveredModels] = useState([]); const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false); const [modelSearch, setModelSearch] = useState(''); @@ -1336,6 +1353,7 @@ function ApiProviderDialog({ const [selectedModelNames, setSelectedModelNames] = useState>( () => new Set(initialDraft.models.map((model) => model.name.toLowerCase())), ); + const modelCardRef = useRef(null); const modelOptions = useMemo(() => { const options = new Map(); @@ -1366,6 +1384,10 @@ function ApiProviderDialog({ field: 'apiKey' | 'remark' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText', value: string, ) => { + setFormError(''); + if (field === 'apiKey' || field === 'baseUrl' || field === 'headersText') { + setModelError(''); + } setDraft((current) => ({ ...current, [field]: value })); }; @@ -1373,6 +1395,7 @@ function ApiProviderDialog({ field: 'disableCooling' | 'websockets' | 'cloakStrictMode' | 'cloakCacheUserId', value: boolean, ) => { + setFormError(''); setDraft((current) => ({ ...current, [field]: value })); }; @@ -1457,8 +1480,7 @@ function ApiProviderDialog({ )); if (!models.length) setModelError(t('apiAccess.error.noAvailableModels')); } catch (requestError) { - setDiscoveredModels([]); - setModelError(String(requestError)); + setModelError(requestErrorMessage(requestError)); } finally { setModelLoading(false); } @@ -1519,7 +1541,20 @@ function ApiProviderDialog({ const submit = async (event: FormEvent) => { event.preventDefault(); - if (await onSave(draft)) onClose(); + setFormError(''); + const result = await onSave(draft); + if (result.saved) { + onClose(); + return; + } + if (result.target === 'models') { + setModelError(result.error); + window.requestAnimationFrame(() => { + modelCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + return; + } + setFormError(result.error); }; const hasModelExclusions = activeSection !== 'openai-compatibility' @@ -1619,7 +1654,7 @@ function ApiProviderDialog({ ) : {t('apiAccess.thinking.empty')}} ) : null} -
+
{t('apiAccess.models.title')}{t('apiAccess.models.description')}
+ {formError ? ( +
+ {formError} +
+ ) : null}
@@ -1754,22 +1794,30 @@ function ApiProviderDialog({
{modelLoading ? (
{t('apiAccess.modelDialog.fetching')}
- ) : modelError ? ( + ) : modelError && modelOptions.length === 0 ? (
{t('apiAccess.modelDialog.fetchFailed')}{modelError}
) : visibleModelOptions.length === 0 ? (
{modelOptions.length ? t('apiAccess.modelDialog.noMatch') : t('apiAccess.modelDialog.none')}{modelOptions.length ? t('apiAccess.modelDialog.tryKeyword') : t('apiAccess.modelDialog.checkCredentials')}
) : ( -
- {visibleModelOptions.map((model) => { - const checked = selectedModelNames.has(model.name.toLowerCase()); - return ( - - ); - })} +
+ {modelError ? ( +
+ {t('apiAccess.modelDialog.fetchFailed')} + {modelError} +
+ ) : null} +
+ {visibleModelOptions.map((model) => { + const checked = selectedModelNames.has(model.name.toLowerCase()); + return ( + + ); + })} +
)}
diff --git a/src/styles.css b/src/styles.css index ac4d64f..fb3ab1f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3104,6 +3104,29 @@ textarea:focus-visible { overflow: auto; } +.model-discovery-results { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + height: 100%; + min-height: 0; +} + +.model-discovery-inline-error { + display: grid; + gap: 2px; + padding: 8px 11px; + border-bottom: 1px solid var(--theme-dfb7b0); + background: var(--theme-fff1ee); + color: var(--theme-9f2d20); + font-size: var(--font-size-meta); +} + +.model-discovery-inline-error span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .model-discovery-row { display: grid; grid-template-columns: 18px minmax(0, 1fr) 18px;