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:10:07 +02:00
commit 9391a252aa
30 files changed
+2566

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
bin/
docs/
scripts/
.env
.git
*.md
+14
View File
@@ -0,0 +1,14 @@
# Where your OpenAI-compatible model server lives (llama.cpp, oMLX, Ollama, LM Studio, ...)
LLM_BASE_URL=http://127.0.0.1:8080/v1
# Leave empty to use the first model the server lists
LLM_MODEL=
# Only if your server requires one
LLM_API_KEY=
# Make it yours
LOCALCHAT_TITLE=localchat
# SYSTEM_PROMPT="You are a patient Go tutor. Keep answers short and show small code examples."
# LOCALCHAT_ADDR=127.0.0.1:3000
# MAX_HISTORY=20
# LLM_TIMEOUT=5m
+4
View File
@@ -0,0 +1,4 @@
bin/
.env
*.local
dist/
+17
View File
@@ -0,0 +1,17 @@
# syntax=docker/dockerfile:1
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
# Templ output (*_templ.go) is committed, so no generate step is needed here.
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /localchat ./cmd/localchat
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /localchat /localchat
ENV LOCALCHAT_ADDR=0.0.0.0:3000
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s CMD ["/localchat", "healthcheck"]
ENTRYPOINT ["/localchat"]
CMD ["serve"]
+34
View File
@@ -0,0 +1,34 @@
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
PLATFORMS := linux/amd64 linux/arm64 darwin/arm64 darwin/amd64 windows/amd64 windows/arm64
.PHONY: generate build run test e2e dist up down
generate: ## regenerate Templ components
go tool templ generate
build: generate ## build ./bin/localchat for this machine
go build -trimpath -ldflags "$(LDFLAGS)" -o bin/localchat ./cmd/localchat
run: build ## run in the foreground (reads ./.env)
./bin/localchat --debug
test: ## unit + handler tests (no model needed)
go test -race ./...
e2e: ## browser test against a running instance + real model
uv run scripts/e2e.py
dist: generate ## cross-compile for every platform into ./dist
@for p in $(PLATFORMS); do \
os=$${p%/*}; arch=$${p#*/}; ext=$$( [ $$os = windows ] && echo .exe ); \
echo " $$os/$$arch"; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -trimpath -ldflags "$(LDFLAGS)" \
-o dist/localchat-$$os-$$arch$$ext ./cmd/localchat || exit 1; \
done
up: ## start app + llama.cpp + Gemma in containers
docker compose up -d --build
down:
docker compose down
+111
View File
@@ -0,0 +1,111 @@
---
title: "localchat: a bare-basic Go + HTMX web app with a local Gemma model, built for a friend"
published: false
tags: hf26challenge, go, htmx, gemma
---
<!--
Before publishing:
- Upload docs/screenshot.png and docs/screenshot-dark.png to DEV and swap in the uploaded image URLs.
- Make https://git.b0b.be/bdeb/localchat public so judges can see the code.
- Optional: save the agent session with DevRelay and paste the embed into "My Agent Session".
- Deadline: Monday Oct 5, 08:59 Brussels time (06:59 UTC).
-->
*This is a submission for the [Hacktoberfest Weekend Challenge: Build for a Friend](https://dev.to/challenges/hacktoberfest-weekend-2026-10-01)*
## What I Built
**localchat** is a small chat app for a local AI model: a Go web server, a browser UI and a small open-weight model running on your own machine. You type, and **Google's Gemma 4 E2B** answers token by token. No account, no API key, and nothing leaves the laptop.
**Who it's for:** a friend of mine wanted to see what a *bare-basic* Go web app with AI integration actually looks like. No frameworks hiding things, no SDK magic, just the essential pieces you can read in one sitting. I'd never built an AI app in Go either, so we treated this as a chance to get the hang of it together and make it really work.
I picked Gemma because I love that small models now run *locally* and are actually useful. A 2B-class model on a laptop gives a fast, private, free assistant, and it's exactly the kind of thing you can hand to a friend without asking them to sign up anywhere.
The problem it solves for my friend:
- **One small, readable project** that shows the whole path: an HTML form → Go handler → model → streamed reply in the browser.
- **Runs on whatever they have.** One ~9 MB binary for Linux, macOS or Windows, or `docker compose up` with the model included.
- **A starting point, not a demo.** They can change the system prompt, swap the model, or install it as a background service, and build their own idea on top.
## Demo
![localchat answering with streamed Markdown](docs/screenshot.png)
![localchat in dark mode](docs/screenshot-dark.png)
Run it yourself in one command (the first start downloads Gemma 4 E2B, ~3 GB):
```sh
docker compose up -d
open http://localhost:3000
```
On my M3 MacBook (24 GB) with Gemma 4 E2B (4-bit, via oMLX), the first token arrives in **about a second**. A short answer with a code block is done in **3–5 s**.
## Code
**Repo:** [git.b0b.be/bdeb/localchat](https://git.b0b.be/bdeb/localchat)
```
cmd/localchat/ entry point: serve + install/start/stop as an OS service
internal/llm/ ~150-line OpenAI-compatible streaming client (stdlib only)
internal/chat/ in-memory conversations, one per browser session
internal/web/ routes, SSE streaming, embedded static assets
internal/web/views/ Templ components
scripts/e2e.py Playwright browser test against the real model
compose.yaml app + llama.cpp + Gemma 4 E2B
```
## How I Built It
**Open-source AI used:**
- **Model:** Google **Gemma 4 E2B** (open weights), 4-bit quantized.
- **Local inference:** **oMLX** (Apple MLX) on my Mac during development, and **llama.cpp** (`llama-server`, GGUF) in the container. My AMD GPU box runs **Lemonade**, which speaks the same API.
- **App stack:** Go 1.27 · Templ · HTMX 2 + its SSE extension · goldmark · kardianos/service.
**How it fits together:**
```
Browser ── HTMX + SSE extension
│ POST /chat → user bubble + empty reply bubble (sse-connect)
│ GET /chat/stream/{id} ← "token" events (append) … "done" (swap in Markdown)
Go (net/http + Templ)
│ POST /v1/chat/completions {stream: true}
Local model server ── Gemma 4 E2B
```
The core idea is that **streaming is just HTML over server-sent events**:
1. Sending a message returns two Templ fragments: your bubble, and an empty assistant bubble with `sse-connect="/chat/stream/{id}"`.
2. The Go handler streams the reply from the model and forwards every chunk as an HTML-escaped `<span>` in a `token` event. HTMX appends them (`hx-swap="beforeend"`), so the answer types itself out with no custom JavaScript.
3. When the model finishes, a `done` event replaces the bubble with server-rendered Markdown (goldmark, raw HTML stripped). Swapping out the `sse-connect` element also closes the stream.
4. Browsers automatically reconnect an EventSource, so a reconnect for a finished reply gets the final HTML instead of a second run of the model. A test checks that the model is only called once.
Other parts I enjoyed:
- **No AI SDK.** OpenAI-compatible streaming is just a `bufio.Scanner` reading `data:` lines, which is the "bare-basic" my friend asked for.
- **It runs as a real service.** `localchat install --user` registers a launchd agent, a systemd unit or a Windows service that points at your `.env`.
- **Everything is embedded** (htmx, the SSE extension, CSS, icon), so it works with the network cable pulled.
- **Tested:** handler tests against a fake OpenAI-style server (streaming, history, session isolation, XSS-safe Markdown, model offline), plus a Playwright browser test against the real Gemma model, both natively and in Docker.
## Why Does Open Innovation Matter?
- **Private by design.** Gemma's weights sit on the laptop, so there's no third-party server to trust. You can paste work code or private notes, and conversations aren't even written to disk.
- **Free to run, forever.** No tokens, no subscription, no free tier that disappears. My friend can leave it running without thinking about a bill.
- **Works offline.** On a train, on a plane, or behind a strict corporate network.
- **Swap anything.** The app only speaks the OpenAI-compatible protocol that the open ecosystem standardized on. I developed against oMLX and shipped with llama.cpp: two different inference engines, the same binary, no code changes. Moving to Gemma 4 E4B or another open model is one environment variable. A closed API would have tied the app to one vendor, one price list and one data policy.
- **Small open models are good enough now.** Gemma 4 E2B at 4-bit fits in a few GB of RAM and writes code snippets and explanations quickly on a laptop with no GPU. That makes a "bare-basic" AI app something you can just hand to a friend.
## My Agent Session
<!-- Paste the DevRelay agent_session embed here, or remove this section. -->
I built this with Claude Code as a pair-programmer: brainstorming ideas, scaffolding the Go/Templ/HTMX app, and testing it end to end against the local Gemma model.
## Prize Categories
- **Best Use of Gemma:** the whole app is built around running Google's Gemma 4 E2B locally (MLX on Mac, GGUF via llama.cpp in Docker).
<!-- Thanks for participating! -->
+125
View File
@@ -0,0 +1,125 @@
# localchat
A small, private chat app for a **local, open-weight AI model**: **Go + Templ + HTMX** on top of any OpenAI-compatible model server. It defaults to **Gemma 4 E2B**, which runs on a normal laptop.
- 🔒 **Private:** prompts and replies never leave your machine.
- ✈️ **Works offline** once the model is downloaded.
- 💸 **Free to run:** no API keys, no per-token billing.
- 🔁 **Model-agnostic:** oMLX, llama.cpp, Ollama, LM Studio, Lemonade, vLLM… change one env var.
- 📦 **One ~9 MB binary** for Linux, macOS and Windows. It can install itself as a background service.
- 🐳 **Or `docker compose up`** for the app plus llama.cpp plus Gemma in one go.
![screenshot](docs/screenshot.png)
## Quick start
### Option A: containers (easiest)
```sh
docker compose up -d # first run downloads Gemma 4 E2B (~3 GB)
open http://localhost:3000
```
To use a different model, set `LLM_HF_REPO=unsloth/gemma-4-E4B-it-GGUF:Q4_K_M docker compose up -d`.
### Option B: binary + a model server you already run
```sh
make build # or grab a binary from `make dist`
cp .env.example .env # point it at your model server
./bin/localchat # → http://127.0.0.1:3000
```
Common `LLM_BASE_URL` values:
| Server | `LLM_BASE_URL` |
|---|---|
| llama.cpp `llama-server` | `http://127.0.0.1:8080/v1` |
| oMLX (Apple Silicon) | `http://127.0.0.1:8000/v1` |
| Ollama | `http://127.0.0.1:11434/v1` |
| LM Studio | `http://127.0.0.1:1234/v1` |
| Lemonade (AMD) | `http://127.0.0.1:13305/api/v1` |
For example, to get a model running quickly with llama.cpp:
```sh
brew install llama.cpp # or a release from github.com/ggml-org/llama.cpp
llama-server -hf unsloth/gemma-4-E2B-it-GGUF:Q4_K_M --no-mmproj
```
### Option C: run it as a background service
The binary registers itself with the OS service manager (systemd, launchd or the Windows Service Control Manager):
```sh
./bin/localchat install --user # per-user (launchd agent / systemd --user); drop --user for system-wide (needs sudo/admin)
./bin/localchat start --user
./bin/localchat status --user
./bin/localchat stop --user && ./bin/localchat uninstall --user
```
The service gets the absolute path of your `.env` (or `--env-file`), so it runs with the same settings you tested with.
## Configuration
Set these as environment variables or in a `.env` file. Real environment variables take precedence over the file.
| Variable | Default | |
|---|---|---|
| `LLM_BASE_URL` | `http://127.0.0.1:8000/v1` | OpenAI-compatible API root |
| `LLM_MODEL` | *(first model the server lists)* | model id |
| `LLM_API_KEY` | | bearer token, if your server needs one |
| `SYSTEM_PROMPT` | friendly, concise assistant | give it a personality or a purpose |
| `LOCALCHAT_TITLE` | `localchat` | name in the header and tab |
| `LOCALCHAT_ADDR` | `127.0.0.1:3000` | listen address (`0.0.0.0:3000` to share on your LAN) |
| `MAX_HISTORY` | `20` | past messages sent as context |
| `LLM_TIMEOUT` | `5m` | maximum time for one reply |
## How it works
```
Browser ── HTMX + SSE extension
│ POST /chat → returns the user bubble + an empty reply bubble
│ GET /chat/stream/{id} ← server-sent events: "token" (append) … "done" (swap in Markdown)
Go (net/http + Templ)
│ POST /v1/chat/completions {stream: true}
Model server (llama.cpp / oMLX / Ollama / …) ── Gemma 4 E2B
```
1. The form posts with `hx-post`. The server stores the message and returns two Templ fragments: your message, and an assistant bubble with `sse-connect="/chat/stream/{id}"`.
2. The SSE handler claims that reply, sends the conversation to the model, and forwards each chunk as an HTML-escaped `token` event. HTMX appends each one (`hx-swap="beforeend"`), so the reply types out live.
3. When the model finishes, a `done` event replaces the whole bubble with the reply rendered as Markdown (goldmark, with raw HTML stripped). That also removes the `sse-connect` element, which closes the stream.
4. Browsers automatically reconnect an EventSource. A reconnect for a reply that is already claimed or finished gets the final state instead of a second generation.
There is no JavaScript framework and no build step. The only JS is htmx, its SSE extension and about 40 lines of UX glue. All of it is embedded in the binary, so the app works fully offline.
## Project layout
```
cmd/localchat/ entry point, CLI + service install/start/stop
internal/config/ env + .env loading
internal/llm/ tiny OpenAI-compatible streaming client (stdlib only)
internal/chat/ in-memory conversations, one per browser session
internal/web/ routes, SSE streaming, embedded static assets
internal/web/views/ Templ components (*.templ → generated *_templ.go)
scripts/e2e.py Playwright browser test against a real model
compose.yaml, Dockerfile
```
## Development
```sh
make test # unit + handler tests with a fake model server, -race
make run # build + run with ./.env
make e2e # headless browser test against the running app + real model
make dist # cross-compile linux/darwin/windows × amd64/arm64
```
After editing a `.templ` file, run `make generate` (or `go tool templ generate --watch`). The generated `*_templ.go` files are committed, so `go build` and Docker builds don't need Templ installed.
## Good to know
- Conversations live in memory and are lost on restart. That's on purpose for privacy, and simple enough to swap for SQLite.
- There is no authentication. It listens on localhost by default. Put a reverse proxy with auth in front before exposing it.
- Speed in Docker depends on how many CPUs the Docker VM gets. With Docker Desktop's 1-CPU setting, Gemma E2B takes about 20 s to start replying, so give it more cores. On a Mac, running the model natively (oMLX or `llama-server`, which use the GPU via Metal) is far faster: about 1.5 s to the first token on an M3.
- GPU in Docker depends on the platform. The compose file runs on CPU everywhere, which E2B handles fine. On Linux with an NVIDIA or AMD GPU, use the `server-cuda` or `server-rocm` llama.cpp image tags.
+201
View File
@@ -0,0 +1,201 @@
// Command localchat is a chat UI for a local, open-weight model.
//
// It runs in the foreground, or registers itself as a background service
// with the OS (systemd on Linux, launchd on macOS, the Service Control
// Manager on Windows).
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"time"
"github.com/kardianos/service"
"git.b0b.be/bdeb/localchat/internal/config"
"git.b0b.be/bdeb/localchat/internal/web"
)
var version = "dev" // set with -ldflags "-X main.version=..."
const usage = `localchat %s: chat with a local AI model in your browser.
Usage:
localchat [command] [flags]
Commands:
serve run in the foreground (default)
install register as a background service that starts on boot/login
uninstall remove the service
start start the installed service
stop stop the installed service
restart restart the installed service
status show whether the service is running
healthcheck exit 0 if the server answers (for container health checks)
version print the version
Flags:
`
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "localchat:", err)
os.Exit(1)
}
}
func run(args []string) error {
cmd := "serve"
if len(args) > 0 && args[0] != "" && args[0][0] != '-' {
cmd, args = args[0], args[1:]
}
fs := flag.NewFlagSet("localchat", flag.ContinueOnError)
envFile := fs.String("env-file", "", "load settings from this KEY=VALUE file (default: ./.env if present)")
user := fs.Bool("user", false, "install as a per-user service (launchd agent / systemd --user) instead of system-wide")
debug := fs.Bool("debug", false, "verbose logging")
fs.Usage = func() {
fmt.Fprintf(fs.Output(), usage, version)
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
if cmd == "version" {
fmt.Println(version)
return nil
}
if *envFile == "" {
if _, err := os.Stat(".env"); err == nil {
*envFile = ".env"
}
}
if *envFile != "" {
abs, err := filepath.Abs(*envFile)
if err != nil {
return err
}
*envFile = abs
if err := config.LoadEnvFile(abs); err != nil {
return fmt.Errorf("env file: %w", err)
}
}
level := slog.LevelInfo
if *debug {
level = slog.LevelDebug
}
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
// The service manager re-runs us as "localchat serve --env-file <abs>",
// so the installed service sees the same settings as this invocation.
svcArgs := []string{"serve"}
if *envFile != "" {
svcArgs = append(svcArgs, "--env-file", *envFile)
}
wd, _ := os.Getwd()
prg := &program{log: log}
svc, err := service.New(prg, &service.Config{
Name: "localchat",
DisplayName: "localchat",
Description: "Chat with a local AI model in your browser.",
Arguments: svcArgs,
WorkingDirectory: wd,
Option: service.KeyValue{"UserService": *user},
})
if err != nil {
return err
}
switch cmd {
case "healthcheck":
return healthcheck()
case "serve":
return svc.Run() // handles Ctrl+C in a terminal and stop requests from the OS
case "status":
st, err := svc.Status()
if err != nil {
return err
}
fmt.Println(map[service.Status]string{
service.StatusRunning: "running",
service.StatusStopped: "stopped",
}[st])
return nil
case "install", "uninstall", "start", "stop", "restart":
if err := service.Control(svc, cmd); err != nil {
return err
}
fmt.Printf("localchat: %s ok\n", cmd)
return nil
default:
fs.Usage()
return fmt.Errorf("unknown command %q", cmd)
}
}
func healthcheck() error {
cfg, err := config.Load()
if err != nil {
return err
}
host, port, err := net.SplitHostPort(cfg.Addr)
if err != nil {
return err
}
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
c := &http.Client{Timeout: 3 * time.Second}
resp, err := c.Get("http://" + net.JoinHostPort(host, port) + "/healthz")
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("healthz: %s", resp.Status)
}
return nil
}
// program adapts the web server to the service lifecycle.
type program struct {
log *slog.Logger
cancel context.CancelFunc
done chan struct{}
}
func (p *program) Start(service.Service) error {
cfg, err := config.Load()
if err != nil {
return err
}
ctx, cancel := context.WithCancel(context.Background())
p.cancel, p.done = cancel, make(chan struct{})
go func() {
defer close(p.done)
if err := web.New(cfg, p.log).Run(ctx); err != nil {
p.log.Error("server stopped", "err", err)
os.Exit(1)
}
}()
return nil
}
func (p *program) Stop(service.Service) error {
p.cancel()
<-p.done
return nil
}
+44
View File
@@ -0,0 +1,44 @@
# localchat + a local Gemma 4 E2B model served by llama.cpp.
#
# docker compose up -d # first start downloads the model (~3 GB)
# open http://localhost:3000
#
# Swap the model with LLM_HF_REPO, e.g. LLM_HF_REPO=unsloth/gemma-4-E4B-it-GGUF:Q4_K_M
services:
llm:
image: ghcr.io/ggml-org/llama.cpp:server
command:
- -hf
- ${LLM_HF_REPO:-unsloth/gemma-4-E2B-it-GGUF:Q4_K_M}
- --no-mmproj # text-only chat; skip the vision projector download
- --ctx-size
- "8192"
- --port
- "8080"
environment:
LLAMA_CACHE: /models
volumes:
- models:/models
healthcheck:
test: ["CMD", "curl", "-fs", "http://localhost:8080/health"]
interval: 10s
timeout: 5s
start_period: 15m # first boot downloads the model
restart: unless-stopped
app:
build: .
image: localchat:latest
ports:
- "3000:3000"
environment:
LLM_BASE_URL: http://llm:8080/v1
LOCALCHAT_TITLE: ${LOCALCHAT_TITLE:-localchat}
SYSTEM_PROMPT: ${SYSTEM_PROMPT:-}
depends_on:
llm:
condition: service_healthy
restart: unless-stopped
volumes:
models:
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+28
View File
@@ -0,0 +1,28 @@
module git.b0b.be/bdeb/localchat
go 1.27.1
require (
github.com/a-h/templ v0.3.1070
github.com/kardianos/service v1.3.0
github.com/yuin/goldmark v1.8.6
)
require (
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
github.com/andybalholm/brotli v1.2.6 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/natefinch/atomic v1.0.1 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/tools v0.51.0 // indirect
)
tool github.com/a-h/templ/cmd/templ
+46
View File
@@ -0,0 +1,46 @@
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
github.com/a-h/templ v0.3.1070 h1:3iCOXHXqFXa99OQzgkUOLJbNzv/QjFGLwKZkBAg+ADA=
github.com/a-h/templ v0.3.1070/go.mod h1:iIqB3g2RHBe/WlnPwa+Bvhh1TKXHWLRnBD9NWHOLm78=
github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI=
github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI=
github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+179
View File
@@ -0,0 +1,179 @@
// Package chat keeps conversations in memory, one per browser session.
package chat
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
"git.b0b.be/bdeb/localchat/internal/llm"
)
// State tracks an assistant reply through its lifecycle.
type State int
const (
Pending State = iota // created, nobody is generating it yet
Streaming // a stream handler owns it
Done // finished (successfully or with Err set)
)
// Message is a chat turn shown in the UI.
type Message struct {
ID string
Role string // "user" or "assistant"
Content string
State State
Err string
}
// Conversation is the history of one session.
type Conversation struct {
mu sync.Mutex
messages []*Message
lastUsed time.Time
}
// Store maps session ids to conversations.
type Store struct {
mu sync.Mutex
convs map[string]*Conversation
}
// NewStore returns an empty store.
func NewStore() *Store {
return &Store{convs: make(map[string]*Conversation)}
}
// NewID returns a random hex id, suitable for sessions and messages.
func NewID() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
// Get returns the conversation for a session, creating it if needed.
func (s *Store) Get(session string) *Conversation {
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.convs[session]
if !ok {
c = &Conversation{}
s.convs[session] = c
}
c.mu.Lock()
c.lastUsed = time.Now()
c.mu.Unlock()
return c
}
// Reset forgets a session's conversation.
func (s *Store) Reset(session string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.convs, session)
}
// Prune drops conversations idle for longer than maxIdle.
func (s *Store) Prune(maxIdle time.Duration) {
cutoff := time.Now().Add(-maxIdle)
s.mu.Lock()
defer s.mu.Unlock()
for id, c := range s.convs {
c.mu.Lock()
idle := c.lastUsed.Before(cutoff)
c.mu.Unlock()
if idle {
delete(s.convs, id)
}
}
}
// Messages returns a snapshot of the conversation.
func (c *Conversation) Messages() []Message {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]Message, len(c.messages))
for i, m := range c.messages {
out[i] = *m
}
return out
}
// Ask appends a user message plus a pending assistant reply and returns both.
func (c *Conversation) Ask(text string) (user, reply Message) {
c.mu.Lock()
defer c.mu.Unlock()
u := &Message{ID: NewID(), Role: "user", Content: text, State: Done}
r := &Message{ID: NewID(), Role: "assistant", State: Pending}
c.messages = append(c.messages, u, r)
return *u, *r
}
// Claim marks a pending reply as streaming and returns the context to send
// to the model: the system prompt plus up to maxHistory finished messages
// before the reply. ok is false if the reply does not exist or someone else
// already claimed it; msg then holds its current state (if it exists).
func (c *Conversation) Claim(id, systemPrompt string, maxHistory int) (prompt []llm.Message, msg Message, ok bool) {
c.mu.Lock()
defer c.mu.Unlock()
idx := c.index(id)
if idx < 0 {
return nil, Message{}, false
}
m := c.messages[idx]
if m.State != Pending {
return nil, *m, false
}
m.State = Streaming
var history []llm.Message
for _, h := range c.messages[:idx] {
if h.State == Done && h.Err == "" && h.Content != "" {
history = append(history, llm.Message{Role: h.Role, Content: h.Content})
}
}
if len(history) > maxHistory {
history = history[len(history)-maxHistory:]
}
if systemPrompt != "" {
prompt = append(prompt, llm.Message{Role: "system", Content: systemPrompt})
}
return append(prompt, history...), *m, true
}
// Append adds streamed text to a reply.
func (c *Conversation) Append(id, delta string) {
c.mu.Lock()
defer c.mu.Unlock()
if i := c.index(id); i >= 0 {
c.messages[i].Content += delta
}
}
// Finish marks a reply as done, recording err if generation failed, and
// returns its final state.
func (c *Conversation) Finish(id string, err error) Message {
c.mu.Lock()
defer c.mu.Unlock()
i := c.index(id)
if i < 0 {
return Message{ID: id, Role: "assistant", State: Done}
}
m := c.messages[i]
m.State = Done
if err != nil {
m.Err = err.Error()
}
return *m
}
func (c *Conversation) index(id string) int {
for i, m := range c.messages {
if m.ID == id {
return i
}
}
return -1
}
+95
View File
@@ -0,0 +1,95 @@
// Package config loads localchat settings from the environment and an
// optional .env file.
package config
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
"time"
)
// DefaultSystemPrompt is used when SYSTEM_PROMPT is not set.
const DefaultSystemPrompt = "You are a friendly, concise assistant running fully offline on the user's own machine. Answer clearly. Use Markdown when it helps."
// Config holds all runtime settings.
type Config struct {
Addr string // LOCALCHAT_ADDR: where the web UI listens
BaseURL string // LLM_BASE_URL: OpenAI-compatible API root, e.g. http://127.0.0.1:8000/v1
APIKey string // LLM_API_KEY: optional bearer token for the model server
Model string // LLM_MODEL: model id; empty means "first model the server lists"
SystemPrompt string // SYSTEM_PROMPT
Title string // LOCALCHAT_TITLE: name shown in the UI
MaxHistory int // MAX_HISTORY: messages sent back to the model as context
Timeout time.Duration // LLM_TIMEOUT: upper bound for a single reply
}
// Load reads the configuration from environment variables, applying defaults.
func Load() (Config, error) {
c := Config{
Addr: env("LOCALCHAT_ADDR", "127.0.0.1:3000"),
BaseURL: strings.TrimRight(env("LLM_BASE_URL", "http://127.0.0.1:8000/v1"), "/"),
APIKey: os.Getenv("LLM_API_KEY"),
Model: os.Getenv("LLM_MODEL"),
SystemPrompt: env("SYSTEM_PROMPT", DefaultSystemPrompt),
Title: env("LOCALCHAT_TITLE", "localchat"),
MaxHistory: 20,
Timeout: 5 * time.Minute,
}
if v := os.Getenv("MAX_HISTORY"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
return c, fmt.Errorf("MAX_HISTORY must be a positive integer, got %q", v)
}
c.MaxHistory = n
}
if v := os.Getenv("LLM_TIMEOUT"); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
return c, fmt.Errorf("LLM_TIMEOUT: %w", err)
}
c.Timeout = d
}
return c, nil
}
func env(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}
// LoadEnvFile sets variables from a KEY=VALUE file. Variables that are
// already set in the environment win, so the real environment can override
// the file. Blank lines and lines starting with # are ignored; values may be
// wrapped in single or double quotes.
func LoadEnvFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
sc := bufio.NewScanner(f)
for n := 1; sc.Scan(); n++ {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(strings.TrimPrefix(line, "export "), "=")
if !ok {
return fmt.Errorf("%s:%d: expected KEY=VALUE", path, n)
}
key, val = strings.TrimSpace(key), strings.TrimSpace(val)
if len(val) >= 2 && (val[0] == '"' || val[0] == '\'') && val[len(val)-1] == val[0] {
val = val[1 : len(val)-1]
}
if _, set := os.LookupEnv(key); !set {
os.Setenv(key, val)
}
}
return sc.Err()
}
+45
View File
@@ -0,0 +1,45 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadEnvFile(t *testing.T) {
path := filepath.Join(t.TempDir(), ".env")
os.WriteFile(path, []byte(`
# comment
LLM_MODEL=from-file
export LOCALCHAT_TITLE="My Chat"
SYSTEM_PROMPT='Talk like a pirate = arr'
LLM_BASE_URL=http://file/v1/
`), 0o600)
t.Setenv("LLM_BASE_URL", "http://env/v1") // real env wins over the file
for _, k := range []string{"LLM_MODEL", "LOCALCHAT_TITLE", "SYSTEM_PROMPT"} {
t.Setenv(k, "")
os.Unsetenv(k)
}
if err := LoadEnvFile(path); err != nil {
t.Fatal(err)
}
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.Model != "from-file" || c.Title != "My Chat" || c.SystemPrompt != "Talk like a pirate = arr" {
t.Fatalf("unexpected config: %+v", c)
}
if c.BaseURL != "http://env/v1" {
t.Fatalf("env should override file, got %q", c.BaseURL)
}
}
func TestLoadValidates(t *testing.T) {
t.Setenv("MAX_HISTORY", "zero")
if _, err := Load(); err == nil {
t.Fatal("want error for bad MAX_HISTORY")
}
}
+175
View File
@@ -0,0 +1,175 @@
// Package llm is a minimal client for OpenAI-compatible chat APIs, as served
// by llama.cpp, oMLX, Ollama, LM Studio, Lemonade, vLLM and friends.
package llm
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
// Message is one chat turn.
type Message struct {
Role string `json:"role"` // "system", "user" or "assistant"
Content string `json:"content"`
}
// Client talks to a single OpenAI-compatible endpoint.
type Client struct {
BaseURL string // e.g. http://127.0.0.1:8000/v1
APIKey string
Model string // empty: use the first model the server lists
HTTP *http.Client
}
func (c *Client) httpClient() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return http.DefaultClient
}
func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, body)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+c.APIKey)
}
return req, nil
}
// Models lists the model ids the server offers. It doubles as a health check.
func (c *Client) Models(ctx context.Context) ([]string, error) {
req, err := c.newRequest(ctx, http.MethodGet, "/models", nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if err := checkStatus(resp); err != nil {
return nil, err
}
var out struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode models: %w", err)
}
ids := make([]string, len(out.Data))
for i, m := range out.Data {
ids[i] = m.ID
}
return ids, nil
}
// ResolveModel returns the configured model, or the first one the server
// lists when none is configured.
func (c *Client) ResolveModel(ctx context.Context) (string, error) {
if c.Model != "" {
return c.Model, nil
}
ids, err := c.Models(ctx)
if err != nil {
return "", err
}
if len(ids) == 0 {
return "", errors.New("model server lists no models; set LLM_MODEL")
}
return ids[0], nil
}
// Stream sends the conversation and calls onDelta for every chunk of the
// reply as it arrives. It returns when the reply is complete, the context is
// cancelled, or onDelta returns an error.
func (c *Client) Stream(ctx context.Context, msgs []Message, onDelta func(string) error) error {
model, err := c.ResolveModel(ctx)
if err != nil {
return err
}
body, err := json.Marshal(map[string]any{
"model": model,
"messages": msgs,
"stream": true,
})
if err != nil {
return err
}
req, err := c.newRequest(ctx, http.MethodPost, "/chat/completions", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Accept", "text/event-stream")
resp, err := c.httpClient().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if err := checkStatus(resp); err != nil {
return err
}
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 64*1024), 1024*1024)
for sc.Scan() {
data, ok := strings.CutPrefix(sc.Text(), "data:")
if !ok {
continue // blank separators, comments, "event:" lines
}
data = strings.TrimSpace(data)
if data == "[DONE]" {
return nil
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return fmt.Errorf("decode stream chunk: %w", err)
}
if chunk.Error != nil {
return fmt.Errorf("model server: %s", chunk.Error.Message)
}
for _, ch := range chunk.Choices {
if ch.Delta.Content == "" {
continue
}
if err := onDelta(ch.Delta.Content); err != nil {
return err
}
}
}
if err := sc.Err(); err != nil {
return err
}
return nil // stream ended without [DONE]; treat what we got as the reply
}
func checkStatus(resp *http.Response) error {
if resp.StatusCode < 300 {
return nil
}
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("model server returned %s: %s", resp.Status, bytes.TrimSpace(msg))
}
+107
View File
@@ -0,0 +1,107 @@
package llm
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeServer mimics an OpenAI-compatible server that streams the given chunks.
func fakeServer(t *testing.T, chunks []string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/models":
fmt.Fprint(w, `{"data":[{"id":"tiny-model"},{"id":"other"}]}`)
case "/v1/chat/completions":
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
http.Error(w, `{"error":"bad key"}`, http.StatusUnauthorized)
return
}
var req struct {
Model string `json:"model"`
Stream bool `json:"stream"`
Msgs []Message `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode request: %v", err)
}
if req.Model != "tiny-model" || !req.Stream || len(req.Msgs) != 1 {
t.Errorf("unexpected request: %+v", req)
}
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, ": keep-alive comment\n\n")
fmt.Fprint(w, `data: {"choices":[{"delta":{"role":"assistant"}}]}`+"\n\n")
for _, c := range chunks {
b, _ := json.Marshal(c)
fmt.Fprintf(w, `data: {"choices":[{"delta":{"content":%s}}]}`+"\n\n", b)
}
fmt.Fprint(w, "data: [DONE]\n\n")
default:
http.NotFound(w, r)
}
}))
}
func TestStream(t *testing.T) {
srv := fakeServer(t, []string{"Hel", "lo", "\n", "world"})
defer srv.Close()
c := &Client{BaseURL: srv.URL + "/v1", APIKey: "secret"} // no model: auto-pick first
var got strings.Builder
err := c.Stream(context.Background(), []Message{{Role: "user", Content: "hi"}}, func(d string) error {
got.WriteString(d)
return nil
})
if err != nil {
t.Fatal(err)
}
if got.String() != "Hello\nworld" {
t.Fatalf("got %q", got.String())
}
}
func TestStreamHTTPError(t *testing.T) {
srv := fakeServer(t, nil)
defer srv.Close()
c := &Client{BaseURL: srv.URL + "/v1", APIKey: "wrong", Model: "tiny-model"}
err := c.Stream(context.Background(), []Message{{Role: "user", Content: "hi"}}, func(string) error { return nil })
if err == nil || !strings.Contains(err.Error(), "401") {
t.Fatalf("want 401 error, got %v", err)
}
}
func TestStreamCallbackErrorStops(t *testing.T) {
srv := fakeServer(t, []string{"a", "b", "c"})
defer srv.Close()
c := &Client{BaseURL: srv.URL + "/v1", APIKey: "secret"}
stop := fmt.Errorf("stop")
n := 0
err := c.Stream(context.Background(), []Message{{Role: "user", Content: "hi"}}, func(string) error {
n++
return stop
})
if err != stop || n != 1 {
t.Fatalf("err=%v n=%d", err, n)
}
}
func TestResolveModel(t *testing.T) {
srv := fakeServer(t, nil)
defer srv.Close()
c := &Client{BaseURL: srv.URL + "/v1"}
if m, err := c.ResolveModel(context.Background()); err != nil || m != "tiny-model" {
t.Fatalf("auto: %q %v", m, err)
}
c.Model = "pinned"
if m, _ := c.ResolveModel(context.Background()); m != "pinned" {
t.Fatalf("pinned: %q", m)
}
}
+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
+70
View File
@@ -0,0 +1,70 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["playwright"]
# ///
"""Browser end-to-end test: loads the UI, sends a message with Enter, waits
for the streamed reply to finish rendering, and saves screenshots.
uv run scripts/e2e.py [--url http://127.0.0.1:3000] [--channel msedge]
"""
import argparse, sys, time
from playwright.sync_api import sync_playwright, expect
p = argparse.ArgumentParser()
p.add_argument("--url", default="http://127.0.0.1:3000")
p.add_argument("--channel", default="msedge", help="installed browser: chrome, msedge, ...")
p.add_argument("--out", default="docs")
p.add_argument("--dark", action="store_true")
p.add_argument("--prompt", default="Give me 3 short tips for learning Go, as a numbered list. Include one tiny code snippet.")
args = p.parse_args()
with sync_playwright() as pw:
browser = pw.chromium.launch(channel=args.channel, headless=True)
page = browser.new_page(viewport={"width": 900, "height": 760},
color_scheme="dark" if args.dark else "light")
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
page.goto(args.url)
expect(page.locator("#health .dot.ok")).to_be_visible(timeout=10_000)
expect(page.locator(".empty")).to_be_visible()
box = page.locator("#composer textarea")
box.fill(args.prompt)
t0 = time.time()
box.press("Enter")
# user bubble shows up and the input clears
expect(page.locator(".msg.user")).to_have_count(1)
expect(box).to_have_value("")
expect(page.locator(".empty")).to_be_hidden()
# tokens stream in (plain spans) ...
page.wait_for_selector(".streaming span", timeout=60_000)
first = time.time() - t0
# ... then get replaced by rendered Markdown
page.wait_for_selector(".msg.assistant .markdown", timeout=180_000)
total = time.time() - t0
page.wait_for_timeout(300)
assert page.locator(".msg.assistant [sse-connect]").count() == 0, "SSE element not cleaned up"
if "numbered list" in args.prompt:
assert page.locator(".msg.assistant ol li").count() >= 3, "expected a numbered list"
if "code" in args.prompt:
assert page.locator(".msg.assistant pre code").count() >= 1, "expected a code block"
page.screenshot(path=f"{args.out}/screenshot{'-dark' if args.dark else ''}.png")
# reload: history persists for the session
page.reload()
expect(page.locator(".msg")).to_have_count(2)
# new chat clears it
page.get_by_role("button", name="New chat").click()
expect(page.locator(".msg")).to_have_count(0)
expect(page.locator(".empty")).to_be_visible()
browser.close()
if errors:
print("JS errors:", errors)
sys.exit(1)
print(f"OK: first token {first:.1f}s, full reply {total:.1f}s")