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:
28 files changed
+2457
No files matched your search
@@ -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>
|
||||
}
|
||||
Reference in new issue
Block a user