import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useOutletContext } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Select } from '@/components/ui/Select'; import { HeaderInputList } from '@/components/ui/HeaderInputList'; import { ModelInputList } from '@/components/ui/ModelInputList'; import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack'; import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell'; import { apiCallApi, getApiCallErrorMessage } from '@/services/api'; import { useNotificationStore } from '@/stores'; import { buildHeaderObject } from '@/utils/headers'; import { buildClaudeMessagesEndpoint } from '@/components/providers/utils'; import type { ClaudeEditOutletContext } from './AiProvidersClaudeEditLayout'; import styles from './AiProvidersPage.module.scss'; import layoutStyles from './AiProvidersEditLayout.module.scss'; const CLAUDE_TEST_TIMEOUT_MS = 30_000; const DEFAULT_ANTHROPIC_VERSION = '2023-06-01'; const getErrorMessage = (err: unknown) => { if (err instanceof Error) return err.message; if (typeof err === 'string') return err; return ''; }; const hasHeader = (headers: Record, name: string) => { const target = name.toLowerCase(); return Object.keys(headers).some((key) => key.toLowerCase() === target); }; const resolveBearerTokenFromAuthorization = (headers: Record): string => { const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === 'authorization'); if (!entry) return ''; const value = String(entry[1] ?? '').trim(); if (!value) return ''; const match = value.match(/^Bearer\s+(.+)$/i); return match?.[1]?.trim() || ''; }; export function AiProvidersClaudeEditPage() { const { t } = useTranslation(); const navigate = useNavigate(); const { showNotification } = useNotificationStore(); const { hasIndexParam, invalidIndexParam, invalidIndex, disableControls, loading, saving, form, setForm, testModel, setTestModel, testStatus, setTestStatus, testMessage, setTestMessage, availableModels, handleBack, handleSave, } = useOutletContext(); const title = hasIndexParam ? t('ai_providers.claude_edit_modal_title') : t('ai_providers.claude_add_modal_title'); const swipeRef = useEdgeSwipeBack({ onBack: handleBack }); const [isTesting, setIsTesting] = useState(false); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { handleBack(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleBack]); const canSave = !disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex && !isTesting; const modelSelectOptions = useMemo(() => { const seen = new Set(); return form.modelEntries.reduce>((acc, entry) => { const name = entry.name.trim(); if (!name || seen.has(name)) return acc; seen.add(name); const alias = entry.alias.trim(); acc.push({ value: name, label: alias && alias !== name ? `${name} (${alias})` : name, }); return acc; }, []); }, [form.modelEntries]); const connectivityConfigSignature = useMemo(() => { const headersSignature = form.headers .map((entry) => `${entry.key.trim()}:${entry.value.trim()}`) .join('|'); const modelsSignature = form.modelEntries .map((entry) => `${entry.name.trim()}:${entry.alias.trim()}`) .join('|'); return [ form.apiKey.trim(), form.baseUrl?.trim() ?? '', testModel.trim(), headersSignature, modelsSignature, ].join('||'); }, [form.apiKey, form.baseUrl, form.headers, form.modelEntries, testModel]); const previousConnectivityConfigRef = useRef(connectivityConfigSignature); useEffect(() => { if (previousConnectivityConfigRef.current === connectivityConfigSignature) { return; } previousConnectivityConfigRef.current = connectivityConfigSignature; setTestStatus('idle'); setTestMessage(''); }, [connectivityConfigSignature, setTestMessage, setTestStatus]); const openClaudeModelDiscovery = () => { navigate('models'); }; const runClaudeConnectivityTest = useCallback(async () => { if (isTesting) return; const modelName = testModel.trim() || availableModels[0] || ''; if (!modelName) { const message = t('ai_providers.claude_test_model_required'); setTestStatus('error'); setTestMessage(message); showNotification(message, 'error'); return; } const customHeaders = buildHeaderObject(form.headers); const apiKey = form.apiKey.trim(); const hasApiKeyHeader = hasHeader(customHeaders, 'x-api-key'); const apiKeyFromAuthorization = resolveBearerTokenFromAuthorization(customHeaders); const resolvedApiKey = apiKey || apiKeyFromAuthorization; if (!resolvedApiKey && !hasApiKeyHeader) { const message = t('ai_providers.claude_test_key_required'); setTestStatus('error'); setTestMessage(message); showNotification(message, 'error'); return; } const endpoint = buildClaudeMessagesEndpoint(form.baseUrl ?? ''); if (!endpoint) { const message = t('ai_providers.claude_test_endpoint_invalid'); setTestStatus('error'); setTestMessage(message); showNotification(message, 'error'); return; } const headers: Record = { 'Content-Type': 'application/json', ...customHeaders, }; if (!hasHeader(headers, 'anthropic-version')) { headers['anthropic-version'] = DEFAULT_ANTHROPIC_VERSION; } if (!Object.prototype.hasOwnProperty.call(headers, 'Anthropic-Version')) { headers['Anthropic-Version'] = headers['anthropic-version'] ?? DEFAULT_ANTHROPIC_VERSION; } if (!hasApiKeyHeader && resolvedApiKey) { headers['x-api-key'] = resolvedApiKey; } if (!Object.prototype.hasOwnProperty.call(headers, 'X-Api-Key') && resolvedApiKey) { headers['X-Api-Key'] = resolvedApiKey; } setIsTesting(true); setTestStatus('loading'); setTestMessage(t('ai_providers.claude_test_running')); try { const result = await apiCallApi.request( { method: 'POST', url: endpoint, header: headers, data: JSON.stringify({ model: modelName, max_tokens: 8, messages: [{ role: 'user', content: 'Hi' }], }), }, { timeout: CLAUDE_TEST_TIMEOUT_MS } ); if (result.statusCode < 200 || result.statusCode >= 300) { throw new Error(getApiCallErrorMessage(result)); } const message = t('ai_providers.claude_test_success'); setTestStatus('success'); setTestMessage(message); showNotification(message, 'success'); } catch (err: unknown) { const message = getErrorMessage(err); const errorCode = typeof err === 'object' && err !== null && 'code' in err ? String((err as { code?: string }).code) : ''; const isTimeout = errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout'); const resolvedMessage = isTimeout ? t('ai_providers.claude_test_timeout', { seconds: CLAUDE_TEST_TIMEOUT_MS / 1000 }) : `${t('ai_providers.claude_test_failed')}: ${message || t('common.unknown_error')}`; setTestStatus('error'); setTestMessage(resolvedMessage); showNotification(resolvedMessage, 'error'); } finally { setIsTesting(false); } }, [ availableModels, form.apiKey, form.baseUrl, form.headers, isTesting, setTestMessage, setTestStatus, showNotification, t, testModel, ]); return ( void handleSave()} loading={saving} disabled={!canSave}> {t('common.save')} } isLoading={loading} loadingLabel={t('common.loading')} > {invalidIndexParam || invalidIndex ? (
{t('common.invalid_provider_index')}
) : (
setForm((prev) => ({ ...prev, apiKey: e.target.value }))} disabled={saving || disableControls || isTesting} /> setForm((prev) => ({ ...prev, prefix: e.target.value }))} hint={t('ai_providers.prefix_hint')} disabled={saving || disableControls || isTesting} /> setForm((prev) => ({ ...prev, baseUrl: e.target.value }))} disabled={saving || disableControls || isTesting} /> setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))} disabled={saving || disableControls || isTesting} /> setForm((prev) => ({ ...prev, headers: entries }))} addLabel={t('common.custom_headers_add')} keyPlaceholder={t('common.custom_headers_key_placeholder')} valuePlaceholder={t('common.custom_headers_value_placeholder')} removeButtonTitle={t('common.delete')} removeButtonAriaLabel={t('common.delete')} disabled={saving || disableControls || isTesting} />
{t('ai_providers.claude_models_hint')}
setForm((prev) => ({ ...prev, modelEntries: entries }))} namePlaceholder={t('common.model_name_placeholder')} aliasPlaceholder={t('common.model_alias_placeholder')} disabled={saving || disableControls || isTesting} hideAddButton className={styles.modelInputList} rowClassName={styles.modelInputRow} inputClassName={styles.modelInputField} removeButtonClassName={styles.modelRowRemoveButton} removeButtonTitle={t('common.delete')} removeButtonAriaLabel={t('common.delete')} />
{t('ai_providers.claude_test_hint')}