218 lines
9.4 KiB
TypeScript
218 lines
9.4 KiB
TypeScript
'use client'
|
|||
|
|
|
||
|
|
import { useEffect, useRef, useState } from 'react'
|
||
|
|
import { ChevronDown } from 'lucide-react'
|
||
|
|
import { Panel } from '@/components/cluster/Panel'
|
||
|
|
import { SlotMeter } from '@/components/cluster/SlotMeter'
|
||
|
|
|
||
|
|
const DAILY_BUDGET = 12
|
||
|
|
|
||
|
|
type Turn = {
|
||
|
|
role: 'user' | 'model'
|
||
|
|
reasoning?: string
|
||
|
|
content: string
|
||
|
|
streaming?: boolean
|
||
|
|
}
|
||
|
|
|
||
|
|
const SEED: Turn[] = [
|
||
|
|
{ role: 'user', content: 'Why is the sms application degraded?' },
|
||
|
|
{
|
||
|
|
role: 'model',
|
||
|
|
reasoning:
|
||
|
|
'The snapshot lists sms with health "degraded" and sync "synced". Sync being clean means the manifests applied fine, so this is a runtime problem rather than a delivery one. The sms namespace shows 0 of 1 pods running. A single pod that never reaches Running, with Argo reporting the desired state as applied, usually means the pod cannot be placed at all.',
|
||
|
|
content:
|
||
|
|
'Delivery is fine — sms is Synced, so Argo applied everything it was asked to. The failure is at runtime: the namespace has one pod and zero running. Argo marks the app degraded because the workload never became ready, not because the manifests are wrong.',
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
const REPLY =
|
||
|
|
'Wave 4 is empty. Waves are sort keys, not a sequence — Argo orders by value and skips gaps, so 3 is followed directly by 5. Nothing is missing.'
|
||
|
|
|
||
|
|
const REPLY_REASONING =
|
||
|
|
'The user is asking about a gap in the wave column display. Argo CD sync waves are integers used purely for ordering; there is no requirement that they be contiguous. The cluster uses 0,1,2,3,5,6,7,8. This is normal and usually happens when a wave is retired or intentionally reserved.'
|
||
|
|
|
||
|
|
export default function ChatPage() {
|
||
|
|
const [turns, setTurns] = useState<Turn[]>(SEED)
|
||
|
|
const [value, setValue] = useState('')
|
||
|
|
const [queue, setQueue] = useState<number | null>(null)
|
||
|
|
const [slots, setSlots] = useState(3)
|
||
|
|
const [used, setUsed] = useState(2)
|
||
|
|
const [openReasoning, setOpenReasoning] = useState<number | null>(1)
|
||
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
|
||
|
|
}, [turns, queue])
|
||
|
|
|
||
|
|
const send = () => {
|
||
|
|
if (!value.trim() || queue !== null || used >= DAILY_BUDGET) return
|
||
|
|
setTurns((t) => [...t, { role: 'user', content: value }])
|
||
|
|
setValue('')
|
||
|
|
setQueue(3)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Placeholder for the queue → stream transition. Real version is SSE from atlas.
|
||
|
|
useEffect(() => {
|
||
|
|
if (queue === null) return
|
||
|
|
if (queue > 0) {
|
||
|
|
const t = setTimeout(() => setQueue((q) => (q === null ? null : q - 1)), 700)
|
||
|
|
return () => clearTimeout(t)
|
||
|
|
}
|
||
|
|
setQueue(null)
|
||
|
|
setSlots((s) => Math.min(s + 1, 6))
|
||
|
|
setUsed((u) => u + 1)
|
||
|
|
setTurns((t) => [...t, { role: 'model', reasoning: REPLY_REASONING, content: '', streaming: true }])
|
||
|
|
|
||
|
|
let i = 0
|
||
|
|
const stream = setInterval(() => {
|
||
|
|
i += 3
|
||
|
|
setTurns((t) => {
|
||
|
|
const next = [...t]
|
||
|
|
const last = next[next.length - 1]
|
||
|
|
if (last?.role !== 'model') return t
|
||
|
|
next[next.length - 1] = { ...last, content: REPLY.slice(0, i), streaming: i < REPLY.length }
|
||
|
|
return next
|
||
|
|
})
|
||
|
|
if (i >= REPLY.length) {
|
||
|
|
clearInterval(stream)
|
||
|
|
setSlots((s) => Math.max(s - 1, 1))
|
||
|
|
}
|
||
|
|
}, 40)
|
||
|
|
return () => clearInterval(stream)
|
||
|
|
}, [queue])
|
||
|
|
|
||
|
|
const exhausted = used >= DAILY_BUDGET
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
<header className="border border-wire bg-deck p-6 md:p-8">
|
||
|
|
<p className="font-data text-[10px] uppercase tracking-[0.2em] text-dim">Surface D — Chat</p>
|
||
|
|
<h1 className="mt-3 max-w-3xl font-signage text-3xl font-semibold leading-[1.1] tracking-signage md:text-5xl">
|
||
|
|
A 32B model, running on one card, <span className="text-lamp">two rooms from here.</span>
|
||
|
|
</h1>
|
||
|
|
<p className="mt-5 max-w-2xl font-plex text-sm leading-relaxed text-dim">
|
||
|
|
It reads a redacted snapshot of the cluster and answers questions about it. It cannot query anything, run
|
||
|
|
anything, or change anything. When all six public slots are busy, you wait in line — the queue is real,
|
||
|
|
and so is the hardware.
|
||
|
|
</p>
|
||
|
|
<div className="mt-6 flex flex-wrap items-center gap-6">
|
||
|
|
<SlotMeter used={slots} size="lg" showCap />
|
||
|
|
<span className="font-data text-[10px] uppercase tracking-[0.14em] text-dim">
|
||
|
|
{DAILY_BUDGET - used} of {DAILY_BUDGET} messages left today
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<div className="grid gap-6 lg:grid-cols-[1fr_260px]">
|
||
|
|
<Panel label="Session" hint="anonymous">
|
||
|
|
<div ref={scrollRef} className="h-[440px] space-y-5 overflow-y-auto pr-1">
|
||
|
|
{turns.map((turn, i) =>
|
||
|
|
turn.role === 'user' ? (
|
||
|
|
<div key={i} className="flex justify-end">
|
||
|
|
<p className="max-w-[85%] border border-wire-lit bg-riser px-3 py-2 font-plex text-sm text-chalk">
|
||
|
|
{turn.content}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div key={i} className="max-w-[92%] space-y-2">
|
||
|
|
{turn.reasoning && (
|
||
|
|
<div className="border-l-2 border-lamp/50">
|
||
|
|
<button
|
||
|
|
onClick={() => setOpenReasoning(openReasoning === i ? null : i)}
|
||
|
|
aria-expanded={openReasoning === i}
|
||
|
|
className="flex w-full items-center gap-2 py-1 pl-3 font-data text-[10px] uppercase tracking-[0.16em] text-lamp focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lamp"
|
||
|
|
>
|
||
|
|
<ChevronDown
|
||
|
|
size={12}
|
||
|
|
className={`transition-transform ${openReasoning === i ? '' : '-rotate-90'}`}
|
||
|
|
/>
|
||
|
|
thinking
|
||
|
|
</button>
|
||
|
|
{openReasoning === i && (
|
||
|
|
<p className="py-1 pl-3 pr-2 font-data text-[11px] leading-relaxed text-dim">
|
||
|
|
{turn.reasoning}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<p className="font-plex text-sm leading-relaxed text-chalk">
|
||
|
|
{turn.content}
|
||
|
|
{turn.streaming && <span className="ml-0.5 inline-block h-4 w-2 animate-pulse bg-lamp align-text-bottom" />}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
),
|
||
|
|
)}
|
||
|
|
|
||
|
|
{queue !== null && (
|
||
|
|
<div className="flex items-center gap-3 border border-lamp/40 px-3 py-2">
|
||
|
|
<SlotMeter used={6} />
|
||
|
|
<p className="font-data text-[11px] text-lamp">
|
||
|
|
all six public slots busy · position {queue} in queue
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<form
|
||
|
|
onSubmit={(e) => {
|
||
|
|
e.preventDefault()
|
||
|
|
send()
|
||
|
|
}}
|
||
|
|
className="mt-3 flex items-center gap-2 border-t border-wire pt-3"
|
||
|
|
>
|
||
|
|
<input
|
||
|
|
value={value}
|
||
|
|
onChange={(e) => setValue(e.target.value)}
|
||
|
|
disabled={exhausted}
|
||
|
|
aria-label="Message"
|
||
|
|
placeholder={exhausted ? 'Daily limit reached — resets in 14h' : 'Ask about the cluster'}
|
||
|
|
className="min-w-0 flex-1 bg-transparent font-plex text-sm text-chalk placeholder:text-dim/60 focus:outline-none disabled:cursor-not-allowed"
|
||
|
|
/>
|
||
|
|
<button
|
||
|
|
type="submit"
|
||
|
|
disabled={exhausted || queue !== null}
|
||
|
|
className="border border-lamp px-3 py-1 font-data text-[10px] uppercase tracking-[0.14em] text-lamp transition-colors hover:bg-lamp hover:text-void focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lamp disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-lamp"
|
||
|
|
>
|
||
|
|
send
|
||
|
|
</button>
|
||
|
|
</form>
|
||
|
|
</Panel>
|
||
|
|
|
||
|
|
<div className="space-y-6">
|
||
|
|
<Panel label="Limits" hint="tier 3">
|
||
|
|
<dl className="space-y-2.5 font-data text-[11px]">
|
||
|
|
{[
|
||
|
|
['public slots', '6 of 8'],
|
||
|
|
['queue depth', '20'],
|
||
|
|
['per session', '12 / day'],
|
||
|
|
['request timeout', '120s'],
|
||
|
|
['output cap', '1500 tokens'],
|
||
|
|
].map(([k, v]) => (
|
||
|
|
<div key={k} className="flex items-baseline gap-2">
|
||
|
|
<dt className="text-dim">{k}</dt>
|
||
|
|
<dd className="ml-auto tabular-nums text-chalk">{v}</dd>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</dl>
|
||
|
|
<p className="mt-4 border-t border-wire pt-3 font-plex text-[11px] leading-relaxed text-dim">
|
||
|
|
Closing this tab frees your slot immediately. Two slots stay reserved so the operator is never locked
|
||
|
|
out of their own hardware.
|
||
|
|
</p>
|
||
|
|
</Panel>
|
||
|
|
|
||
|
|
<Panel label="Cannot do" hint="by construction">
|
||
|
|
<ul className="space-y-1.5 font-data text-[10px] text-dim">
|
||
|
|
{['call tools', 'query the cluster', 'read logs', 'change anything', 'see Secret contents'].map((x) => (
|
||
|
|
<li key={x} className="flex items-center gap-2">
|
||
|
|
<span className="h-px w-3 bg-wire-lit" />
|
||
|
|
{x}
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</Panel>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|