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:
30 files changed
+2566
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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 <b>there</b>", // 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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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 |
Vendored
+1
@@ -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
|
||||
Vendored
+1
@@ -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}})();
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user