The onboarding and provider settings get a Sign in button: the IDE shows a short code, opens the turkservis.online approval page, and after approval saves the TurkServis provider itself and hands a separate member key to the AITURK CLI over stdin (aiturk login --key-stdin --keep-other-account). Keys never reach the renderer, logs, process arguments or URLs. Pasting an API key remains as the fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
446 lines
15 KiB
TypeScript
446 lines
15 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
|
|
import { AiturkLogin } from '@/components/aiturk-login'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import { Input } from '@/components/ui/input'
|
|
import {
|
|
activateCustomEndpoint,
|
|
deleteCustomEndpoint,
|
|
getCustomEndpoints,
|
|
saveCustomEndpoint,
|
|
validateCustomEndpoint
|
|
} from '@/hermes'
|
|
import { useI18n } from '@/i18n'
|
|
import { useAiturkCopy } from '@/i18n/aiturk'
|
|
import { AITURK_API_BASE_URL } from '@/lib/aiturk-provider'
|
|
import { triggerHaptic } from '@/lib/haptics'
|
|
import { Check, Globe, Loader2, Plus, Save, Trash2, Zap } from '@/lib/icons'
|
|
import { cn } from '@/lib/utils'
|
|
import { confirm } from '@/store/confirm'
|
|
import { notify, notifyError } from '@/store/notifications'
|
|
import type { CustomEndpoint, CustomEndpointUpdate } from '@/types/hermes'
|
|
|
|
import { EmptyState, Pill, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives'
|
|
|
|
interface CustomEndpointsSettingsProps {
|
|
onConfigSaved?: () => void
|
|
onMainModelChanged?: (provider: string, model: string) => void
|
|
}
|
|
|
|
interface EndpointForm {
|
|
apiKey: string
|
|
baseUrl: string
|
|
contextLength: string
|
|
discoverModels: boolean
|
|
id: string
|
|
makeDefault: boolean
|
|
model: string
|
|
name: string
|
|
}
|
|
|
|
const EMPTY_FORM: EndpointForm = {
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
contextLength: '',
|
|
discoverModels: true,
|
|
id: '',
|
|
makeDefault: true,
|
|
model: '',
|
|
name: ''
|
|
}
|
|
|
|
function formFromEndpoint(endpoint: CustomEndpoint): EndpointForm {
|
|
return {
|
|
apiKey: '',
|
|
baseUrl: endpoint.base_url,
|
|
contextLength: endpoint.context_length ? String(endpoint.context_length) : '',
|
|
discoverModels: endpoint.discover_models,
|
|
id: endpoint.id,
|
|
makeDefault: Boolean(endpoint.is_current),
|
|
model: endpoint.model,
|
|
name: endpoint.name
|
|
}
|
|
}
|
|
|
|
function toPayload(form: EndpointForm, models?: string[]): CustomEndpointUpdate {
|
|
const contextLength = Number.parseInt(form.contextLength, 10)
|
|
|
|
return {
|
|
id: form.id.trim() || undefined,
|
|
name: form.name.trim(),
|
|
base_url: form.baseUrl.trim(),
|
|
model: form.model.trim(),
|
|
api_key: form.apiKey.trim() || undefined,
|
|
context_length: Number.isFinite(contextLength) && contextLength > 0 ? contextLength : undefined,
|
|
discover_models: form.discoverModels,
|
|
make_default: form.makeDefault,
|
|
models: models?.length ? models : undefined
|
|
}
|
|
}
|
|
|
|
export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: CustomEndpointsSettingsProps) {
|
|
const { t } = useI18n()
|
|
const aiturkCopy = useAiturkCopy()
|
|
const c = aiturkCopy.custom
|
|
const loginCopy = aiturkCopy.login
|
|
const loginLabel = loginCopy.settingsSignIn
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [testing, setTesting] = useState(false)
|
|
const [activating, setActivating] = useState<string | null>(null)
|
|
const [deleting, setDeleting] = useState<string | null>(null)
|
|
const [endpoints, setEndpoints] = useState<CustomEndpoint[]>([])
|
|
const [form, setForm] = useState<EndpointForm>(EMPTY_FORM)
|
|
const [discoveredModels, setDiscoveredModels] = useState<string[]>([])
|
|
|
|
async function refresh() {
|
|
const data = await getCustomEndpoints()
|
|
setEndpoints(data.endpoints)
|
|
}
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
async function load() {
|
|
try {
|
|
const data = await getCustomEndpoints()
|
|
|
|
if (cancelled) {
|
|
return
|
|
}
|
|
|
|
setEndpoints(data.endpoints)
|
|
const current = data.endpoints.find(endpoint => endpoint.is_current) ?? data.endpoints[0]
|
|
|
|
if (current) {
|
|
setForm(formFromEndpoint(current))
|
|
setDiscoveredModels(current.models)
|
|
}
|
|
} catch (err) {
|
|
notifyError(err, c.loadFailed)
|
|
} finally {
|
|
if (!cancelled) {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
void load()
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
// One-click sign-in saved the TurkServis provider in the main process:
|
|
// reload the list and tell the app its default model changed.
|
|
async function handleAiturkConnected() {
|
|
try {
|
|
const data = await getCustomEndpoints()
|
|
setEndpoints(data.endpoints)
|
|
const current = data.endpoints.find(endpoint => endpoint.id === 'turkservis')
|
|
|
|
if (current) {
|
|
setForm(formFromEndpoint(current))
|
|
setDiscoveredModels(current.models)
|
|
|
|
if (current.is_current) {
|
|
onMainModelChanged?.(current.id, current.model)
|
|
}
|
|
}
|
|
|
|
triggerHaptic('success')
|
|
onConfigSaved?.()
|
|
notify({ kind: 'success', message: loginCopy.connected })
|
|
} catch (err) {
|
|
notifyError(err, c.loadFailed)
|
|
}
|
|
}
|
|
|
|
async function handleSave() {
|
|
try {
|
|
setSaving(true)
|
|
const response = await saveCustomEndpoint(toPayload(form, discoveredModels))
|
|
setEndpoints(response.endpoints)
|
|
const saved = response.endpoints.find(endpoint => endpoint.id === response.id)
|
|
|
|
if (saved) {
|
|
setForm(formFromEndpoint(saved))
|
|
setDiscoveredModels(saved.models)
|
|
}
|
|
|
|
if (saved && saved.is_current) {
|
|
onMainModelChanged?.(saved.id, saved.model)
|
|
}
|
|
|
|
triggerHaptic('success')
|
|
onConfigSaved?.()
|
|
notify({ kind: 'success', message: c.saved })
|
|
} catch (err) {
|
|
notifyError(err, c.saveFailed)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
async function handleValidate() {
|
|
try {
|
|
setTesting(true)
|
|
const response = await validateCustomEndpoint(toPayload(form))
|
|
setDiscoveredModels(response.models)
|
|
|
|
if (response.ok) {
|
|
if (!form.model && response.models[0]) {
|
|
setForm(current => ({ ...current, model: response.models[0] }))
|
|
}
|
|
|
|
notify({
|
|
kind: 'success',
|
|
message: response.models.length
|
|
? c.found(response.models.length)
|
|
: c.reachable
|
|
})
|
|
} else {
|
|
notify({
|
|
kind: response.reachable ? 'warning' : 'error',
|
|
message: response.message || c.validationFailed
|
|
})
|
|
}
|
|
} catch (err) {
|
|
notifyError(err, c.validationFailed)
|
|
} finally {
|
|
setTesting(false)
|
|
}
|
|
}
|
|
|
|
async function handleActivate(endpoint: CustomEndpoint) {
|
|
try {
|
|
setActivating(endpoint.id)
|
|
const response = await activateCustomEndpoint(endpoint.id)
|
|
await refresh()
|
|
onConfigSaved?.()
|
|
onMainModelChanged?.(response.provider, response.model)
|
|
triggerHaptic('success')
|
|
} catch (err) {
|
|
notifyError(err, c.activationFailed)
|
|
} finally {
|
|
setActivating(null)
|
|
}
|
|
}
|
|
|
|
async function handleDelete(endpoint: CustomEndpoint) {
|
|
if (!(await confirm({ destructive: true, title: c.confirmDelete(endpoint.name) }))) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
setDeleting(endpoint.id)
|
|
const response = await deleteCustomEndpoint(endpoint.id)
|
|
setEndpoints(response.endpoints)
|
|
|
|
if (form.id === endpoint.id) {
|
|
setForm(EMPTY_FORM)
|
|
setDiscoveredModels([])
|
|
}
|
|
|
|
onConfigSaved?.()
|
|
triggerHaptic('success')
|
|
} catch (err) {
|
|
notifyError(err, c.deleteFailed)
|
|
} finally {
|
|
setDeleting(null)
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return <SettingsSkeleton sections={[{ heading: true, rows: 3 }]} />
|
|
}
|
|
|
|
const allModelOptions = Array.from(new Set([...discoveredModels, form.model].filter(Boolean)))
|
|
const canSave = form.name.trim() && form.baseUrl.trim() && form.model.trim()
|
|
|
|
return (
|
|
<SettingsContent>
|
|
<div className="space-y-6">
|
|
<section>
|
|
<SectionHeading icon={Globe} meta={`${endpoints.length}`} title={c.title} />
|
|
<div className="divide-y divide-border/40 rounded-md border border-border/50">
|
|
{endpoints.length ? (
|
|
endpoints.map(endpoint => (
|
|
<div className="grid gap-3 p-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center" key={endpoint.id}>
|
|
<button
|
|
className="min-w-0 text-left"
|
|
onClick={() => {
|
|
setForm(formFromEndpoint(endpoint))
|
|
setDiscoveredModels(endpoint.models)
|
|
}}
|
|
type="button"
|
|
>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span className="truncate text-sm font-medium">{endpoint.name}</span>
|
|
{endpoint.is_current && (
|
|
<Pill tone="primary">
|
|
<Check className="size-3" />
|
|
{c.active}
|
|
</Pill>
|
|
)}
|
|
{endpoint.source === 'direct-config' && <Pill>config.yaml</Pill>}
|
|
</div>
|
|
<div className="mt-1 truncate font-mono text-[0.7rem] text-muted-foreground">
|
|
{endpoint.base_url}
|
|
</div>
|
|
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
|
<span>{endpoint.model}</span>
|
|
{endpoint.has_api_key && <span>{endpoint.api_key_preview ?? c.keySet}</span>}
|
|
</div>
|
|
</button>
|
|
<div className="flex items-center gap-2 sm:justify-end">
|
|
<Button
|
|
disabled={endpoint.is_current || activating === endpoint.id}
|
|
onClick={() => void handleActivate(endpoint)}
|
|
size="sm"
|
|
variant="outline"
|
|
>
|
|
{activating === endpoint.id ? <Loader2 className="animate-spin" /> : <Zap />}
|
|
{c.use}
|
|
</Button>
|
|
{endpoint.source !== 'direct-config' && (
|
|
<Button
|
|
className="hover:text-destructive"
|
|
disabled={deleting === endpoint.id}
|
|
onClick={() => void handleDelete(endpoint)}
|
|
size="icon-sm"
|
|
title={c.delete}
|
|
variant="ghost"
|
|
>
|
|
{deleting === endpoint.id ? <Loader2 className="animate-spin" /> : <Trash2 />}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<EmptyState description={c.emptyDesc} title={c.empty} />
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<div className="mb-3 flex flex-wrap items-start gap-2">
|
|
<AiturkLogin label={loginLabel} onConnected={handleAiturkConnected} />
|
|
<Button onClick={() => {
|
|
setForm({ ...EMPTY_FORM, name: 'TurkServis · AITURK', baseUrl: AITURK_API_BASE_URL })
|
|
setDiscoveredModels([])
|
|
}} variant="outline">{c.preset}</Button>
|
|
</div>
|
|
<SectionHeading icon={Plus} title={form.id ? c.edit : c.add} />
|
|
<div className="grid gap-3 rounded-md border border-border/50 p-3">
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<label className="grid gap-1.5 text-xs text-muted-foreground">
|
|
{c.name}
|
|
<Input
|
|
onChange={event => setForm(current => ({ ...current, name: event.target.value }))}
|
|
placeholder="TurkServis"
|
|
value={form.name}
|
|
/>
|
|
</label>
|
|
<label className="grid gap-1.5 text-xs text-muted-foreground">
|
|
{c.providerId}
|
|
<Input
|
|
onChange={event => setForm(current => ({ ...current, id: event.target.value }))}
|
|
placeholder="turkservis"
|
|
value={form.id}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<label className="grid gap-1.5 text-xs text-muted-foreground">
|
|
{c.url}
|
|
<Input
|
|
onChange={event => setForm(current => ({ ...current, baseUrl: event.target.value }))}
|
|
placeholder="http://127.0.0.1:8081/v1"
|
|
value={form.baseUrl}
|
|
/>
|
|
</label>
|
|
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_12rem]">
|
|
<label className="grid gap-1.5 text-xs text-muted-foreground">
|
|
{c.model}
|
|
<Input
|
|
list="custom-endpoint-models"
|
|
onChange={event => setForm(current => ({ ...current, model: event.target.value }))}
|
|
placeholder="gpt-5.4"
|
|
value={form.model}
|
|
/>
|
|
<datalist id="custom-endpoint-models">
|
|
{allModelOptions.map(model => (
|
|
<option key={model} value={model} />
|
|
))}
|
|
</datalist>
|
|
</label>
|
|
<label className="grid gap-1.5 text-xs text-muted-foreground">
|
|
{c.context}
|
|
<Input
|
|
inputMode="numeric"
|
|
onChange={event => setForm(current => ({ ...current, contextLength: event.target.value }))}
|
|
placeholder={c.auto}
|
|
value={form.contextLength}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<label className="grid gap-1.5 text-xs text-muted-foreground">
|
|
{c.apiKey}
|
|
<Input
|
|
onChange={event => setForm(current => ({ ...current, apiKey: event.target.value }))}
|
|
placeholder={form.id ? c.keepKey : c.optional}
|
|
type="password"
|
|
value={form.apiKey}
|
|
/>
|
|
</label>
|
|
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
|
<label className="flex items-center gap-2">
|
|
<Checkbox
|
|
checked={form.makeDefault}
|
|
onCheckedChange={checked => setForm(current => ({ ...current, makeDefault: checked === true }))}
|
|
/>
|
|
{c.newChats}
|
|
</label>
|
|
<label className="flex items-center gap-2">
|
|
<Checkbox
|
|
checked={form.discoverModels}
|
|
onCheckedChange={checked => setForm(current => ({ ...current, discoverModels: checked === true }))}
|
|
/>
|
|
{c.discover}
|
|
</label>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
disabled={testing || !form.baseUrl.trim()}
|
|
onClick={() => void handleValidate()}
|
|
variant="outline"
|
|
>
|
|
{testing ? <Loader2 className="animate-spin" /> : <Zap />}
|
|
{c.test}
|
|
</Button>
|
|
<Button disabled={saving || !canSave} onClick={() => void handleSave()}>
|
|
{saving ? <Loader2 className="animate-spin" /> : <Save />}
|
|
{t.common.save}
|
|
</Button>
|
|
<Button
|
|
className={cn(!form.id && 'hidden')}
|
|
onClick={() => {
|
|
setForm(EMPTY_FORM)
|
|
setDiscoveredModels([])
|
|
}}
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
{c.newEndpoint}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</SettingsContent>
|
|
)
|
|
}
|