358 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|