diff --git a/src/components/providers/AmpcodeSection/AmpcodeModal.tsx b/src/components/providers/AmpcodeSection/AmpcodeModal.tsx new file mode 100644 index 0000000..3b201e5 --- /dev/null +++ b/src/components/providers/AmpcodeSection/AmpcodeModal.tsx @@ -0,0 +1,264 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Modal } from '@/components/ui/Modal'; +import { ModelInputList } from '@/components/ui/ModelInputList'; +import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; +import { useConfigStore, useNotificationStore } from '@/stores'; +import { ampcodeApi } from '@/services/api'; +import type { AmpcodeConfig } from '@/types'; +import { maskApiKey } from '@/utils/format'; +import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '../utils'; +import type { AmpcodeFormState } from '../types'; + +interface AmpcodeModalProps { + isOpen: boolean; + disableControls: boolean; + onClose: () => void; + onBusyChange?: (busy: boolean) => void; +} + +export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) { + const { t } = useTranslation(); + const { showNotification } = useNotificationStore(); + const config = useConfigStore((state) => state.config); + const updateConfigValue = useConfigStore((state) => state.updateConfigValue); + const clearCache = useConfigStore((state) => state.clearCache); + + const [form, setForm] = useState(() => buildAmpcodeFormState(null)); + const [loading, setLoading] = useState(false); + const [loaded, setLoaded] = useState(false); + const [mappingsDirty, setMappingsDirty] = useState(false); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + const initializedRef = useRef(false); + + const getErrorMessage = (err: unknown) => { + if (err instanceof Error) return err.message; + if (typeof err === 'string') return err; + return ''; + }; + + useEffect(() => { + onBusyChange?.(loading || saving); + }, [loading, saving, onBusyChange]); + + useEffect(() => { + if (!isOpen) { + initializedRef.current = false; + setLoading(false); + setSaving(false); + setError(''); + setLoaded(false); + setMappingsDirty(false); + setForm(buildAmpcodeFormState(null)); + onBusyChange?.(false); + return; + } + if (initializedRef.current) return; + initializedRef.current = true; + + setLoading(true); + setLoaded(false); + setMappingsDirty(false); + setError(''); + setForm(buildAmpcodeFormState(config?.ampcode ?? null)); + + void (async () => { + try { + const ampcode = await ampcodeApi.getAmpcode(); + setLoaded(true); + updateConfigValue('ampcode', ampcode); + clearCache('ampcode'); + setForm(buildAmpcodeFormState(ampcode)); + } catch (err: unknown) { + setError(getErrorMessage(err) || t('notification.refresh_failed')); + } finally { + setLoading(false); + } + })(); + }, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]); + + const clearAmpcodeUpstreamApiKey = async () => { + if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return; + setSaving(true); + setError(''); + try { + await ampcodeApi.clearUpstreamApiKey(); + const previous = config?.ampcode ?? {}; + const next: AmpcodeConfig = { ...previous }; + delete next.upstreamApiKey; + updateConfigValue('ampcode', next); + clearCache('ampcode'); + showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success'); + } catch (err: unknown) { + const message = getErrorMessage(err); + setError(message); + showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); + } finally { + setSaving(false); + } + }; + + const saveAmpcode = async () => { + if (!loaded && mappingsDirty) { + const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm')); + if (!confirmed) return; + } + + setSaving(true); + setError(''); + try { + const upstreamUrl = form.upstreamUrl.trim(); + const overrideKey = form.upstreamApiKey.trim(); + const modelMappings = entriesToAmpcodeMappings(form.mappingEntries); + + if (upstreamUrl) { + await ampcodeApi.updateUpstreamUrl(upstreamUrl); + } else { + await ampcodeApi.clearUpstreamUrl(); + } + + await ampcodeApi.updateForceModelMappings(form.forceModelMappings); + + if (loaded || mappingsDirty) { + if (modelMappings.length) { + await ampcodeApi.saveModelMappings(modelMappings); + } else { + await ampcodeApi.clearModelMappings(); + } + } + + if (overrideKey) { + await ampcodeApi.updateUpstreamApiKey(overrideKey); + } + + const previous = config?.ampcode ?? {}; + const next: AmpcodeConfig = { + upstreamUrl: upstreamUrl || undefined, + forceModelMappings: form.forceModelMappings, + }; + + if (previous.upstreamApiKey) { + next.upstreamApiKey = previous.upstreamApiKey; + } + + if (Array.isArray(previous.modelMappings)) { + next.modelMappings = previous.modelMappings; + } + + if (overrideKey) { + next.upstreamApiKey = overrideKey; + } + + if (loaded || mappingsDirty) { + if (modelMappings.length) { + next.modelMappings = modelMappings; + } else { + delete next.modelMappings; + } + } + + updateConfigValue('ampcode', next); + clearCache('ampcode'); + showNotification(t('notification.ampcode_updated'), 'success'); + onClose(); + } catch (err: unknown) { + const message = getErrorMessage(err); + setError(message); + showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); + } finally { + setSaving(false); + } + }; + + return ( + + + + + } + > + {error &&
{error}
} + setForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))} + disabled={loading || saving} + hint={t('ai_providers.ampcode_upstream_url_hint')} + /> + setForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))} + disabled={loading || saving} + hint={t('ai_providers.ampcode_upstream_api_key_hint')} + /> +
+
+ {t('ai_providers.ampcode_upstream_api_key_current', { + key: config?.ampcode?.upstreamApiKey + ? maskApiKey(config.ampcode.upstreamApiKey) + : t('common.not_set'), + })} +
+ +
+ +
+ setForm((prev) => ({ ...prev, forceModelMappings: value }))} + disabled={loading || saving} + /> +
{t('ai_providers.ampcode_force_model_mappings_hint')}
+
+ +
+ + { + setMappingsDirty(true); + setForm((prev) => ({ ...prev, mappingEntries: entries })); + }} + addLabel={t('ai_providers.ampcode_model_mappings_add_btn')} + namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')} + aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')} + disabled={loading || saving} + /> +
{t('ai_providers.ampcode_model_mappings_hint')}
+
+
+ ); +} diff --git a/src/components/providers/AmpcodeSection/AmpcodeSection.tsx b/src/components/providers/AmpcodeSection/AmpcodeSection.tsx new file mode 100644 index 0000000..ca87940 --- /dev/null +++ b/src/components/providers/AmpcodeSection/AmpcodeSection.tsx @@ -0,0 +1,111 @@ +import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; +import iconAmp from '@/assets/icons/amp.svg'; +import type { AmpcodeConfig } from '@/types'; +import { maskApiKey } from '@/utils/format'; +import styles from '@/pages/AiProvidersPage.module.scss'; +import { useTranslation } from 'react-i18next'; +import { AmpcodeModal } from './AmpcodeModal'; + +interface AmpcodeSectionProps { + config: AmpcodeConfig | null | undefined; + loading: boolean; + disableControls: boolean; + isSaving: boolean; + isSwitching: boolean; + isBusy: boolean; + isModalOpen: boolean; + onOpen: () => void; + onCloseModal: () => void; + onBusyChange: (busy: boolean) => void; +} + +export function AmpcodeSection({ + config, + loading, + disableControls, + isSaving, + isSwitching, + isBusy, + isModalOpen, + onOpen, + onCloseModal, + onBusyChange, +}: AmpcodeSectionProps) { + const { t } = useTranslation(); + + return ( + <> + + + {t('ai_providers.ampcode_title')} + + } + extra={ + + } + > + {loading ? ( +
{t('common.loading')}
+ ) : ( + <> +
+ {t('ai_providers.ampcode_upstream_url_label')}: + {config?.upstreamUrl || t('common.not_set')} +
+
+ + {t('ai_providers.ampcode_upstream_api_key_label')}: + + + {config?.upstreamApiKey ? maskApiKey(config.upstreamApiKey) : t('common.not_set')} + +
+
+ + {t('ai_providers.ampcode_force_model_mappings_label')}: + + + {(config?.forceModelMappings ?? false) ? t('common.yes') : t('common.no')} + +
+
+ {t('ai_providers.ampcode_model_mappings_count')}: + {config?.modelMappings?.length || 0} +
+ {config?.modelMappings?.length ? ( +
+ {config.modelMappings.slice(0, 5).map((mapping) => ( + + {mapping.from} + {mapping.to} + + ))} + {config.modelMappings.length > 5 && ( + + +{config.modelMappings.length - 5} + + )} +
+ ) : null} + + )} +
+ + + + ); +} diff --git a/src/components/providers/AmpcodeSection/index.ts b/src/components/providers/AmpcodeSection/index.ts new file mode 100644 index 0000000..b645805 --- /dev/null +++ b/src/components/providers/AmpcodeSection/index.ts @@ -0,0 +1 @@ +export { AmpcodeSection } from './AmpcodeSection'; diff --git a/src/components/providers/ClaudeSection/ClaudeModal.tsx b/src/components/providers/ClaudeSection/ClaudeModal.tsx new file mode 100644 index 0000000..bf56c0a --- /dev/null +++ b/src/components/providers/ClaudeSection/ClaudeModal.tsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/Button'; +import { HeaderInputList } from '@/components/ui/HeaderInputList'; +import { Input } from '@/components/ui/Input'; +import { Modal } from '@/components/ui/Modal'; +import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList'; +import type { ProviderKeyConfig } from '@/types'; +import { buildHeaderObject, headersToEntries } from '@/utils/headers'; +import { excludedModelsToText } from '../utils'; +import type { ProviderFormState, ProviderModalProps } from '../types'; + +interface ClaudeModalProps extends ProviderModalProps { + isSaving: boolean; +} + +const buildEmptyForm = (): ProviderFormState => ({ + apiKey: '', + prefix: '', + baseUrl: '', + proxyUrl: '', + headers: {}, + models: [], + excludedModels: [], + modelEntries: [{ name: '', alias: '' }], + excludedText: '', +}); + +export function ClaudeModal({ + isOpen, + editIndex, + initialData, + onClose, + onSave, + isSaving, +}: ClaudeModalProps) { + const { t } = useTranslation(); + const [form, setForm] = useState(buildEmptyForm); + + useEffect(() => { + if (!isOpen) return; + if (initialData) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setForm({ + ...initialData, + headers: initialData.headers ?? {}, + modelEntries: modelsToEntries(initialData.models), + excludedText: excludedModelsToText(initialData.excludedModels), + }); + return; + } + setForm(buildEmptyForm()); + }, [initialData, isOpen]); + + return ( + + + + + } + > + setForm((prev) => ({ ...prev, apiKey: e.target.value }))} + /> + setForm((prev) => ({ ...prev, prefix: e.target.value }))} + hint={t('ai_providers.prefix_hint')} + /> + setForm((prev) => ({ ...prev, baseUrl: e.target.value }))} + /> + setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))} + /> + setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))} + addLabel={t('common.custom_headers_add')} + keyPlaceholder={t('common.custom_headers_key_placeholder')} + valuePlaceholder={t('common.custom_headers_value_placeholder')} + /> +
+ + setForm((prev) => ({ ...prev, modelEntries: entries }))} + addLabel={t('ai_providers.claude_models_add_btn')} + namePlaceholder={t('common.model_name_placeholder')} + aliasPlaceholder={t('common.model_alias_placeholder')} + disabled={isSaving} + /> +
+
+ +