Files
spring-ai/chat-ui-htmx/README.md
T

56 lines
4.1 KiB
Markdown

# chat-ui-htmx
Companion code for [Streaming AI Chat UI with Spring AI, SSE and htmx (No React Needed)](https://ankurm.com/spring-ai-streaming-chat-ui-sse-htmx/), part of the [Spring AI series](../README.md) on ankurm.com.
A chat page with no JavaScript of our own: htmx and its SSE extension (both vendored under `src/main/resources/static/vendor/`, no CDN) post the message, open an `EventSource`, and append each token to the page. The server side is one `ChatClient` with `MessageChatMemoryAdvisor` (conversation id taken from the HTTP session), a `Flux<ServerSentEvent<String>>` endpoint, a Stop button, and the traps found on the way: a leading space that EventSource strips, model text that must be HTML-escaped, a cancel that does not close the upstream connection, and a model-side stream that ignores back-pressure.
**The model is a STUB.** `OpenAiStubServer` (`src/main/java/com/ankurm/chatui/stub/`) is a small OpenAI-compatible `POST /v1/chat/completions` server that streams SSE chunks with a controllable delay and answers from a template built from the request it received. It does no inference. The real Spring AI `OpenAiChatModel` talks to it over real HTTP. A real provider was **not** run; to try one: `CHAT_BASE_URL=https://api.openai.com/v1 CHAT_MODEL=<model> OPENAI_API_KEY=<key> java -jar target/chat-ui-htmx-1.0.0.jar` (the base URL must end in `/v1`: the starter does not append it).
| Component | Version |
|---|---|
| Spring Boot | 4.1.2 |
| Spring AI | 2.0.2 (OpenAI starter, openai-java 4.80.0, OkHttp 4.12.0) |
| htmx / htmx-ext-sse | 2.0.11 / 2.2.4 (vendored, sha256 in `static/vendor/SHA256SUMS`) |
| Java | 25 (LTS) |
## Run
```bash
mvn -DskipTests package
mvn -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt
java -cp "target/classes:$(cat target/cp.txt)" com.ankurm.chatui.stub.OpenAiStubServer 38181 20 & # port, ms between chunks
java -jar target/chat-ui-htmx-1.0.0.jar # http://localhost:38180/
scripts/run-all.sh # regenerates output/ (about 5 minutes; step 2 needs Playwright for Python + Chromium)
```
Stub directives (put them at the start of the message): `count N` streams N one-word chunks, `big N` streams N chunks of 32 KiB, `html ...` streams text containing markup. Anything else is echoed with the number of messages and earlier user turns it saw. `POST /stub/config?delayMs=N` changes the delay, `GET /stub/stats` shows counters.
App switches: `--chat.cancel-fix=false` (turn the cancel fix off), `--chat.backpressure=none|limitRate|slow|bounded`, `--chat.memory.max-messages=20`.
## Files
| File | What |
|---|---|
| `ChatController.java` | `POST /chat` (HTML fragment), `GET /chat/stream` (SSE), `POST /chat/stop`, `POST /chat/new` |
| `ChatConfig.java` | `MessageWindowChatMemory` + `ChatClient` with `MessageChatMemoryAdvisor` |
| `CancelOnCloseConfig.java` | OkHttp interceptor that makes a cancelled SSE stream close its connection |
| `stub/OpenAiStubServer.java` | the stub model server |
| `static/index.html` | the whole front end |
| `ChatFlowTest`, `OkHttpCloseReproTest` | tests that also write `output/01`-`05` |
| `scripts/browser-test.py` | headless Chromium driving the real page (`output/06`) |
| `scripts/cancel-upstream.sh`, `backpressure.sh`, `slow_client.py` | the cancel and back-pressure experiments |
## Captured output
| File | What |
|---|---|
| `output/01-sse-transcript.txt` | raw SSE lines for one turn, and the text with and without the leading-space padding |
| `output/02-memory.txt` | same session remembers, a new session does not |
| `output/03-escaping.txt` | markup in model output arrives escaped |
| `output/04-cancel-with-fix.txt` | socket closed by the client: Flux cancelled, upstream closed |
| `output/05-okhttp-close-vs-cancel.txt` | plain OkHttp: `response.close()` leaks, `call.cancel()` does not |
| `output/06-browser.txt`, `07-stop-logs.txt` | the page in Chromium, and the server and stub logs of the Stop click |
| `output/08-cancel-upstream.txt` | 8 aborted streams without and with the fix |
| `output/09-backpressure.txt` | slow client, four ways to shape the Flux |
| `output/10-long-stream.txt` | a 35 s answer with no async timeout configured |