Files
aiturk-hermes-ide/apps/desktop/src/app/settings/custom-endpoints-settings.tsx
T
yilsemandClaude Opus 5.5 25d8c18c10 feat(aiturk): one-click sign-in for AITURK IDE (1.0.0-beta.14)
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
2026-10-03 15:13:23 +03:00

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>
)
}