feat: localchat, a Go + Templ + HTMX chat app for local Gemma models

Streams replies over SSE from any OpenAI-compatible server (oMLX,
llama.cpp, Ollama, ...). Single binary that can install itself as an
OS service; docker compose bundles llama.cpp + Gemma 4 E2B.
This commit is contained in:
bdeb1337 committed 2026-10-05 07:25:42 +02:00
commit bb71b49fde
28 files changed
+2459

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
package views
import "git.b0b.be/bdeb/localchat/internal/chat"
// Page is the full chat screen.
templ Page(title string, msgs []chat.Message) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ title }</title>
<link rel="stylesheet" href="/static/app.css"/>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
<script src="/static/htmx.min.js" defer></script>
<script src="/static/sse.min.js" defer></script>
<script src="/static/app.js" defer></script>
</head>
<body>
<header>
<h1>{ title }</h1>
@Health(HealthInfo{Checking: true})
<form hx-post="/chat/reset" hx-target="#messages" hx-swap="innerHTML">
<button type="submit" class="ghost" title="Start a new conversation">New chat</button>
</form>
</header>
<main id="messages" aria-live="polite">
@Messages(msgs)
</main>
<form id="composer" hx-post="/chat" hx-target="#messages" hx-swap="beforeend" hx-disabled-elt="find button">
<textarea name="message" rows="1" placeholder="Ask anything… (Enter to send, Shift+Enter for a new line)" required autofocus></textarea>
<button type="submit">Send</button>
</form>
</body>
</html>
}
// Messages renders a whole conversation, or the empty state.
templ Messages(msgs []chat.Message) {
if len(msgs) == 0 {
<div class="empty">
<p class="big">Hi! 👋</p>
<p>Everything you type stays on this machine. The model runs locally.</p>
</div>
}
for _, m := range msgs {
if m.Role == "assistant" && m.State != chat.Done {
@StreamingReply(m)
} else {
@MessageView(m)
}
}
}
// Exchange is returned after sending: the user's message plus a reply that
// streams in over server-sent events.
templ Exchange(user, reply chat.Message) {
@MessageView(user)
@StreamingReply(reply)
}
// MessageView renders a finished message.
templ MessageView(m chat.Message) {
<div class={ "msg", m.Role } id={ "msg-" + m.ID }>
<div class="bubble">
if m.Role == "assistant" {
<div class="content markdown">
@Markdown(m.Content)
</div>
if m.Err != "" {
<p class="error">⚠️ { m.Err }</p>
}
} else {
<div class="content plain">{ m.Content }</div>
}
</div>
</div>
}
// StreamingReply opens an SSE connection. "token" events append text as it
// arrives; the final "done" event swaps in the rendered Markdown and closes
// the connection.
templ StreamingReply(m chat.Message) {
<div
class="msg assistant"
id={ "msg-" + m.ID }
hx-ext="sse"
sse-connect={ "/chat/stream/" + m.ID }
sse-swap="done"
hx-swap="outerHTML"
sse-close="done"
>
<div class="bubble">
<div class="content plain streaming" sse-swap="token" hx-swap="beforeend"></div>
</div>
</div>
}
// Token is one streamed chunk of text.
templ Token(text string) {
<span>{ text }</span>
}
// HealthInfo describes the model server status shown in the header.
type HealthInfo struct {
Checking bool
OK bool
Model string
Err string
}
templ Health(h HealthInfo) {
<div id="health" class="health" hx-get="/health" hx-trigger={ healthTrigger(h) } hx-swap="outerHTML">
if h.Checking {
<span class="dot"></span> connecting…
} else if h.OK {
<span class="dot ok"></span> { h.Model }
} else {
<span class="dot bad"></span> <span title={ h.Err }>model offline</span>
}
</div>
}