Files
opsxshell/src/components/settings/AiProviderSettings.tsx
T

474 lines
18 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Check, CheckCircle2, ChevronDown, Plus, Save, Trash2, Wifi } from 'lucide-react'
import type { AiBillingMode, AiProviderConfig } from '../../../shared/types/ai.types'
import {
AI_PROVIDER_FAMILY_ORDER,
createCustomProvider,
createProviderFromPreset,
presetsByFamily,
resolveProviderFamily,
} from '../../ai/providerPresets'
import { useAiProviderStore } from '../../stores/aiProviderStore'
import Button from '../common/Button'
type TestState = 'idle' | 'testing' | 'success' | 'error'
function selectedModel(provider: AiProviderConfig | null) {
if (!provider) return null
return provider.models.find((model) => model.id === provider.defaultModelId) || null
}
function connectionSnapshot(provider: AiProviderConfig, apiKey: string): string {
const model = selectedModel(provider)
return JSON.stringify({
id: provider.id,
name: provider.name.trim(),
protocol: provider.protocol,
authStyle: provider.authStyle,
baseUrl: provider.baseUrl.trim(),
allowInsecureHttp: provider.allowInsecureHttp,
model,
credential: apiKey.trim() ? `entered:${apiKey.trim()}` : provider.hasApiKey ? 'stored' : 'missing',
})
}
function validDraft(provider: AiProviderConfig | null, apiKey: string): boolean {
if (!provider?.name.trim() || !provider.baseUrl.trim() || !selectedModel(provider)?.model.trim()) return false
return provider.authStyle === 'none' || provider.hasApiKey === true || apiKey.trim().length > 0
}
export default function AiProviderSettings() {
const { t } = useTranslation(['settings', 'common'])
const store = useAiProviderStore()
const [selectedId, setSelectedId] = useState('')
const [presetId, setPresetId] = useState('deepseek')
const [draft, setDraft] = useState<AiProviderConfig | null>(null)
const [apiKey, setApiKey] = useState('')
const [manualModel, setManualModel] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [testState, setTestState] = useState<TestState>('idle')
const [verifiedSnapshot, setVerifiedSnapshot] = useState<string | null>(null)
const [message, setMessage] = useState('')
const [testAnswer, setTestAnswer] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
void store.load()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!selectedId && store.providers[0]) setSelectedId(store.providers[0].id)
}, [selectedId, store.providers])
useEffect(() => {
const provider = store.providers.find((item) => item.id === selectedId)
if (!provider) return
setDraft(structuredClone(provider))
setApiKey('')
setManualModel(provider.models.length === 0)
setAdvancedOpen(resolveProviderFamily(provider) === 'custom' || provider.presetId === 'vllm')
setTestState('idle')
setVerifiedSnapshot(null)
setMessage('')
setTestAnswer('')
}, [selectedId, store.providers])
const currentSnapshot = useMemo(() => (draft ? connectionSnapshot(draft, apiKey) : ''), [apiKey, draft])
const verified = !!currentSnapshot && verifiedSnapshot === currentSnapshot && testState === 'success'
const currentModel = selectedModel(draft)
const isStored = !!draft && store.providers.some((provider) => provider.id === draft.id)
const canTest = validDraft(draft, apiKey) && testState !== 'testing'
const family = draft ? resolveProviderFamily(draft) : null
const needsEndpoint = family === 'custom' || family === 'local' || draft?.presetId === 'vllm'
const chooseDraft = (provider: AiProviderConfig) => {
setSelectedId(provider.id)
setDraft(provider)
setApiKey('')
setManualModel(provider.models.length === 0)
setAdvancedOpen(resolveProviderFamily(provider) === 'custom' || provider.presetId === 'vllm')
setTestState('idle')
setVerifiedSnapshot(null)
setMessage('')
setTestAnswer('')
}
const addPreset = () => chooseDraft(createProviderFromPreset(presetId))
const addCustom = () => chooseDraft(createCustomProvider())
const updateDraft = (changes: Partial<AiProviderConfig>) => {
if (!draft) return
setDraft({ ...draft, ...changes })
if (testState !== 'idle') setMessage(t('ai.retest'))
}
const chooseModel = (modelId: string) => {
if (!draft) return
if (modelId === '__manual') {
setManualModel(true)
return
}
setManualModel(false)
updateDraft({ defaultModelId: modelId })
}
const defaultManualBilling = (): AiBillingMode => {
if (!draft) return 'payg'
const resolved = resolveProviderFamily(draft)
if (resolved === 'local') return 'local'
if (resolved === 'plan') return 'plan'
return 'payg'
}
const updateManualModel = (modelId: string) => {
if (!draft) return
const active = selectedModel(draft)
if (active) {
updateDraft({
models: draft.models.map((model) =>
model.id === active.id ? { ...model, model: modelId, label: modelId || t('ai.customModel') } : model
),
})
return
}
const id = `manual-${Date.now().toString(36)}`
updateDraft({
models: [
{
id,
model: modelId,
label: modelId || t('ai.customModel'),
capability: 'chat',
billing: defaultManualBilling(),
},
],
defaultModelId: id,
})
}
const testConnection = async () => {
if (!draft || !currentModel || !canTest) return
setTestState('testing')
setVerifiedSnapshot(null)
setMessage(t('ai.testingMessage'))
setTestAnswer('')
const snapshot = currentSnapshot
try {
const result = await window.opsxApi.aiTestProvider({
provider: draft,
model: currentModel,
apiKey: apiKey.trim() || undefined,
})
if (!result.success) throw new Error(result.error || t('ai.testFailed'))
setTestState('success')
setVerifiedSnapshot(snapshot)
setTestAnswer(result.answer?.trim() || '')
setMessage(t('ai.available', { latency: result.latencyMs ?? '-' }))
} catch (error) {
setTestState('error')
setMessage(error instanceof Error ? error.message : String(error))
}
}
const save = async () => {
if (!draft || !verified) return
setSaving(true)
try {
await store.saveProvider(draft, apiKey)
if (!store.activeProviderId || !isStored) await store.setActiveProvider(draft.id)
setApiKey('')
setMessage(t('ai.saved'))
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error))
setTestState('error')
} finally {
setSaving(false)
}
}
const remove = async () => {
if (!draft) return
if (isStored) await store.deleteProvider(draft.id)
setDraft(null)
setSelectedId('')
setApiKey('')
setVerifiedSnapshot(null)
setMessage('')
setTestAnswer('')
}
return (
<div className="space-y-5">
<div className="flex items-center gap-2">
<select
aria-label={t('ai.presets')}
value={presetId}
onChange={(event) => setPresetId(event.target.value)}
className="ui-select-pill"
>
{AI_PROVIDER_FAMILY_ORDER.map((familyId) => (
<optgroup key={familyId} label={t(`ai.family.${familyId}`)}>
{presetsByFamily(familyId).map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.id === 'custom' ? t('ai.family.custom') : preset.name}
</option>
))}
</optgroup>
))}
</select>
<Button
type="button"
variant="primary"
size="sm"
icon={<Plus className="h-3.5 w-3.5" />}
onClick={() => (presetId === 'custom' ? addCustom() : addPreset())}
>
{t('ai.add')}
</Button>
</div>
<div className="grid min-h-[22rem] grid-cols-[12rem_minmax(0,1fr)] gap-5">
<aside className="border-dark-700/45 bg-dark-800/50 h-fit overflow-hidden rounded-2xl border py-2">
<div className="px-3 pb-1.5 text-[11px] font-medium text-txt-faint">{t('ai.configured')}</div>
{store.providers.length === 0 ? (
<p className="px-3 py-5 text-xs leading-relaxed text-txt-faint">{t('ai.configuredHint')}</p>
) : (
store.providers.map((provider) => {
const model = provider.models.find((item) => item.id === provider.defaultModelId)
return (
<button
key={provider.id}
type="button"
onClick={() => setSelectedId(provider.id)}
className={`w-full px-3 py-2 text-left transition-colors ${
selectedId === provider.id
? 'bg-accent-blue/12 text-accent-blue'
: 'hover:bg-dark-700/50 text-content-secondary'
}`}
>
<div className="flex items-center gap-1.5">
{store.activeProviderId === provider.id && <Check className="h-3 w-3 flex-shrink-0" />}
<span className="truncate text-sm font-medium">{provider.name}</span>
</div>
<div className="mt-0.5 truncate pl-4 text-[10px] text-txt-faint">
{model?.label || t('ai.noModel')}
</div>
</button>
)
})
)}
</aside>
<main className="min-w-0 max-w-lg">
{!draft ? (
<div className="border-dark-600/80 flex h-40 items-center justify-center rounded-2xl border border-dashed text-sm text-txt-faint">
{t('ai.emptyHint')}
</div>
) : (
<div className="space-y-3">
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
<FormRow label={t('ai.name')}>
<CompactField>
<input
value={draft.name}
onChange={(event) => updateDraft({ name: event.target.value })}
className="ui-input-sm"
/>
</CompactField>
</FormRow>
<FormRow label={t('ai.model')}>
{manualModel || draft.models.length === 0 ? (
<CompactField>
<input
aria-label={t('ai.model')}
value={currentModel?.model || ''}
onChange={(event) => updateManualModel(event.target.value)}
placeholder="deepseek-v4-pro"
className="ui-input-sm font-mono"
/>
</CompactField>
) : (
<select
aria-label={t('ai.model')}
value={draft.defaultModelId}
onChange={(event) => chooseModel(event.target.value)}
className="ui-select-pill"
>
{draft.models.map((model) => (
<option key={model.id} value={model.id}>
{model.label || model.model}
</option>
))}
<option value="__manual">{t('ai.otherModels')}</option>
</select>
)}
</FormRow>
{manualModel && draft.models.length > 0 && (
<div className="flex justify-end px-4 py-2">
<button
type="button"
className="text-xs text-accent-blue hover:underline"
onClick={() => setManualModel(false)}
>
{t('ai.backToList')}
</button>
</div>
)}
<FormRow label={draft.authStyle === 'none' ? t('ai.apiKeyOptional') : t('ai.apiKey')}>
<CompactField wide>
<input
type="password"
autoComplete="off"
value={apiKey}
disabled={draft.authStyle === 'none'}
onChange={(event) => setApiKey(event.target.value)}
placeholder={
draft.hasApiKey
? t('ai.savedKeepBlank')
: draft.authStyle === 'none'
? t('ai.notRequired')
: 'sk-...'
}
className="ui-input-sm"
/>
</CompactField>
</FormRow>
{(needsEndpoint || advancedOpen) && (
<FormRow label={t('ai.endpoint')}>
<CompactField wide>
<input
aria-label={t('ai.endpoint')}
value={currentModel?.baseUrl || draft.baseUrl}
onChange={(event) => updateDraft({ baseUrl: event.target.value })}
placeholder="https://api.example.com/v1"
className="ui-input-sm font-mono"
disabled={!!currentModel?.baseUrl && !needsEndpoint}
/>
</CompactField>
</FormRow>
)}
</div>
<button
type="button"
onClick={() => setAdvancedOpen((open) => !open)}
className="flex items-center gap-1 px-0.5 text-xs text-txt-faint hover:text-txt-muted"
>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
{t('ai.advanced')}
</button>
{advancedOpen && (
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
<FormRow label={t('ai.protocol')}>
<select
value={draft.protocol}
onChange={(event) =>
updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] })
}
className="ui-select-pill"
>
<option value="openai-chat">OpenAI Chat</option>
<option value="openai-responses">OpenAI Responses</option>
<option value="anthropic">Anthropic</option>
</select>
</FormRow>
<FormRow label={t('ai.auth')}>
<select
value={draft.authStyle}
onChange={(event) =>
updateDraft({ authStyle: event.target.value as AiProviderConfig['authStyle'] })
}
className="ui-select-pill"
>
<option value="bearer">Bearer</option>
<option value="x-api-key">x-api-key</option>
<option value="both">Bearer + x-api-key</option>
<option value="none">{t('ai.authNone')}</option>
</select>
</FormRow>
<FormRow label={t('ai.allowHttp')}>
<input
type="checkbox"
checked={draft.allowInsecureHttp}
onChange={(event) => updateDraft({ allowInsecureHttp: event.target.checked })}
className="h-3.5 w-3.5 accent-blue-500"
/>
</FormRow>
</div>
)}
<div className="flex flex-wrap items-center gap-2 pt-0.5">
<Button
type="button"
variant="secondary"
size="sm"
disabled={!canTest}
loading={testState === 'testing'}
icon={testState === 'testing' ? undefined : <Wifi className="h-3.5 w-3.5" />}
onClick={() => void testConnection()}
>
{testState === 'testing' ? t('ai.testing') : t('ai.test')}
</Button>
<Button
type="button"
variant="primary"
size="sm"
disabled={!verified || saving}
loading={saving}
icon={saving ? undefined : <Save className="h-3.5 w-3.5" />}
onClick={() => void save()}
>
{t('common:action.save')}
</Button>
{isStored && store.activeProviderId !== draft.id && (
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void store.setActiveProvider(draft.id)}
>
{t('ai.setActive')}
</Button>
)}
<div className="flex-1" />
<button
type="button"
title={t('ai.deleteTitle')}
onClick={() => void remove()}
className="rounded p-1.5 text-txt-faint hover:bg-dark-700 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div
className={`flex min-h-5 items-center gap-1.5 text-xs ${verified ? 'text-emerald-400' : testState === 'error' ? 'text-red-300' : 'text-txt-faint'}`}
role={testState === 'error' ? 'alert' : 'status'}
>
{verified && <CheckCircle2 className="h-3.5 w-3.5" />}
<span>{message}</span>
</div>
{verified && testAnswer && <p className="text-xs text-content-secondary">{testAnswer}</p>}
</div>
)}
</main>
</div>
</div>
)
}
function FormRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 px-4 py-3">
<span className="min-w-[6.5rem] text-sm text-content-primary">{label}</span>
<div className="ml-auto flex-shrink-0">{children}</div>
</label>
)
}
function CompactField({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) {
return <div className={wide ? 'w-[17.5rem] max-w-full' : 'w-[12.5rem] max-w-full'}>{children}</div>
}