import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useOutletContext } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; import { Card } from '@/components/ui/Card'; import { Input } from '@/components/ui/Input'; import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell'; import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack'; import { modelsApi } from '@/services/api'; import type { ModelInfo } from '@/utils/models'; import { buildHeaderObject } from '@/utils/headers'; import type { ClaudeEditOutletContext } from './AiProvidersClaudeEditLayout'; import styles from './AiProvidersPage.module.scss'; import layoutStyles from './AiProvidersEditLayout.module.scss'; const getErrorMessage = (err: unknown) => { if (err instanceof Error) return err.message; if (typeof err === 'string') return err; return ''; }; export function AiProvidersClaudeModelsPage() { const { t } = useTranslation(); const navigate = useNavigate(); const { disableControls, loading: initialLoading, saving, form, mergeDiscoveredModels, } = useOutletContext(); const [endpoint, setEndpoint] = useState(''); const [models, setModels] = useState([]); const [fetching, setFetching] = useState(false); const [error, setError] = useState(''); const [search, setSearch] = useState(''); const [selected, setSelected] = useState>(new Set()); const autoFetchSignatureRef = useRef(''); const filteredModels = useMemo(() => { const filter = search.trim().toLowerCase(); if (!filter) return models; return models.filter((model) => { const name = (model.name || '').toLowerCase(); const alias = (model.alias || '').toLowerCase(); const desc = (model.description || '').toLowerCase(); return name.includes(filter) || alias.includes(filter) || desc.includes(filter); }); }, [models, search]); const fetchClaudeModelDiscovery = useCallback(async () => { setFetching(true); setError(''); const headerObject = buildHeaderObject(form.headers); try { const list = await modelsApi.fetchClaudeModelsViaApiCall( form.baseUrl ?? '', form.apiKey.trim() || undefined, headerObject ); setModels(list); } catch (err: unknown) { setModels([]); const message = getErrorMessage(err); const hasCustomXApiKey = Object.keys(headerObject).some( (key) => key.toLowerCase() === 'x-api-key' ); const hasAuthorization = Object.keys(headerObject).some( (key) => key.toLowerCase() === 'authorization' ); const shouldAttachDiag = message.toLowerCase().includes('x-api-key') || message.includes('401'); const diag = shouldAttachDiag ? ` [diag: apiKeyField=${form.apiKey.trim() ? 'yes' : 'no'}, customXApiKey=${ hasCustomXApiKey ? 'yes' : 'no' }, customAuthorization=${hasAuthorization ? 'yes' : 'no'}]` : ''; setError(`${t('ai_providers.claude_models_fetch_error')}: ${message}${diag}`); } finally { setFetching(false); } }, [form.apiKey, form.baseUrl, form.headers, t]); useEffect(() => { if (initialLoading) return; const nextEndpoint = modelsApi.buildClaudeModelsEndpoint(form.baseUrl ?? ''); setEndpoint(nextEndpoint); setModels([]); setSearch(''); setSelected(new Set()); setError(''); const headerObject = buildHeaderObject(form.headers); const hasCustomXApiKey = Object.keys(headerObject).some( (key) => key.toLowerCase() === 'x-api-key' ); const hasAuthorization = Object.keys(headerObject).some( (key) => key.toLowerCase() === 'authorization' ); const hasApiKeyField = Boolean(form.apiKey.trim()); const canAutoFetch = hasApiKeyField || hasCustomXApiKey || hasAuthorization; // Avoid firing a guaranteed 401 on initial render (common while the parent form is still // initializing), and avoid duplicate auto-fetches (e.g. React StrictMode in dev). if (!canAutoFetch) return; const headerSignature = Object.entries(headerObject) .sort(([a], [b]) => a.toLowerCase().localeCompare(b.toLowerCase())) .map(([key, value]) => `${key}:${value}`) .join('|'); const signature = `${nextEndpoint}||${form.apiKey.trim()}||${headerSignature}`; if (autoFetchSignatureRef.current === signature) return; autoFetchSignatureRef.current = signature; void fetchClaudeModelDiscovery(); }, [fetchClaudeModelDiscovery, form.apiKey, form.baseUrl, form.headers, initialLoading]); const handleBack = useCallback(() => { navigate(-1); }, [navigate]); const swipeRef = useEdgeSwipeBack({ onBack: handleBack }); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { handleBack(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleBack]); const toggleSelection = (name: string) => { setSelected((prev) => { const next = new Set(prev); if (next.has(name)) { next.delete(name); } else { next.add(name); } return next; }); }; const handleApply = () => { const selectedModels = models.filter((model) => selected.has(model.name)); if (selectedModels.length) { mergeDiscoveredModels(selectedModels); } handleBack(); }; const canApply = !disableControls && !saving && !fetching; return ( {t('ai_providers.claude_models_fetch_apply')} } isLoading={initialLoading} loadingLabel={t('common.loading')} >
{t('ai_providers.claude_models_fetch_hint')}
setSearch(e.target.value)} disabled={fetching} /> {error &&
{error}
} {fetching ? (
{t('ai_providers.claude_models_fetch_loading')}
) : models.length === 0 ? (
{t('ai_providers.claude_models_fetch_empty')}
) : filteredModels.length === 0 ? (
{t('ai_providers.claude_models_search_empty')}
) : (
{filteredModels.map((model) => { const checked = selected.has(model.name); return ( ); })}
)}
); }