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,6 @@
|
|||||||
|
bin/
|
||||||
|
docs/
|
||||||
|
scripts/
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
bin/
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
|
dist/
|
||||||
+17
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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! -->
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
Reference in new issue
Block a user