diff --git a/README.md b/README.md index f845897..6b00e22 100644 --- a/README.md +++ b/README.md @@ -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` 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/) | | [`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>` 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/). diff --git a/chat-ui-htmx/README.md b/chat-ui-htmx/README.md new file mode 100644 index 0000000..969f87b --- /dev/null +++ b/chat-ui-htmx/README.md @@ -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>` 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= OPENAI_API_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 | diff --git a/chat-ui-htmx/output/01-sse-transcript.txt b/chat-ui-htmx/output/01-sse-transcript.txt new file mode 100644 index 0000000..8cf1564 --- /dev/null +++ b/chat-ui-htmx/output/01-sse-transcript.txt @@ -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 + +event:done +data: + +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 diff --git a/chat-ui-htmx/output/02-memory.txt b/chat-ui-htmx/output/02-memory.txt new file mode 100644 index 0000000..264b19d --- /dev/null +++ b/chat-ui-htmx/output/02-memory.txt @@ -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? diff --git a/chat-ui-htmx/output/03-escaping.txt b/chat-ui-htmx/output/03-escaping.txt new file mode 100644 index 0000000..e2eca8d --- /dev/null +++ b/chat-ui-htmx/output/03-escaping.txt @@ -0,0 +1 @@ +data sent to the browser: Here is <b>bold</b> and <img src=x onerror=alert(1)> & done diff --git a/chat-ui-htmx/output/04-cancel-with-fix.txt b/chat-ui-htmx/output/04-cancel-with-fix.txt new file mode 100644 index 0000000..a14707d --- /dev/null +++ b/chat-ui-htmx/output/04-cancel-with-fix.txt @@ -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 diff --git a/chat-ui-htmx/output/05-okhttp-close-vs-cancel.txt b/chat-ui-htmx/output/05-okhttp-close-vs-cancel.txt new file mode 100644 index 0000000..4af9a74 --- /dev/null +++ b/chat-ui-htmx/output/05-okhttp-close-vs-cancel.txt @@ -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 diff --git a/chat-ui-htmx/output/06-browser.txt b/chat-ui-htmx/output/06-browser.txt new file mode 100644 index 0000000..1d23294 --- /dev/null +++ b/chat-ui-htmx/output/06-browser.txt @@ -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 bold and & done' + elements created inside the answer: 0 + 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 diff --git a/chat-ui-htmx/output/07-stop-logs.txt b/chat-ui-htmx/output/07-stop-logs.txt new file mode 100644 index 0000000..0175fe3 --- /dev/null +++ b/chat-ui-htmx/output/07-stop-logs.txt @@ -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 diff --git a/chat-ui-htmx/output/08-cancel-upstream.txt b/chat-ui-htmx/output/08-cancel-upstream.txt new file mode 100644 index 0000000..853ad40 --- /dev/null +++ b/chat-ui-htmx/output/08-cancel-upstream.txt @@ -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 diff --git a/chat-ui-htmx/output/09-backpressure.txt b/chat-ui-htmx/output/09-backpressure.txt new file mode 100644 index 0000000..ecbbd6f --- /dev/null +++ b/chat-ui-htmx/output/09-backpressure.txt @@ -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...) + diff --git a/chat-ui-htmx/output/10-long-stream.txt b/chat-ui-htmx/output/10-long-stream.txt new file mode 100644 index 0000000..a30d689 --- /dev/null +++ b/chat-ui-htmx/output/10-long-stream.txt @@ -0,0 +1,7 @@ +$ curl -sN "localhost:38180/chat/stream?message=count 700" (700 tokens x 50 ms = 35 s) + +event:done +data: + +elapsed: 38 s +turn 1 complete, 700 tokens diff --git a/chat-ui-htmx/output/11-missing-conversation-id.txt b/chat-ui-htmx/output/11-missing-conversation-id.txt new file mode 100644 index 0000000..6883f9c --- /dev/null +++ b/chat-ui-htmx/output/11-missing-conversation-id.txt @@ -0,0 +1 @@ +stream failed: IllegalArgumentException: conversationId cannot be null diff --git a/chat-ui-htmx/output/12-memory-after-cancel.txt b/chat-ui-htmx/output/12-memory-after-cancel.txt new file mode 100644 index 0000000..3c09034 --- /dev/null +++ b/chat-ui-htmx/output/12-memory-after-cancel.txt @@ -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? diff --git a/chat-ui-htmx/pom.xml b/chat-ui-htmx/pom.xml new file mode 100644 index 0000000..6428a1c --- /dev/null +++ b/chat-ui-htmx/pom.xml @@ -0,0 +1,73 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.2 + + + + com.ankurm + chat-ui-htmx + 1.0.0 + chat-ui-htmx + Streaming chat UI: Spring AI ChatClient to Server-Sent Events to htmx, with per-session memory, cancel and back-pressure. + + + 25 + 2.0.2 + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.ai + spring-ai-starter-model-openai + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + -Duser.timezone=UTC -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8 + + + + + diff --git a/chat-ui-htmx/scripts/backpressure.sh b/chat-ui-htmx/scripts/backpressure.sh new file mode 100755 index 0000000..28ee8b8 --- /dev/null +++ b/chat-ui-htmx/scripts/backpressure.sh @@ -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 [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 diff --git a/chat-ui-htmx/scripts/browser-test.py b/chat-ui-htmx/scripts/browser-test.py new file mode 100755 index 0000000..3ebbb71 --- /dev/null +++ b/chat-ui-htmx/scripts/browser-test.py @@ -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(" elements created inside the answer:", page.locator(f"#out-{n} b").count()) + print(" 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() diff --git a/chat-ui-htmx/scripts/cancel-upstream.sh b/chat-ui-htmx/scripts/cancel-upstream.sh new file mode 100755 index 0000000..4454b20 --- /dev/null +++ b/chat-ui-htmx/scripts/cancel-upstream.sh @@ -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 [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 diff --git a/chat-ui-htmx/scripts/lib.sh b/chat-ui-htmx/scripts/lib.sh new file mode 100755 index 0000000..ed45b70 --- /dev/null +++ b/chat-ui-htmx/scripts/lib.sh @@ -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; } diff --git a/chat-ui-htmx/scripts/run-all.sh b/chat-ui-htmx/scripts/run-all.sh new file mode 100755 index 0000000..19937c9 --- /dev/null +++ b/chat-ui-htmx/scripts/run-all.sh @@ -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/^$//' + 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" diff --git a/chat-ui-htmx/scripts/slow_client.py b/chat-ui-htmx/scripts/slow_client.py new file mode 100755 index 0000000..d4c5554 --- /dev/null +++ b/chat-ui-htmx/scripts/slow_client.py @@ -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() diff --git a/chat-ui-htmx/src/main/java/com/ankurm/chatui/CancelOnCloseConfig.java b/chat-ui-htmx/src/main/java/com/ankurm/chatui/CancelOnCloseConfig.java new file mode 100644 index 0000000..dfb8aea --- /dev/null +++ b/chat-ui-htmx/src/main/java/com/ankurm/chatui/CancelOnCloseConfig.java @@ -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(); + }); + } +} diff --git a/chat-ui-htmx/src/main/java/com/ankurm/chatui/ChatConfig.java b/chat-ui-htmx/src/main/java/com/ankurm/chatui/ChatConfig.java new file mode 100644 index 0000000..147ff74 --- /dev/null +++ b/chat-ui-htmx/src/main/java/com/ankurm/chatui/ChatConfig.java @@ -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(); + } +} diff --git a/chat-ui-htmx/src/main/java/com/ankurm/chatui/ChatController.java b/chat-ui-htmx/src/main/java/com/ankurm/chatui/ChatController.java new file mode 100644 index 0000000..3e6589b --- /dev/null +++ b/chat-ui-htmx/src/main/java/com/ankurm/chatui/ChatController.java @@ -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 """ +
%s
+
+ + + + + +
+ """.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 " [stopped]" + + ""; + } + + @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> stream(@RequestParam String message, + @RequestParam(defaultValue = "0") int turn, + HttpSession session) { + String cid = conversationId(session); + AtomicLong tokens = new AtomicLong(); + AtomicLong requested = new AtomicLong(); + + Flux 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.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())); + } +} diff --git a/chat-ui-htmx/src/main/java/com/ankurm/chatui/ChatUiApplication.java b/chat-ui-htmx/src/main/java/com/ankurm/chatui/ChatUiApplication.java new file mode 100644 index 0000000..e9370d6 --- /dev/null +++ b/chat-ui-htmx/src/main/java/com/ankurm/chatui/ChatUiApplication.java @@ -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); + } +} diff --git a/chat-ui-htmx/src/main/java/com/ankurm/chatui/stub/OpenAiStubServer.java b/chat-ui-htmx/src/main/java/com/ankurm/chatui/stub/OpenAiStubServer.java new file mode 100644 index 0000000..f00d702 --- /dev/null +++ b/chat-ui-htmx/src/main/java/com/ankurm/chatui/stub/OpenAiStubServer.java @@ -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. + * + *
+ * 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."
+ * 
+ */ +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 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 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 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 bold and & 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(); + } +} diff --git a/chat-ui-htmx/src/main/resources/application.yml b/chat-ui-htmx/src/main/resources/application.yml new file mode 100644 index 0000000..529cf00 --- /dev/null +++ b/chat-ui-htmx/src/main/resources/application.yml @@ -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= OPENAI_API_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 diff --git a/chat-ui-htmx/src/main/resources/static/index.html b/chat-ui-htmx/src/main/resources/static/index.html new file mode 100644 index 0000000..a894de1 --- /dev/null +++ b/chat-ui-htmx/src/main/resources/static/index.html @@ -0,0 +1,29 @@ + + + + +Chat UI with Spring AI, SSE and htmx + + + + + + +

Chat

+
+
+ + +
+

+ + diff --git a/chat-ui-htmx/src/main/resources/static/vendor/SHA256SUMS b/chat-ui-htmx/src/main/resources/static/vendor/SHA256SUMS new file mode 100644 index 0000000..3ae79dc --- /dev/null +++ b/chat-ui-htmx/src/main/resources/static/vendor/SHA256SUMS @@ -0,0 +1,2 @@ +d6fdc75f204e6bdefa99b69bf1e6d4ac69b8a364f77929f45c13476b4000f717 htmx.min.js +3b5992a541619babefc4c169505af474df5c3039da51e59b96ccf9241ecd61d2 htmx-ext-sse.js diff --git a/chat-ui-htmx/src/main/resources/static/vendor/htmx-ext-sse.js b/chat-ui-htmx/src/main/resources/static/vendor/htmx-ext-sse.js new file mode 100644 index 0000000..886e3f9 --- /dev/null +++ b/chat-ui-htmx/src/main/resources/static/vendor/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 + } +})() diff --git a/chat-ui-htmx/src/main/resources/static/vendor/htmx.min.js b/chat-ui-htmx/src/main/resources/static/vendor/htmx.min.js new file mode 100644 index 0000000..e6b8394 --- /dev/null +++ b/chat-ui-htmx/src/main/resources/static/vendor/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Y={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=pn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.11"};Y.onLoad=V;Y.process=Bt;Y.on=ye;Y.off=xe;Y.trigger=ae;Y.ajax=In;Y.find=a;Y.findAll=x;Y.closest=g;Y.remove=z;Y.addClass=w;Y.removeClass=S;Y.toggleClass=G;Y.takeClass=W;Y.swap=C;Y.defineExtension=zn;Y.removeExtension=Jn;Y.logAll=$;Y.logNone=_;Y.parseInterval=d;Y._=e;const n={addTriggerHandler:Et,bodyContains:ie,canAccessLocalStorage:U,findThisElement:we,filterValues:xn,swap:C,hasAttribute:s,getAttributeValue:f,getClosestAttributeValue:te,getClosestMatch:A,getExpressionVars:qn,getHeaders:yn,getInputValues:pn,getInternalData:re,getSwapSpecification:vn,getTriggerSpecs:lt,getTarget:Se,makeFragment:D,mergeObjects:se,makeSettleInfo:En,oobSwap:Te,querySelectorExt:ce,settleImmediately:Qt,shouldCancel:dt,triggerEvent:ae,triggerErrorEvent:ue,withExtensions:Vt};const he=["get","post","put","delete","patch"];const R=he.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function Q(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function f(e,t){return Q(e,t)||Q(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ee(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):ee()}function A(e,t){while(e&&!t(e)){e=u(e)}return e||null}function o(e,t,n){const r=f(t,n);const o=f(t,"hx-disinherit");var i=f(t,"hx-inherit");if(e!==t){if(Y.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function te(t,n){let r=null;A(t,function(e){return!!(r=o(t,le(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function I(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=ee().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Y.config.inlineScriptNonce){t.nonce=Y.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function k(e){if(!Y.config.allowScriptTags){e.querySelectorAll("script").forEach(e=>e.remove());return}Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){T(e)}finally{e.remove()}}})}function D(e){e=e.replace(/]*)?)>/gi,'");const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=I(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=I(t);L(r,i.body);r.title=i.title}else{const i=I('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){k(r)}return r}function ne(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function P(e){return typeof e==="function"}function M(e){return t(e,"Object")}function re(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e=0}function ie(e){return e.getRootNode({composed:true})===document}function X(e){return e.trim().split(/\s+/)}function se(e,t){return Object.assign({},e,t)}function v(e){try{return JSON.parse(e)}catch(e){T(e);return null}}function U(){const e="htmx:sessionStorageTest";try{sessionStorage.setItem(e,e);sessionStorage.removeItem(e);return true}catch(e){return false}}function j(e){try{const t=new URL(e,window.location.href);e=t.pathname+t.search}catch(e){}if(e!="/"){e=e.replace(/\/+$/,"")}return e}function e(e){return Tn(ee().body,function(){return eval(e)})}function V(t){const e=Y.on("htmx:load",function(e){t(e.detail.elt)});return e}function $(){Y.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Y.logger=null}function a(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return a(ee(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ee(),e)}}function b(){return window}function z(e,t){e=E(e);if(t){b().setTimeout(function(){z(e);e=null},t)}else{u(e).removeChild(e)}}function le(e){return e instanceof Element?e:null}function J(e){return e instanceof HTMLElement?e:null}function K(e){return typeof e==="string"?e:null}function p(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function w(e,t,n){e=le(E(e));if(!e){return}if(n){b().setTimeout(function(){w(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function S(e,t,n){let r=le(E(e));if(!r){return}if(n){b().setTimeout(function(){S(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function G(e,t){e=E(e);e.classList.toggle(t)}function W(e,t){e=E(e);oe(e.parentElement.children,function(e){S(e,t)});w(le(e),t)}function g(e,t){e=le(E(e));if(e){return e.closest(t)}return null}function l(e,t){return e.substring(0,t.length)===t}function Z(e,t){return e.substring(e.length-t.length)===t}function de(e){const t=e.trim();if(l(t,"<")&&Z(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(t,r,n){if(r.indexOf("global ")===0){return m(t,r.slice(7),true)}t=E(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=de(o.shift());let e;if(r.indexOf("closest ")===0){e=g(le(t),de(r.slice(8)))}else if(r.indexOf("find ")===0){e=a(p(t),de(r.slice(5)))}else if(r==="next"||r==="nextElementSibling"){e=le(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,de(r.slice(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=le(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=ge(t,de(r.slice(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=q(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=p(q(t,!!n));i.push(...F(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=p(q(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ce(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(ee().body,e)[0]}}function E(e,t){if(typeof e==="string"){return a(p(t)||document,e)}else{return e}}function me(e,t,n,r){if(P(t)){return{target:ee().body,event:K(e),listener:t,options:n}}else{return{target:E(e),event:K(t),listener:n,options:r}}}function ye(t,n,r,o){Wn(function(){const e=me(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=P(n);return e?n:r}function xe(t,n,r){Wn(function(){const e=me(t,n,r);e.target.removeEventListener(e.event,e.listener)});return P(n)?n:r}const be=ee().createElement("output");function ve(t,n){const e=te(t,n);if(e){if(e==="this"){return[we(t,n)]}else{const r=m(t,e);const o=/(^|,)(\s*)inherit(\s*)($|,)/.test(e);if(o){const i=le(A(t,function(e){return e!==t&&s(le(e),n)}));if(i){r.push(...ve(i,n))}}if(r.length===0){T('The selector "'+e+'" on '+n+" returned no matches!");return[be]}else{return r}}}}function we(e,t){return le(A(e,function(e){return f(le(e),t)!=null}))}function Se(e){const t=te(e,"hx-target");if(t){if(t==="this"){return we(e,"hx-target")}else{return ce(e,t)}}else{const n=re(e);if(n.boosted){return ee().body}else{return e}}}function Ee(e){return Y.config.attributesToSettle.includes(e)}function Ce(t,n){oe(Array.from(t.attributes),function(e){if(!n.hasAttribute(e.name)&&Ee(e.name)){t.removeAttribute(e.name)}});oe(n.attributes,function(e){if(Ee(e.name)){t.setAttribute(e.name,e.value)}})}function Oe(t,e){const n=Kn(e);for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=m(t,n,false);if(r.length){oe(r,function(e){let t;const n=o.cloneNode(true);t=ee().createDocumentFragment();t.appendChild(n);if(!Oe(s,e)){t=p(n)}const r={shouldSwap:true,target:e,fragment:t};if(!ae(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){Re(t);Ve(s,e,e,t,i);He()}oe(i.elts,function(e){ae(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);ue(ee().body,"htmx:oobErrorNoTarget",{content:o,target:n})}return e}function He(){const e=a("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=a("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function Re(e){oe(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=f(e,"id");const n=ee().getElementById(t);if(n!=null){if(e.moveBefore){let e=a("#--htmx-preserve-pantry--");if(e==null){ee().body.insertAdjacentHTML("afterend","
");e=a("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function qe(i,e,s){oe(e.querySelectorAll("[id]"),function(t){const n=Q(t,"id");if(n&&n.length>0){const e=p(i);const r=e&&e.querySelector(CSS.escape(t.tagName)+"#"+CSS.escape(n));if(r&&r!==e){const o=t.cloneNode();Ce(t,r);s.tasks.push(function(){Ce(t,o)})}}})}function Ae(e){return function(){S(e,Y.config.addedClass);Bt(le(e));Ne(p(e));ae(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=J(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function c(e,t,n,r){qe(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;w(le(o),Y.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function _e(e,n,r){var t=x(e,"[hx-swap-oob], [data-hx-swap-oob]");oe(t,function(e){if(Y.config.allowNestedOobSwaps||e.parentElement===null){const t=f(e,"hx-swap-oob");if(t!=null){Te(t,e,n,r)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function C(d,p,g,m){if(!m){m={}}let y=null;let n=null;let e=function(){ne(m.beforeSwapCallback);d=E(d);const r=m.contextElement&&m.contextElement.isConnected?q(m.contextElement,false):ee();const e=document.activeElement;let t={};t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null};const o=En(d);if(g.swapStyle==="textContent"){d.textContent=p}else{let n=typeof p==="string"?D(p):p;o.title=m.title||n.title;if(m.historyRequest){n=n.querySelector("[hx-history-elt],[data-hx-history-elt]")||n}if(m.selectOOB){const s=m.selectOOB.split(",");for(let t=0;t0){b().setTimeout(n,g.settleDelay)}else{n()}};let t=Y.config.globalViewTransitions;if(g.hasOwnProperty("transition")){t=g.transition}const r=m.contextElement||ee();if(t&&ae(r,"htmx:beforeTransition",m.eventInfo)&&typeof Promise!=="undefined"&&document.startViewTransition){const o=new Promise(function(e,t){y=e;n=t});const i=e;e=function(){document.startViewTransition(function(){i();return o})}}try{if(g?.swapDelay&&g.swapDelay>0){b().setTimeout(e,g.swapDelay)}else{e()}}catch(e){ue(r,"htmx:swapError",m.eventInfo);ne(n);throw e}}function ze(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=v(r)||{};for(const i of Object.keys(o)){let e=o[i];if(M(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}ae(n,i,e)}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=Tn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){ue(ee().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(nt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function O(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function ot(e){let t;if(e.length>0&&Qe.test(e[0])){e.shift();t=O(e,et).trim();e.shift()}else{t=O(e,Ke)}return t}const it="input, textarea, select";function st(e,t,n){const r=[];const o=tt(t);do{O(o,Ye);const l=o.length;const c=O(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};O(o,Ye);u.pollInterval=d(O(o,/[,\[\s]/));O(o,Ye);var i=rt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=rt(e,o,"event");if(i){a.eventFilter=i}O(o,Ye);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(O(o,Ke))}else if(f==="from"&&o[0]===":"){o.shift();if(Qe.test(o[0])){var s=ot(o)}else{var s=O(o,Ke);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=ot(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=ot(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(O(o,Ke))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=O(o,Ke)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=ot(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=O(o,Ke)}else{ue(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ye)}r.push(a)}}if(o.length===l){ue(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ye)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function lt(e){const t=f(e,"hx-trigger");let n=[];if(t){const r=Y.config.triggerSpecsCache;n=r&&r[t]||st(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,it)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function ct(e){re(e).cancelled=true}function ut(e,t,n){const r=re(e);r.timeout=b().setTimeout(function(){if(ie(e)&&r.cancelled!==true){if(!gt(n,e,Ut("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ut(e,t,n)}},n.pollInterval)}function at(e){return location.hostname===e.hostname&&Q(e,"href")&&Q(e,"href").indexOf("#")!==0}function ft(e){return g(e,Y.config.disableSelector)}function ht(t,n,e){if(t instanceof HTMLAnchorElement&&at(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(Q(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=Q(t,"href")}else{const i=Q(t,"method");r=i?i.toLowerCase():"get";o=Q(t,"action");if(o==null||o===""){o=location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){mt(t,function(e,t){const n=le(e);if(ft(n)){y(n);return}fe(r,o,n,t)},n,e,true)})}}function dt(e,t){if(e.type==="submit"&&t.tagName==="FORM"){return true}else if(e.type==="click"){const n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit"){return true}const r=t.closest("a");const o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href"))){return true}}return false}function pt(e,t){return re(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;ue(ee().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function mt(l,c,e,u,a){const f=re(l);let t;if(u.from){t=m(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}oe(t,function(i){const s=function(e){if(!ie(l)){i.removeEventListener(u.trigger,s);return}if(pt(l,e)){return}if(a||dt(e,i)){e.preventDefault()}if(gt(u,l,e)){return}const t=re(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(le(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=e.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){ae(l,"htmx:trigger");c(l,e);f.throttle=b().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=b().setTimeout(function(){ae(l,"htmx:trigger");c(l,e)},u.delay)}else{ae(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let yt=false;let xt=null;function bt(){if(!xt){xt=function(){yt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(yt){yt=false;oe(ee().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){vt(e)})}},200)}}function vt(e){if(!s(e,"data-hx-revealed")&&B(e)){e.setAttribute("data-hx-revealed","true");const t=re(e);if(t.initHash){ae(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){ae(e,"revealed")},{once:true})}}}function wt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;ae(e,"htmx:trigger");t(e)}};if(r>0){b().setTimeout(o,r)}else{o()}}function St(t,n,e){let i=false;oe(he,function(r){if(s(t,"hx-"+r)){const o=f(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){Et(t,e,n,function(e,t){const n=le(e);if(ft(n)){y(n);return}fe(r,o,n,t)})})}});return i}function Et(r,e,t,n){if(e.trigger==="revealed"){bt();mt(r,n,t,e);vt(le(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ce(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ut(le(r),n,e)}else{mt(r,n,t,e)}}function Ct(e){const t=le(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function qt(e){const t=Nt(e.target);const n=Lt(e);if(n){n.lastButtonClicked=t}}function At(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Nt(e){return g(le(e),"button, input[type='submit']")}function It(e){return e.form||g(e,"form")}function Lt(e){const t=Nt(e.target);if(!t){return}const n=It(t);if(!n){return}return re(n)}function kt(e){e.addEventListener("click",qt);e.addEventListener("focusin",qt);e.addEventListener("focusout",At)}function Dt(t,e,n){const r=re(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){Tn(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){ke(t);for(let e=0;eY.config.historyCacheSize){i.shift()}while(i.length>0){try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){ue(ee().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Kt(t){if(!U()){return null}t=j(t);const n=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){r.response=this.response;ae(ee().body,"htmx:historyCacheMissLoad",r);C(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:true});_t(r.path);ae(ee().body,"htmx:historyRestore",{path:e,cacheMiss:true,serverResponse:r.response})}else{ue(ee().body,"htmx:historyCacheMissLoadError",r)}};if(ae(ee().body,"htmx:historyCacheMiss",r)){t.send()}}function tn(e){Wt();e=e||location.pathname+location.search;const t=Kt(e);if(t){const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll};const r={path:e,item:t,historyElt:zt(),swapSpec:n};if(ae(ee().body,"htmx:historyCacheHit",r)){C(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title});_t(r.path);ae(ee().body,"htmx:historyRestore",r)}}else{if(Y.config.refreshOnHistoryMiss){Y.location.reload(true)}else{en(e)}}}function nn(e){let t=ve(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){const t=re(e);t.requestCount=(t.requestCount||0)+1;w(e,Y.config.requestClass)});return t}function rn(e){let t=ve(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){const t=re(e);t.requestCount=(t.requestCount||0)+1;if(!e.hasAttribute("disabled")){e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")}});return t}function on(e,t){oe(e.concat(t),function(e){const t=re(e);t.requestCount=(t.requestCount||1)-1});oe(e,function(e){const t=re(e);if(t.requestCount===0){S(e,Y.config.requestClass)}});oe(t,function(e){const t=re(e);if(t.requestCount===0&&e.hasAttribute("data-disabled-by-htmx")){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function sn(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);oe(e,e=>r.append(t,e))}}function an(e){if(e instanceof HTMLSelectElement&&e.multiple){return F(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e instanceof HTMLInputElement&&e.files){return F(e.files)}return e.value}function fn(t,n,r,e,o){if(e==null||sn(t,e)){return}else{t.push(e)}if(ln(e)){const i=Q(e,"name");cn(i,an(e),n);if(o){hn(e,r)}}if(e instanceof HTMLFormElement){oe(e.elements,function(e){if(t.indexOf(e)>=0){un(e.name,an(e),n)}else{t.push(e)}if(o){hn(e,r)}});new FormData(e).forEach(function(e,t){if(e instanceof File&&e.name===""){return}cn(t,e,n)})}}function hn(e,t){const n=e;if(n.willValidate){ae(n,"htmx:validation:validate");if(!n.checkValidity()){if(ae(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&Y.config.reportValidityOfForms){n.reportValidity()}t.push({elt:n,message:n.validationMessage,validity:n.validity})}}}function dn(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function pn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=re(e);if(s.lastButtonClicked&&!ie(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||f(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){fn(n,o,i,It(e),l)}fn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&Q(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=Q(u,"name");cn(a,u.value,o)}const c=ve(e,"hx-include");oe(c,function(e){fn(n,r,i,le(e),l);if(!h(e,"form")){oe(p(e).querySelectorAll(it),function(e){fn(n,r,i,e,l)})}});dn(r,o);return{errors:i,formData:r,values:Mn(r)}}function gn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function mn(e){e=Dn(e);let n="";e.forEach(function(e,t){n=gn(n,t,e)});return n}function yn(e,t,n){const r={"HX-Request":"true","HX-Trigger":Q(e,"id"),"HX-Trigger-Name":Q(e,"name"),"HX-Target":f(t,"id"),"HX-Current-URL":location.href};On(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(re(e).boosted){r["HX-Boosted"]="true"}return r}function xn(n,e){const t=te(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){oe(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;oe(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function bn(e){return!!Q(e,"href")&&Q(e,"href").indexOf("#")>=0}function vn(e,t){const n=t||te(e,"hx-swap");const r={swapStyle:re(e).boosted?"innerHTML":Y.config.defaultSwapStyle,swapDelay:Y.config.defaultSwapDelay,settleDelay:Y.config.defaultSettleDelay};if(Y.config.scrollIntoViewOnBoost&&re(e).boosted&&!bn(e)){r.show="top"}if(n){const s=X(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{T("Unknown modifier in hx-swap: "+l)}}}}return r}function wn(e){return te(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&Q(e,"enctype")==="multipart/form-data"}function Sn(t,n,r){let o=null;Vt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(wn(n)){return dn(new FormData,Dn(r))}else{return mn(r)}}}function En(e){return{tasks:[],elts:[e]}}function Cn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=le(ce(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}if(typeof t.scroll==="number"){b().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=le(ce(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Y.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Y.config.scrollBehavior})}}}function On(r,e,o,i,s){if(i==null){i={}}if(r==null){return i}const l=f(r,e);if(l){let e=l.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=Tn(r,function(){if(s){return Function("event","return ("+e+")").call(r,s)}else{return Function("return ("+e+")").call(r)}},{})}else{n=v(e)}for(const c of Object.keys(n)){if(i[c]==null){i[c]=n[c]}}}return On(le(u(r)),e,o,i,s)}function Tn(e,t,n){if(Y.config.allowEval){return t()}else{ue(e,"htmx:evalDisallowedError");return n}}function Hn(e,t,n){return On(e,"hx-vars",true,n,t)}function Rn(e,t,n){return On(e,"hx-vals",false,n,t)}function qn(e,t){return se(Hn(e,t),Rn(e,t))}function An(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function Nn(t){if(t.responseURL){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){ue(ee().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function H(e,t){return e.getResponseHeader(t)!==null}function In(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return fe(t,n,null,null,{targetOverride:E(r)||be,returnPromise:true})}else{let e=E(r.target);if(r.target&&!e||r.source&&!e&&!E(r.source)){e=be}return fe(t,n,E(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true,push:r.push,replace:r.replace,selectOOB:r.selectOOB})}}else{return fe(t,n,null,null,{returnPromise:true})}}function Ln(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function kn(e,t,n){const r=new URL(t,location.protocol!=="about:"?location.href:window.origin);const o=location.protocol!=="about:"?location.origin:window.origin;const i=o===r.origin;if(Y.config.selfRequestsOnly){if(!i){return false}}return ae(e,"htmx:validateUrl",se({url:r,sameHost:i},n))}function Dn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n of Object.keys(e)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}return t}function Pn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function Mn(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Pn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function fe(t,n,r,o,i,P){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ee().body}const M=i.handler||Vn;const F=i.select||null;if(!ie(r)){ne(s);return e}const c=i.targetOverride||le(Se(r));if(c==null||c==be){ue(r,"htmx:targetError",{target:te(r,"hx-target")});ne(l);return e}let u=re(r);const a=u.lastButtonClicked;if(a){const A=Q(a,"formaction");if(A!=null){n=A}const N=Q(a,"formmethod");if(N!=null){if(he.includes(N.toLowerCase())){t=N}else{ne(s);return e}}}const f=te(r,"hx-confirm");if(P===undefined){const K=function(e){return fe(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(ae(r,"htmx:confirm",G)===false){ne(s);return e}}let h=r;let d=te(r,"hx-sync");let p=null;let B=false;if(d){const I=d.split(":");const L=I[0].trim();if(L==="this"){h=we(r,"hx-sync")}else{h=le(ce(r,L))}d=(I[1]||"drop").trim();u=re(h);if(d==="drop"&&u.xhr&&u.abortable!==true){ne(s);return e}else if(d==="abort"){if(u.xhr){ne(s);return e}else{B=true}}else if(d==="replace"){ae(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");p=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){ae(h,"htmx:abort")}else{if(p==null){if(o){const k=re(o);if(k&&k.triggerSpec&&k.triggerSpec.queue){p=k.triggerSpec.queue}}if(p==null){p="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(p==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){fe(t,n,r,o,i)})}else if(p==="all"){u.queuedRequests.push(function(){fe(t,n,r,o,i)})}else if(p==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){fe(t,n,r,o,i)})}ne(s);return e}}const g=new XMLHttpRequest;u.xhr=g;u.abortable=B;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const X=te(r,"hx-prompt");if(X){var y=prompt(X);if(y===null||!ae(r,"htmx:prompt",{prompt:y,target:c})){ne(s);m();return e}}if(f&&!P){if(!confirm(f)){ne(s);m();return e}}let x=yn(r,c,y);if(t!=="get"&&!wn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=se(x,i.headers)}const U=pn(r,t);let b=U.errors;const j=U.formData;if(i.values){dn(j,Dn(i.values))}const V=Dn(qn(r,o));const v=dn(j,V);let w=xn(v,r);if(Y.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",Q(c,"id")||"true")}if(n==null||n===""){n=location.href}const S=On(r,"hx-request");const $=re(r).boosted;let E=Y.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:$,useUrlParams:E,formData:w,parameters:Mn(w),unfilteredFormData:v,unfilteredParameters:Mn(v),headers:x,elt:r,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Y.config.withCredentials,timeout:i.timeout||S.timeout||Y.config.timeout,path:n,triggeringEvent:o};if(!ae(r,"htmx:configRequest",C)){ne(s);m();return e}n=C.path;t=C.verb;x=C.headers;w=Dn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){ae(r,"htmx:validation:halted",C);ne(s);m();return e}const _=n.split("#");const z=_[0];const O=_[1];let T=n;if(E){T=z;const Z=!w.keys().next().done;if(Z){if(T.indexOf("?")<0){T+="?"}else{T+="&"}T+=mn(w);if(O){T+="#"+O}}}if(!kn(r,T,C)){ue(r,"htmx:invalidPath",C);ne(l);m();return e}g.open(t.toUpperCase(),T,true);g.overrideMimeType("text/html");g.withCredentials=C.withCredentials;g.timeout=C.timeout;if(S.noHeaders){}else{for(const D of Object.keys(x)){An(g,D,x[D])}}const H={xhr:g,target:c,requestConfig:C,etc:i,boosted:$,select:F,pathInfo:{requestPath:n,finalRequestPath:T,responsePath:null,anchor:O}};g.onload=function(){try{const t=Ln(r);H.pathInfo.responsePath=Nn(g);M(r,H);if(H.keepIndicators!==true){on(R,q)}ae(r,"htmx:afterRequest",H);ae(r,"htmx:afterOnLoad",H);if(!ie(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(ie(n)){e=n}}if(e){ae(e,"htmx:afterRequest",H);ae(e,"htmx:afterOnLoad",H)}}ne(s)}catch(e){ue(r,"htmx:onLoadError",se({error:e},H));throw e}finally{m()}};g.onerror=function(){on(R,q);ue(r,"htmx:afterRequest",H);ue(r,"htmx:sendError",H);ne(l);m()};g.onabort=function(){on(R,q);ue(r,"htmx:afterRequest",H);ue(r,"htmx:sendAbort",H);ne(l);m()};g.ontimeout=function(){on(R,q);ue(r,"htmx:afterRequest",H);ue(r,"htmx:timeout",H);ne(l);m()};if(!ae(r,"htmx:beforeRequest",H)){ne(s);m();return e}var R=nn(r);var q=rn(r);oe(["loadstart","loadend","progress","abort"],function(t){oe([g,g.upload],function(e){e.addEventListener(t,function(e){ae(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ae(r,"htmx:beforeSend",H);const J=E?null:Sn(g,r,w);g.send(J);return e}function Fn(e,t){const n=t.xhr;let r=null;let o=null;if(H(n,"HX-Push")){r=n.getResponseHeader("HX-Push");o="push"}else if(H(n,"HX-Push-Url")){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(H(n,"HX-Replace-Url")){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=t.etc.push||te(e,"hx-push-url");let c=t.etc.replace||te(e,"hx-replace-url");if(c==="false")c=null;const u=re(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function Bn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Xn(e){for(var t=0;t`+`.${t}{opacity:0;visibility: hidden} `+`.${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`+"")}}function Yn(){const e=ee().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Qn(){const e=Yn();if(e){Y.config=se(Y.config,e)}}Wn(function(){Qn();Zn();let e=ee().body;Bt(e);const t=ee().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.detail.elt||e.target;const n=re(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){tn();oe(t,function(e){ae(e,"htmx:restored",{document:ee(),triggerEvent:ae})})}else{if(n){n(e)}}};b().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Y}(); \ No newline at end of file diff --git a/chat-ui-htmx/src/test/java/com/ankurm/chatui/ChatFlowTest.java b/chat-ui-htmx/src/test/java/com/ankurm/chatui/ChatFlowTest.java new file mode 100644 index 0000000..28906f0 --- /dev/null +++ b/chat-ui-htmx/src/test/java/com/ankurm/chatui/ChatFlowTest.java @@ -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 raw = new ArrayList<>(); + + List 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 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 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() ? "" : 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("").doesNotContain(" { + 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"); + } +} diff --git a/chat-ui-htmx/src/test/java/com/ankurm/chatui/OkHttpCloseReproTest.java b/chat-ui-htmx/src/test/java/com/ankurm/chatui/OkHttpCloseReproTest.java new file mode 100644 index 0000000..a6264ca --- /dev/null +++ b/chat-ui-htmx/src/test/java/com/ankurm/chatui/OkHttpCloseReproTest.java @@ -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); + } +} diff --git a/chat-ui-htmx/src/test/java/com/ankurm/chatui/StubSupport.java b/chat-ui-htmx/src/test/java/com/ankurm/chatui/StubSupport.java new file mode 100644 index 0000000..093458c --- /dev/null +++ b/chat-ui-htmx/src/test/java/com/ankurm/chatui/StubSupport.java @@ -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() { + } +} diff --git a/chat-ui-htmx/src/test/java/com/ankurm/chatui/Transcript.java b/chat-ui-htmx/src/test/java/com/ankurm/chatui/Transcript.java new file mode 100644 index 0000000..abc6e64 --- /dev/null +++ b/chat-ui-htmx/src/test/java/com/ankurm/chatui/Transcript.java @@ -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); + } + } +}