From 5c85df486e76c7aa34f3ccfeb1346c21017ca2f1 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Fri, 30 Jan 2026 01:30:36 +0800 Subject: [PATCH] feat: replace AI provider modals with dedicated edit pages --- .../common/SecondaryScreenShell.module.scss | 84 ++++ .../common/SecondaryScreenShell.tsx | 78 ++++ src/components/layout/MainLayout.tsx | 20 +- .../AmpcodeSection/AmpcodeSection.tsx | 26 +- .../providers/ClaudeSection/ClaudeSection.tsx | 27 +- .../providers/CodexSection/CodexSection.tsx | 27 +- .../providers/GeminiSection/GeminiSection.tsx | 27 +- .../providers/OpenAISection/OpenAISection.tsx | 25 +- .../providers/VertexSection/VertexSection.tsx | 25 +- src/pages/AiProvidersAmpcodeEditPage.tsx | 306 ++++++++++++++ src/pages/AiProvidersClaudeEditPage.tsx | 275 +++++++++++++ src/pages/AiProvidersCodexEditPage.tsx | 265 +++++++++++++ src/pages/AiProvidersGeminiEditPage.tsx | 244 ++++++++++++ src/pages/AiProvidersOpenAIEditLayout.tsx | 290 ++++++++++++++ src/pages/AiProvidersOpenAIEditPage.tsx | 373 ++++++++++++++++++ src/pages/AiProvidersOpenAIModelsPage.tsx | 222 +++++++++++ src/pages/AiProvidersPage.tsx | 285 +------------ src/pages/AiProvidersVertexEditPage.tsx | 279 +++++++++++++ ...AuthFilesOAuthExcludedEditPage.module.scss | 80 ---- src/pages/AuthFilesOAuthExcludedEditPage.tsx | 54 +-- ...thFilesOAuthModelAliasEditPage.module.scss | 80 ---- .../AuthFilesOAuthModelAliasEditPage.tsx | 55 +-- src/router/MainRoutes.tsx | 34 ++ 23 files changed, 2536 insertions(+), 645 deletions(-) create mode 100644 src/components/common/SecondaryScreenShell.module.scss create mode 100644 src/components/common/SecondaryScreenShell.tsx create mode 100644 src/pages/AiProvidersAmpcodeEditPage.tsx create mode 100644 src/pages/AiProvidersClaudeEditPage.tsx create mode 100644 src/pages/AiProvidersCodexEditPage.tsx create mode 100644 src/pages/AiProvidersGeminiEditPage.tsx create mode 100644 src/pages/AiProvidersOpenAIEditLayout.tsx create mode 100644 src/pages/AiProvidersOpenAIEditPage.tsx create mode 100644 src/pages/AiProvidersOpenAIModelsPage.tsx create mode 100644 src/pages/AiProvidersVertexEditPage.tsx diff --git a/src/components/common/SecondaryScreenShell.module.scss b/src/components/common/SecondaryScreenShell.module.scss new file mode 100644 index 0000000..1f86817 --- /dev/null +++ b/src/components/common/SecondaryScreenShell.module.scss @@ -0,0 +1,84 @@ +@use '../../styles/variables' as *; + +.container { + display: flex; + flex-direction: column; + gap: $spacing-lg; + min-height: 0; +} + +.topBar { + position: sticky; + top: 0; + z-index: 5; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: $spacing-md; + padding: $spacing-sm $spacing-md; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + min-height: 44px; +} + +.topBarTitle { + min-width: 0; + text-align: center; + font-size: 16px; + font-weight: 650; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + justify-self: center; +} + +.backButton { + padding-left: 6px; + padding-right: 10px; + justify-self: start; + gap: 0; +} + +.backButton > span:last-child { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.backIcon { + display: inline-flex; + align-items: center; + justify-content: center; + + svg { + display: block; + } +} + +.backText { + font-weight: 600; + line-height: 18px; +} + +.rightSlot { + justify-self: end; + display: flex; + justify-content: flex-end; +} + +.loadingState { + display: flex; + align-items: center; + justify-content: center; + gap: $spacing-sm; + padding: $spacing-2xl 0; + color: var(--text-secondary); +} + +.content { + display: flex; + flex-direction: column; + gap: $spacing-lg; +} + diff --git a/src/components/common/SecondaryScreenShell.tsx b/src/components/common/SecondaryScreenShell.tsx new file mode 100644 index 0000000..177a29f --- /dev/null +++ b/src/components/common/SecondaryScreenShell.tsx @@ -0,0 +1,78 @@ +import { forwardRef, type ReactNode } from 'react'; +import { Button } from '@/components/ui/Button'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { IconChevronLeft } from '@/components/ui/icons'; +import styles from './SecondaryScreenShell.module.scss'; + +export type SecondaryScreenShellProps = { + title: ReactNode; + onBack?: () => void; + backLabel?: string; + backAriaLabel?: string; + rightAction?: ReactNode; + isLoading?: boolean; + loadingLabel?: ReactNode; + className?: string; + contentClassName?: string; + children?: ReactNode; +}; + +export const SecondaryScreenShell = forwardRef( + function SecondaryScreenShell( + { + title, + onBack, + backLabel = 'Back', + backAriaLabel, + rightAction, + isLoading = false, + loadingLabel = 'Loading...', + className = '', + contentClassName = '', + children, + }, + ref + ) { + const containerClassName = [styles.container, className].filter(Boolean).join(' '); + const contentClasses = [styles.content, contentClassName].filter(Boolean).join(' '); + const titleTooltip = typeof title === 'string' ? title : undefined; + const resolvedBackAriaLabel = backAriaLabel ?? backLabel; + + return ( +
+
+ {onBack ? ( + + ) : ( +
+ )} +
+ {title} +
+
{rightAction}
+
+ + {isLoading ? ( +
+ + {loadingLabel} +
+ ) : ( +
{children}
+ )} +
+ ); + } +); + diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 72af167..735d307 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -376,6 +376,20 @@ export function MainLayout() { pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath; + const aiProvidersIndex = navOrder.indexOf('/ai-providers'); + if (aiProvidersIndex !== -1) { + if (normalizedPath === '/ai-providers') return aiProvidersIndex; + if (normalizedPath.startsWith('/ai-providers/')) { + if (normalizedPath.startsWith('/ai-providers/gemini')) return aiProvidersIndex + 0.1; + if (normalizedPath.startsWith('/ai-providers/codex')) return aiProvidersIndex + 0.2; + if (normalizedPath.startsWith('/ai-providers/claude')) return aiProvidersIndex + 0.3; + if (normalizedPath.startsWith('/ai-providers/vertex')) return aiProvidersIndex + 0.4; + if (normalizedPath.startsWith('/ai-providers/ampcode')) return aiProvidersIndex + 0.5; + if (normalizedPath.startsWith('/ai-providers/openai')) return aiProvidersIndex + 0.6; + return aiProvidersIndex + 0.05; + } + } + const authFilesIndex = navOrder.indexOf('/auth-files'); if (authFilesIndex !== -1) { if (normalizedPath === '/auth-files') return authFilesIndex; @@ -405,7 +419,11 @@ export function MainLayout() { const to = normalize(toPathname); const isAuthFiles = (pathname: string) => pathname === '/auth-files' || pathname.startsWith('/auth-files/'); - return isAuthFiles(from) && isAuthFiles(to) ? 'ios' : 'vertical'; + const isAiProviders = (pathname: string) => + pathname === '/ai-providers' || pathname.startsWith('/ai-providers/'); + if (isAuthFiles(from) && isAuthFiles(to)) return 'ios'; + if (isAiProviders(from) && isAiProviders(to)) return 'ios'; + return 'vertical'; }, []); const handleRefreshAll = async () => { diff --git a/src/components/providers/AmpcodeSection/AmpcodeSection.tsx b/src/components/providers/AmpcodeSection/AmpcodeSection.tsx index ca87940..bce0b79 100644 --- a/src/components/providers/AmpcodeSection/AmpcodeSection.tsx +++ b/src/components/providers/AmpcodeSection/AmpcodeSection.tsx @@ -5,32 +5,21 @@ 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; + onEdit: () => void; } export function AmpcodeSection({ config, loading, disableControls, - isSaving, isSwitching, - isBusy, - isModalOpen, - onOpen, - onCloseModal, - onBusyChange, + onEdit, }: AmpcodeSectionProps) { const { t } = useTranslation(); @@ -46,8 +35,8 @@ export function AmpcodeSection({ extra={ @@ -99,13 +88,6 @@ export function AmpcodeSection({ )} - - ); } diff --git a/src/components/providers/ClaudeSection/ClaudeSection.tsx b/src/components/providers/ClaudeSection/ClaudeSection.tsx index 275aedd..d581508 100644 --- a/src/components/providers/ClaudeSection/ClaudeSection.tsx +++ b/src/components/providers/ClaudeSection/ClaudeSection.tsx @@ -16,8 +16,6 @@ import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; import { getStatsBySource, hasDisableAllModelsRule } from '../utils'; -import type { ProviderFormState } from '../types'; -import { ClaudeModal } from './ClaudeModal'; interface ClaudeSectionProps { configs: ProviderKeyConfig[]; @@ -25,16 +23,11 @@ interface ClaudeSectionProps { usageDetails: UsageDetail[]; loading: boolean; disableControls: boolean; - isSaving: boolean; isSwitching: boolean; - isModalOpen: boolean; - modalIndex: number | null; onAdd: () => void; onEdit: (index: number) => void; onDelete: (index: number) => void; onToggle: (index: number, enabled: boolean) => void; - onCloseModal: () => void; - onSave: (data: ProviderFormState, index: number | null) => Promise; } export function ClaudeSection({ @@ -43,20 +36,15 @@ export function ClaudeSection({ usageDetails, loading, disableControls, - isSaving, isSwitching, - isModalOpen, - modalIndex, onAdd, onEdit, onDelete, onToggle, - onCloseModal, - onSave, }: ClaudeSectionProps) { const { t } = useTranslation(); - const actionsDisabled = disableControls || isSaving || isSwitching; - const toggleDisabled = disableControls || loading || isSaving || isSwitching; + const actionsDisabled = disableControls || loading || isSwitching; + const toggleDisabled = disableControls || loading || isSwitching; const statusBarCache = useMemo(() => { const cache = new Map>(); @@ -76,8 +64,6 @@ export function ClaudeSection({ return cache; }, [configs, usageDetails]); - const initialData = modalIndex !== null ? configs[modalIndex] : undefined; - return ( <> - - ); } diff --git a/src/components/providers/CodexSection/CodexSection.tsx b/src/components/providers/CodexSection/CodexSection.tsx index 9facbe3..f89cc83 100644 --- a/src/components/providers/CodexSection/CodexSection.tsx +++ b/src/components/providers/CodexSection/CodexSection.tsx @@ -17,8 +17,6 @@ import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; import { getStatsBySource, hasDisableAllModelsRule } from '../utils'; -import type { ProviderFormState } from '../types'; -import { CodexModal } from './CodexModal'; interface CodexSectionProps { configs: ProviderKeyConfig[]; @@ -26,17 +24,12 @@ interface CodexSectionProps { usageDetails: UsageDetail[]; loading: boolean; disableControls: boolean; - isSaving: boolean; isSwitching: boolean; resolvedTheme: string; - isModalOpen: boolean; - modalIndex: number | null; onAdd: () => void; onEdit: (index: number) => void; onDelete: (index: number) => void; onToggle: (index: number, enabled: boolean) => void; - onCloseModal: () => void; - onSave: (data: ProviderFormState, index: number | null) => Promise; } export function CodexSection({ @@ -45,21 +38,16 @@ export function CodexSection({ usageDetails, loading, disableControls, - isSaving, isSwitching, resolvedTheme, - isModalOpen, - modalIndex, onAdd, onEdit, onDelete, onToggle, - onCloseModal, - onSave, }: CodexSectionProps) { const { t } = useTranslation(); - const actionsDisabled = disableControls || isSaving || isSwitching; - const toggleDisabled = disableControls || loading || isSaving || isSwitching; + const actionsDisabled = disableControls || loading || isSwitching; + const toggleDisabled = disableControls || loading || isSwitching; const statusBarCache = useMemo(() => { const cache = new Map>(); @@ -79,8 +67,6 @@ export function CodexSection({ return cache; }, [configs, usageDetails]); - const initialData = modalIndex !== null ? configs[modalIndex] : undefined; - return ( <> - - ); } diff --git a/src/components/providers/GeminiSection/GeminiSection.tsx b/src/components/providers/GeminiSection/GeminiSection.tsx index a53aba1..1d25f3c 100644 --- a/src/components/providers/GeminiSection/GeminiSection.tsx +++ b/src/components/providers/GeminiSection/GeminiSection.tsx @@ -13,11 +13,9 @@ import { type UsageDetail, } from '@/utils/usage'; import styles from '@/pages/AiProvidersPage.module.scss'; -import type { GeminiFormState } from '../types'; import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; import { getStatsBySource, hasDisableAllModelsRule } from '../utils'; -import { GeminiModal } from './GeminiModal'; interface GeminiSectionProps { configs: GeminiKeyConfig[]; @@ -25,16 +23,11 @@ interface GeminiSectionProps { usageDetails: UsageDetail[]; loading: boolean; disableControls: boolean; - isSaving: boolean; isSwitching: boolean; - isModalOpen: boolean; - modalIndex: number | null; onAdd: () => void; onEdit: (index: number) => void; onDelete: (index: number) => void; onToggle: (index: number, enabled: boolean) => void; - onCloseModal: () => void; - onSave: (data: GeminiFormState, index: number | null) => Promise; } export function GeminiSection({ @@ -43,20 +36,15 @@ export function GeminiSection({ usageDetails, loading, disableControls, - isSaving, isSwitching, - isModalOpen, - modalIndex, onAdd, onEdit, onDelete, onToggle, - onCloseModal, - onSave, }: GeminiSectionProps) { const { t } = useTranslation(); - const actionsDisabled = disableControls || isSaving || isSwitching; - const toggleDisabled = disableControls || loading || isSaving || isSwitching; + const actionsDisabled = disableControls || loading || isSwitching; + const toggleDisabled = disableControls || loading || isSwitching; const statusBarCache = useMemo(() => { const cache = new Map>(); @@ -76,8 +64,6 @@ export function GeminiSection({ return cache; }, [configs, usageDetails]); - const initialData = modalIndex !== null ? configs[modalIndex] : undefined; - return ( <> - - ); } diff --git a/src/components/providers/OpenAISection/OpenAISection.tsx b/src/components/providers/OpenAISection/OpenAISection.tsx index a17e69b..1d46761 100644 --- a/src/components/providers/OpenAISection/OpenAISection.tsx +++ b/src/components/providers/OpenAISection/OpenAISection.tsx @@ -17,8 +17,6 @@ import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; import { getOpenAIProviderStats, getStatsBySource } from '../utils'; -import type { OpenAIFormState } from '../types'; -import { OpenAIModal } from './OpenAIModal'; interface OpenAISectionProps { configs: OpenAIProviderConfig[]; @@ -26,16 +24,11 @@ interface OpenAISectionProps { usageDetails: UsageDetail[]; loading: boolean; disableControls: boolean; - isSaving: boolean; isSwitching: boolean; resolvedTheme: string; - isModalOpen: boolean; - modalIndex: number | null; onAdd: () => void; onEdit: (index: number) => void; onDelete: (index: number) => void; - onCloseModal: () => void; - onSave: (data: OpenAIFormState, index: number | null) => Promise; } export function OpenAISection({ @@ -44,19 +37,14 @@ export function OpenAISection({ usageDetails, loading, disableControls, - isSaving, isSwitching, resolvedTheme, - isModalOpen, - modalIndex, onAdd, onEdit, onDelete, - onCloseModal, - onSave, }: OpenAISectionProps) { const { t } = useTranslation(); - const actionsDisabled = disableControls || isSaving || isSwitching; + const actionsDisabled = disableControls || loading || isSwitching; const statusBarCache = useMemo(() => { const cache = new Map>(); @@ -77,8 +65,6 @@ export function OpenAISection({ return cache; }, [configs, usageDetails]); - const initialData = modalIndex !== null ? configs[modalIndex] : undefined; - return ( <> - - ); } diff --git a/src/components/providers/VertexSection/VertexSection.tsx b/src/components/providers/VertexSection/VertexSection.tsx index b3faa6b..4303f78 100644 --- a/src/components/providers/VertexSection/VertexSection.tsx +++ b/src/components/providers/VertexSection/VertexSection.tsx @@ -15,8 +15,6 @@ import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; import { getStatsBySource } from '../utils'; -import type { VertexFormState } from '../types'; -import { VertexModal } from './VertexModal'; interface VertexSectionProps { configs: ProviderKeyConfig[]; @@ -24,15 +22,10 @@ interface VertexSectionProps { usageDetails: UsageDetail[]; loading: boolean; disableControls: boolean; - isSaving: boolean; isSwitching: boolean; - isModalOpen: boolean; - modalIndex: number | null; onAdd: () => void; onEdit: (index: number) => void; onDelete: (index: number) => void; - onCloseModal: () => void; - onSave: (data: VertexFormState, index: number | null) => Promise; } export function VertexSection({ @@ -41,18 +34,13 @@ export function VertexSection({ usageDetails, loading, disableControls, - isSaving, isSwitching, - isModalOpen, - modalIndex, onAdd, onEdit, onDelete, - onCloseModal, - onSave, }: VertexSectionProps) { const { t } = useTranslation(); - const actionsDisabled = disableControls || isSaving || isSwitching; + const actionsDisabled = disableControls || loading || isSwitching; const statusBarCache = useMemo(() => { const cache = new Map>(); @@ -72,8 +60,6 @@ export function VertexSection({ return cache; }, [configs, usageDetails]); - const initialData = modalIndex !== null ? configs[modalIndex] : undefined; - return ( <> - - ); } diff --git a/src/pages/AiProvidersAmpcodeEditPage.tsx b/src/pages/AiProvidersAmpcodeEditPage.tsx new file mode 100644 index 0000000..f436c90 --- /dev/null +++ b/src/pages/AiProvidersAmpcodeEditPage.tsx @@ -0,0 +1,306 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useLocation, useNavigate } 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 { ModelInputList } from '@/components/ui/ModelInputList'; +import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; +import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack'; +import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell'; +import { ampcodeApi } from '@/services/api'; +import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; +import type { AmpcodeConfig } from '@/types'; +import { maskApiKey } from '@/utils/format'; +import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '@/components/providers/utils'; +import type { AmpcodeFormState } from '@/components/providers'; + +type LocationState = { fromAiProviders?: boolean } | null; + +export function AiProvidersAmpcodeEditPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const { showNotification, showConfirmation } = useNotificationStore(); + const connectionStatus = useAuthStore((state) => state.connectionStatus); + const disableControls = connectionStatus !== 'connected'; + + 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 title = useMemo(() => t('ai_providers.ampcode_modal_title'), [t]); + + const getErrorMessage = (err: unknown) => { + if (err instanceof Error) return err.message; + if (typeof err === 'string') return err; + return ''; + }; + + const handleBack = useCallback(() => { + const state = location.state as LocationState; + if (state?.fromAiProviders) { + navigate(-1); + return; + } + navigate('/ai-providers', { replace: true }); + }, [location.state, 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]); + + useEffect(() => { + if (initializedRef.current) return; + initializedRef.current = true; + + setLoading(true); + setLoaded(false); + setMappingsDirty(false); + setError(''); + setForm(buildAmpcodeFormState(config?.ampcode ?? null)); + + let cancelled = false; + ampcodeApi + .getAmpcode() + .then((ampcode) => { + if (cancelled) return; + setLoaded(true); + updateConfigValue('ampcode', ampcode); + clearCache('ampcode'); + setForm(buildAmpcodeFormState(ampcode)); + }) + .catch((err: unknown) => { + if (cancelled) return; + setError(getErrorMessage(err) || t('notification.refresh_failed')); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [clearCache, config?.ampcode, t, updateConfigValue]); + + const clearAmpcodeUpstreamApiKey = async () => { + showConfirmation({ + title: t('ai_providers.ampcode_clear_upstream_api_key_title', { + defaultValue: 'Clear Upstream API Key', + }), + message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'), + variant: 'danger', + confirmText: t('common.confirm'), + onConfirm: async () => { + 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 performSaveAmpcode = async () => { + 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'); + handleBack(); + } 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) { + showConfirmation({ + title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }), + message: t('ai_providers.ampcode_mappings_overwrite_confirm'), + variant: 'secondary', + confirmText: t('common.confirm'), + onConfirm: performSaveAmpcode, + }); + return; + } + + await performSaveAmpcode(); + }; + + const canSave = !disableControls && !saving && !loading; + + return ( + void saveAmpcode()} loading={saving} disabled={!canSave}> + {t('common.save')} + + } + isLoading={loading} + loadingLabel={t('common.loading')} + > + + {error &&
{error}
} + setForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))} + disabled={loading || saving || disableControls} + hint={t('ai_providers.ampcode_upstream_url_hint')} + /> + setForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))} + disabled={loading || saving || disableControls} + 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 || disableControls} + /> +
{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 || disableControls} + /> +
{t('ai_providers.ampcode_model_mappings_hint')}
+
+
+
+ ); +} diff --git a/src/pages/AiProvidersClaudeEditPage.tsx b/src/pages/AiProvidersClaudeEditPage.tsx new file mode 100644 index 0000000..c2e6661 --- /dev/null +++ b/src/pages/AiProvidersClaudeEditPage.tsx @@ -0,0 +1,275 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useLocation, useNavigate, useParams } 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 { HeaderInputList } from '@/components/ui/HeaderInputList'; +import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList'; +import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack'; +import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell'; +import { providersApi } from '@/services/api'; +import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; +import type { ProviderKeyConfig } from '@/types'; +import { buildHeaderObject, headersToEntries } from '@/utils/headers'; +import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils'; +import type { ProviderFormState } from '@/components/providers'; + +type LocationState = { fromAiProviders?: boolean } | null; + +const buildEmptyForm = (): ProviderFormState => ({ + apiKey: '', + prefix: '', + baseUrl: '', + proxyUrl: '', + headers: [], + models: [], + excludedModels: [], + modelEntries: [{ name: '', alias: '' }], + excludedText: '', +}); + +const parseIndexParam = (value: string | undefined) => { + if (!value) return null; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; +}; + +export function AiProvidersClaudeEditPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const params = useParams<{ index?: string }>(); + + const { showNotification } = useNotificationStore(); + const connectionStatus = useAuthStore((state) => state.connectionStatus); + const disableControls = connectionStatus !== 'connected'; + + const fetchConfig = useConfigStore((state) => state.fetchConfig); + const updateConfigValue = useConfigStore((state) => state.updateConfigValue); + const clearCache = useConfigStore((state) => state.clearCache); + + const [configs, setConfigs] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [form, setForm] = useState(() => buildEmptyForm()); + + const hasIndexParam = typeof params.index === 'string'; + const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]); + const invalidIndexParam = hasIndexParam && editIndex === null; + + const initialData = useMemo(() => { + if (editIndex === null) return undefined; + return configs[editIndex]; + }, [configs, editIndex]); + + const invalidIndex = editIndex !== null && !initialData; + + const title = + editIndex !== null + ? t('ai_providers.claude_edit_modal_title') + : t('ai_providers.claude_add_modal_title'); + + const handleBack = useCallback(() => { + const state = location.state as LocationState; + if (state?.fromAiProviders) { + navigate(-1); + return; + } + navigate('/ai-providers', { replace: true }); + }, [location.state, 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]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(''); + + fetchConfig('claude-api-key') + .then((value) => { + if (cancelled) return; + setConfigs(Array.isArray(value) ? (value as ProviderKeyConfig[]) : []); + }) + .catch((err: unknown) => { + if (cancelled) return; + const message = err instanceof Error ? err.message : ''; + setError(message || t('notification.refresh_failed')); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [fetchConfig, t]); + + useEffect(() => { + if (loading) return; + + if (initialData) { + setForm({ + ...initialData, + headers: headersToEntries(initialData.headers), + modelEntries: modelsToEntries(initialData.models), + excludedText: excludedModelsToText(initialData.excludedModels), + }); + return; + } + setForm(buildEmptyForm()); + }, [initialData, loading]); + + const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex; + + const handleSave = useCallback(async () => { + if (!canSave) return; + + setSaving(true); + setError(''); + try { + const payload: ProviderKeyConfig = { + apiKey: form.apiKey.trim(), + prefix: form.prefix?.trim() || undefined, + baseUrl: (form.baseUrl ?? '').trim() || undefined, + proxyUrl: form.proxyUrl?.trim() || undefined, + headers: buildHeaderObject(form.headers), + models: form.modelEntries + .map((entry) => { + const name = entry.name.trim(); + if (!name) return null; + const alias = entry.alias.trim(); + return { name, alias: alias || name }; + }) + .filter(Boolean) as ProviderKeyConfig['models'], + excludedModels: parseExcludedModels(form.excludedText), + }; + + const nextList = + editIndex !== null + ? configs.map((item, idx) => (idx === editIndex ? payload : item)) + : [...configs, payload]; + + await providersApi.saveClaudeConfigs(nextList); + updateConfigValue('claude-api-key', nextList); + clearCache('claude-api-key'); + showNotification( + editIndex !== null ? t('notification.claude_config_updated') : t('notification.claude_config_added'), + 'success' + ); + handleBack(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : ''; + setError(message); + showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); + } finally { + setSaving(false); + } + }, [ + canSave, + clearCache, + configs, + editIndex, + form, + handleBack, + showNotification, + t, + updateConfigValue, + ]); + + return ( + + {t('common.save')} + + } + isLoading={loading} + loadingLabel={t('common.loading')} + > + + {error &&
{error}
} + {invalidIndexParam || invalidIndex ? ( +
Invalid provider index.
+ ) : ( + <> + setForm((prev) => ({ ...prev, apiKey: e.target.value }))} + disabled={disableControls || saving} + /> + setForm((prev) => ({ ...prev, prefix: e.target.value }))} + hint={t('ai_providers.prefix_hint')} + disabled={disableControls || saving} + /> + setForm((prev) => ({ ...prev, baseUrl: e.target.value }))} + disabled={disableControls || saving} + /> + setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))} + disabled={disableControls || saving} + /> + 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')} + disabled={disableControls || saving} + /> +
+ + 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={disableControls || saving} + /> +
+
+ +