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:24:09 +02:00
commit d1b512ff77
28 files changed
+2457

No files matched your search

+241
View File
@@ -0,0 +1,241 @@
// Package web serves the chat UI: Templ pages, HTMX fragments and an SSE
// endpoint that streams model replies token by token.
package web
import (
"bytes"
"context"
"embed"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/a-h/templ"
"git.b0b.be/bdeb/localchat/internal/chat"
"git.b0b.be/bdeb/localchat/internal/config"
"git.b0b.be/bdeb/localchat/internal/llm"
"git.b0b.be/bdeb/localchat/internal/web/views"
)
//go:embed static
var staticFS embed.FS
const (
sessionCookie = "localchat_sid"
maxMessageLen = 32 << 10
)
// Server is the chat web app.
type Server struct {
cfg config.Config
llm *llm.Client
store *chat.Store
log *slog.Logger
}
// New wires a server from config.
func New(cfg config.Config, log *slog.Logger) *Server {
return &Server{
cfg: cfg,
llm: &llm.Client{
BaseURL: cfg.BaseURL,
APIKey: cfg.APIKey,
Model: cfg.Model,
HTTP: &http.Client{}, // per-request deadlines come from contexts
},
store: chat.NewStore(),
log: log,
}
}
// Handler returns the app's routes.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(staticFS))
mux.HandleFunc("GET /{$}", s.index)
mux.HandleFunc("GET /health", s.health)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("ok")) })
mux.HandleFunc("POST /chat", s.send)
mux.HandleFunc("POST /chat/reset", s.reset)
mux.HandleFunc("GET /chat/stream/{id}", s.stream)
return s.withSession(mux)
}
// Run serves until ctx is cancelled, then shuts down gracefully.
func (s *Server) Run(ctx context.Context) error {
srv := &http.Server{
Addr: s.cfg.Addr,
Handler: s.Handler(),
ReadHeaderTimeout: 10 * time.Second,
// No WriteTimeout: replies stream for as long as the model talks.
// LLM_TIMEOUT bounds each reply instead.
}
go func() {
t := time.NewTicker(10 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.store.Prune(24 * time.Hour)
}
}
}()
errc := make(chan error, 1)
go func() { errc <- srv.ListenAndServe() }()
s.log.Info("localchat listening", "url", "http://"+s.cfg.Addr, "llm", s.cfg.BaseURL, "model", s.cfg.Model)
select {
case err := <-errc:
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
}
type ctxKey struct{}
// withSession gives every browser a random session cookie.
func (s *Server) withSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sid := ""
if c, err := r.Cookie(sessionCookie); err == nil && len(c.Value) == 32 {
sid = c.Value
} else {
sid = chat.NewID()
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: sid,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, sid)))
})
}
func session(r *http.Request) string {
sid, _ := r.Context().Value(ctxKey{}).(string)
return sid
}
func (s *Server) render(w http.ResponseWriter, r *http.Request, c templ.Component) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := c.Render(r.Context(), w); err != nil {
s.log.Error("render", "err", err)
}
}
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
msgs := s.store.Get(session(r)).Messages()
s.render(w, r, views.Page(s.cfg.Title, msgs))
}
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
model, err := s.llm.ResolveModel(ctx)
if err == nil && s.cfg.Model != "" {
_, err = s.llm.Models(ctx) // configured model: still check the server is up
}
h := views.HealthInfo{OK: err == nil, Model: model}
if err != nil {
h.Err = err.Error()
}
s.render(w, r, views.Health(h))
}
func (s *Server) send(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxMessageLen)
text := strings.TrimSpace(r.FormValue("message"))
if text == "" {
http.Error(w, "message is empty or too long", http.StatusBadRequest)
return
}
user, reply := s.store.Get(session(r)).Ask(text)
s.render(w, r, views.Exchange(user, reply))
}
func (s *Server) reset(w http.ResponseWriter, r *http.Request) {
s.store.Reset(session(r))
s.render(w, r, views.Messages(nil))
}
// stream generates a reply and pushes it to the browser as server-sent
// events. EventSource reconnects automatically, so a reply that is already
// claimed or finished is answered with its current state instead of being
// generated twice.
func (s *Server) stream(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
conv := s.store.Get(session(r))
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no") // don't let reverse proxies buffer
sse := &sseWriter{w: w, rc: http.NewResponseController(w)}
prompt, msg, ok := conv.Claim(id, s.cfg.SystemPrompt, s.cfg.MaxHistory)
if !ok {
if msg.ID == "" {
msg = chat.Message{ID: id, Role: "assistant", State: chat.Done,
Err: "This reply was lost, probably because the server restarted. Please ask again."}
}
sse.component(r.Context(), "done", views.MessageView(msg))
return
}
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.Timeout)
defer cancel()
start := time.Now()
err := s.llm.Stream(ctx, prompt, func(delta string) error {
conv.Append(id, delta)
return sse.component(ctx, "token", views.Token(delta))
})
if r.Context().Err() != nil { // browser went away; keep what we have
conv.Finish(id, errors.New("interrupted"))
return
}
if errors.Is(err, context.DeadlineExceeded) {
err = fmt.Errorf("reply took longer than %s and was cut off", s.cfg.Timeout)
}
if err != nil {
s.log.Warn("generation failed", "err", err)
} else {
s.log.Debug("reply done", "id", id, "took", time.Since(start))
}
sse.component(r.Context(), "done", views.MessageView(conv.Finish(id, err)))
}
type sseWriter struct {
w http.ResponseWriter
rc *http.ResponseController
}
// component sends one rendered Templ component as a named SSE event.
func (s *sseWriter) component(ctx context.Context, event string, c templ.Component) error {
var buf bytes.Buffer
if err := c.Render(ctx, &buf); err != nil {
return err
}
var b strings.Builder
b.WriteString("event: " + event + "\n")
for _, line := range strings.Split(buf.String(), "\n") {
b.WriteString("data: " + line + "\n")
}
b.WriteString("\n")
if _, err := s.w.Write([]byte(b.String())); err != nil {
return err
}
return s.rc.Flush()
}
+201
View File
@@ -0,0 +1,201 @@
package web
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"time"
"git.b0b.be/bdeb/localchat/internal/config"
)
// fakeLLM streams back "echo: <last user message>" and records what it got.
func fakeLLM(t *testing.T, seen *[][]map[string]string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/models" {
fmt.Fprint(w, `{"data":[{"id":"fake"}]}`)
return
}
var req struct {
Messages []map[string]string `json:"messages"`
}
json.NewDecoder(r.Body).Decode(&req)
*seen = append(*seen, req.Messages)
last := req.Messages[len(req.Messages)-1]["content"]
w.Header().Set("Content-Type", "text/event-stream")
for _, part := range []string{"echo: ", last, "\n\n**bold** <script>x</script>"} {
b, _ := json.Marshal(part)
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":%s}}]}\n\n", b)
}
fmt.Fprint(w, "data: [DONE]\n\n")
}))
}
func newTestApp(t *testing.T, llmURL string) (*httptest.Server, *http.Client) {
t.Helper()
cfg := config.Config{
BaseURL: llmURL + "/v1",
SystemPrompt: "be nice",
Title: "test",
MaxHistory: 20,
Timeout: 10 * time.Second,
}
app := httptest.NewServer(New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil))).Handler())
t.Cleanup(app.Close)
jar, _ := cookiejar.New(nil)
return app, &http.Client{Jar: jar}
}
var streamURL = regexp.MustCompile(`sse-connect="(/chat/stream/[0-9a-f]+)"`)
func get(t *testing.T, c *http.Client, u string) string {
t.Helper()
resp, err := c.Get(u)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
// ask posts a message, follows the SSE stream and returns the raw events.
func ask(t *testing.T, app *httptest.Server, c *http.Client, msg string) string {
t.Helper()
resp, err := c.PostForm(app.URL+"/chat", url.Values{"message": {msg}})
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
m := streamURL.FindStringSubmatch(string(body))
if m == nil {
t.Fatalf("no stream url in %s", body)
}
return get(t, c, app.URL+m[1])
}
func TestChatFlow(t *testing.T) {
var seen [][]map[string]string
llm := fakeLLM(t, &seen)
defer llm.Close()
app, c := newTestApp(t, llm.URL)
if page := get(t, c, app.URL+"/"); !strings.Contains(page, `id="composer"`) {
t.Fatal("index page missing composer")
}
if h := get(t, c, app.URL+"/health"); !strings.Contains(h, "dot ok") || !strings.Contains(h, "fake") {
t.Fatalf("health: %s", h)
}
events := ask(t, app, c, "hi <b>there</b>")
for _, want := range []string{
"event: token\ndata: <span>echo: </span>",
"hi &lt;b&gt;there&lt;/b&gt;", // user text is escaped in tokens
"event: done",
"<strong>bold</strong>", // final reply is rendered Markdown
"<!-- raw HTML omitted -->", // ... with raw HTML from the model dropped
} {
if !strings.Contains(events, want) {
t.Errorf("stream missing %q:\n%s", want, events)
}
}
if strings.Contains(events, "<script>") {
t.Error("raw <script> leaked into the page")
}
// second turn sends system prompt + full history
ask(t, app, c, "again")
last := seen[len(seen)-1]
roles := []string{}
for _, m := range last {
roles = append(roles, m["role"])
}
if got := strings.Join(roles, ","); got != "system,user,assistant,user" {
t.Fatalf("history roles = %s", got)
}
// page reload shows the history, reset clears it
if page := get(t, c, app.URL+"/"); strings.Count(page, `class="msg `) != 4 {
t.Fatal("expected 4 messages after reload")
}
resp, _ := c.Post(app.URL+"/chat/reset", "", nil)
resp.Body.Close()
if page := get(t, c, app.URL+"/"); strings.Contains(page, `class="msg `) {
t.Fatal("reset did not clear history")
}
}
func TestStreamReconnectDoesNotRegenerate(t *testing.T) {
var seen [][]map[string]string
llm := fakeLLM(t, &seen)
defer llm.Close()
app, c := newTestApp(t, llm.URL)
resp, _ := c.PostForm(app.URL+"/chat", url.Values{"message": {"once"}})
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
path := streamURL.FindStringSubmatch(string(body))[1]
get(t, c, app.URL+path)
again := get(t, c, app.URL+path)
if len(seen) != 1 {
t.Fatalf("model called %d times, want 1", len(seen))
}
if strings.Contains(again, "event: token") || !strings.Contains(again, "echo: once") {
t.Fatalf("reconnect should replay the final message only:\n%s", again)
}
}
func TestSessionsAreIsolated(t *testing.T) {
var seen [][]map[string]string
llm := fakeLLM(t, &seen)
defer llm.Close()
app, alice := newTestApp(t, llm.URL)
jar, _ := cookiejar.New(nil)
bob := &http.Client{Jar: jar}
ask(t, app, alice, "alice secret")
if strings.Contains(get(t, bob, app.URL+"/"), "alice secret") {
t.Fatal("bob can see alice's chat")
}
resp, _ := bob.PostForm(app.URL+"/chat", url.Values{"message": {"x"}})
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
path := streamURL.FindStringSubmatch(string(body))[1]
if out := get(t, alice, app.URL+path); !strings.Contains(out, "lost") {
t.Fatalf("alice should not be able to stream bob's reply:\n%s", out)
}
}
func TestEmptyMessageRejected(t *testing.T) {
var seen [][]map[string]string
llm := fakeLLM(t, &seen)
defer llm.Close()
app, c := newTestApp(t, llm.URL)
resp, _ := c.PostForm(app.URL+"/chat", url.Values{"message": {" "}})
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status %d", resp.StatusCode)
}
}
func TestModelOffline(t *testing.T) {
app, c := newTestApp(t, "http://127.0.0.1:1") // nothing listens here
if h := get(t, c, app.URL+"/health"); !strings.Contains(h, "model offline") {
t.Fatalf("health: %s", h)
}
events := ask(t, app, c, "hello?")
if !strings.Contains(events, "event: done") || !strings.Contains(events, `class="error"`) {
t.Fatalf("want an error bubble:\n%s", events)
}
}
+156
View File
@@ -0,0 +1,156 @@
:root {
--bg: #f7f7f5;
--surface: #ffffff;
--text: #1d1d1f;
--muted: #6e6e73;
--border: #e3e3e0;
--accent: #3b6cf6;
--accent-text: #ffffff;
--user-bg: #3b6cf6;
--user-text: #ffffff;
--code-bg: #f0f0ed;
--ok: #22a356;
--bad: #d64545;
--radius: 14px;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #131315;
--surface: #1d1d20;
--text: #ececef;
--muted: #9a9aa2;
--border: #2c2c31;
--accent: #6b8fff;
--user-bg: #3d5fd9;
--code-bg: #26262b;
}
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
display: grid;
grid-template-rows: auto 1fr auto;
max-width: 820px;
margin: 0 auto;
background: var(--bg);
color: var(--text);
font: 16px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
header h1 { font-size: 1.05rem; margin: 0; flex: 1; }
header form { margin: 0; }
.health { display: flex; align-items: center; gap: 6px; font-size: .85rem; color: var(--muted); }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.dot.ok { background: var(--ok); }
.dot.bad { background: var(--bad); }
#messages {
overflow-y: auto;
padding: 20px 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.empty { margin: auto; text-align: center; color: var(--muted); }
.empty .big { font-size: 2rem; margin: 0; }
.empty:not(:only-child) { display: none; }
.msg { display: flex; }
.msg.user { justify-content: flex-end; }
.bubble {
max-width: min(85%, 680px);
padding: 10px 14px;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--border);
overflow-wrap: anywhere;
}
.msg.user .bubble {
background: var(--user-bg);
color: var(--user-text);
border-color: transparent;
border-bottom-right-radius: 4px;
}
.msg.assistant .bubble { border-bottom-left-radius: 4px; }
.content.plain { white-space: pre-wrap; }
.markdown > :first-child { margin-top: 0; }
.markdown > :last-child { margin-bottom: 0; }
.markdown pre {
background: var(--code-bg);
padding: 10px 12px;
border-radius: 8px;
overflow-x: auto;
font-size: .9em;
tab-size: 4;
}
.markdown code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.markdown :not(pre) > code { background: var(--code-bg); padding: 1px 5px; border-radius: 5px; font-size: .9em; }
.markdown table { border-collapse: collapse; }
.markdown th, .markdown td { border: 1px solid var(--border); padding: 4px 8px; }
.markdown a { color: var(--accent); }
.error { color: var(--bad); font-size: .9rem; margin: 6px 0 0; }
/* typing indicator while waiting for the first token */
.streaming:empty::after {
content: "● ● ●";
letter-spacing: 2px;
color: var(--muted);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: .3; } }
#composer {
display: flex;
gap: 8px;
padding: 12px 16px 18px;
border-top: 1px solid var(--border);
}
#composer textarea {
flex: 1;
resize: none;
max-height: 200px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
font: inherit;
}
#composer textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
button {
padding: 0 18px;
border: 0;
border-radius: var(--radius);
background: var(--accent);
color: var(--accent-text);
font: inherit;
font-weight: 600;
cursor: pointer;
}
button:disabled { opacity: .5; cursor: progress; }
button.ghost {
padding: 6px 12px;
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
font-weight: 500;
font-size: .85rem;
}
+39
View File
@@ -0,0 +1,39 @@
// Small UX touches on top of HTMX: Enter to send, auto-growing textarea,
// clearing the input after sending, and keeping the newest message in view.
(() => {
const form = document.getElementById("composer");
const input = form.querySelector("textarea");
const messages = document.getElementById("messages");
const grow = () => {
input.style.height = "auto";
input.style.height = input.scrollHeight + "px";
};
const nearBottom = () =>
messages.scrollHeight - messages.scrollTop - messages.clientHeight < 120;
const scrollDown = () => (messages.scrollTop = messages.scrollHeight);
input.addEventListener("input", grow);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
if (input.value.trim()) form.requestSubmit();
}
});
form.addEventListener("htmx:afterRequest", (e) => {
if (e.detail.successful) {
form.reset();
grow();
scrollDown();
}
});
// Follow streaming tokens, unless the user scrolled up to read.
let follow = true;
messages.addEventListener("scroll", () => (follow = nearBottom()));
document.body.addEventListener("htmx:sseMessage", () => follow && scrollDown());
document.body.addEventListener("htmx:afterSwap", () => follow && scrollDown());
scrollDown();
})();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect x="2" y="4" width="28" height="20" rx="7" fill="#3b6cf6"/><path d="M9 24l-3 6 9-6z" fill="#3b6cf6"/><circle cx="10" cy="14" r="2" fill="#fff"/><circle cx="16" cy="14" r="2" fill="#fff"/><circle cx="22" cy="14" r="2" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 302 B

+1
View File
@@ -0,0 +1 @@
var htmx=function(){"use strict";const Y={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=pn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.11"};Y.onLoad=V;Y.process=Bt;Y.on=ye;Y.off=xe;Y.trigger=ae;Y.ajax=In;Y.find=a;Y.findAll=x;Y.closest=g;Y.remove=z;Y.addClass=w;Y.removeClass=S;Y.toggleClass=G;Y.takeClass=W;Y.swap=C;Y.defineExtension=zn;Y.removeExtension=Jn;Y.logAll=$;Y.logNone=_;Y.parseInterval=d;Y._=e;const n={addTriggerHandler:Et,bodyContains:ie,canAccessLocalStorage:U,findThisElement:we,filterValues:xn,swap:C,hasAttribute:s,getAttributeValue:f,getClosestAttributeValue:te,getClosestMatch:A,getExpressionVars:qn,getHeaders:yn,getInputValues:pn,getInternalData:re,getSwapSpecification:vn,getTriggerSpecs:lt,getTarget:Se,makeFragment:D,mergeObjects:se,makeSettleInfo:En,oobSwap:Te,querySelectorExt:ce,settleImmediately:Qt,shouldCancel:dt,triggerEvent:ae,triggerErrorEvent:ue,withExtensions:Vt};const he=["get","post","put","delete","patch"];const R=he.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function Q(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function f(e,t){return Q(e,t)||Q(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ee(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):ee()}function A(e,t){while(e&&!t(e)){e=u(e)}return e||null}function o(e,t,n){const r=f(t,n);const o=f(t,"hx-disinherit");var i=f(t,"hx-inherit");if(e!==t){if(Y.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function te(t,n){let r=null;A(t,function(e){return!!(r=o(t,le(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function I(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=ee().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Y.config.inlineScriptNonce){t.nonce=Y.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function k(e){if(!Y.config.allowScriptTags){e.querySelectorAll("script").forEach(e=>e.remove());return}Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){T(e)}finally{e.remove()}}})}function D(e){e=e.replace(/<hx-([a-z]+)((?:\s[^>]*)?)>/gi,'<template hx type="$1"$2>').replace(/<\/hx-[a-z]+>/gi,"</template>");const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=I(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=I(t);L(r,i.body);r.title=i.title}else{const i=I('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){oLine truncated
+1
View File
@@ -0,0 +1 @@
(function(){var g;htmx.defineExtension("sse",{init:function(e){g=e;if(htmx.createEventSource==undefined){htmx.createEventSource=t}},getSelectors:function(){return["[sse-connect]","[data-sse-connect]","[sse-swap]","[data-sse-swap]"]},onEvent:function(e,t){var r=t.target||t.detail.elt;switch(e){case"htmx:beforeCleanupElement":var n=g.getInternalData(r);var s=n.sseEventSource;if(s){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"nodeReplaced"});n.sseEventSource.close()}return;case"htmx:afterProcessNode":i(r)}}});function t(e){return new EventSource(e,{withCredentials:true})}function a(n){if(g.getAttributeValue(n,"sse-swap")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var t=g.getAttributeValue(n,"sse-swap");var r=t.split(",");for(var i=0;i<r.length;i++){const u=r[i].trim();const c=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(u,c);return}if(!g.triggerEvent(n,"htmx:sseBeforeMessage",e)){return}f(n,e.data);g.triggerEvent(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=c;a.addEventListener(u,c)}}if(g.getAttributeValue(n,"hx-trigger")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var o=g.getTriggerSpecs(n);o.forEach(function(t){if(t.trigger.slice(0,4)!=="sse:"){return}var r=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(t.trigger.slice(4),r)}htmx.trigger(n,t.trigger,e);htmx.trigger(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=r;a.addEventListener(t.trigger.slice(4),r)})}}function i(e,t){if(e==null){return null}if(g.getAttributeValue(e,"sse-connect")){var r=g.getAttributeValue(e,"sse-connect");if(r==null){return}n(e,r,t)}a(e)}function n(r,e,n){var s=htmx.createEventSource(e);s.onerror=function(e){g.triggerErrorEvent(r,"htmx:sseError",{error:e,source:s});if(l(r)){return}if(s.readyState===EventSource.CLOSED){n=n||0;n=Math.max(Math.min(n*2,128),1);var t=n*500;window.setTimeout(function(){i(r,n)},t)}};s.onopen=function(e){g.triggerEvent(r,"htmx:sseOpen",{source:s});if(n&&n>0){const t=r.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]");for(let e=0;e<t.length;e++){a(t[e])}n=0}};g.getInternalData(r).sseEventSource=s;var t=g.getAttributeValue(r,"sse-close");if(t){s.addEventListener(t,function(){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"message"});s.close()})}}function l(e){if(!g.bodyContains(e)){var t=g.getInternalData(e).sseEventSource;if(t!=undefined){g.triggerEvent(e,"htmx:sseClose",{source:t,type:"nodeMissing"});t.close();return true}}return false}function f(t,r){g.withExtensions(t,function(e){r=e.transformResponse(r,null,t)});var e=g.getSwapSpecification(t);var n=g.getTarget(t);g.swap(n,r,e,{contextElement:t})}function v(e){return g.getInternalData(e).sseEventSource!=null}})();
+29
View File
@@ -0,0 +1,29 @@
package views
import (
"bytes"
"github.com/a-h/templ"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
)
// md renders CommonMark + GFM. Raw HTML in model output is escaped
// (goldmark's default), so a reply can't inject markup into the page.
var md = goldmark.New(goldmark.WithExtensions(extension.GFM))
// Markdown renders model output as HTML, falling back to plain text.
func Markdown(src string) templ.Component {
var buf bytes.Buffer
if err := md.Convert([]byte(src), &buf); err != nil {
return Token(src)
}
return templ.Raw(buf.String())
}
func healthTrigger(h HealthInfo) string {
if h.Checking {
return "load"
}
return "every 30s"
}
+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>
}
+474
View File
@@ -0,0 +1,474 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1070
package views
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "git.b0b.be/bdeb/localchat/internal/chat"
// Page is the full chat screen.
func Page(title string, msgs []chat.Message) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 12, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 21, Col: 15}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Health(HealthInfo{Checking: true}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Messages(msgs).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// Messages renders a whole conversation, or the empty state.
func Messages(msgs []chat.Message) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
if templ_7745c5c3_Var4 == nil {
templ_7745c5c3_Var4 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if len(msgs) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"empty\"><p class=\"big\">Hi! 👋</p><p>Everything you type stays on this machine. The model runs locally.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, m := range msgs {
if m.Role == "assistant" && m.State != chat.Done {
templ_7745c5c3_Err = StreamingReply(m).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = MessageView(m).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
return nil
})
}
// Exchange is returned after sending: the user's message plus a reply that
// streams in over server-sent events.
func Exchange(user, reply chat.Message) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
if templ_7745c5c3_Var5 == nil {
templ_7745c5c3_Var5 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = MessageView(user).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = StreamingReply(reply).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// MessageView renders a finished message.
func MessageView(m chat.Message) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
if templ_7745c5c3_Var6 == nil {
templ_7745c5c3_Var6 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var7 = []any{"msg", m.Role}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var7).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("msg-" + m.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 64, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><div class=\"bubble\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if m.Role == "assistant" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"content markdown\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Markdown(m.Content).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if m.Err != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<p class=\"error\">⚠️ ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(m.Err)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 71, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"content plain\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(m.Content)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 74, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// 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.
func StreamingReply(m chat.Message) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var12 := templ.GetChildren(ctx)
if templ_7745c5c3_Var12 == nil {
templ_7745c5c3_Var12 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"msg assistant\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("msg-" + m.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 86, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" hx-ext=\"sse\" sse-connect=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("/chat/stream/" + m.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 88, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// Token is one streamed chunk of text.
func Token(text string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var15 := templ.GetChildren(ctx)
if templ_7745c5c3_Var15 == nil {
templ_7745c5c3_Var15 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 101, Col: 13}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// HealthInfo describes the model server status shown in the header.
type HealthInfo struct {
Checking bool
OK bool
Model string
Err string
}
func Health(h HealthInfo) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var17 := templ.GetChildren(ctx)
if templ_7745c5c3_Var17 == nil {
templ_7745c5c3_Var17 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div id=\"health\" class=\"health\" hx-get=\"/health\" hx-trigger=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(healthTrigger(h))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 113, Col: 79}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" hx-swap=\"outerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if h.Checking {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<span class=\"dot\"></span> connecting…")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if h.OK {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<span class=\"dot ok\"></span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(h.Model)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 117, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span class=\"dot bad\"></span> <span title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Err)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 119, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">model offline</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate