From 57730f6196e1bfe006bbbe0389dba5bc0906db5b Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 12 Jul 2026 07:40:01 +0800 Subject: [PATCH] Web UI: runtime console + KV session panel, with runtime/storage libs and tests (#32) * Add web runtime console and KV sessions * test(web): cover runtime and KV session behavior * Fix runtime helper formatting --- web/README.md | 9 +++ web/src/App.tsx | 131 +++++++++++++++++++++++++++++------- web/src/index.css | 14 +++- web/src/lib/api.test.ts | 58 +++++++++++++++- web/src/lib/api.ts | 69 +++++++++++++++++-- web/src/lib/runtime.test.ts | 41 +++++++++++ web/src/lib/runtime.ts | 9 +++ web/src/lib/storage.test.ts | 35 ++++++++++ web/src/lib/storage.ts | 19 ++++++ 9 files changed, 349 insertions(+), 36 deletions(-) create mode 100644 web/src/lib/runtime.test.ts create mode 100644 web/src/lib/runtime.ts create mode 100644 web/src/lib/storage.test.ts create mode 100644 web/src/lib/storage.ts diff --git a/web/README.md b/web/README.md index 0e9fce5..c867d01 100644 --- a/web/README.md +++ b/web/README.md @@ -16,3 +16,12 @@ Local validation: npm test npm run build ``` + +The test suite stays browser-light: API requests use a mocked `fetch`, while +runtime capability and storage behavior are covered through pure helpers. It +checks that `/health` is resolved next to (not below) the OpenAI `/v1` prefix, +supports both boolean and numeric `scheduler.active` responses, and sends the +colibrì-specific `cache_slot` field only when KV-slot support was advertised. + +The endpoint and selected model are persisted locally. API keys are intentionally +memory-only; startup/persistence also removes the legacy `colibri.apiKey` value. diff --git a/web/src/App.tsx b/web/src/App.tsx index cd4475d..2a3f323 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -19,50 +19,111 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" -import { listModels, streamChat, type ChatMessage } from "@/lib/api" +import { getHealth, listModels, streamChat, type ChatMessage, type HealthResponse, type StreamChatResult } from "@/lib/api" +import { activeRequests, supportsCacheSlots } from "@/lib/runtime" +import { persistPublicSettings, stored } from "@/lib/storage" import { cn } from "@/lib/utils" -const stored = (key: string, fallback: string) => localStorage.getItem(key) || fallback const message = (role: ChatMessage["role"], content: string): ChatMessage => ({ id: crypto.randomUUID(), role, content }) export default function App() { - const [baseUrl, setBaseUrl] = useState(() => stored("colibri.baseUrl", "http://127.0.0.1:8000/v1")) - const [apiKey, setApiKey] = useState(() => stored("colibri.apiKey", "")) + const [baseUrl, setBaseUrl] = useState(() => stored(localStorage, "colibri.baseUrl", "http://127.0.0.1:8000/v1")) + const [apiKey, setApiKey] = useState("") const [models, setModels] = useState([]) - const [model, setModel] = useState(() => stored("colibri.model", "glm-5.2-colibri")) + const [model, setModel] = useState(() => stored(localStorage, "colibri.model", "glm-5.2-colibri")) const [temperature, setTemperature] = useState(0.7) const [maxTokens, setMaxTokens] = useState(512) const [thinking, setThinking] = useState(false) - const [messages, setMessages] = useState([]) + const [cacheSlot, setCacheSlot] = useState(0) + const [conversations, setConversations] = useState>({ 0: [] }) + const [health, setHealth] = useState(null) + const [healthError, setHealthError] = useState("") + const [lastRun, setLastRun] = useState(null) const [draft, setDraft] = useState("") const [loading, setLoading] = useState(false) const [connecting, setConnecting] = useState(false) const [connected, setConnected] = useState(false) const [error, setError] = useState("") const abortRef = useRef(null) + const probeRef = useRef(null) const bottomRef = useRef(null) + const messages = conversations[cacheSlot] || [] + const kvSlots = Math.max(1, health?.kv_slots || 1) + const active = activeRequests(health) + const capacity = health?.scheduler?.capacity || kvSlots + const failures = health?.scheduler ? health.scheduler.rejected + health.scheduler.timed_out + health.scheduler.cancelled : 0 + + const updateMessages = (next: ChatMessage[] | ((current: ChatMessage[]) => ChatMessage[])) => + setConversations((current) => ({ + ...current, + [cacheSlot]: typeof next === "function" ? next(current[cacheSlot] || []) : next, + })) useEffect(() => { - localStorage.setItem("colibri.baseUrl", baseUrl) - localStorage.setItem("colibri.apiKey", apiKey) - localStorage.setItem("colibri.model", model) - }, [apiKey, baseUrl, model]) + persistPublicSettings(localStorage, baseUrl, model) + }, [baseUrl, model]) + + useEffect(() => { + setConnected(false) + setHealth(null) + setHealthError("") + }, [baseUrl, apiKey]) + + useEffect(() => () => { + probeRef.current?.abort() + abortRef.current?.abort() + }, []) + + useEffect(() => { + if (!connected) return + let disposed = false + const poll = async () => { + if (document.visibilityState === "hidden") return + try { + const result = await getHealth(baseUrl, apiKey) + if (!disposed) { setHealth(result); setHealthError("") } + } catch (cause) { + if (!disposed) setHealthError(cause instanceof Error ? cause.message : "Runtime metrics unavailable") + } + } + const timer = window.setInterval(() => void poll(), 5000) + return () => { disposed = true; window.clearInterval(timer) } + }, [apiKey, baseUrl, connected]) + + useEffect(() => { + if (cacheSlot >= kvSlots) setCacheSlot(0) + }, [cacheSlot, kvSlots]) + + useEffect(() => setLastRun(null), [cacheSlot]) useEffect(() => bottomRef.current?.scrollIntoView({ behavior: "smooth" }), [messages]) const connect = async () => { + probeRef.current?.abort() + const controller = new AbortController() + probeRef.current = controller setConnecting(true) setError("") try { - const found = await listModels(baseUrl, apiKey) + const found = await listModels(baseUrl, apiKey, controller.signal) setModels(found) if (found.length && !found.includes(model)) setModel(found[0]) setConnected(true) + try { + setHealth(await getHealth(baseUrl, apiKey, controller.signal)) + setHealthError("") + } catch (cause) { + if (!controller.signal.aborted) { + setHealth(null) + setHealthError(cause instanceof Error ? cause.message : "Runtime metrics unavailable") + } + } } catch (cause) { + if (controller.signal.aborted) return setConnected(false) setError(cause instanceof Error ? cause.message : "Could not reach the server.") } finally { - setConnecting(false) + if (probeRef.current === controller) { probeRef.current = null; setConnecting(false) } } } @@ -76,12 +137,12 @@ export default function App() { const history = [...messages, user] setDraft("") setError("") - setMessages([...history, assistant]) + updateMessages([...history, assistant]) setLoading(true) const controller = new AbortController() abortRef.current = controller try { - await streamChat({ + const result = await streamChat({ baseUrl, apiKey, model, @@ -89,19 +150,21 @@ export default function App() { temperature, maxTokens, enableThinking: thinking, + cacheSlot: supportsCacheSlots(health) ? cacheSlot : undefined, signal: controller.signal, onDelta: (delta) => - setMessages((current) => current.map((item) => + updateMessages((current) => current.map((item) => item.id === assistant.id ? { ...item, content: item.content + delta } : item, )), }) + setLastRun(result) setConnected(true) } catch (cause) { if (controller.signal.aborted) { - setMessages((current) => current.filter((item) => item.id !== assistant.id || item.content)) + updateMessages((current) => current.filter((item) => item.id !== assistant.id || item.content)) } else { setError(cause instanceof Error ? cause.message : "Generation failed.") - setMessages((current) => current.filter((item) => item.id !== assistant.id || item.content)) + updateMessages((current) => current.filter((item) => item.id !== assistant.id || item.content)) } } finally { abortRef.current = null @@ -120,20 +183,36 @@ export default function App() {
Connection
- - -
{connected ? "Engine reachable" : "Not connected"}
+
{connected ? "Engine reachable" : "Not connected"}
+
+ +
+
Runtime
+ {health?.scheduler ? <> +
+
Active{active} / {capacity}
+
Queued{health.scheduler.queued} / {health.scheduler.max_queue}
+
Completed{health.scheduler.completed}
+
Failures{failures}
+
+
Scheduler online {kvSlots} KV
+ :

{connected ? (healthError || "Runtime metrics unavailable") : "Probe the server to inspect runtime state."}

}
Inference
+ {health?.kv_slots && health.kv_slots > 1 ? : null} - -
@@ -144,7 +223,7 @@ export default function App() {
ACTIVE MODEL{model}
-
stream
+
{lastRun?.queueWaitMs != null ? queue {Math.round(lastRun.queueWaitMs)}ms : null} slot {cacheSlot + 1}
@@ -163,7 +242,7 @@ export default function App() { {messages.map((item) => (
{item.role === "user" ? "Y" : }
-
{item.role === "user" ? "You" : "colibrì"}
{item.content || }
+
{item.role === "user" ? "You" : "colibrì"}
{item.content || }
))}
@@ -172,9 +251,9 @@ export default function App() {
- {error &&
{error}
} + {error &&
{error}
}
-