Repository Public
1'use client';2import { useState } from 'react';3import { useQueryClient } from '@tanstack/react-query';4import type { LocalModel } from '@cr/shared';5import { api, errorMessage } from '@/lib/api';6import { qk, useModels } from '@/lib/queries';7import { fmtDateTime, fmtNum } from '@/lib/format';8import { toast } from '@/stores/toasts';9import { Button } from '@/components/ui/button';10import { StatusDot } from '@/components/ui/badge';11import { EmptyState, ErrorState, Section, Spinner } from '@/components/ui/misc';12import { useBrand } from '@/stores/branding';1314export function LocalModels() {15 const brand = useBrand();16 const q = useModels();17 const qc = useQueryClient();18 const [testing, setTesting] = useState<string | null>(null);19 const test = async (m: LocalModel) => {20 setTesting(m.id);21 try {22 // ModelTest (server): a real small completion; null latency/throughput = not measurable23 const r = await api.post<{ ok: boolean; checkedAt: string; latencyMs: number | null; tokensPerSec: number | null; error: string | null }>(`/cluster/models/${encodeURIComponent(m.id)}/test`);24 qc.setQueryData<LocalModel[]>(qk.models, (l) => l?.map((x) => (x.id === m.id ? { ...x, health: r.ok ? 'ONLINE' : 'OFFLINE', checkedAt: r.checkedAt, latencyMs: r.latencyMs, tokensPerSec: r.tokensPerSec } : x)));25 if (!r.ok) toast.warning('Model test failed', r.error ?? 'The endpoint did not answer.');26 } catch (e) {27 toast.error('Model test failed', errorMessage(e));28 } finally {29 setTesting(null);30 }31 };32 return (33 <Section title="Local models" aside={`Local model inference is separate from the ${brand.agentName} runtime`}>34 {q.isPending ? <div className="flex justify-center p-6"><Spinner /></div>35 : q.isError ? <ErrorState error={q.error} onRetry={() => q.refetch()} />36 : q.data.length === 0 ? <EmptyState title="No local model endpoints configured" className="py-8" />37 : (38 <ul className="divide-y divide-line" data-testid="models">39 {q.data.map((m) => (40 <li key={m.id} className="flex flex-wrap items-center gap-x-5 gap-y-1.5 px-4 py-2.5 text-xs">41 <StatusDot health={m.health} withLabel />42 <div className="min-w-0 flex-1 basis-40">43 <p className="truncate text-[13px] font-medium text-text">{m.name}</p>44 <p className="truncate text-[11px] text-muted">{m.host}{m.engine ? ` · ${m.engine}` : ''} · {m.endpoint}</p>45 </div>46 <dl className="tnum flex gap-5">47 <div><dt className="text-[11px] text-muted">Latency</dt><dd>{m.latencyMs == null ? 'not measured' : `${fmtNum(m.latencyMs, 0)} ms`}</dd></div>48 <div><dt className="text-[11px] text-muted">Throughput</dt><dd>{m.tokensPerSec == null ? 'not measured' : `${fmtNum(m.tokensPerSec, 1)} tok/s`}</dd></div>49 <div><dt className="text-[11px] text-muted">Checked</dt><dd>{m.checkedAt ? fmtDateTime(m.checkedAt) : 'never'}</dd></div>50 </dl>51 <Button size="sm" loading={testing === m.id} onClick={() => test(m)}>Run test</Button>52 </li>53 ))}54 </ul>55 )}56 </Section>57 );58}59