Add chat-ui-htmx module: streaming chat page on Spring AI 2.0.2 with htmx and its SSE extension (vendored), MessageChatMemoryAdvisor keyed by the HTTP session, a Stop button, and the traps measured: EventSource strips one leading space per data line, model text must be HTML-escaped, a cancelled stream left the upstream connection open in 7 of 8 runs until an OkHttp interceptor calls Call.cancel (8 of 8 closed after), and the SDK push source ignores back-pressure (limitRate changes only the demand; a bounded buffer works). The model is a stub OpenAI-compatible SSE server written for the module; no real provider was run.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b6Kg9RWSTJzyz6KP9X
This commit is contained in:
@@ -33,5 +33,6 @@ Runnable companion code for the Spring AI articles on [ankurm.com](https://ankur
|
|||||||
| [`image-gen/`](image-gen) | `ImageModel` and `ImagePrompt` with `OpenAiImageOptions` (what the real `OpenAiImageModel` puts on the wire, a per-call options object that silently resets the model, a `List<ImageMessage>` prompt that throws), a prompt gate with `ModerationModel` that fails closed, and safe storage in MinIO: sniffed content type, byte and pixel caps, server-chosen keys, a fetcher for provider URLs, presigned reads. A per-image price table from the vendors' pages dated 2026-10-28. No image provider called: the `ImageModel` and `ModerationModel` are hand-written fakes and a mock server; the MinIO tests use a real server through Testcontainers. Spring Boot 4.1.2, Spring AI 2.0.2, Java 25. | [Image Generation and Vision in Spring AI: ImageModel, Prompt Options and Safe Storage](https://ankurm.com/spring-ai-image-generation-imagemodel-s3-minio-safe-storage/) |
|
| [`image-gen/`](image-gen) | `ImageModel` and `ImagePrompt` with `OpenAiImageOptions` (what the real `OpenAiImageModel` puts on the wire, a per-call options object that silently resets the model, a `List<ImageMessage>` prompt that throws), a prompt gate with `ModerationModel` that fails closed, and safe storage in MinIO: sniffed content type, byte and pixel caps, server-chosen keys, a fetcher for provider URLs, presigned reads. A per-image price table from the vendors' pages dated 2026-10-28. No image provider called: the `ImageModel` and `ModerationModel` are hand-written fakes and a mock server; the MinIO tests use a real server through Testcontainers. Spring Boot 4.1.2, Spring AI 2.0.2, Java 25. | [Image Generation and Vision in Spring AI: ImageModel, Prompt Options and Safe Storage](https://ankurm.com/spring-ai-image-generation-imagemodel-s3-minio-safe-storage/) |
|
||||||
| [`batch-llm/`](batch-llm) | 100,000 synthetic support tickets classified by a `ChatClient` inside a Spring Batch 6 chunk step: a Resilience4j rate limiter and retry around the model call (and where Spring Batch's own retry and skip belong instead), replies that are not valid JSON skipped and recorded, a restart after `kill -9` in a second JVM that ends with a byte-identical result, and a cost ledger row committed with every chunk. **The model is a deterministic simulated `ChatModel`, not a language model; token counts are estimates and prices are configurable placeholders.** Spring Boot 4.1.2, Spring Batch 6.0.6, Spring AI 2.0.2, Resilience4j 2.4.0, Java 25. | [Spring AI + Spring Batch: Classifying 100,000 Support Tickets with an LLM](https://ankurm.com/spring-ai-spring-batch-llm-ticket-classification/) |
|
| [`batch-llm/`](batch-llm) | 100,000 synthetic support tickets classified by a `ChatClient` inside a Spring Batch 6 chunk step: a Resilience4j rate limiter and retry around the model call (and where Spring Batch's own retry and skip belong instead), replies that are not valid JSON skipped and recorded, a restart after `kill -9` in a second JVM that ends with a byte-identical result, and a cost ledger row committed with every chunk. **The model is a deterministic simulated `ChatModel`, not a language model; token counts are estimates and prices are configurable placeholders.** Spring Boot 4.1.2, Spring Batch 6.0.6, Spring AI 2.0.2, Resilience4j 2.4.0, Java 25. | [Spring AI + Spring Batch: Classifying 100,000 Support Tickets with an LLM](https://ankurm.com/spring-ai-spring-batch-llm-ticket-classification/) |
|
||||||
| [`docker-model-runner/`](docker-model-runner) | The Spring AI OpenAI starter pointed at Docker Model Runner and at Ollama by changing only `base-url` and the model name (no `/v1` is appended: the path is used exactly as given), plus a benchmark runner: time to first token, decode tokens/second, container memory from `docker stats` and tool-call success over 10 runs. Measured with the real `docker/model-runner` v1.2.9 CPU image and Ollama 0.40.13 on one shared 2-vCPU sandbox, Qwen2.5-0.5B Q4_K_M; no GPU, and Docker Desktop's `docker model` path was not used. Spring Boot 4.1.2, Spring AI 2.0.2, Java 25. | [Spring AI with Docker Model Runner vs Ollama: Local LLMs Benchmarked on the Same Laptop](https://ankurm.com/spring-ai-docker-model-runner-vs-ollama/) |
|
| [`docker-model-runner/`](docker-model-runner) | The Spring AI OpenAI starter pointed at Docker Model Runner and at Ollama by changing only `base-url` and the model name (no `/v1` is appended: the path is used exactly as given), plus a benchmark runner: time to first token, decode tokens/second, container memory from `docker stats` and tool-call success over 10 runs. Measured with the real `docker/model-runner` v1.2.9 CPU image and Ollama 0.40.13 on one shared 2-vCPU sandbox, Qwen2.5-0.5B Q4_K_M; no GPU, and Docker Desktop's `docker model` path was not used. Spring Boot 4.1.2, Spring AI 2.0.2, Java 25. | [Spring AI with Docker Model Runner vs Ollama: Local LLMs Benchmarked on the Same Laptop](https://ankurm.com/spring-ai-docker-model-runner-vs-ollama/) |
|
||||||
|
| [`chat-ui-htmx/`](chat-ui-htmx) | A streaming chat page with no JavaScript of our own: htmx and its SSE extension (vendored) post the message and append each token from a `Flux<ServerSentEvent<String>>` over `ChatClient` with `MessageChatMemoryAdvisor` keyed by the HTTP session. Measured traps: EventSource strips one leading space per data line, model text must be HTML-escaped, a cancelled stream left the upstream connection open in 7 of 8 runs until an OkHttp interceptor made it `call.cancel()` (closed in 8 of 8 after), and the SDK's push source ignores back-pressure (`limitRate` changes nothing; a bounded buffer does). Checked in headless Chromium. **The model is a stub OpenAI-compatible SSE server I wrote, not a language model; no real provider was run.** Spring Boot 4.1.2, Spring AI 2.0.2, htmx 2.0.11, Java 25. | [Streaming AI Chat UI with Spring AI, SSE and htmx (No React Needed)](https://ankurm.com/spring-ai-streaming-chat-ui-sse-htmx/) |
|
||||||
|
|
||||||
Upgrading from Spring AI 1.x: [migration guide](https://ankurm.com/spring-ai-1-to-2-migration-guide/).
|
Upgrading from Spring AI 1.x: [migration guide](https://ankurm.com/spring-ai-1-to-2-migration-guide/).
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 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 |
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
$ GET /chat/stream?message=hello world (raw lines, first 14 and last 5)
|
||||||
|
event:token
|
||||||
|
data: [stub]
|
||||||
|
|
||||||
|
event:token
|
||||||
|
data: saw
|
||||||
|
|
||||||
|
event:token
|
||||||
|
data: 2
|
||||||
|
|
||||||
|
event:token
|
||||||
|
data: messages.
|
||||||
|
|
||||||
|
event:token
|
||||||
|
data: Earlier
|
||||||
|
...
|
||||||
|
data: world
|
||||||
|
<blank>
|
||||||
|
event:done
|
||||||
|
data:
|
||||||
|
<blank>
|
||||||
|
event names seen: [token, done]
|
||||||
|
joined token text as EventSource delivers it: [stub] saw 2 messages. Earlier user turns: none. You said: hello world
|
||||||
|
same text if the server did not pad: [stub]saw2messages.Earlieruserturns:none.Yousaid:helloworld
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
session A, turn 1: [stub] saw 2 messages. Earlier user turns: none. You said: My name is Asha and I like tea
|
||||||
|
session A, turn 2: [stub] saw 4 messages. Earlier user turns: My name is Asha and I like tea. You said: What did I just tell you?
|
||||||
|
session B, turn 1: [stub] saw 2 messages. Earlier user turns: none. You said: What did I just tell you?
|
||||||
|
session A, turn 3: [stub] saw 6 messages. Earlier user turns: My name is Asha and I like tea | What did I just tell you?. You said: And now?
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
data sent to the browser: Here is <b>bold</b> and <img src=x onerror=alert(1)> & done
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
client read 40000 bytes then closed the socket
|
||||||
|
Flux cancelled on the server: 1
|
||||||
|
stub saw the upstream connection close: 1 (within 50 ms)
|
||||||
|
stub chunks written for this request: 5 of 400 requested
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
plain OkHttp 4.12.0, 10 runs each, stub streaming 60 chunks at 20 ms, reader thread blocked in a read
|
||||||
|
response.close() threw: java.lang.IllegalStateException: Unbalanced enter/exit
|
||||||
|
response.close() from another thread: upstream connection closed in 0 of 10 runs
|
||||||
|
call.cancel() from another thread: upstream connection closed in 10 of 10 runs
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
== turn 1 (session A)
|
||||||
|
A1: '[stub] saw 2 messages. Earlier user turns: none. You said: My name is Asha and I like tea'
|
||||||
|
== turn 2 (session A, same cookie)
|
||||||
|
A2: '[stub] saw 4 messages. Earlier user turns: My name is Asha and I like tea. You said: What did I just tell you?'
|
||||||
|
cookies: ['JSESSIONID']
|
||||||
|
== turn 1 (session B, fresh browser context, no cookie)
|
||||||
|
B1: '[stub] saw 2 messages. Earlier user turns: none. You said: What did I just tell you?'
|
||||||
|
== New conversation button in session A, then ask again
|
||||||
|
A3: '[stub] saw 2 messages. Earlier user turns: none. You said: Do you remember me?'
|
||||||
|
== model output containing markup
|
||||||
|
rendered text: 'Here is <b>bold</b> and <img src=x onerror=alert(1)> & done'
|
||||||
|
<b> elements created inside the answer: 0
|
||||||
|
<img> elements created inside the answer: 0
|
||||||
|
alert dialogs fired: 0
|
||||||
|
== stop button
|
||||||
|
text at click : 'w1 w2 w3 w4 w5 w6 w7 w8 w9 w10 w11'
|
||||||
|
text 1.5 s on : 'w1 w2 w3 w4 w5 w6 w7 w8 w9 w10 w11 w12'
|
||||||
|
grew after stop: False
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- app log (the turn started by the Stop-button test)
|
||||||
|
turn 6 start conversation=def8b65c-b58a-4677-b149-e71575426ebd message=count 200
|
||||||
|
turn 6 CANCELLED by client after 14 tokens
|
||||||
|
-- stub log
|
||||||
|
stub: request #6 model=stub-gpt stream=true messages=6 roles=system,user,assistant,user,assistant,user latest="count 200"
|
||||||
|
stub: stream #6 client closed the connection after 15 chunks
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
== chunk delay 20 ms, 8 runs, app args: --chat.cancel-fix=false
|
||||||
|
run 1: upstream closed
|
||||||
|
run 2: upstream LEFT OPEN
|
||||||
|
run 3: upstream LEFT OPEN
|
||||||
|
run 4: upstream LEFT OPEN
|
||||||
|
run 5: upstream LEFT OPEN
|
||||||
|
run 6: upstream LEFT OPEN
|
||||||
|
run 7: upstream LEFT OPEN
|
||||||
|
run 8: upstream LEFT OPEN
|
||||||
|
upstream closed: 1 of 8, left open: 7 of 8
|
||||||
|
|
||||||
|
== chunk delay 20 ms, 8 runs, app args: (none)
|
||||||
|
run 1: upstream closed
|
||||||
|
run 2: upstream closed
|
||||||
|
run 3: upstream closed
|
||||||
|
run 4: upstream closed
|
||||||
|
run 5: upstream closed
|
||||||
|
run 6: upstream closed
|
||||||
|
run 7: upstream closed
|
||||||
|
run 8: upstream closed
|
||||||
|
upstream closed: 8 of 8, left open: 0 of 8
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
== chat.backpressure=none (stub delay 0 ms, 600 chunks x 32 KiB, client reads 64 KiB every 0.2 s)
|
||||||
|
t= 1.0s client got 0 KiB stub: chunksWritten=0 finished=0 abortedByClient=0 app heap after GC: used 34669K
|
||||||
|
t= 2.8s client got 197 KiB stub: chunksWritten=452 finished=0 abortedByClient=0 app heap after GC: used 134096K
|
||||||
|
t= 5.5s client got 896 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 86306K
|
||||||
|
t= 8.9s client got 1832 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 109343K
|
||||||
|
t=13.3s client got 2967 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 116565K
|
||||||
|
-- app log (demand + outcome):
|
||||||
|
213 web-side demand request(1)
|
||||||
|
213 model-side demand request(1)
|
||||||
|
1 CANCELLED by client after 213 tokens
|
||||||
|
|
||||||
|
== chat.backpressure=limitRate (stub delay 0 ms, 600 chunks x 32 KiB, client reads 64 KiB every 0.2 s)
|
||||||
|
t= 1.0s client got 0 KiB stub: chunksWritten=0 finished=0 abortedByClient=0 app heap after GC: used 42630K
|
||||||
|
t= 2.8s client got 242 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 86598K
|
||||||
|
t= 5.2s client got 896 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 86745K
|
||||||
|
t= 8.5s client got 1768 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 109571K
|
||||||
|
t=12.9s client got 2967 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 116491K
|
||||||
|
-- app log (demand + outcome):
|
||||||
|
213 web-side demand request(1)
|
||||||
|
35 model-side demand request(6)
|
||||||
|
1 model-side demand request(8)
|
||||||
|
1 CANCELLED by client after 213 tokens
|
||||||
|
|
||||||
|
== chat.backpressure=slow (stub delay 0 ms, 600 chunks x 32 KiB, client reads 64 KiB every 0.2 s)
|
||||||
|
t= 1.0s client got 0 KiB stub: chunksWritten=0 finished=0 abortedByClient=0 app heap after GC: used 40942K
|
||||||
|
t= 2.6s client got 72 KiB stub: chunksWritten=218 finished=0 abortedByClient=0 app heap after GC: used 146705K
|
||||||
|
t= 5.3s client got 787 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 111009K
|
||||||
|
t= 8.6s client got 1659 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 109188K
|
||||||
|
t=13.0s client got 2858 KiB stub: chunksWritten=603 finished=1 abortedByClient=0 app heap after GC: used 119593K
|
||||||
|
-- app log (demand + outcome):
|
||||||
|
221 model-side demand request(1)
|
||||||
|
220 web-side demand request(1)
|
||||||
|
1 CANCELLED by client after 220 tokens
|
||||||
|
|
||||||
|
== chat.backpressure=bounded (stub delay 0 ms, 600 chunks x 32 KiB, client reads 64 KiB every 0.2 s)
|
||||||
|
t= 1.0s client got 0 KiB stub: chunksWritten=0 finished=0 abortedByClient=0 app heap after GC: used 61061K
|
||||||
|
t= 2.7s client got 133 KiB stub: chunksWritten=256 finished=0 abortedByClient=0 app heap after GC: used 58420K
|
||||||
|
t= 5.1s client got 787 KiB stub: chunksWritten=256 finished=0 abortedByClient=1 app heap after GC: used 58420K
|
||||||
|
t= 8.5s client got 1659 KiB stub: chunksWritten=256 finished=0 abortedByClient=1 app heap after GC: used 30251K
|
||||||
|
t=12.8s client got 2858 KiB stub: chunksWritten=256 finished=0 abortedByClient=1 app heap after GC: used 30381K
|
||||||
|
-- app log (demand + outcome):
|
||||||
|
144 web-side demand request(1)
|
||||||
|
1 model-side demand request(unbounded)
|
||||||
|
1 error reactor.core.Exceptions$OverflowException: The receiver is overrun by more signals than expected (bounded queue...)
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
$ curl -sN "localhost:38180/chat/stream?message=count 700" (700 tokens x 50 ms = 35 s)
|
||||||
|
<blank>
|
||||||
|
event:done
|
||||||
|
data:
|
||||||
|
<blank>
|
||||||
|
elapsed: 38 s
|
||||||
|
turn 1 complete, 700 tokens
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
stream failed: IllegalArgumentException: conversationId cannot be null
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
turn 1 'warm' completed, turn 2 'count 300' cancelled after a few tokens, then turn 3:
|
||||||
|
[stub] saw 5 messages. Earlier user turns: warm | count 300. You said: What do you remember?
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.1.2</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.ankurm</groupId>
|
||||||
|
<artifactId>chat-ui-htmx</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>chat-ui-htmx</name>
|
||||||
|
<description>Streaming chat UI: Spring AI ChatClient to Server-Sent Events to htmx, with per-session memory, cancel and back-pressure.</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
<spring-ai.version>2.0.2</spring-ai.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-bom</artifactId>
|
||||||
|
<version>${spring-ai.version}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-starter-model-openai</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.projectreactor</groupId>
|
||||||
|
<artifactId>reactor-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<argLine>-Duser.timezone=UTC -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8</argLine>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# A slow client against a fast model: curl reads 300 KB/s of a 600 x 32 KB stream (19.6 MB) while the
|
||||||
|
# stub writes with no delay. Samples how far the stub got and how much heap the app holds.
|
||||||
|
# Usage: backpressure.sh <none|limitRate|slow> [extra app args]
|
||||||
|
source "$(dirname "$0")/lib.sh"; set +e
|
||||||
|
MODE=${1:-none}; shift
|
||||||
|
stop_all; start_stub 0; start_app --chat.backpressure="$MODE" --chat.slow-millis=5 "$@"
|
||||||
|
echo "== chat.backpressure=$MODE (stub delay 0 ms, 600 chunks x 32 KiB, client reads 64 KiB every 0.2 s)"
|
||||||
|
echo 0 > "$RUN/recv.bytes"
|
||||||
|
python3 scripts/slow_client.py "http://localhost:$APP_PORT/chat/stream?message=big%20600&turn=1" "$RUN/recv.bytes" &
|
||||||
|
CURL=$!
|
||||||
|
T0=$(date +%s.%N)
|
||||||
|
for pause in 1 1 2 3 4; do
|
||||||
|
sleep "$pause"
|
||||||
|
st=$(curl -s "localhost:$STUB_PORT/stub/stats" | sed 's/requests=[0-9]* //')
|
||||||
|
recv=$(cat "$RUN/recv.bytes")
|
||||||
|
now=$(date +%s.%N)
|
||||||
|
jcmd "$(cat $RUN/app.pid)" GC.run >/dev/null 2>&1
|
||||||
|
heap=$(jcmd "$(cat $RUN/app.pid)" GC.heap_info 2>/dev/null | grep -o "used [0-9]*K" | head -1)
|
||||||
|
printf "t=%4.1fs client got %5d KiB stub: %s app heap after GC: %s\n" "$(echo "$now - $T0" | bc)" $((recv/1024)) "$st" "$heap"
|
||||||
|
done
|
||||||
|
wait $CURL 2>/dev/null
|
||||||
|
echo "-- app log (demand + outcome):"
|
||||||
|
grep "demand\|CANCELLED\|complete\|error" $RUN/app.log | sed "s/turn 1 //" | sort | uniq -c | sort -rn | head -6
|
||||||
|
stop_all
|
||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Drives the real page in headless Chromium (Playwright for Python) against the running app + stub.
|
||||||
|
Prints a transcript to stdout. Needs: pip install playwright && playwright install chromium."""
|
||||||
|
import sys, time, urllib.request
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
APP = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:38180"
|
||||||
|
STUB = sys.argv[2] if len(sys.argv) > 2 else "http://localhost:38181"
|
||||||
|
|
||||||
|
def stub(path):
|
||||||
|
return urllib.request.urlopen(STUB + path, data=b"" if "config" in path else None).read().decode().strip()
|
||||||
|
|
||||||
|
def ask(page, text):
|
||||||
|
n = page.locator(".msg.bot").count()
|
||||||
|
page.fill("input[name=message]", text)
|
||||||
|
page.click("form button")
|
||||||
|
page.wait_for_selector(f".msg.bot >> nth={n}")
|
||||||
|
return int(page.locator(".msg.bot span[id^=out-]").last.get_attribute("id")[4:])
|
||||||
|
|
||||||
|
def wait_done(page, n, timeout=20000):
|
||||||
|
page.wait_for_function(f"!document.querySelector('#stop-{n}')", timeout=timeout)
|
||||||
|
|
||||||
|
def bot_text(page, n):
|
||||||
|
return page.locator(f"#out-{n}").inner_text()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
b = p.chromium.launch()
|
||||||
|
ctx = b.new_context()
|
||||||
|
page = ctx.new_page()
|
||||||
|
dialogs = []
|
||||||
|
page.on("dialog", lambda d: (dialogs.append(d.message), d.dismiss()))
|
||||||
|
page.goto(APP + "/")
|
||||||
|
print("== turn 1 (session A)")
|
||||||
|
n = ask(page, "My name is Asha and I like tea"); wait_done(page, n)
|
||||||
|
print("A1:", repr(bot_text(page, n)))
|
||||||
|
print("== turn 2 (session A, same cookie)")
|
||||||
|
n = ask(page, "What did I just tell you?"); wait_done(page, n)
|
||||||
|
print("A2:", repr(bot_text(page, n)))
|
||||||
|
print("cookies:", [c["name"] for c in ctx.cookies()])
|
||||||
|
print("== turn 1 (session B, fresh browser context, no cookie)")
|
||||||
|
ctx2 = b.new_context(); page2 = ctx2.new_page(); page2.goto(APP + "/")
|
||||||
|
n = ask(page2, "What did I just tell you?"); wait_done(page2, n)
|
||||||
|
print("B1:", repr(bot_text(page2, n)))
|
||||||
|
print("== New conversation button in session A, then ask again")
|
||||||
|
page.click("text=New conversation"); time.sleep(0.5)
|
||||||
|
n = ask(page, "Do you remember me?"); wait_done(page, n)
|
||||||
|
print("A3:", repr(bot_text(page, n)))
|
||||||
|
print("== model output containing markup")
|
||||||
|
n = ask(page, "html please"); wait_done(page, n)
|
||||||
|
print("rendered text:", repr(bot_text(page, n)))
|
||||||
|
print("<b> elements created inside the answer:", page.locator(f"#out-{n} b").count())
|
||||||
|
print("<img> elements created inside the answer:", page.locator(f"#out-{n} img").count())
|
||||||
|
print("alert dialogs fired:", len(dialogs))
|
||||||
|
print("== stop button")
|
||||||
|
stub("/stub/config?delayMs=50")
|
||||||
|
n = ask(page, "count 200")
|
||||||
|
page.wait_for_function(f"document.querySelector('#out-{n}').innerText.length > 30")
|
||||||
|
before = bot_text(page, n)
|
||||||
|
t0 = time.time(); page.click(f"#stop-{n}")
|
||||||
|
page.wait_for_function(f"!document.querySelector('#stop-{n}')")
|
||||||
|
time.sleep(1.5)
|
||||||
|
after = bot_text(page, n)
|
||||||
|
print("text at click :", repr(before))
|
||||||
|
print("text 1.5 s on :", repr(after))
|
||||||
|
print("grew after stop:", len(after.replace(' [stopped]','')) > len(before.replace(' [stopped]','')) + 6)
|
||||||
|
stub("/stub/config?delayMs=20")
|
||||||
|
b.close()
|
||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Aborts a streaming request from the client side N times and asks the stub what happened to the
|
||||||
|
# upstream HTTP stream: closed (abortedByClient) or left open. Usage: cancel-upstream.sh <chunk-delay-ms> <runs> [app args]
|
||||||
|
source "$(dirname "$0")/lib.sh"; set +e
|
||||||
|
DELAY=${1:-20}; RUNS=${2:-10}; shift 2
|
||||||
|
stop_all; start_stub "$DELAY"; start_app "$@"
|
||||||
|
echo "== chunk delay ${DELAY} ms, ${RUNS} runs, app args: ${*:-(none)}"
|
||||||
|
closed=0; open=0
|
||||||
|
for r in $(seq 1 "$RUNS"); do
|
||||||
|
b=$(curl -s "localhost:$STUB_PORT/stub/stats" | sed 's/.*abortedByClient=//')
|
||||||
|
curl -sN --max-time 1 "localhost:$APP_PORT/chat/stream?message=big%20400&turn=$r" >/dev/null
|
||||||
|
sleep 2
|
||||||
|
a=$(curl -s "localhost:$STUB_PORT/stub/stats" | sed 's/.*abortedByClient=//')
|
||||||
|
if [ "$a" -gt "$b" ]; then closed=$((closed+1)); res=closed; else open=$((open+1)); res="LEFT OPEN"; fi
|
||||||
|
echo "run $r: upstream $res"
|
||||||
|
done
|
||||||
|
echo "upstream closed: $closed of $RUNS, left open: $open of $RUNS"
|
||||||
|
stop_all
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared helpers: JDK 25, ports, start/stop of the stub and the app. Stop is by recorded PID only.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
export JAVA_HOME="${CHATUI_JAVA_HOME:-/tmp/tools/jdk-25.0.5+8}"
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
APP_PORT=${APP_PORT:-38180}
|
||||||
|
STUB_PORT=${STUB_PORT:-38181}
|
||||||
|
RUN=target/run; mkdir -p "$RUN" output
|
||||||
|
|
||||||
|
build() { [ -f target/cp.txt ] || mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt >/dev/null
|
||||||
|
mvn -B -q -DskipTests package >/dev/null; }
|
||||||
|
|
||||||
|
start_stub() { # $1 = delay ms
|
||||||
|
setsid nohup sh -c 'echo $$ > "$1"; shift; exec java "$@"' sh "$RUN/stub.pid" -cp "target/classes:$(cat target/cp.txt)" \
|
||||||
|
com.ankurm.chatui.stub.OpenAiStubServer "$STUB_PORT" "${1:-20}" > "$RUN/stub.log" 2>&1 < /dev/null &
|
||||||
|
for i in $(seq 1 30); do curl -s -o /dev/null "localhost:$STUB_PORT/stub/stats" && return; sleep 0.5; done; }
|
||||||
|
|
||||||
|
start_app() { # extra args are passed to the app (e.g. --chat.backpressure=limitRate)
|
||||||
|
setsid nohup sh -c 'echo $$ > "$1"; shift; exec java "$@"' sh "$RUN/app.pid" -jar target/chat-ui-htmx-1.0.0.jar \
|
||||||
|
--server.port="$APP_PORT" "$@" > "$RUN/app.log" 2>&1 < /dev/null &
|
||||||
|
for i in $(seq 1 60); do curl -s -o /dev/null "localhost:$APP_PORT/" && return; sleep 1; done; }
|
||||||
|
|
||||||
|
stop_pid() { [ -f "$RUN/$1.pid" ] && kill "$(cat "$RUN/$1.pid")" 2>/dev/null || true; rm -f "$RUN/$1.pid"; sleep 1; }
|
||||||
|
stop_all() { stop_pid app; stop_pid stub; }
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regenerates every file in output/. Needs JDK 25, Maven, curl, python3 and (for step 2) Playwright for
|
||||||
|
# Python with Chromium. Timing-dependent figures (back-pressure samples, cancel counts) differ run to run.
|
||||||
|
source "$(dirname "$0")/lib.sh"; set +e
|
||||||
|
echo "[1/6] unit + integration tests (write output/01..05)"
|
||||||
|
mvn -B -q test 2>&1 | grep "Tests run:\|FAIL\|ERROR" | tail -3
|
||||||
|
build
|
||||||
|
echo "[2/6] real browser (headless Chromium): memory, escaping, Stop button -> output/06-browser.txt, 07-stop-logs.txt"
|
||||||
|
stop_all; start_stub 20; start_app
|
||||||
|
python3 scripts/browser-test.py "http://localhost:$APP_PORT" "http://localhost:$STUB_PORT" > output/06-browser.txt 2>&1
|
||||||
|
sleep 12 # let the stub finish or abort the 200-token stream
|
||||||
|
{ echo "-- app log (the turn started by the Stop-button test)"; grep -E "CANCELLED|count 200" "$RUN/app.log"
|
||||||
|
echo "-- stub log"; grep -E "count 200|stream #.* (finished|client closed)" "$RUN/stub.log" | tail -2; } > output/07-stop-logs.txt
|
||||||
|
stop_all
|
||||||
|
echo "[3/6] client abort, upstream closed or left open? 8 runs each -> output/08-cancel-upstream.txt"
|
||||||
|
{ scripts/cancel-upstream.sh 20 8 --chat.cancel-fix=false; echo; scripts/cancel-upstream.sh 20 8; } 2>&1 | grep -v "^Picked\|Done" > output/08-cancel-upstream.txt
|
||||||
|
echo "[4/6] slow client, four ways to shape the Flux -> output/09-backpressure.txt"
|
||||||
|
for m in none limitRate slow bounded; do scripts/backpressure.sh "$m"; echo; done 2>&1 | grep -v "^Picked\|Done" > output/09-backpressure.txt
|
||||||
|
echo "[5/6] a 35 s answer with no async timeout configured -> output/10-long-stream.txt"
|
||||||
|
stop_all; start_stub 50; start_app
|
||||||
|
{ echo '$ curl -sN "localhost:'$APP_PORT'/chat/stream?message=count 700" (700 tokens x 50 ms = 35 s)'
|
||||||
|
s=$(date +%s); curl -sN "localhost:$APP_PORT/chat/stream?message=count%20700&turn=1" | tail -n 4 | sed 's/^$/<blank>/'
|
||||||
|
echo "elapsed: $(( $(date +%s) - s )) s"; grep -E "complete|error" "$RUN/app.log"; } > output/10-long-stream.txt 2>&1
|
||||||
|
stop_all
|
||||||
|
echo "[6/6] done"
|
||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""A deliberately slow SSE reader on a raw socket: recv() up to CHUNK bytes every INTERVAL seconds
|
||||||
|
(default 64 KiB / 0.2 s, about 320 KB/s at most) and keep the running byte count in a file so another
|
||||||
|
process can sample it. Usage: slow_client.py URL COUNTER_FILE [CHUNK] [INTERVAL] [SECONDS]"""
|
||||||
|
import socket, sys, time, urllib.parse
|
||||||
|
url, counter = sys.argv[1], sys.argv[2]
|
||||||
|
chunk = int(sys.argv[3]) if len(sys.argv) > 3 else 64 * 1024
|
||||||
|
interval = float(sys.argv[4]) if len(sys.argv) > 4 else 0.2
|
||||||
|
limit = float(sys.argv[5]) if len(sys.argv) > 5 else 14.0
|
||||||
|
u = urllib.parse.urlparse(url)
|
||||||
|
s = socket.create_connection((u.hostname, u.port))
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 64 * 1024)
|
||||||
|
s.sendall(f"GET {u.path}?{u.query} HTTP/1.1\r\nHost: {u.hostname}\r\nAccept: text/event-stream\r\n\r\n".encode())
|
||||||
|
total, t0 = 0, time.time()
|
||||||
|
while time.time() - t0 < limit:
|
||||||
|
data = s.recv(chunk)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
total += len(data)
|
||||||
|
open(counter, "w").write(str(total))
|
||||||
|
time.sleep(interval)
|
||||||
|
s.close()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.ankurm.chatui;
|
||||||
|
|
||||||
|
import okhttp3.Interceptor;
|
||||||
|
import okhttp3.MediaType;
|
||||||
|
import okhttp3.Response;
|
||||||
|
import okhttp3.ResponseBody;
|
||||||
|
import okio.BufferedSource;
|
||||||
|
import org.springframework.ai.openai.http.okhttp.OpenAiHttpClientBuilderCustomizer;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a cancelled stream actually close its HTTP connection.
|
||||||
|
*
|
||||||
|
* Spring AI 2.0.2's OkHttp client closes a streaming response with {@code response.body().close()}.
|
||||||
|
* When that runs on a different thread from the one blocked reading the body, OkHttp 4.12 throws
|
||||||
|
* {@code IllegalStateException: Unbalanced enter/exit} and leaves the connection open, so the model
|
||||||
|
* server keeps generating. {@code Call.cancel()} is the call OkHttp documents for this and is safe from
|
||||||
|
* any thread, so this interceptor wraps SSE response bodies to call it first.
|
||||||
|
* Switch off with {@code chat.cancel-fix=false} to reproduce the leak (scripts/cancel-upstream.sh).
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@ConditionalOnProperty(name = "chat.cancel-fix", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class CancelOnCloseConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
OpenAiHttpClientBuilderCustomizer cancelOnClose() {
|
||||||
|
return builder -> builder.interceptor(chain -> {
|
||||||
|
Response response = chain.proceed(chain.request());
|
||||||
|
ResponseBody body = response.body();
|
||||||
|
if (body == null || body.contentType() == null || !"event-stream".equals(body.contentType().subtype())) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
var call = chain.call();
|
||||||
|
return response.newBuilder().body(new ResponseBody() {
|
||||||
|
@Override public MediaType contentType() { return body.contentType(); }
|
||||||
|
@Override public long contentLength() { return body.contentLength(); }
|
||||||
|
@Override public BufferedSource source() { return body.source(); }
|
||||||
|
@Override public void close() {
|
||||||
|
call.cancel();
|
||||||
|
try {
|
||||||
|
body.close();
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// the call is already cancelled; closing the body can only complain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).build();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.ankurm.chatui;
|
||||||
|
|
||||||
|
import org.springframework.ai.chat.client.ChatClient;
|
||||||
|
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
|
||||||
|
import org.springframework.ai.chat.memory.ChatMemory;
|
||||||
|
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class ChatConfig {
|
||||||
|
|
||||||
|
/** In-memory window of the last N messages per conversation id (the default repository is a map). */
|
||||||
|
@Bean
|
||||||
|
ChatMemory chatMemory(@Value("${chat.memory.max-messages:20}") int maxMessages) {
|
||||||
|
return MessageWindowChatMemory.builder().maxMessages(maxMessages).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
ChatClient chatClient(ChatModel model, ChatMemory memory) {
|
||||||
|
return ChatClient.builder(model)
|
||||||
|
.defaultSystem("You are a concise assistant.")
|
||||||
|
.defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package com.ankurm.chatui;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpSession;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.ai.chat.client.ChatClient;
|
||||||
|
import org.springframework.ai.chat.memory.ChatMemory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.codec.ServerSentEvent;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.util.HtmlUtils;
|
||||||
|
import org.springframework.web.util.UriUtils;
|
||||||
|
import reactor.core.publisher.BufferOverflowStrategy;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three endpoints and no JavaScript of our own:
|
||||||
|
* POST /chat returns an HTML fragment (the user bubble and an empty assistant bubble that opens an
|
||||||
|
* EventSource), GET /chat/stream is the Server-Sent Events stream, POST /chat/stop swaps the stream
|
||||||
|
* element out so htmx closes the EventSource.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class ChatController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger("chat");
|
||||||
|
static final String CID = "conversationId";
|
||||||
|
|
||||||
|
private final ChatClient chatClient;
|
||||||
|
private final AtomicInteger turns = new AtomicInteger();
|
||||||
|
/** Test hook: how many streams were cancelled by the client (also logged). */
|
||||||
|
public final AtomicInteger cancelled = new AtomicInteger();
|
||||||
|
/** none | limitRate | slow | bounded: how the Flux is shaped between the model and the socket. */
|
||||||
|
private final String backpressure;
|
||||||
|
private final int slowMillis;
|
||||||
|
|
||||||
|
public ChatController(ChatClient chatClient,
|
||||||
|
@Value("${chat.backpressure:none}") String backpressure,
|
||||||
|
@Value("${chat.slow-millis:0}") int slowMillis) {
|
||||||
|
this.chatClient = chatClient;
|
||||||
|
this.backpressure = backpressure;
|
||||||
|
this.slowMillis = slowMillis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The conversation id lives in the HTTP session, so a new browser (new cookie) is a new conversation. */
|
||||||
|
static String conversationId(HttpSession session) {
|
||||||
|
Object id = session.getAttribute(CID);
|
||||||
|
if (id == null) {
|
||||||
|
id = UUID.randomUUID().toString();
|
||||||
|
session.setAttribute(CID, id);
|
||||||
|
}
|
||||||
|
return id.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/chat", produces = MediaType.TEXT_HTML_VALUE)
|
||||||
|
public String send(@RequestParam String message) {
|
||||||
|
int turn = turns.incrementAndGet();
|
||||||
|
String url = "/chat/stream?turn=" + turn + "&message=" + UriUtils.encodeQueryParam(message, UTF_8);
|
||||||
|
return """
|
||||||
|
<div class="msg user">%s</div>
|
||||||
|
<div class="msg bot">
|
||||||
|
<span id="out-%d"></span>
|
||||||
|
<span id="sse-%d" hx-ext="sse" sse-connect="%s" sse-swap="token"
|
||||||
|
sse-close="done" hx-target="#out-%d" hx-swap="beforeend">
|
||||||
|
<span sse-swap="done" hx-target="#stop-%d" hx-swap="delete"></span>
|
||||||
|
</span>
|
||||||
|
<button id="stop-%d" type="button" class="stop"
|
||||||
|
hx-post="/chat/stop?turn=%d" hx-target="#sse-%d" hx-swap="outerHTML">Stop</button>
|
||||||
|
</div>
|
||||||
|
""".formatted(HtmlUtils.htmlEscape(message), turn, turn,
|
||||||
|
HtmlUtils.htmlEscape(url), turn, turn, turn, turn, turn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the stream element; htmx cleans the removed element up and closes its EventSource. */
|
||||||
|
@PostMapping(value = "/chat/stop", produces = MediaType.TEXT_HTML_VALUE)
|
||||||
|
public String stop(@RequestParam int turn) {
|
||||||
|
return "<em id=\"sse-" + turn + "\"> [stopped]</em>"
|
||||||
|
+ "<span id=\"stop-" + turn + "\" hx-swap-oob=\"delete\"></span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/chat/new")
|
||||||
|
public String newConversation(HttpSession session) {
|
||||||
|
session.removeAttribute(CID);
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
|
public Flux<ServerSentEvent<String>> stream(@RequestParam String message,
|
||||||
|
@RequestParam(defaultValue = "0") int turn,
|
||||||
|
HttpSession session) {
|
||||||
|
String cid = conversationId(session);
|
||||||
|
AtomicLong tokens = new AtomicLong();
|
||||||
|
AtomicLong requested = new AtomicLong();
|
||||||
|
|
||||||
|
Flux<String> content = chatClient.prompt()
|
||||||
|
.user(message)
|
||||||
|
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, cid))
|
||||||
|
.stream()
|
||||||
|
.content()
|
||||||
|
.doOnRequest(n -> log.info("turn {} model-side demand request({})", turn, n == Long.MAX_VALUE ? "unbounded" : n));
|
||||||
|
|
||||||
|
// Optional shaping between the model and the socket (see scripts/backpressure.sh). "model-side"
|
||||||
|
// demand is logged above this switch and "web-side" demand below it.
|
||||||
|
content = switch (backpressure) {
|
||||||
|
case "limitRate" -> content.limitRate(8);
|
||||||
|
// A push source (the SDK reader thread) cannot be slowed by demand; bound the queue and
|
||||||
|
// fail the slow client instead of buffering without limit.
|
||||||
|
case "bounded" -> content.onBackpressureBuffer(16, BufferOverflowStrategy.ERROR);
|
||||||
|
case "slow" -> content.delayElements(Duration.ofMillis(slowMillis));
|
||||||
|
default -> content;
|
||||||
|
};
|
||||||
|
|
||||||
|
return content
|
||||||
|
.doOnRequest(n -> {
|
||||||
|
requested.addAndGet(n);
|
||||||
|
log.info("turn {} web-side demand request({})", turn, n == Long.MAX_VALUE ? "unbounded" : n);
|
||||||
|
})
|
||||||
|
.map(token -> {
|
||||||
|
tokens.incrementAndGet();
|
||||||
|
// Model text is untrusted: htmx inserts the data as HTML, so escape it here.
|
||||||
|
// A browser strips ONE leading space from every data line (SSE spec), and Spring writes
|
||||||
|
// "data:" with no space of its own, so a token like " world" would lose its space.
|
||||||
|
// Prefix every line with a space that the client strips again.
|
||||||
|
String data = " " + HtmlUtils.htmlEscape(token).replace("\n", "\n ");
|
||||||
|
return ServerSentEvent.builder(data).event("token").build();
|
||||||
|
})
|
||||||
|
.concatWith(Flux.just(ServerSentEvent.<String>builder("").event("done").build()))
|
||||||
|
.doOnSubscribe(s -> log.info("turn {} start conversation={} message={}", turn, cid, message))
|
||||||
|
.doOnCancel(() -> cancelled.incrementAndGet())
|
||||||
|
.doOnCancel(() -> log.info("turn {} CANCELLED by client after {} tokens", turn, tokens.get()))
|
||||||
|
.doOnComplete(() -> log.info("turn {} complete, {} tokens", turn, tokens.get()))
|
||||||
|
.doOnError(e -> log.info("turn {} error {}", turn, e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ankurm.chatui;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class ChatUiApplication {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(ChatUiApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package com.ankurm.chatui.stub;
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import tools.jackson.databind.JsonNode;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STUB, NOT A LANGUAGE MODEL. A small OpenAI-compatible server for POST /v1/chat/completions that
|
||||||
|
* streams Server-Sent Events chunks ("data: {chat.completion.chunk}" ... "data: [DONE]") with a
|
||||||
|
* controllable delay between chunks. Its "answers" are templates built from the request it received,
|
||||||
|
* so a test can see exactly which messages the client sent (that is how conversation memory is
|
||||||
|
* observed). It does no inference.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* java -cp ... com.ankurm.chatui.stub.OpenAiStubServer [port=38181] [delayMs=20]
|
||||||
|
* POST /stub/config?delayMs=N change the delay between chunks at runtime
|
||||||
|
* GET /stub/stats request, chunk and aborted-stream counters
|
||||||
|
* latest user message "count N" streams N one-word chunks (w1 w2 ... wN)
|
||||||
|
* latest user message "big N" streams N chunks of 32 KB each (to make a slow reader visible)
|
||||||
|
* latest user message "html..." streams text containing markup, to show why the UI escapes it
|
||||||
|
* anything else "[stub] saw N messages. Earlier user turns: a | b. You said: c."
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public class OpenAiStubServer {
|
||||||
|
|
||||||
|
private static final JsonMapper JSON = JsonMapper.builder().build();
|
||||||
|
private static final Pattern COUNT = Pattern.compile("^count (\\d+).*", Pattern.DOTALL);
|
||||||
|
private static final Pattern BIG = Pattern.compile("^big (\\d+).*", Pattern.DOTALL);
|
||||||
|
private static final String KB32 = "x".repeat(32 * 1024);
|
||||||
|
|
||||||
|
private static volatile long delayMs = 20;
|
||||||
|
private static final AtomicInteger requests = new AtomicInteger();
|
||||||
|
private static final AtomicLong chunksWritten = new AtomicLong();
|
||||||
|
private static final AtomicInteger aborted = new AtomicInteger();
|
||||||
|
private static final AtomicInteger finished = new AtomicInteger();
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
start(args.length > 0 ? Integer.parseInt(args[0]) : 38181, args.length > 1 ? Long.parseLong(args[1]) : 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts the stub (also used in-process by the tests). */
|
||||||
|
public static HttpServer start(int port, long delay) throws IOException {
|
||||||
|
delayMs = delay;
|
||||||
|
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
|
||||||
|
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
|
||||||
|
server.createContext("/stub/config", ex -> {
|
||||||
|
String q = ex.getRequestURI().getQuery();
|
||||||
|
if (q != null && q.startsWith("delayMs=")) {
|
||||||
|
delayMs = Long.parseLong(q.substring(8));
|
||||||
|
}
|
||||||
|
reply(ex, 200, "delayMs=" + delayMs + "\n");
|
||||||
|
});
|
||||||
|
server.createContext("/stub/stats", ex -> reply(ex, 200,
|
||||||
|
"requests=" + requests + " chunksWritten=" + chunksWritten
|
||||||
|
+ " finished=" + finished + " abortedByClient=" + aborted + "\n"));
|
||||||
|
server.createContext("/v1/chat/completions", OpenAiStubServer::completions);
|
||||||
|
server.start();
|
||||||
|
System.out.println("stub: listening on " + port + " delayMs=" + delayMs);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setDelayMs(long ms) {
|
||||||
|
delayMs = ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counters for tests: {requests, chunksWritten, finished, abortedByClient}. */
|
||||||
|
public static long[] counters() {
|
||||||
|
return new long[] {requests.get(), chunksWritten.get(), finished.get(), aborted.get()};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void reply(HttpExchange ex, int code, String body) throws IOException {
|
||||||
|
byte[] b = body.getBytes(StandardCharsets.UTF_8);
|
||||||
|
ex.sendResponseHeaders(code, b.length);
|
||||||
|
try (OutputStream os = ex.getResponseBody()) {
|
||||||
|
os.write(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void completions(HttpExchange ex) throws IOException {
|
||||||
|
int id = requests.incrementAndGet();
|
||||||
|
JsonNode body = JSON.readTree(ex.getRequestBody());
|
||||||
|
String model = body.path("model").asText("?");
|
||||||
|
boolean stream = body.path("stream").asBoolean(false);
|
||||||
|
boolean usage = body.path("stream_options").path("include_usage").asBoolean(false);
|
||||||
|
List<String> userTurns = new ArrayList<>();
|
||||||
|
StringBuilder roles = new StringBuilder();
|
||||||
|
int n = 0;
|
||||||
|
for (JsonNode m : body.path("messages")) {
|
||||||
|
n++;
|
||||||
|
String role = m.path("role").asText();
|
||||||
|
roles.append(roles.isEmpty() ? "" : ",").append(role);
|
||||||
|
if (role.equals("user")) {
|
||||||
|
userTurns.add(text(m.path("content")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String latest = userTurns.isEmpty() ? "" : userTurns.getLast();
|
||||||
|
List<String> earlier = userTurns.size() > 1 ? userTurns.subList(0, userTurns.size() - 1) : List.of();
|
||||||
|
System.out.println("stub: request #" + id + " model=" + model + " stream=" + stream
|
||||||
|
+ " messages=" + n + " roles=" + roles + " latest=\"" + latest + "\"");
|
||||||
|
if (!stream) {
|
||||||
|
reply(ex, 400, "{\"error\":{\"message\":\"stub only streams\"}}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> tokens = new ArrayList<>();
|
||||||
|
Matcher c = COUNT.matcher(latest);
|
||||||
|
if (c.matches()) {
|
||||||
|
for (int i = 1; i <= Integer.parseInt(c.group(1)); i++) {
|
||||||
|
tokens.add((i == 1 ? "" : " ") + "w" + i);
|
||||||
|
}
|
||||||
|
} else if (BIG.matcher(latest).matches()) {
|
||||||
|
Matcher m = BIG.matcher(latest);
|
||||||
|
m.matches();
|
||||||
|
for (int i = 1; i <= Integer.parseInt(m.group(1)); i++) {
|
||||||
|
tokens.add(KB32);
|
||||||
|
}
|
||||||
|
} else if (latest.startsWith("html")) {
|
||||||
|
for (String w : "Here is <b>bold</b> and <img src=x onerror=alert(1)> & done".split("(?= )")) {
|
||||||
|
tokens.add(w);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String text = "[stub] saw " + n + " messages. Earlier user turns: "
|
||||||
|
+ (earlier.isEmpty() ? "none" : String.join(" | ", earlier)) + ". You said: " + latest;
|
||||||
|
for (String w : text.split("(?= )")) {
|
||||||
|
tokens.add(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ex.getResponseHeaders().add("Content-Type", "text/event-stream");
|
||||||
|
ex.getResponseHeaders().add("Cache-Control", "no-cache");
|
||||||
|
ex.sendResponseHeaders(200, 0); // chunked
|
||||||
|
long created = System.currentTimeMillis() / 1000;
|
||||||
|
String cid = "chatcmpl-stub-" + id;
|
||||||
|
int sent = 0;
|
||||||
|
try (OutputStream os = ex.getResponseBody()) {
|
||||||
|
write(os, chunk(cid, created, model, "{\"role\":\"assistant\",\"content\":\"\"}", null));
|
||||||
|
for (String t : tokens) {
|
||||||
|
if (delayMs > 0) {
|
||||||
|
Thread.sleep(delayMs);
|
||||||
|
}
|
||||||
|
write(os, chunk(cid, created, model, "{\"content\":" + JSON.writeValueAsString(t) + "}", null));
|
||||||
|
sent++;
|
||||||
|
}
|
||||||
|
write(os, chunk(cid, created, model, "{}", "stop"));
|
||||||
|
if (usage) {
|
||||||
|
write(os, "{\"id\":\"" + cid + "\",\"object\":\"chat.completion.chunk\",\"created\":" + created
|
||||||
|
+ ",\"model\":\"" + model + "\",\"choices\":[],\"usage\":{\"prompt_tokens\":" + n
|
||||||
|
+ ",\"completion_tokens\":" + sent + ",\"total_tokens\":" + (n + sent) + "}}");
|
||||||
|
}
|
||||||
|
os.write("data: [DONE]\n\n".getBytes(StandardCharsets.UTF_8));
|
||||||
|
os.flush();
|
||||||
|
finished.incrementAndGet();
|
||||||
|
System.out.println("stub: stream #" + id + " finished after " + sent + " chunks");
|
||||||
|
} catch (IOException e) {
|
||||||
|
aborted.incrementAndGet();
|
||||||
|
System.out.println("stub: stream #" + id + " client closed the connection after " + sent + " chunks");
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(JsonNode content) {
|
||||||
|
if (content.isString()) {
|
||||||
|
return content.asString();
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (JsonNode part : content) {
|
||||||
|
sb.append(part.path("text").asString(""));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String chunk(String id, long created, String model, String delta, String finish) {
|
||||||
|
return "{\"id\":\"" + id + "\",\"object\":\"chat.completion.chunk\",\"created\":" + created
|
||||||
|
+ ",\"model\":\"" + model + "\",\"choices\":[{\"index\":0,\"delta\":" + delta
|
||||||
|
+ ",\"finish_reason\":" + (finish == null ? "null" : "\"" + finish + "\"") + "}]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void write(OutputStream os, String json) throws IOException {
|
||||||
|
os.write(("data: " + json + "\n\n").getBytes(StandardCharsets.UTF_8));
|
||||||
|
os.flush();
|
||||||
|
chunksWritten.incrementAndGet();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# The OpenAI starter used as a generic OpenAI-compatible client. Defaults point at the STUB server in
|
||||||
|
# this module (com.ankurm.chatui.stub.OpenAiStubServer). To use a real provider:
|
||||||
|
# CHAT_BASE_URL=https://api.openai.com/v1 CHAT_MODEL=<model> OPENAI_API_KEY=<key>
|
||||||
|
# base-url must END IN /v1: the starter does not append it, the SDK adds only /chat/completions.
|
||||||
|
server:
|
||||||
|
port: ${SERVER_PORT:38180}
|
||||||
|
spring:
|
||||||
|
ai:
|
||||||
|
openai:
|
||||||
|
base-url: ${CHAT_BASE_URL:http://localhost:38181/v1}
|
||||||
|
api-key: ${OPENAI_API_KEY:not-needed}
|
||||||
|
# SDK default read timeout is 60 s; a slow model can exceed it.
|
||||||
|
timeout: 5m
|
||||||
|
chat:
|
||||||
|
options:
|
||||||
|
model: ${CHAT_MODEL:stub-gpt}
|
||||||
|
logging:
|
||||||
|
pattern:
|
||||||
|
console: "%msg%n"
|
||||||
|
level:
|
||||||
|
root: warn
|
||||||
|
chat: info
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Chat UI with Spring AI, SSE and htmx</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="/vendor/htmx.min.js"></script>
|
||||||
|
<script src="/vendor/htmx-ext-sse.js"></script>
|
||||||
|
<style>
|
||||||
|
body { font: 16px/1.5 system-ui, sans-serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; }
|
||||||
|
#log { min-height: 12rem; }
|
||||||
|
.msg { padding: .5rem .75rem; margin: .5rem 0; border-radius: .5rem; white-space: pre-wrap; }
|
||||||
|
.user { background: #e8eefc; }
|
||||||
|
.bot { background: #f4f5f7; }
|
||||||
|
.stop { float: right; }
|
||||||
|
form { display: flex; gap: .5rem; }
|
||||||
|
input[name=message] { flex: 1; padding: .5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Chat</h1>
|
||||||
|
<div id="log"></div>
|
||||||
|
<form hx-post="/chat" hx-target="#log" hx-swap="beforeend" hx-on::after-request="this.reset()">
|
||||||
|
<input name="message" autocomplete="off" placeholder="Say something" required autofocus>
|
||||||
|
<button>Send</button>
|
||||||
|
</form>
|
||||||
|
<p><button hx-post="/chat/new" hx-swap="none" hx-on::after-request="document.getElementById('log').innerHTML=''">New conversation</button></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
d6fdc75f204e6bdefa99b69bf1e6d4ac69b8a364f77929f45c13476b4000f717 htmx.min.js
|
||||||
|
3b5992a541619babefc4c169505af474df5c3039da51e59b96ccf9241ecd61d2 htmx-ext-sse.js
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
/*
|
||||||
|
Server Sent Events Extension
|
||||||
|
============================
|
||||||
|
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
/** @type {import("../htmx").HtmxInternalApi} */
|
||||||
|
var api
|
||||||
|
|
||||||
|
htmx.defineExtension('sse', {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Init saves the provided reference to the internal HTMX API.
|
||||||
|
*
|
||||||
|
* @param {import("../htmx").HtmxInternalApi} api
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
init: function(apiRef) {
|
||||||
|
// store a reference to the internal API.
|
||||||
|
api = apiRef
|
||||||
|
|
||||||
|
// set a function in the public API for creating new EventSource objects
|
||||||
|
if (htmx.createEventSource == undefined) {
|
||||||
|
htmx.createEventSource = createEventSource
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getSelectors: function() {
|
||||||
|
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onEvent handles all events passed to this extension.
|
||||||
|
*
|
||||||
|
* @param {string} name
|
||||||
|
* @param {Event} evt
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
onEvent: function(name, evt) {
|
||||||
|
var parent = evt.target || evt.detail.elt
|
||||||
|
switch (name) {
|
||||||
|
case 'htmx:beforeCleanupElement':
|
||||||
|
var internalData = api.getInternalData(parent)
|
||||||
|
// Try to remove remove an EventSource when elements are removed
|
||||||
|
var source = internalData.sseEventSource
|
||||||
|
if (source) {
|
||||||
|
api.triggerEvent(parent, 'htmx:sseClose', {
|
||||||
|
source,
|
||||||
|
type: 'nodeReplaced',
|
||||||
|
})
|
||||||
|
internalData.sseEventSource.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
// Try to create EventSources when elements are processed
|
||||||
|
case 'htmx:afterProcessNode':
|
||||||
|
ensureEventSourceOnElement(parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/// ////////////////////////////////////////////
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
/// ////////////////////////////////////////////
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createEventSource is the default method for creating new EventSource objects.
|
||||||
|
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @returns EventSource
|
||||||
|
*/
|
||||||
|
function createEventSource(url) {
|
||||||
|
return new EventSource(url, { withCredentials: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* registerSSE looks for attributes that can contain sse events, right
|
||||||
|
* now hx-trigger and sse-swap and adds listeners based on these attributes too
|
||||||
|
* the closest event source
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
*/
|
||||||
|
function registerSSE(elt) {
|
||||||
|
// Add message handlers for every `sse-swap` attribute
|
||||||
|
if (api.getAttributeValue(elt, 'sse-swap')) {
|
||||||
|
// Find closest existing event source
|
||||||
|
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||||
|
if (sourceElement == null) {
|
||||||
|
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||||
|
return null // no eventsource in parentage, orphaned element
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set internalData and source
|
||||||
|
var internalData = api.getInternalData(sourceElement)
|
||||||
|
var source = internalData.sseEventSource
|
||||||
|
|
||||||
|
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
|
||||||
|
var sseEventNames = sseSwapAttr.split(',')
|
||||||
|
|
||||||
|
for (var i = 0; i < sseEventNames.length; i++) {
|
||||||
|
const sseEventName = sseEventNames[i].trim()
|
||||||
|
const listener = function(event) {
|
||||||
|
// If the source is missing then close SSE
|
||||||
|
if (maybeCloseSSESource(sourceElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the body no longer contains the element, remove the listener
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
source.removeEventListener(sseEventName, listener)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// swap the response into the DOM and trigger a notification
|
||||||
|
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
swap(elt, event.data)
|
||||||
|
api.triggerEvent(elt, 'htmx:sseMessage', event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the new listener
|
||||||
|
api.getInternalData(elt).sseEventListener = listener
|
||||||
|
source.addEventListener(sseEventName, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add message handlers for every `hx-trigger="sse:*"` attribute
|
||||||
|
if (api.getAttributeValue(elt, 'hx-trigger')) {
|
||||||
|
// Find closest existing event source
|
||||||
|
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||||
|
if (sourceElement == null) {
|
||||||
|
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||||
|
return null // no eventsource in parentage, orphaned element
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set internalData and source
|
||||||
|
var internalData = api.getInternalData(sourceElement)
|
||||||
|
var source = internalData.sseEventSource
|
||||||
|
|
||||||
|
var triggerSpecs = api.getTriggerSpecs(elt)
|
||||||
|
triggerSpecs.forEach(function(ts) {
|
||||||
|
if (ts.trigger.slice(0, 4) !== 'sse:') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var listener = function (event) {
|
||||||
|
if (maybeCloseSSESource(sourceElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
source.removeEventListener(ts.trigger.slice(4), listener)
|
||||||
|
}
|
||||||
|
// Trigger events to be handled by the rest of htmx
|
||||||
|
htmx.trigger(elt, ts.trigger, event)
|
||||||
|
htmx.trigger(elt, 'htmx:sseMessage', event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the new listener
|
||||||
|
api.getInternalData(elt).sseEventListener = listener
|
||||||
|
source.addEventListener(ts.trigger.slice(4), listener)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
|
||||||
|
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
|
||||||
|
* is created and stored in the element's internalData.
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
* @param {number} retryCount
|
||||||
|
* @returns {EventSource | null}
|
||||||
|
*/
|
||||||
|
function ensureEventSourceOnElement(elt, retryCount) {
|
||||||
|
if (elt == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle extension source creation attribute
|
||||||
|
if (api.getAttributeValue(elt, 'sse-connect')) {
|
||||||
|
var sseURL = api.getAttributeValue(elt, 'sse-connect')
|
||||||
|
if (sseURL == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureEventSource(elt, sseURL, retryCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerSSE(elt)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureEventSource(elt, url, retryCount) {
|
||||||
|
var source = htmx.createEventSource(url)
|
||||||
|
|
||||||
|
source.onerror = function(err) {
|
||||||
|
// Log an error event
|
||||||
|
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
|
||||||
|
|
||||||
|
// If parent no longer exists in the document, then clean up this EventSource
|
||||||
|
if (maybeCloseSSESource(elt)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try to reconnect the EventSource
|
||||||
|
if (source.readyState === EventSource.CLOSED) {
|
||||||
|
retryCount = retryCount || 0
|
||||||
|
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
|
||||||
|
var timeout = retryCount * 500
|
||||||
|
window.setTimeout(function() {
|
||||||
|
ensureEventSourceOnElement(elt, retryCount)
|
||||||
|
}, timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source.onopen = function(evt) {
|
||||||
|
api.triggerEvent(elt, 'htmx:sseOpen', { source })
|
||||||
|
|
||||||
|
if (retryCount && retryCount > 0) {
|
||||||
|
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
|
||||||
|
for (let i = 0; i < childrenToFix.length; i++) {
|
||||||
|
registerSSE(childrenToFix[i])
|
||||||
|
}
|
||||||
|
// We want to increase the reconnection delay for consecutive failed attempts only
|
||||||
|
retryCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getInternalData(elt).sseEventSource = source
|
||||||
|
|
||||||
|
|
||||||
|
var closeAttribute = api.getAttributeValue(elt, "sse-close");
|
||||||
|
if (closeAttribute) {
|
||||||
|
// close eventsource when this message is received
|
||||||
|
source.addEventListener(closeAttribute, function() {
|
||||||
|
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||||
|
source,
|
||||||
|
type: 'message',
|
||||||
|
})
|
||||||
|
source.close()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* maybeCloseSSESource confirms that the parent element still exists.
|
||||||
|
* If not, then any associated SSE source is closed and the function returns true.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
function maybeCloseSSESource(elt) {
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
var source = api.getInternalData(elt).sseEventSource
|
||||||
|
if (source != undefined) {
|
||||||
|
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||||
|
source,
|
||||||
|
type: 'nodeMissing',
|
||||||
|
})
|
||||||
|
source.close()
|
||||||
|
// source = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
* @param {string} content
|
||||||
|
*/
|
||||||
|
function swap(elt, content) {
|
||||||
|
api.withExtensions(elt, function(extension) {
|
||||||
|
content = extension.transformResponse(content, null, elt)
|
||||||
|
})
|
||||||
|
|
||||||
|
var swapSpec = api.getSwapSpecification(elt)
|
||||||
|
var target = api.getTarget(elt)
|
||||||
|
api.swap(target, content, swapSpec, { contextElement: elt })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function hasEventSource(node) {
|
||||||
|
return api.getInternalData(node).sseEventSource != null
|
||||||
|
}
|
||||||
|
})()
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,219 @@
|
|||||||
|
package com.ankurm.chatui;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import com.ankurm.chatui.stub.OpenAiStubServer;
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.net.CookieManager;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole chain on a random port: HTTP -> Spring MVC -> ChatClient + memory -> OpenAiChatModel ->
|
||||||
|
* STUB server. Transcripts land in output/01-sse-transcript.txt, 02-memory.txt, 03-escaping.txt and
|
||||||
|
* 04-cancel-with-fix.txt.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
class ChatFlowTest {
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void stub() {
|
||||||
|
StubSupport.ensureStarted();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void props(DynamicPropertyRegistry r) {
|
||||||
|
r.add("spring.ai.openai.base-url", () -> "http://localhost:" + StubSupport.PORT + "/v1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Value("${local.server.port}")
|
||||||
|
int port;
|
||||||
|
@Autowired
|
||||||
|
ChatController controller;
|
||||||
|
|
||||||
|
/** One SSE event as a browser's EventSource would see it. */
|
||||||
|
record Event(String name, String data) {
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpClient browser() {
|
||||||
|
return HttpClient.newBuilder().cookieHandler(new CookieManager()).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the raw stream and returns (raw lines, events parsed per the WHATWG rules). */
|
||||||
|
List<String> raw = new ArrayList<>();
|
||||||
|
|
||||||
|
List<Event> stream(HttpClient client, String message) throws Exception {
|
||||||
|
raw = new ArrayList<>();
|
||||||
|
var req = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/chat/stream?turn=1&message="
|
||||||
|
+ URLEncoder.encode(message, StandardCharsets.UTF_8))).header("Accept", "text/event-stream").build();
|
||||||
|
var res = client.send(req, HttpResponse.BodyHandlers.ofInputStream());
|
||||||
|
List<Event> events = new ArrayList<>();
|
||||||
|
String name = "message";
|
||||||
|
StringBuilder data = new StringBuilder();
|
||||||
|
boolean has = false;
|
||||||
|
try (var in = new BufferedReader(new InputStreamReader(res.body(), StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
while ((line = in.readLine()) != null) {
|
||||||
|
raw.add(line);
|
||||||
|
if (line.isEmpty()) {
|
||||||
|
if (has) {
|
||||||
|
events.add(new Event(name, data.toString()));
|
||||||
|
}
|
||||||
|
name = "message";
|
||||||
|
data.setLength(0);
|
||||||
|
has = false;
|
||||||
|
} else if (line.startsWith("event:")) {
|
||||||
|
name = strip(line.substring(6));
|
||||||
|
} else if (line.startsWith("data:")) {
|
||||||
|
if (has) {
|
||||||
|
data.append('\n');
|
||||||
|
}
|
||||||
|
data.append(strip(line.substring(5)));
|
||||||
|
has = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The SSE spec: remove ONE leading space from the field value. */
|
||||||
|
static String strip(String v) {
|
||||||
|
return v.startsWith(" ") ? v.substring(1) : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String text(List<Event> events) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
events.stream().filter(e -> e.name().equals("token")).forEach(e -> sb.append(e.data()));
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sseFramesAndTheLeadingSpaceRule() throws Exception {
|
||||||
|
var t = new Transcript("01-sse-transcript.txt");
|
||||||
|
var events = stream(browser(), "hello world");
|
||||||
|
t.line("$ GET /chat/stream?message=hello world (raw lines, first 14 and last 5)");
|
||||||
|
raw.stream().limit(14).forEach(t::line);
|
||||||
|
t.line("...");
|
||||||
|
raw.subList(raw.size() - 5, raw.size()).forEach(l -> t.line(l.isEmpty() ? "<blank>" : l));
|
||||||
|
String joined = text(events);
|
||||||
|
t.line("event names seen: " + events.stream().map(Event::name).distinct().toList());
|
||||||
|
t.line("joined token text as EventSource delivers it: " + joined);
|
||||||
|
// What the browser would show if the server had NOT put a space in front of every data line:
|
||||||
|
String naive = events.stream().filter(e -> e.name().equals("token")).map(e -> strip(e.data()))
|
||||||
|
.reduce("", String::concat);
|
||||||
|
t.line("same text if the server did not pad: " + naive);
|
||||||
|
assertThat(joined).isEqualTo("[stub] saw 2 messages. Earlier user turns: none. You said: hello world");
|
||||||
|
assertThat(naive).doesNotContain(" ");
|
||||||
|
assertThat(events.getLast().name()).isEqualTo("done");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void memoryFollowsTheHttpSession() throws Exception {
|
||||||
|
var t = new Transcript("02-memory.txt");
|
||||||
|
var a = browser();
|
||||||
|
t.line("session A, turn 1: " + text(stream(a, "My name is Asha and I like tea")));
|
||||||
|
t.line("session A, turn 2: " + text(stream(a, "What did I just tell you?")));
|
||||||
|
var b = browser();
|
||||||
|
String other = text(stream(b, "What did I just tell you?"));
|
||||||
|
t.line("session B, turn 1: " + other);
|
||||||
|
String third = text(stream(a, "And now?"));
|
||||||
|
t.line("session A, turn 3: " + third);
|
||||||
|
assertThat(third).contains("saw 6 messages").contains("My name is Asha and I like tea | What did I just tell you?");
|
||||||
|
assertThat(other).contains("saw 2 messages").contains("Earlier user turns: none");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void modelTextIsEscapedBeforeItReachesInnerHtml() throws Exception {
|
||||||
|
var t = new Transcript("03-escaping.txt");
|
||||||
|
var events = stream(browser(), "html please");
|
||||||
|
String joined = text(events);
|
||||||
|
t.line("data sent to the browser: " + joined);
|
||||||
|
assertThat(joined).contains("<b>bold</b>").contains("<img src=x onerror=alert(1)>");
|
||||||
|
assertThat(joined).doesNotContain("<b>").doesNotContain("<img");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void closingTheConnectionCancelsTheFluxAndTheUpstreamStream() throws Exception {
|
||||||
|
var t = new Transcript("04-cancel-with-fix.txt");
|
||||||
|
OpenAiStubServer.setDelayMs(50);
|
||||||
|
long[] before = OpenAiStubServer.counters();
|
||||||
|
int cancelledBefore = controller.cancelled.get();
|
||||||
|
try (var socket = new java.net.Socket("localhost", port)) {
|
||||||
|
socket.getOutputStream().write(("GET /chat/stream?turn=9&message=big%20400 HTTP/1.1\r\nHost: localhost\r\n\r\n")
|
||||||
|
.getBytes(StandardCharsets.US_ASCII));
|
||||||
|
socket.getInputStream().readNBytes(40_000);
|
||||||
|
} // closing the socket is the "client abort"
|
||||||
|
long t0 = System.currentTimeMillis();
|
||||||
|
long[] after = before;
|
||||||
|
for (int i = 0; i < 100 && after[3] == before[3]; i++) {
|
||||||
|
Thread.sleep(50);
|
||||||
|
after = OpenAiStubServer.counters();
|
||||||
|
}
|
||||||
|
long took = System.currentTimeMillis() - t0;
|
||||||
|
OpenAiStubServer.setDelayMs(20);
|
||||||
|
t.line("client read 40000 bytes then closed the socket");
|
||||||
|
t.line("Flux cancelled on the server: " + (controller.cancelled.get() - cancelledBefore));
|
||||||
|
t.line("stub saw the upstream connection close: " + (after[3] - before[3]) + " (within " + took + " ms)");
|
||||||
|
t.line("stub chunks written for this request: " + (after[1] - before[1]) + " of 400 requested");
|
||||||
|
assertThat(controller.cancelled.get() - cancelledBefore).isEqualTo(1);
|
||||||
|
assertThat(after[3] - before[3]).isEqualTo(1);
|
||||||
|
assertThat(after[1] - before[1]).isLessThan(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
org.springframework.ai.chat.client.ChatClient chatClient;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aMemoryAdvisorWithoutAConversationIdFails() {
|
||||||
|
var t = new Transcript("11-missing-conversation-id.txt");
|
||||||
|
try {
|
||||||
|
chatClient.prompt().user("hi").stream().content().blockLast();
|
||||||
|
t.line("no exception");
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
t.line("stream failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||||
|
assertThat(e.getMessage()).contains("conversationId cannot be null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void whatMemoryKeepsWhenTheClientCancelsMidAnswer() throws Exception {
|
||||||
|
var t = new Transcript("12-memory-after-cancel.txt");
|
||||||
|
OpenAiStubServer.setDelayMs(30);
|
||||||
|
var client = browser();
|
||||||
|
// turn 1: ask for 300 tokens over a raw socket that shares the session cookie, read a little, hang up
|
||||||
|
var warm = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/chat/stream?message=warm")).build();
|
||||||
|
client.send(warm, HttpResponse.BodyHandlers.discarding()); // sets JSESSIONID in the client's cookie jar
|
||||||
|
String cookie = client.cookieHandler().map(h -> {
|
||||||
|
try {
|
||||||
|
return h.get(URI.create("http://localhost:" + port + "/"), java.util.Map.of()).get("Cookie").getFirst();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}).orElseThrow();
|
||||||
|
try (var socket = new java.net.Socket("localhost", port)) {
|
||||||
|
socket.getOutputStream().write(("GET /chat/stream?message=count%20300 HTTP/1.1\r\nHost: localhost\r\nCookie: "
|
||||||
|
+ cookie + "\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
|
||||||
|
socket.getInputStream().readNBytes(600);
|
||||||
|
}
|
||||||
|
Thread.sleep(1500);
|
||||||
|
String next = text(stream(client, "What do you remember?"));
|
||||||
|
OpenAiStubServer.setDelayMs(20);
|
||||||
|
t.line("turn 1 'warm' completed, turn 2 'count 300' cancelled after a few tokens, then turn 3:");
|
||||||
|
t.line(next);
|
||||||
|
assertThat(next).contains("Earlier user turns: warm | count 300");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.ankurm.chatui;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import com.ankurm.chatui.stub.OpenAiStubServer;
|
||||||
|
import okhttp3.MediaType;
|
||||||
|
import okhttp3.OkHttpClient;
|
||||||
|
import okhttp3.Request;
|
||||||
|
import okhttp3.RequestBody;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No Spring AI, no SDK: plain OkHttp 4.12 against the stub. A reader thread is blocked reading the
|
||||||
|
* SSE body; another thread then closes the response, or cancels the call. Which one closes the socket?
|
||||||
|
*/
|
||||||
|
class OkHttpCloseReproTest {
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void stub() {
|
||||||
|
StubSupport.ensureStarted();
|
||||||
|
}
|
||||||
|
|
||||||
|
int run(boolean cancelInsteadOfClose, Transcript t) throws Exception {
|
||||||
|
var http = new OkHttpClient();
|
||||||
|
int closed = 0;
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
long[] before = OpenAiStubServer.counters();
|
||||||
|
var req = new Request.Builder().url("http://localhost:" + StubSupport.PORT + "/v1/chat/completions")
|
||||||
|
.post(RequestBody.create("{\"model\":\"m\",\"stream\":true,\"messages\":[{\"role\":\"user\","
|
||||||
|
+ "\"content\":\"count 60\"}]}", MediaType.get("application/json"))).build();
|
||||||
|
var call = http.newCall(req);
|
||||||
|
var response = call.execute();
|
||||||
|
var source = response.body().source();
|
||||||
|
var reader = new Thread(() -> {
|
||||||
|
try {
|
||||||
|
while (source.readUtf8Line() != null) {
|
||||||
|
// keep reading
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// closed under us
|
||||||
|
}
|
||||||
|
});
|
||||||
|
reader.start();
|
||||||
|
Thread.sleep(150);
|
||||||
|
try {
|
||||||
|
if (cancelInsteadOfClose) {
|
||||||
|
call.cancel();
|
||||||
|
} else {
|
||||||
|
response.close();
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
if (i == 0) {
|
||||||
|
t.line(" response.close() threw: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Thread.sleep(500);
|
||||||
|
if (OpenAiStubServer.counters()[3] - before[3] == 1) {
|
||||||
|
closed++;
|
||||||
|
}
|
||||||
|
reader.join(2000);
|
||||||
|
}
|
||||||
|
return closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void closeFromAnotherThreadLeaksCancelDoesNot() throws Exception {
|
||||||
|
var t = new Transcript("05-okhttp-close-vs-cancel.txt");
|
||||||
|
t.line("plain OkHttp 4.12.0, 10 runs each, stub streaming 60 chunks at 20 ms, reader thread blocked in a read");
|
||||||
|
int viaClose = run(false, t);
|
||||||
|
t.line("response.close() from another thread: upstream connection closed in " + viaClose + " of 10 runs");
|
||||||
|
int viaCancel = run(true, t);
|
||||||
|
t.line("call.cancel() from another thread: upstream connection closed in " + viaCancel + " of 10 runs");
|
||||||
|
assertThat(viaCancel).isEqualTo(10);
|
||||||
|
assertThat(viaClose).isLessThanOrEqualTo(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.ankurm.chatui;
|
||||||
|
|
||||||
|
import com.ankurm.chatui.stub.OpenAiStubServer;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/** Starts the in-process stub once per JVM on port 38193. */
|
||||||
|
final class StubSupport {
|
||||||
|
static final int PORT = 38193;
|
||||||
|
private static boolean started;
|
||||||
|
|
||||||
|
static synchronized void ensureStarted() {
|
||||||
|
if (!started) {
|
||||||
|
try {
|
||||||
|
OpenAiStubServer.start(PORT, 20);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
started = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private StubSupport() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.ankurm.chatui;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
|
||||||
|
/** Appends lines to output/NN-name.txt (the file is truncated by the first call of a JVM run). */
|
||||||
|
final class Transcript {
|
||||||
|
private final Path file;
|
||||||
|
|
||||||
|
Transcript(String name) {
|
||||||
|
this.file = Path.of("output", name);
|
||||||
|
try {
|
||||||
|
Files.createDirectories(file.getParent());
|
||||||
|
Files.writeString(file, "");
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void line(String s) {
|
||||||
|
System.out.println(s);
|
||||||
|
try {
|
||||||
|
Files.writeString(file, s + "\n", StandardOpenOption.APPEND);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user