Thai sexual-health information chat demo by Opendream.
Jane helps a person describe a need in Thai, checks for crisis language first, then suggests public services and a handoff summary. It is a local demo, not a clinic, not a counsellor, and not medical advice.
If the text looks like a crisis (self-harm, violence, emergency), Jane stops the normal flow and shows Thailand support lines with tap-to-call (tel:): 1323, 1300, 1669, and 191. Mixed or unclear crisis wording shows all four.
Minimum age for the demo is 15 (MIN_AGE in src/config.ts). Name: เจน (Thai) / Jane (English), PRODUCT_NAME / PRODUCT_NAME_EN in src/config.ts.
The shipped default is card-first: Jane maps the turn to an approved answer-bank card. Chat-first is experimental and off (CHAT_FIRST = false in src/config.ts). Crisis wording always bypasses the model, on both paths.
The default model (qwen3-0.6b-ft, v40) is a JSON card-choice fine-tune ({o,c,q}), not a prose chat model. Chat-first needs a prose-trained model.
Opt in with the URL param (works on the production build and npm run dev):
?chatFirst=1(alsoonortrue) — experimental chat-first?chatFirst=0(alsoofforfalse) forces card-first
Example: http://127.0.0.1:5173/?chatFirst=1
When chat-first is on:
- Jane chats first; the model writes the visible line and reviewed bank cards are fallback-only. The person can tap ขอข้อมูลเลย to see the card immediately.
- CLEAR crisis words bypass the model and go straight to the existing crisis screen (
src/core/crisis.ts, then the add-only check insrc/core/chat-crisis.ts). Zero model calls. - AMBIGUOUS turns (vague distress, no crisis word) show the turn's bank card plus a fixed DRAFT soft line (
ถ้าตอนนี้รู้สึกไม่ปลอดภัย กดตรงนี้ได้เลยนะคะ) and a button that opens the crisis screen. No model text; the model is not called. - Crisis detection stays rules-only on both paths. Extra chat-crisis rules also run when chat-first is off (
CHAT_CRISIS_CARD_FIRST = true).
Screenshots from the local e2e run live in shots/:
| Start | Suggestions | Crisis |
|---|---|---|
![]() |
![]() |
![]() |
More stills (handoff labelled DEMO — nothing was sent, under-age gate, model-loading pill) are in the same folder.
The app works without any model.
- Start screen: not medical advice, on-device privacy, age band, quick-exit on every screen.
- The person types a need in Thai. Crisis rules run first (
src/core/crisis.ts), then rule-based mapping, then 1–3 services fromsrc/data/services.jsonwith an open-now check in Asia/Bangkok. - A handoff summary is built from a fixed template with PII stripped. The person can edit, approve, or cancel.
- Approve shows a simulated handoff screen labelled DEMO — nothing was sent, plus the real public channel as text (not a live send).
- An optional on-device Qwen3 model can refine low-confidence routing (card-first) or write experimental chat-first replies when
?chatFirst=1is set. Crisis detection stays rules-only and always runs first.
Needs Node.js 18+ (verified here on Node 22). The static server binds 127.0.0.1 only.
npm ci
npm test # vitest run (unit tests)
npm run bank:validate # answer-bank + guard
npm run e2e # build + Playwright (headless Chrome on 127.0.0.1:4173)
npm run dev # http://127.0.0.1:5173 (hot reload)
npm run build && node scripts/serve.mjs # http://127.0.0.1:4173Experimental chat-first demo (same servers, needs a prose-trained model): append ?chatFirst=1 to the URL.
npm run e2e uses /usr/bin/google-chrome (override with CHROME_PATH). Step screenshots go to e2e-shots/ (gitignored). The WASM model e2e test is skipped when weights are not installed.
Optional filming bundle: python3 scripts/make-bundle.py → see bundle/README-RUN.md.
Weights are never in this repository. The demo runs on rules if they are missing.
The default model is the fine-tuned Qwen3 0.6B (qwen3-0.6b-ft in src/config.ts), WASM-only. It is a JSON card-choice fine-tune ({o,c,q}), not a prose chat model. It has no URL and is never downloaded. Place the GGUF yourself at models/gguf/Qwen3-0.6B-jane-ft-Q4_K_M.gguf (sha256 1a8056e4f1d0afba32ab87c24f8c2740258b130a9bb7bb5311e742c921daf8e2, 484220256 bytes), then node scripts/fetch-weights.mjs --model qwen3-0.6b-ft --verify-only to register it in models/installed.json.
If that GGUF is missing at load, the app falls back to installed qwen3-1.7b. If 1.7B is also missing, it logs a console warning, shows the rules-only / model-unavailable pill (data-state="failed"), and continues on rules.
To fetch the Qwen3-1.7B builds (WebGPU + WASM GGUF, about 2.1 GB) from their official pinned sources:
node scripts/fetch-weights.mjs
# optional larger WebGPU model:
node scripts/fetch-weights.mjs --model qwen3-4bMODEL (src/config.ts) |
WebGPU | WASM (CPU) | Official source |
|---|---|---|---|
qwen3-0.6b-ft (default) |
none (WASM only) | GGUF Q4_K_M ~0.48 GB, JSON card-choice fine-tune (operator-placed, never fetched) | Jane fine-tune; Qwen3-0.6B base is Apache-2.0 |
qwen3-1.7b (?model=qwen3-1.7b) |
MLC q4f16 / q4f32 ~0.99 GB | GGUF Q4_K_M 1.11 GB | Qwen3-1.7B, MLC build, unsloth GGUF |
qwen3-4b |
MLC q4f16 / q4f32 ~2.29 GB | falls back to 1.7B | Qwen3 blog |
off |
none | none | rules only |
Files land in models/ (gitignored) after sha256 checks against scripts/weights-manifest.json. The app loads them from /models/ on 127.0.0.1 into Cache Storage (model-weights-v1, model files only). Testing overrides: ?model=off, ?model=qwen3-1.7b, ?runtime=webgpu, ?runtime=wasm.
Qwen3.5 0.8B/2B and Gemma are not fetched by these scripts. See THIRD_PARTY.md.
- Eval and unit tests use synthetic prompts only. Do not commit real chat logs.
- No logging of user messages. Chat state is in memory. The static server does not receive chat content. There is no
consolelogging of user text. - No image upload. There is no file input.
- No
fetch/XHR in the app; strict CSP (default-src 'self'). Source URLs are shown as text, not links. - Nothing user-identifying is written to localStorage, IndexedDB, or cookies. sessionStorage may hold a single non-personal
jane.webgpu.failedflag after a WebGPU device loss. Cache Storage holds model files only. - Quick-exit and erase drop in-memory state.
The code is under the Apache License 2.0 (see the root LICENSE). package.json license is Apache-2.0. The answer-bank text (cards under src/data/bank/: answer_th, titles, variants, key facts, triggers, chip labels) is licensed CC BY-SA 4.0 (see src/data/bank/LICENSE; attribution Opendream). That CC BY-SA grant covers the answer-bank text only: it does not cover illustrations, art, images, sprites, or the brand mark. The eval question set (evals/ item files) is separately licensed CC BY-SA 4.0 as text (see evals/LICENSE; attribution Opendream). Quiz content is adapted from 'Judies, Opendream 2017' (Opendream owns Judies and relicenses its quiz content under CC BY-SA 4.0).
Illustration © Opendream, 2018. Some figures redrawn with AI assistance, 2026.
Art: 2018 Sex Safe Safe vector stars and approved figures in src/assets/jane/ (all rights reserved; not covered by Apache-2.0 or CC BY-SA). Provenance: src/assets/jane/MANIFEST.md. Never use the 2020 Jane art. UI font is self-hosted Mitr (SIL OFL 1.1, Cadson Demak). Third-party models and other notices: NOTICE and THIRD_PARTY.md.
Opendream — https://opendream.co.th/en/contact_en — info@opendream.co.th


