Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b6Kg9RWSTJzyz6KP9X
chat-ui-htmx
Companion code for Streaming AI Chat UI with Spring AI, SSE and htmx (No React Needed), part of the Spring AI series 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
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 |