474 lines
18 KiB
TypeScript
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>
|
|
}
|