Skip to content

Commit df10097

Browse files
sherm8nclaude
andcommitted
feat: built-in web chat UI at GET /
Why: Until now the only way to interact with Ravnest was curl + JSON or installing Open WebUI separately. This ships a complete chat UI in a single HTML file served from / on the same port as the API. Users open localhost:8000 in a browser and get: - Dark theme, responsive layout - Conversation history (in-memory, multi-turn) - Streaming responses with live cursor - Status indicator (connected / loading / error) - Model name in header (from /v1/models) - Auto-resize textarea, Enter to send / Shift+Enter newline - Auto-reconnect polling when API is loading - Vanilla JS, no build step, no dependencies Implementation: - deploy/chat_ui.html — single self-contained file (~250 lines) - api_server.py reads it once at app startup, serves at GET / - Falls back to a stub message if the HTML isn't found Tests: 222 -> 226 (4 new) - TestChatUI: returns HTML, contains chat elements, uses streaming, polls /health for loading state Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f9bfb59 commit df10097

4 files changed

Lines changed: 340 additions & 2 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,12 @@ for the leaf to come online and prints progress while waiting.
106106

107107
### Using the API
108108

109-
Once running, send requests to the OpenAI-compatible endpoint:
109+
**Easiest:** open `http://localhost:8000` in your browser. Ravnest ships
110+
with a built-in chat UI — no setup, no Docker, no Open WebUI required.
111+
Streaming, dark mode, conversation history. Works on the same port as
112+
the API.
113+
114+
**Programmatic:** send requests to the OpenAI-compatible endpoint:
110115
```bash
111116
curl -X POST http://localhost:8000/v1/chat/completions \
112117
-H "Content-Type: application/json" \

deploy/api_server.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
import uuid
1616
from typing import List, Optional
1717

18+
from pathlib import Path
19+
1820
from fastapi import FastAPI, HTTPException, Request
19-
from fastapi.responses import StreamingResponse
21+
from fastapi.responses import StreamingResponse, HTMLResponse
2022
from fastapi.middleware.cors import CORSMiddleware
2123
from pydantic import BaseModel
2224

@@ -171,6 +173,20 @@ def validate_request(request):
171173
)
172174
return prompt, prompt_token_count
173175

176+
# Load the chat UI HTML once at startup
177+
chat_ui_path = Path(__file__).parent / "chat_ui.html"
178+
chat_ui_html = chat_ui_path.read_text() if chat_ui_path.exists() else None
179+
180+
@app.get("/", response_class=HTMLResponse)
181+
def chat_ui():
182+
"""Built-in web chat UI — open in a browser to chat with the model."""
183+
if chat_ui_html is None:
184+
return HTMLResponse(
185+
"<h1>Ravnest API</h1><p>Chat UI not bundled. POST to /v1/chat/completions.</p>",
186+
status_code=200,
187+
)
188+
return HTMLResponse(chat_ui_html)
189+
174190
@app.get("/health")
175191
def health():
176192
if not app.state.ready:

deploy/chat_ui.html

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Ravnest Chat</title>
7+
<style>
8+
* { box-sizing: border-box; margin: 0; padding: 0; }
9+
body {
10+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
11+
background: #0f1419;
12+
color: #e6e6e6;
13+
height: 100vh;
14+
display: flex;
15+
flex-direction: column;
16+
}
17+
header {
18+
background: #1a1f2e;
19+
padding: 12px 20px;
20+
border-bottom: 1px solid #2a3142;
21+
display: flex;
22+
align-items: center;
23+
justify-content: space-between;
24+
}
25+
header h1 {
26+
font-size: 16px;
27+
font-weight: 600;
28+
display: flex;
29+
align-items: center;
30+
gap: 8px;
31+
}
32+
.dot {
33+
width: 8px;
34+
height: 8px;
35+
border-radius: 50%;
36+
background: #4ade80;
37+
}
38+
.dot.loading { background: #fbbf24; }
39+
.dot.error { background: #ef4444; }
40+
.meta {
41+
font-size: 12px;
42+
color: #888;
43+
font-family: ui-monospace, "SF Mono", Menlo, monospace;
44+
}
45+
#messages {
46+
flex: 1;
47+
overflow-y: auto;
48+
padding: 20px;
49+
max-width: 800px;
50+
margin: 0 auto;
51+
width: 100%;
52+
}
53+
.msg {
54+
margin: 16px 0;
55+
display: flex;
56+
flex-direction: column;
57+
gap: 4px;
58+
}
59+
.msg .role {
60+
font-size: 11px;
61+
color: #888;
62+
text-transform: uppercase;
63+
letter-spacing: 0.5px;
64+
font-weight: 600;
65+
}
66+
.msg .content {
67+
background: #1a1f2e;
68+
padding: 12px 16px;
69+
border-radius: 8px;
70+
line-height: 1.5;
71+
white-space: pre-wrap;
72+
word-wrap: break-word;
73+
}
74+
.msg.user .content { background: #2a3142; }
75+
.msg.assistant .content { background: #1a1f2e; }
76+
.empty {
77+
text-align: center;
78+
color: #666;
79+
margin-top: 80px;
80+
font-size: 14px;
81+
}
82+
.empty h2 { font-size: 18px; color: #888; margin-bottom: 8px; }
83+
.cursor::after {
84+
content: "▊";
85+
color: #4ade80;
86+
animation: blink 1s steps(2) infinite;
87+
}
88+
@keyframes blink { 50% { opacity: 0; } }
89+
form {
90+
background: #1a1f2e;
91+
border-top: 1px solid #2a3142;
92+
padding: 16px 20px;
93+
display: flex;
94+
gap: 12px;
95+
max-width: 800px;
96+
margin: 0 auto;
97+
width: 100%;
98+
}
99+
textarea {
100+
flex: 1;
101+
background: #0f1419;
102+
border: 1px solid #2a3142;
103+
border-radius: 6px;
104+
color: #e6e6e6;
105+
padding: 10px 14px;
106+
font-family: inherit;
107+
font-size: 14px;
108+
resize: none;
109+
min-height: 44px;
110+
max-height: 200px;
111+
}
112+
textarea:focus { outline: none; border-color: #4ade80; }
113+
button {
114+
background: #4ade80;
115+
color: #0f1419;
116+
border: none;
117+
border-radius: 6px;
118+
padding: 0 20px;
119+
font-weight: 600;
120+
cursor: pointer;
121+
font-size: 14px;
122+
}
123+
button:disabled { background: #444; color: #888; cursor: not-allowed; }
124+
button:hover:not(:disabled) { background: #22c55e; }
125+
</style>
126+
</head>
127+
<body>
128+
129+
<header>
130+
<h1><span class="dot" id="status-dot"></span> Ravnest Chat</h1>
131+
<span class="meta" id="meta">connecting...</span>
132+
</header>
133+
134+
<div id="messages">
135+
<div class="empty" id="empty">
136+
<h2>Distributed inference, in your browser</h2>
137+
<p>Type a message below to chat with the model running on your cluster.</p>
138+
</div>
139+
</div>
140+
141+
<form id="form">
142+
<textarea id="input" placeholder="Type a message... (Enter to send, Shift+Enter for newline)" rows="1"></textarea>
143+
<button type="submit" id="send">Send</button>
144+
</form>
145+
146+
<script>
147+
const messagesEl = document.getElementById("messages");
148+
const emptyEl = document.getElementById("empty");
149+
const formEl = document.getElementById("form");
150+
const inputEl = document.getElementById("input");
151+
const sendBtn = document.getElementById("send");
152+
const statusDot = document.getElementById("status-dot");
153+
const metaEl = document.getElementById("meta");
154+
155+
const conversation = [];
156+
157+
// Auto-resize textarea
158+
inputEl.addEventListener("input", () => {
159+
inputEl.style.height = "auto";
160+
inputEl.style.height = Math.min(inputEl.scrollHeight, 200) + "px";
161+
});
162+
163+
// Enter to send, Shift+Enter for newline
164+
inputEl.addEventListener("keydown", (e) => {
165+
if (e.key === "Enter" && !e.shiftKey) {
166+
e.preventDefault();
167+
formEl.requestSubmit();
168+
}
169+
});
170+
171+
async function loadStatus() {
172+
try {
173+
const [healthResp, modelsResp] = await Promise.all([
174+
fetch("/health"),
175+
fetch("/v1/models"),
176+
]);
177+
const health = await healthResp.json();
178+
const models = await modelsResp.json();
179+
if (health.status === "ok") {
180+
statusDot.className = "dot";
181+
const modelId = models.data[0]?.id || "ravnest";
182+
metaEl.textContent = modelId;
183+
} else {
184+
statusDot.className = "dot loading";
185+
metaEl.textContent = "loading model...";
186+
setTimeout(loadStatus, 2000);
187+
}
188+
} catch (e) {
189+
statusDot.className = "dot error";
190+
metaEl.textContent = "API unreachable";
191+
setTimeout(loadStatus, 5000);
192+
}
193+
}
194+
195+
function addMessage(role, content) {
196+
if (emptyEl) emptyEl.remove();
197+
const div = document.createElement("div");
198+
div.className = "msg " + role;
199+
const roleEl = document.createElement("div");
200+
roleEl.className = "role";
201+
roleEl.textContent = role;
202+
const contentEl = document.createElement("div");
203+
contentEl.className = "content";
204+
contentEl.textContent = content;
205+
div.appendChild(roleEl);
206+
div.appendChild(contentEl);
207+
messagesEl.appendChild(div);
208+
messagesEl.scrollTop = messagesEl.scrollHeight;
209+
return contentEl;
210+
}
211+
212+
formEl.addEventListener("submit", async (e) => {
213+
e.preventDefault();
214+
const text = inputEl.value.trim();
215+
if (!text) return;
216+
217+
inputEl.value = "";
218+
inputEl.style.height = "auto";
219+
sendBtn.disabled = true;
220+
inputEl.disabled = true;
221+
222+
conversation.push({ role: "user", content: text });
223+
addMessage("user", text);
224+
225+
const assistantEl = addMessage("assistant", "");
226+
assistantEl.classList.add("cursor");
227+
228+
try {
229+
const resp = await fetch("/v1/chat/completions", {
230+
method: "POST",
231+
headers: { "Content-Type": "application/json" },
232+
body: JSON.stringify({
233+
model: "ravnest",
234+
messages: conversation,
235+
max_tokens: 256,
236+
stream: true,
237+
}),
238+
});
239+
240+
if (!resp.ok) {
241+
const err = await resp.text();
242+
assistantEl.textContent = "[Error: " + resp.status + " " + err + "]";
243+
assistantEl.classList.remove("cursor");
244+
conversation.pop(); // don't keep failed turns in history
245+
return;
246+
}
247+
248+
const reader = resp.body.getReader();
249+
const decoder = new TextDecoder();
250+
let assistantText = "";
251+
let buffer = "";
252+
253+
while (true) {
254+
const { done, value } = await reader.read();
255+
if (done) break;
256+
buffer += decoder.decode(value, { stream: true });
257+
const lines = buffer.split("\n");
258+
buffer = lines.pop() || "";
259+
for (const line of lines) {
260+
if (!line.startsWith("data: ")) continue;
261+
const payload = line.slice(6);
262+
if (payload === "[DONE]") continue;
263+
try {
264+
const chunk = JSON.parse(payload);
265+
const delta = chunk.choices?.[0]?.delta?.content || "";
266+
assistantText += delta;
267+
assistantEl.textContent = assistantText;
268+
messagesEl.scrollTop = messagesEl.scrollHeight;
269+
} catch (err) {
270+
// skip malformed chunks
271+
}
272+
}
273+
}
274+
275+
assistantEl.classList.remove("cursor");
276+
if (assistantText) {
277+
conversation.push({ role: "assistant", content: assistantText });
278+
}
279+
} catch (err) {
280+
assistantEl.textContent = "[Error: " + err.message + "]";
281+
assistantEl.classList.remove("cursor");
282+
conversation.pop();
283+
} finally {
284+
sendBtn.disabled = false;
285+
inputEl.disabled = false;
286+
inputEl.focus();
287+
}
288+
});
289+
290+
loadStatus();
291+
inputEl.focus();
292+
</script>
293+
294+
</body>
295+
</html>

tests/test_api_server.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,28 @@ def test_chat_template_function_directly(self):
369369
assert "<|assistant|>" in result
370370

371371

372+
class TestChatUI:
373+
def test_root_returns_html(self, client, app):
374+
resp = client.get("/")
375+
assert resp.status_code == 200
376+
assert "text/html" in resp.headers["content-type"]
377+
378+
def test_root_has_chat_elements(self, client, app):
379+
resp = client.get("/")
380+
text = resp.text
381+
assert "Ravnest" in text
382+
assert "messages" in text
383+
assert "/v1/chat/completions" in text
384+
385+
def test_root_uses_streaming(self, client, app):
386+
resp = client.get("/")
387+
assert "stream" in resp.text
388+
389+
def test_root_handles_loading_state(self, client, app):
390+
resp = client.get("/")
391+
assert "/health" in resp.text
392+
393+
372394
class TestQueueing:
373395
def test_queue_endpoint(self, client, app):
374396
resp = client.get("/v1/queue")

0 commit comments

Comments
 (0)