Files
..

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