Files
tolaria/src/components/AiProviderSettings.tsx
2026-05-26 10:52:53 +02:00

358 lines
13 KiB
TypeScript

import { useId, useState } from 'react'
import {
DEFAULT_MODEL_CAPABILITIES,
aiModelProviderCatalog,
aiModelProviderCatalogEntry,
configuredModelTargets,
isLocalAiProvider,
normalizeAiModelProviders,
type AiModelApiKeyStorage,
type AiModelProvider,
type AiModelProviderKind,
} from '../lib/aiTargets'
import type { createTranslator } from '../lib/i18n'
import { deleteAiModelProviderApiKey, saveAiModelProviderApiKey, testAiModelProvider } from '../utils/aiProviderSecrets'
import { Button } from './ui/button'
import { Input } from './ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/select'
type Translate = ReturnType<typeof createTranslator>
type ProviderMode = 'local' | 'api'
type TestState = 'idle' | 'testing' | 'success'
interface AiProviderSettingsProps {
t: Translate
mode: ProviderMode
providers: AiModelProvider[]
onChange: (providers: AiModelProvider[]) => void
}
interface ProviderDraft {
kind: AiModelProviderKind
name: string
baseUrl: string
modelId: string
apiKeyStorage: AiModelApiKeyStorage
apiKey: string
apiKeyEnvVar: string
}
function providerKindsForMode(mode: ProviderMode): AiModelProviderKind[] {
return aiModelProviderCatalog()
.filter((entry) => entry.local === (mode === 'local'))
.map((entry) => entry.kind)
}
function initialDraft(mode: ProviderMode): ProviderDraft {
const [kind] = providerKindsForMode(mode)
if (!kind) throw new Error(`No AI model providers are configured for ${mode} mode`)
return draftFromProviderKind(kind)
}
function draftFromProviderKind(kind: AiModelProviderKind): ProviderDraft {
const defaults = aiModelProviderCatalogEntry(kind)
return {
kind,
name: defaults.name,
baseUrl: defaults.base_url,
modelId: '',
apiKeyStorage: defaults.api_key_storage,
apiKey: '',
apiKeyEnvVar: defaults.api_key_env_var ?? '',
}
}
function providerKindOptions(mode: ProviderMode, t: Translate): Array<{ value: AiModelProviderKind; label: string }> {
return providerKindsForMode(mode).map((kind) => {
const defaults = aiModelProviderCatalogEntry(kind)
return { value: kind, label: t(defaults.label_key) }
})
}
function providerPresetPatch(kind: AiModelProviderKind): Pick<ProviderDraft, 'kind' | 'name' | 'baseUrl' | 'apiKeyStorage' | 'apiKeyEnvVar'> {
const defaults = draftFromProviderKind(kind)
return {
kind,
name: defaults.name,
baseUrl: defaults.baseUrl,
apiKeyStorage: defaults.apiKeyStorage,
apiKeyEnvVar: defaults.apiKeyEnvVar,
}
}
function buildProvider(draft: ProviderDraft, providerId: string): AiModelProvider {
return {
id: providerId,
name: draft.name,
kind: draft.kind,
base_url: draft.baseUrl || null,
api_key_storage: draft.apiKeyStorage,
api_key_env_var: draft.apiKeyStorage === 'env' ? draft.apiKeyEnvVar || null : null,
headers: null,
models: [{
id: draft.modelId,
display_name: null,
context_window: null,
max_output_tokens: null,
capabilities: DEFAULT_MODEL_CAPABILITIES,
}],
}
}
function providerModeTitle(mode: ProviderMode, t: Translate): string {
return mode === 'local' ? t('settings.aiProviders.localTitle') : t('settings.aiProviders.apiTitle')
}
function providerModeDescription(mode: ProviderMode, t: Translate): string {
return mode === 'local' ? t('settings.aiProviders.localDescription') : t('settings.aiProviders.apiDescription')
}
function providerStorageLabel(provider: AiModelProvider, t: Translate): string {
if (provider.api_key_storage === 'local_file') return t('settings.aiProviders.keyLocalSaved')
if (provider.api_key_storage === 'env' && provider.api_key_env_var) {
return t('settings.aiProviders.keyEnvSaved', { env: provider.api_key_env_var })
}
return t('settings.aiProviders.noKey')
}
function visibleProviders(providers: AiModelProvider[], mode: ProviderMode): AiModelProvider[] {
return providers.filter((provider) => mode === 'local' ? isLocalAiProvider(provider) : !isLocalAiProvider(provider))
}
function editableInputClassName(): string {
return 'border-border bg-background text-foreground placeholder:text-muted-foreground/65 shadow-xs'
}
function LabeledInput({
label,
value,
onChange,
placeholder,
type = 'text',
}: {
label: string
value: string
onChange: (value: string) => void
placeholder?: string
type?: 'text' | 'password'
}) {
const inputId = useId()
return (
<label htmlFor={inputId} className="space-y-1.5 text-xs font-medium text-foreground">
<span>{label}</span>
<Input
id={inputId}
type={type}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className={editableInputClassName()}
/>
</label>
)
}
function ProviderKindSelect({
mode,
t,
value,
onChange,
}: {
mode: ProviderMode
t: Translate
value: AiModelProviderKind
onChange: (value: AiModelProviderKind) => void
}) {
const triggerId = useId()
return (
<label htmlFor={triggerId} className="space-y-1.5 text-xs font-medium text-foreground">
<span>{t('settings.aiProviders.kind')}</span>
<Select value={value} onValueChange={(next) => onChange(next as AiModelProviderKind)}>
<SelectTrigger id={triggerId} className={`h-9 ${editableInputClassName()}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{providerKindOptions(mode, t).map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</label>
)
}
function ApiKeyStorageFields({
t,
draft,
updateDraft,
}: {
t: Translate
draft: ProviderDraft
updateDraft: (patch: Partial<ProviderDraft>) => void
}) {
const triggerId = useId()
return (
<>
<label htmlFor={triggerId} className="space-y-1.5 text-xs font-medium text-foreground">
<span>{t('settings.aiProviders.keyStorage')}</span>
<Select value={draft.apiKeyStorage} onValueChange={(next) => updateDraft({ apiKeyStorage: next as AiModelApiKeyStorage })}>
<SelectTrigger id={triggerId} className={`h-9 ${editableInputClassName()}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local_file">{t('settings.aiProviders.keyStorage.local')}</SelectItem>
<SelectItem value="env">{t('settings.aiProviders.keyStorage.env')}</SelectItem>
<SelectItem value="none">{t('settings.aiProviders.keyStorage.none')}</SelectItem>
</SelectContent>
</Select>
</label>
{draft.apiKeyStorage === 'local_file' ? (
<LabeledInput
label={t('settings.aiProviders.key')}
value={draft.apiKey}
onChange={(apiKey) => updateDraft({ apiKey })}
placeholder={t('settings.aiProviders.keyPlaceholder')}
type="password"
/>
) : null}
{draft.apiKeyStorage === 'env' ? (
<LabeledInput
label={t('settings.aiProviders.keyEnv')}
value={draft.apiKeyEnvVar}
onChange={(apiKeyEnvVar) => updateDraft({ apiKeyEnvVar })}
placeholder={aiModelProviderCatalogEntry(draft.kind).api_key_env_var ?? ''}
/>
) : null}
</>
)
}
function ProviderList({
t,
mode,
providers,
onRemove,
}: {
t: Translate
mode: ProviderMode
providers: AiModelProvider[]
onRemove: (providerId: string) => void
}) {
const visible = visibleProviders(providers, mode)
if (visible.length === 0) {
return <div className="rounded-md border border-dashed border-border bg-background px-3 py-2 text-xs text-muted-foreground">{t('settings.aiProviders.empty')}</div>
}
return (
<div className="space-y-2">
{configuredModelTargets(visible).map((target) => (
<div key={target.id} className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2 text-sm">
<div className="min-w-0">
<div className="truncate font-medium text-foreground">{target.label}</div>
<div className="truncate text-xs text-muted-foreground">
{target.provider.base_url || t('settings.aiProviders.defaultEndpoint')} · {providerStorageLabel(target.provider, t)}
</div>
</div>
<Button type="button" variant="ghost" size="sm" onClick={() => onRemove(target.provider.id)}>
{t('common.remove')}
</Button>
</div>
))}
</div>
)
}
export function AiProviderSettings({ t, mode, providers, onChange }: AiProviderSettingsProps) {
const [draft, setDraft] = useState<ProviderDraft>(() => initialDraft(mode))
const [error, setError] = useState<string | null>(null)
const [testState, setTestState] = useState<TestState>('idle')
const updateDraft = (patch: Partial<ProviderDraft>) => setDraft((current) => ({ ...current, ...patch }))
const resetTest = () => {
setTestState('idle')
setError(null)
}
const updateForm = (patch: Partial<ProviderDraft>) => {
resetTest()
updateDraft(patch)
}
const updateKind = (kind: AiModelProviderKind) => updateForm(providerPresetPatch(kind))
const canSave = draft.name.trim() && draft.modelId.trim() && (draft.apiKeyStorage !== 'local_file' || draft.apiKey.trim())
const apiKeyOverride = draft.apiKeyStorage === 'local_file' ? draft.apiKey : null
const addProvider = async () => {
const providerId = `${draft.kind}-${Date.now().toString(36)}`
setError(null)
try {
if (draft.apiKeyStorage === 'local_file') {
await saveAiModelProviderApiKey(providerId, draft.apiKey)
}
onChange(normalizeAiModelProviders([...providers, buildProvider(draft, providerId)]))
setDraft((current) => ({ ...draftFromProviderKind(current.kind), name: current.name, baseUrl: current.baseUrl }))
setTestState('idle')
} catch (error) {
setError(error instanceof Error ? error.message : String(error))
}
}
const testProvider = async () => {
setError(null)
setTestState('testing')
try {
await testAiModelProvider(buildProvider(draft, 'draft-provider-test'), draft.modelId, apiKeyOverride)
setTestState('success')
} catch (error) {
setTestState('idle')
setError(error instanceof Error ? error.message : String(error))
}
}
const removeProvider = (providerId: string) => {
void deleteAiModelProviderApiKey(providerId)
onChange(providers.filter((provider) => provider.id !== providerId))
}
return (
<div className="rounded-md border border-border bg-card p-3" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<div className="text-sm font-medium text-foreground">{providerModeTitle(mode, t)}</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">{providerModeDescription(mode, t)}</div>
</div>
<ProviderList t={t} mode={mode} providers={providers} onRemove={removeProvider} />
<div className="grid grid-cols-2 gap-3">
<ProviderKindSelect mode={mode} t={t} value={draft.kind} onChange={updateKind} />
<LabeledInput label={t('settings.aiProviders.name')} value={draft.name} onChange={(name) => updateForm({ name })} />
<LabeledInput label={t('settings.aiProviders.baseUrl')} value={draft.baseUrl} onChange={(baseUrl) => updateForm({ baseUrl })} />
<LabeledInput label={t('settings.aiProviders.model')} value={draft.modelId} onChange={(modelId) => updateForm({ modelId })} placeholder={aiModelProviderCatalogEntry(draft.kind).default_model_id} />
{mode === 'api' ? <ApiKeyStorageFields t={t} draft={draft} updateDraft={updateForm} /> : null}
</div>
<div className="text-xs leading-5 text-muted-foreground">
{mode === 'api' ? t('settings.aiProviders.keySafetyLocal') : t('settings.aiProviders.localSafety')}
</div>
{testState === 'success' ? <div className="text-xs text-emerald-700">{t('settings.aiProviders.testSuccess')}</div> : null}
{error ? <div className="text-xs text-destructive">{error}</div> : null}
<div className="flex items-center gap-3">
<Button type="button" size="sm" onClick={() => void addProvider()} disabled={!canSave}>
{mode === 'local' ? t('settings.aiProviders.addLocal') : t('settings.aiProviders.addApi')}
</Button>
<Button
type="button"
variant="link"
size="sm"
className="h-auto px-0 text-muted-foreground hover:text-foreground"
onClick={() => void testProvider()}
disabled={!canSave || testState === 'testing'}
>
{testState === 'testing' ? t('settings.aiProviders.testing') : t('settings.aiProviders.test')}
</Button>
</div>
</div>
)
}