feat: Open WebUI-style assistant workspace — 3-column layout, markdown/math/code/tables, autosave with images, translation (LibreTranslate+DeepL), citation modal, Learning Hub moved in, handoff removed

This commit is contained in:
Daniel 2026-09-08 18:52:35 +02:00
parent 9bbcf0049a
commit 8b072496e2
29 changed files with 2106 additions and 326 deletions

View file

@ -19,6 +19,8 @@ services:
LITELLM_TTS_VOICE: sherpa/kokoro:am_adam
LITELLM_TTS_VOICES: sherpa/kokoro:am_adam,sherpa/kokoro:am_michael,sherpa/kokoro:af_bella,sherpa/kokoro:af_nicole,sherpa/kokoro:bf_emma,sherpa/kokoro:bm_lewis
CLINICAL_ASSISTANT_PROMPT_POOL_TARGET: 1000
LIBRETRANSLATE_URL: ${LIBRETRANSLATE_URL:-http://libretranslate:5000}
DEEPL_API_BASE: ${DEEPL_API_BASE:-https://api.deepl.com/v2}
GENERATED_IMAGES_S3_ENDPOINT: http://assets:9000
GENERATED_IMAGES_S3_REGION: us-east-1
GENERATED_IMAGES_S3_BUCKET: generated-images
@ -42,6 +44,7 @@ services:
- danvics_monitoring
- danvics_speech
- ped-ai-storage-assets
- openwebui_openwebui
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 30s
@ -103,3 +106,6 @@ networks:
ped-ai-storage-assets:
external: true
name: ped-ai-storage_assets
openwebui_openwebui:
external: true
name: openwebui_openwebui-network

View file

@ -74,14 +74,23 @@ Clinical answer response caching is intentionally disabled. Redis can support pr
## Image Attachments
Users can attach up to 4 images (PNG, JPEG, WebP) to an outgoing clinical question. Attachments are **input-only**:
Users can attach up to 4 images (PNG, JPEG, WebP) to an outgoing clinical question. Attachments **ride the outgoing question only for inference** and **persist with the saved chat** once the question is sent:
- They are validated client-side and authoritatively on the server (MIME allowlist, canonical base64, ≤ 5 MiB per image, ≤ 4 images, ≤ 10 MiB decoded total). Invalid input is rejected with 400 before any retrieval or provider call.
- They are sent **only** with the outgoing clinical question, never with handoff summaries, image generation, saved chats, downloads or transcripts.
- They are sent **only** with the outgoing clinical question for inference. Attaching images never disables retrieval: RAG/includeContext runs exactly as without images.
- The conversation budget counts text only: images are excluded from the UTF-16 code-unit count. The server still validates every request.
- Once sent, the message's attachments are stored in the saved chat payload (same bounded limits, re-validated on every save) and restored as thumbnails on load.
- Only OpenAI-compatible providers (LiteLLM, OpenRouter, Azure) receive them as multimodal content parts (`text` + `image_url` data URIs) on the latest user message; the system/retrieval/history structure is unchanged. Legacy direct adapters (Bedrock/Vertex) refuse with a clear 400 before contacting the provider.
- Attachments clear on a successful send and on New chat; a rejected send keeps them for correction.
## Autosave, titles and saved-chat updates
After each completed assistant turn (and on any change to the conversation), the chat is autosaved with an 800 ms debounce to `POST /api/clinical-assistant/chats`. New chats get a title derived from the first user message (first 60 characters); later saves include the chat `id` and update the same row in place. Failures surface once per change and never block chat flow; oversized saves keep the 8 MiB / 400 / 413 semantics and are retried only on the next change, never truncated. The raw transcript stays canonical. The generated sidebar image and per-message image jobs persist with the chat again.
## Translation
Every message offers Translate with a target-language picker and a provider choice. The default provider is the local LibreTranslate container (`LIBRETRANSLATE_URL`, default `http://libretranslate:5000`); DeepL is an admin-configured alternative (`DEEPL_API_KEY` + `DEEPL_API_BASE`, default `https://api.deepl.com/v2`, `api-free.deepl.com` also allowed). The admin default lives in `clinical_assistant.translate_provider` (libretranslate|deepl). Responses are cached per provider+message+lang; transient provider failures fall back to the other configured provider once; validation failures never fall back. No patient data leaves the local network unless the admin explicitly configures DeepL.
## Settings
Important settings include:
@ -93,6 +102,7 @@ Important settings include:
| `clinical_assistant.search_limit` | Number of MCP results requested |
| `clinical_assistant.context_chars` | Context characters requested from MCP |
| `clinical_assistant.system_behavior` | Admin-editable assistant behavior guidance |
| `clinical_assistant.translate_provider` | Default translation provider: `libretranslate` or `deepl` |
## Testing Priorities
@ -101,8 +111,10 @@ Add or update tests when changing:
- citation rendering,
- source title cleanup,
- named-source provenance behavior,
- table rendering,
- table rendering and table copy/CSV actions,
- image intent routing,
- image attachment validation and multimodal payload shape,
- image attachment validation, multimodal payload shape and saved-chat roundtrips,
- autosave debounce, title derivation and saved-chat updates,
- translation validation, caching and provider fallback,
- MCP result normalization,
- model discovery or settings behavior.

View file

@ -313,6 +313,13 @@
<input id="assistant-context-chars" type="number" min="300" max="4000" value="1400" class="admin-control">
</div>
</div>
<div class="admin-row">
<label for="assistant-translate-provider" class="admin-row-label">Translation provider</label>
<select id="assistant-translate-provider" class="admin-control">
<option value="libretranslate">Local LibreTranslate (default)</option>
<option value="deepl">DeepL (requires DEEPL_API_KEY)</option>
</select>
</div>
<div class="admin-row">
<strong class="admin-row-label">Conversation input budget</strong>
<p id="assistant-conversation-budget" role="status" style="margin:0;font-size:13px;color:var(--g600);">Loading server budget metadata...</p>

View file

@ -1,5 +1,6 @@
<div class="module-header assistant-header">
<div>
<div class="assistant-topbar">
<button id="btn-assistant-goback" class="btn-sm btn-ghost" type="button" title="Back to the main menu"><i class="fas fa-arrow-left"></i> Go back</button>
<div class="assistant-topbar-title">
<h2><i class="fas fa-brain" style="color:var(--purple);"></i> AI Clinical Assistant</h2>
</div>
<div class="assistant-status" id="assistant-status">
@ -9,50 +10,76 @@
</div>
<div class="assistant-layout">
<section class="assistant-main card">
<div class="assistant-toolbar">
<div>
<strong>Clinical Question</strong>
<span id="assistant-model-label" class="model-tag">Admin model</span>
<aside class="assistant-history">
<button id="btn-assistant-clear" class="assistant-new-chat" type="button"><i class="fas fa-plus"></i> New chat</button>
<nav class="assistant-view-switch" aria-label="Assistant views">
<button id="btn-assistant-chat-view" class="active" type="button"><i class="fas fa-comments"></i> Chat</button>
<button id="btn-assistant-learning-view" type="button"><i class="fas fa-graduation-cap"></i> Learning Hub</button>
</nav>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-bookmark"></i> Saved Chats</h3>
<button id="btn-assistant-save" class="btn-sm btn-ghost" type="button"><i class="fas fa-floppy-disk"></i> Save</button>
</div>
<div class="assistant-toolbar-actions">
<button id="btn-assistant-clear" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate-left"></i> New chat</button>
<button id="btn-assistant-copy" class="btn-sm btn-ghost" type="button"><i class="fas fa-copy"></i> Copy answer</button>
<button id="btn-assistant-save" class="btn-sm btn-ghost" type="button"><i class="fas fa-bookmark"></i> Save chat</button>
<button id="btn-assistant-download-chat" class="btn-sm btn-ghost" type="button">Download transcript</button>
<button id="btn-assistant-handoff" class="btn-sm btn-ghost" type="button">Request handoff summary</button>
<button id="btn-assistant-export-pdf" class="btn-sm btn-ghost" type="button"><i class="fas fa-file-pdf"></i> Export PDF</button>
<div id="assistant-saved-chats" class="assistant-saved-chats">
<p class="assistant-muted">Saved chats appear here after you click Save chat.</p>
</div>
</div>
<div id="assistant-messages" class="assistant-messages" aria-live="polite">
<div class="assistant-empty">
<i class="fas fa-book-medical"></i>
<h3>Evidence-first pediatric assistant</h3>
<p>Ask a real clinical question. Citations stay linked to the source cards on the right.</p>
<div class="assistant-examples">
<button type="button" data-assistant-example="In a 4-year-old with acute wheeze, when should magnesium sulfate be considered and what dose is recommended?">Status asthma escalation</button>
<button type="button" data-assistant-example="What are the red flags for bilious vomiting in neonates, and what immediate workup is recommended?">Bilious vomiting</button>
<button type="button" data-assistant-example="Compare bronchiolitis and asthma management in infants, citing clinical references.">Bronchiolitis vs asthma</button>
<div id="assistant-save-panel" class="assistant-save-panel" hidden>
<label for="assistant-save-title">Save chat as</label>
<input id="assistant-save-title" type="text" maxlength="160" autocomplete="off">
<div class="assistant-save-actions">
<button id="btn-assistant-save-confirm" class="btn-sm btn-primary" type="button">Save</button>
<button id="btn-assistant-save-cancel" class="btn-sm btn-ghost" type="button">Cancel</button>
</div>
</div>
</div>
</aside>
<form id="assistant-form" class="assistant-composer">
<label for="assistant-input">Clinical question</label>
<textarea id="assistant-input" rows="3" placeholder="Ask a focused clinical question..." autocomplete="off" aria-describedby="assistant-context-budget assistant-context-warning"></textarea>
<p id="assistant-context-budget" class="assistant-muted" aria-live="polite">Loading conversation limit; history and draft are counted in UTF-16 code units. The server validates each request.</p>
<div id="assistant-context-warning" role="alert" hidden></div>
<div id="assistant-attachments" class="assistant-attachments" aria-label="Attached images" hidden></div>
<textarea id="assistant-handoff-text" hidden readonly></textarea>
<div class="assistant-composer-footer">
<label class="assistant-attach" for="assistant-attach-input" title="Attach up to 4 images (PNG, JPEG, WebP)"><i class="fas fa-paperclip"></i> Attach images</label>
<input type="file" id="assistant-attach-input" accept="image/png,image/jpeg,image/webp" multiple hidden>
<label class="assistant-check"><input type="checkbox" id="assistant-include-context" checked> retrieve broader context</label>
<button id="btn-assistant-cancel" class="btn-sm btn-ghost" type="button" hidden><i class="fas fa-stop"></i> Cancel search</button>
<button id="btn-assistant-send" class="btn-generate" type="submit"><i class="fas fa-paper-plane"></i> Ask</button>
<section class="assistant-main card">
<div id="assistant-chat-view">
<div class="assistant-toolbar">
<div>
<strong>Clinical Question</strong>
<span id="assistant-model-label" class="model-tag">Admin model</span>
</div>
<div class="assistant-toolbar-actions">
<button id="btn-assistant-export-pdf" class="btn-sm btn-ghost" type="button"><i class="fas fa-file-pdf"></i> Export PDF</button>
<button id="btn-assistant-download-chat" class="btn-sm btn-ghost" type="button">Download transcript</button>
</div>
</div>
</form>
<div id="assistant-messages" class="assistant-messages" aria-live="polite">
<div class="assistant-empty">
<i class="fas fa-book-medical"></i>
<h3>Evidence-first pediatric assistant</h3>
<p>Ask a real clinical question. Citations stay linked to the source cards on the right.</p>
<div class="assistant-examples">
<button type="button" data-assistant-example="In a 4-year-old with acute wheeze, when should magnesium sulfate be considered and what dose is recommended?">Status asthma escalation</button>
<button type="button" data-assistant-example="What are the red flags for bilious vomiting in neonates, and what immediate workup is recommended?">Bilious vomiting</button>
<button type="button" data-assistant-example="Compare bronchiolitis and asthma management in infants, citing clinical references.">Bronchiolitis vs asthma</button>
</div>
</div>
</div>
<form id="assistant-form" class="assistant-composer">
<label for="assistant-input">Clinical question</label>
<textarea id="assistant-input" rows="3" placeholder="Ask a focused clinical question..." autocomplete="off" aria-describedby="assistant-context-budget assistant-context-warning"></textarea>
<p id="assistant-context-budget" class="assistant-muted" aria-live="polite">Loading conversation limit; history and draft are counted in UTF-16 code units. The server validates each request.</p>
<div id="assistant-context-warning" role="alert" hidden></div>
<div id="assistant-attachments" class="assistant-attachments" aria-label="Attached images" hidden></div>
<div class="assistant-composer-footer">
<label class="assistant-attach" for="assistant-attach-input" title="Attach up to 4 images (PNG, JPEG, WebP)"><i class="fas fa-paperclip"></i> Attach images</label>
<input type="file" id="assistant-attach-input" accept="image/png,image/jpeg,image/webp" multiple hidden>
<label class="assistant-check"><input type="checkbox" id="assistant-include-context" checked> retrieve broader context</label>
<button id="btn-assistant-cancel" class="btn-sm btn-ghost" type="button" hidden><i class="fas fa-stop"></i> Cancel search</button>
<button id="btn-assistant-send" class="btn-generate" type="submit"><i class="fas fa-paper-plane"></i> Ask</button>
</div>
</form>
</div>
<div id="assistant-learning-view" class="assistant-learning-view" hidden>
<div id="assistant-learning-root" aria-live="polite"></div>
</div>
</section>
<aside class="assistant-side">
@ -68,21 +95,6 @@
</div>
</div>
<div class="card">
<div class="card-header"><h3><i class="fas fa-bookmark"></i> Saved Chats</h3></div>
<div id="assistant-saved-chats" class="assistant-saved-chats">
<p class="assistant-muted">Saved chats appear here after you click Save chat.</p>
</div>
<div id="assistant-save-panel" class="assistant-save-panel" hidden>
<label for="assistant-save-title">Save chat as</label>
<input id="assistant-save-title" type="text" maxlength="160" autocomplete="off">
<div class="assistant-save-actions">
<button id="btn-assistant-save-confirm" class="btn-sm btn-primary" type="button">Save</button>
<button id="btn-assistant-save-cancel" class="btn-sm btn-ghost" type="button">Cancel</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3><i class="fas fa-quote-right"></i> Sources</h3></div>
<div id="assistant-sources" class="assistant-sources">
@ -92,21 +104,4 @@
</aside>
</div>
<!-- Clinical handoff dialog: uses the site's standard modal pattern. -->
<div id="assistant-handoff-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="assistant-handoff-heading">
<div class="modal-content">
<div class="modal-header">
<h2 id="assistant-handoff-heading">Clinical handoff</h2>
<button id="btn-assistant-handoff-close" class="modal-close" type="button" aria-label="Close handoff"><i class="fas fa-xmark"></i></button>
</div>
<div class="modal-body">
<div id="assistant-handoff-preview" class="assistant-bubble" role="region" aria-labelledby="assistant-handoff-heading"></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px;flex-wrap:wrap;">
<button id="btn-assistant-copy-handoff" class="btn-sm btn-primary" type="button">Copy handoff</button>
<button id="btn-assistant-handoff-done" class="btn-sm btn-ghost" type="button">Close</button>
</div>
</div>
</div>
</div>
<link rel="stylesheet" href="/css/assistant.css">

View file

@ -1,12 +1,26 @@
.assistant-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; }
.assistant-topbar { display:flex; align-items:center; gap:12px; padding:6px 12px; border-bottom:1px solid var(--g200); background:var(--g50); position:sticky; top:0; z-index:4; }
.assistant-topbar-title { flex:1; min-width:0; }
.assistant-topbar-title h2 { font-size:15px; margin:0; color:var(--g800); }
.assistant-topbar-title h2 i { margin-right:6px; }
.assistant-status { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--g500); background:white; border:1px solid var(--g200); border-radius:999px; padding:5px 10px; box-shadow:var(--shadow); }
.assistant-dot { width:8px; height:8px; border-radius:50%; background:var(--green); display:inline-block; }
.assistant-status.busy .assistant-dot { background:var(--amber); animation:pulse 1.5s infinite; }
.assistant-status.error .assistant-dot { background:var(--red); }
.assistant-layout { display:grid; grid-template-columns:minmax(0,1fr) 330px; gap:14px; align-items:start; }
.assistant-layout { display:grid; grid-template-columns:260px minmax(0,1fr) 330px; gap:14px; align-items:start; }
.assistant-layout > * { min-width:0; }
.assistant-main { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 190px); min-width:0; }
.assistant-history { display:grid; gap:10px; align-content:start; position:sticky; top:52px; }
.assistant-new-chat { display:flex; align-items:center; justify-content:center; gap:8px; width:100%; border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:10px; padding:10px 12px; font-size:13px; font-weight:700; cursor:pointer; }
.assistant-new-chat:hover { border-color:var(--purple); background:var(--purple-light); }
.assistant-view-switch { display:grid; grid-template-columns:1fr 1fr; gap:4px; background:var(--g100); border:1px solid var(--g200); border-radius:10px; padding:4px; }
.assistant-view-switch button { border:0; border-radius:7px; padding:7px 6px; font-size:12px; font-weight:600; color:var(--g500); background:transparent; cursor:pointer; }
.assistant-view-switch button.active { background:white; color:var(--purple); box-shadow:var(--shadow); }
.assistant-main { display:flex; flex-direction:column; min-height:calc(100vh - 200px); min-width:0; }
#assistant-chat-view { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 200px); min-width:0; }
#assistant-chat-view[hidden], .assistant-learning-view[hidden] { display:none; }
.assistant-learning-view { min-height:calc(100vh - 200px); }
#assistant-learning-root { padding:4px 2px; }
.assistant-toolbar { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 14px; border-bottom:1px solid var(--g200); background:var(--g50); }
.assistant-toolbar-actions { display:flex; gap:6px; flex-wrap:wrap; }
.assistant-messages { padding:16px; max-height:65vh; overflow-y:auto; overflow-x:hidden; background:linear-gradient(180deg,#fff,var(--g50)); min-width:0; }
@ -18,9 +32,24 @@
.assistant-suggestion-buttons { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
.assistant-suggestion-buttons button { border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; text-align:left; }
.assistant-suggestion-buttons button:hover { border-color:var(--purple); background:var(--purple-light); }
.assistant-msg { max-width:900px; min-width:0; margin:0 0 14px; display:grid; gap:6px; }
.assistant-msg { max-width:900px; min-width:0; margin:0 0 14px; display:grid; gap:6px; position:relative; }
.assistant-msg.user { margin-left:auto; max-width:760px; }
.assistant-msg-label { font-size:11px; font-weight:700; color:var(--g400); text-transform:uppercase; letter-spacing:.04em; }
.assistant-msg-actions { display:flex; gap:4px; opacity:0; transition:opacity .12s ease; }
.assistant-msg:hover .assistant-msg-actions, .assistant-msg-actions:focus-within { opacity:1; }
.assistant-msg-actions button { border:1px solid var(--g200); background:white; color:var(--g500); border-radius:7px; padding:3px 7px; font-size:11px; cursor:pointer; }
.assistant-msg-actions button:hover { color:var(--purple); border-color:var(--purple-light); }
.assistant-translate-pop { display:grid; gap:4px; border:1px solid var(--g200); background:white; border-radius:10px; padding:8px; box-shadow:var(--shadow); position:absolute; right:0; top:100%; z-index:6; min-width:200px; }
.assistant-translate-providers { display:flex; gap:4px; border-bottom:1px solid var(--g100); padding-bottom:6px; }
.assistant-translate-providers button { flex:1; border:1px solid var(--g200); background:var(--g50); color:var(--g600); border-radius:7px; padding:4px; font-size:11px; font-weight:700; cursor:pointer; }
.assistant-translate-providers button.active { background:var(--purple); color:white; border-color:var(--purple); }
.assistant-translate-pop [data-assistant-translate-lang] { border:0; background:transparent; text-align:left; padding:4px 6px; font-size:12px; color:var(--g700); border-radius:6px; cursor:pointer; }
.assistant-translate-pop [data-assistant-translate-lang]:hover { background:var(--g50); }
.assistant-translated-text { white-space:pre-wrap; margin:0; }
.assistant-message-attachments { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
.assistant-message-attachment { margin:0; display:grid; gap:3px; justify-items:center; }
.assistant-message-attachment img { width:64px; height:64px; object-fit:cover; border-radius:8px; border:1px solid var(--g200); background:var(--g50); display:block; }
.assistant-message-attachment figcaption { font-size:10px; color:var(--g500); max-width:72px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.assistant-bubble { border:1px solid var(--g200); border-radius:14px; padding:12px 14px; background:white; box-shadow:var(--shadow); font-size:13px; line-height:1.75; overflow-wrap:anywhere; min-width:0; max-width:100%; }
.assistant-msg.user .assistant-bubble { white-space:pre-wrap; background:var(--blue); color:white; border-color:var(--blue); }
.assistant-bubble h1, .assistant-bubble h2, .assistant-bubble h3 { margin:16px 0 8px; line-height:1.25; color:var(--g900); }
@ -33,7 +62,15 @@
.assistant-bubble ul, .assistant-bubble ol { padding-left:20px; margin:8px 0; }
.assistant-bubble li { margin:4px 0; }
.assistant-bubble blockquote { margin:10px 0; padding:8px 12px; border-left:3px solid var(--blue); background:var(--blue-light); color:var(--g700); border-radius:8px; }
.assistant-table-scroll { max-width:100%; overflow-x:auto; overflow-y:hidden; -webkit-overflow-scrolling:touch; margin:12px 0; border:1px solid var(--g200); border-radius:12px; background:white; box-shadow:inset 0 -1px 0 rgba(0,0,0,.03); }
.assistant-bubble hr { border:0; border-top:1px solid var(--g200); margin:18px 0; }
.assistant-bubble img { max-width:100%; border-radius:8px; margin:10px 0; }
.assistant-msg.user .assistant-bubble code { background:rgba(255,255,255,.25); color:#fff; }
.assistant-msg.user .assistant-bubble a { color:#fff; text-decoration:underline; }
.assistant-msg.user .assistant-bubble pre { background:rgba(0,0,0,.35); }
.assistant-msg.user .assistant-bubble blockquote { border-left-color:rgba(255,255,255,.6); background:rgba(255,255,255,.15); color:#fff; }
.assistant-msg.user .assistant-bubble hr { border-top-color:rgba(255,255,255,.35); }
.assistant-table-scroll { position:relative; max-width:100%; overflow-x:auto; overflow-y:hidden; -webkit-overflow-scrolling:touch; margin:12px 0; border:1px solid var(--g200); border-radius:12px; background:white; box-shadow:inset 0 -1px 0 rgba(0,0,0,.03); scrollbar-width:none; }
.assistant-table-scroll::-webkit-scrollbar { display:none; }
.assistant-table-scroll table { width:max-content; min-width:100%; max-width:none; border-collapse:separate; border-spacing:0; margin:0; border:0; border-radius:0; font-size:12px; }
.assistant-table-scroll::after { content:'Swipe table'; display:none; position:sticky; left:0; bottom:0; padding:3px 9px; font-size:10px; font-weight:700; color:var(--g500); background:linear-gradient(90deg,rgba(255,255,255,.95),rgba(255,255,255,0)); pointer-events:none; }
.assistant-bubble th, .assistant-bubble td { padding:8px 10px; border-bottom:1px solid var(--g200); vertical-align:top; text-align:left; }
@ -42,6 +79,14 @@
.assistant-bubble [align="center"] { text-align:center; }
.assistant-bubble th { background:var(--g50); font-weight:700; color:var(--g800); }
.assistant-bubble tr:last-child td { border-bottom:0; }
.assistant-table-actions { position:absolute; top:6px; right:8px; z-index:2; display:flex; gap:4px; opacity:0; transition:opacity .12s ease; }
.assistant-table-scroll:hover .assistant-table-actions, .assistant-table-scroll:focus-within .assistant-table-actions { opacity:1; }
.assistant-table-actions button { border:1px solid var(--g200); background:white; color:var(--g600); border-radius:6px; padding:2px 7px; font-size:10px; font-weight:700; cursor:pointer; }
.assistant-table-actions button:hover { color:var(--purple); border-color:var(--purple-light); }
.assistant-codeblock { position:relative; margin:10px 0; }
.assistant-codeblock pre { margin:0; }
.assistant-code-copy { position:absolute; top:6px; right:8px; border:1px solid rgba(255,255,255,.25); background:rgba(17,24,39,.7); color:#fff; border-radius:6px; padding:2px 8px; font-size:10px; font-weight:700; cursor:pointer; }
.assistant-code-copy:hover { background:rgba(17,24,39,.95); }
.assistant-bubble code { background:var(--g100); border-radius:4px; padding:1px 4px; }
.assistant-bubble pre { background:var(--g900); color:white; border-radius:8px; padding:10px; overflow:auto; margin:10px 0; }
.assistant-bubble .katex-display { overflow-x:auto; overflow-y:hidden; padding:4px 0; }
@ -111,10 +156,19 @@
.assistant-source-excerpt strong { color:var(--g700); }
.assistant-muted { color:var(--g500); font-size:12px; line-height:1.6; }
.assistant-mermaid { background:white; border:1px solid var(--g200); border-radius:10px; padding:10px; margin:10px 0; overflow:auto; }
@media (max-width: 960px) { .assistant-layout { grid-template-columns:1fr; } .assistant-main { min-height:auto; grid-template-rows:auto minmax(320px,1fr) auto; } }
.assistant-source-modal { position:fixed; inset:0; z-index:10000; background:rgba(15,23,42,.72); display:flex; align-items:center; justify-content:center; padding:24px; }
.assistant-source-modal .modal-content { width:100%; max-width:640px; max-height:88vh; overflow:auto; }
.assistant-source-modal-meta { font-size:13px; font-weight:700; color:var(--g800); margin-bottom:10px; }
.assistant-source-modal-excerpt { white-space:pre-wrap; font-size:12px; line-height:1.6; color:var(--g600); background:var(--g50); border:1px solid var(--g200); border-radius:10px; padding:10px 12px; max-height:52vh; overflow:auto; margin-bottom:10px; }
@media (max-width: 960px) { .assistant-layout { grid-template-columns:minmax(0,1fr) 300px; } .assistant-history { position:static; grid-column:1 / -1; grid-template-columns:auto 1fr; align-items:start; } .assistant-new-chat { grid-column:1; } .assistant-view-switch { grid-column:1; } .assistant-history .card { grid-column:2; grid-row:1 / span 2; } .assistant-main { min-height:auto; } #assistant-chat-view { min-height:auto; grid-template-rows:auto minmax(320px,1fr) auto; } }
@media (max-width: 640px) {
.assistant-header { flex-direction:column; align-items:stretch; }
.assistant-topbar { flex-wrap:wrap; }
.assistant-topbar-title { flex-basis:100%; }
.assistant-status { align-self:flex-start; }
.assistant-layout { grid-template-columns:1fr; }
.assistant-history { grid-column:1; grid-template-columns:1fr; }
.assistant-history .card { grid-column:1; grid-row:auto; }
.assistant-toolbar { flex-direction:column; align-items:stretch; }
.assistant-toolbar-actions { display:grid; grid-template-columns:1fr 1fr; }
.assistant-toolbar-actions .btn-sm { width:100%; justify-content:center; }

View file

@ -21,6 +21,7 @@
<script src="/vendor/marked/marked.umd.js" defer></script>
<script src="/vendor/markdown-it/markdown-it.min.js" defer></script>
<script src="/vendor/katex/katex.min.js" defer></script>
<script src="/vendor/katex/contrib/mhchem.min.js" defer></script>
<script src="/vendor/mathjax/tex-mml-chtml.js" defer></script>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">
@ -281,10 +282,6 @@
<i class="fas fa-brain" style="color:var(--purple);"></i>
<span>AI Assistant</span>
</button>
<button class="tab-btn" data-tab="learning">
<i class="fas fa-graduation-cap"></i>
<span>Learning Hub</span>
</button>
<button class="tab-btn hidden" data-tab="cms" id="cms-tab-btn">
<i class="fas fa-pen-to-square"></i>
<span>Content Manager</span>
@ -341,7 +338,6 @@
<section id="notes-tab" class="tab-content" data-component="notes"></section>
<section id="diagrams-tab" class="tab-content" data-component="diagrams"></section>
<section id="assistant-tab" class="tab-content" data-component="assistant"></section>
<section id="learning-tab" class="tab-content" data-component="learning"></section>
<section id="cms-tab" class="tab-content" data-component="cms"></section>
<section id="docs-tab" class="tab-content" data-component="admin-docs"></section>
<section id="admin-tab" class="tab-content" data-component="admin"></section>

View file

@ -83,6 +83,7 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
renderAssistantImageModels([], window._assistantImageModelValue);
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
setValue('assistant-translate-provider', ['libretranslate', 'deepl'].includes(cfg['clinical_assistant.translate_provider']) ? cfg['clinical_assistant.translate_provider'] : 'libretranslate');
var budgetLabel = document.getElementById('assistant-conversation-budget');
if (budgetLabel) budgetLabel.textContent =
budget.limit.toLocaleString() + ' ' + budget.unit + ' (' + budget.measure + ') — ' + budget.env +
@ -302,7 +303,8 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
Promise.all([
putAssistantConfig('clinical_assistant.chat_model', getValue('assistant-chat-model')),
putAssistantConfig('clinical_assistant.search_limit', getValue('assistant-search-limit') || '8'),
putAssistantConfig('clinical_assistant.context_chars', getValue('assistant-context-chars') || '1400')
putAssistantConfig('clinical_assistant.context_chars', getValue('assistant-context-chars') || '1400'),
putAssistantConfig('clinical_assistant.translate_provider', getValue('assistant-translate-provider') || 'libretranslate')
]).then(function() {
if (status) status.textContent = '';
showToast('Assistant settings saved', 'success');

View file

@ -41,13 +41,6 @@ export function fetchAssistantChat(payload, options) {
}).then(parseJsonWithStatus);
}
export function requestAssistantHandoff(history) {
return fetch('/api/clinical-assistant/handoff', {
method: 'POST', headers: authHeaders(), credentials: 'same-origin',
body: JSON.stringify({ history: history })
}).then(parseJsonWithStatus);
}
export function requestAssistantImage(prompt) {
return fetch('/api/clinical-assistant/image', {
method: 'POST',
@ -57,6 +50,15 @@ export function requestAssistantImage(prompt) {
}).then(function(r) { return r.json(); });
}
export function translateAssistantMessage(message, target, provider) {
return fetch('/api/clinical-assistant/translate', {
method: 'POST',
headers: authHeaders(),
credentials: 'same-origin',
body: JSON.stringify({ message: message, target: target, provider: provider })
}).then(parseJsonWithStatus);
}
var imageDraft = null;
export function startAssistantImageJob(prompt, history) {
const input = { prompt, ...(history === undefined ? {} : { history }) };
@ -104,9 +106,10 @@ export function deleteSavedAssistantChat(id) {
}).then(function(r) { return r.json(); });
}
// Image attachments are input-only: they ride the outgoing clinical question
// payload and are never persisted or sent with handoff. The server enforces
// the same limits authoritatively; these mirror them for early feedback.
// Image attachments ride the outgoing clinical question payload for inference.
// Once a question is sent, its attachments persist with the saved chat. The
// server enforces the same limits authoritatively; these mirror them for early
// feedback.
export var assistantAttachmentLimits = Object.freeze({
mimeTypes: ['image/png', 'image/jpeg', 'image/webp'],
maxImages: 4,

View file

@ -1,6 +1,24 @@
// Recognize HTML syntax (including quoted >), not clinical comparisons such as <1 month.
var HTML_TAG_PATTERN = /<!--[\s\S]*?-->|<\/[A-Za-z][A-Za-z0-9-]*\s*>|<[A-Za-z][A-Za-z0-9-]*(?:\s+[A-Za-z_:][A-Za-z0-9_.:-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`\x00-\x20]+))?)*\s*\/?>/;
// Mirrors the Open WebUI safeImageUrl allowlist: data:image/* and same-origin
// URLs only. External/protocol-relative image URLs never render as <img>;
// they fall back to their alt text instead.
export function safeImageUrl(src) {
var s = String(src == null ? '' : src).trim();
if (!s) return '';
if (/^data:image\/[a-z0-9.+-]+;/i.test(s)) return s;
if (/^blob:/i.test(s)) return s;
try {
var base = (typeof window !== 'undefined' && window.location && window.location.href) || 'https://synthetic.invalid/';
var url = new URL(s, base);
if (url.protocol === 'http:' || url.protocol === 'https:') return url.origin === new URL(base).origin ? s : '';
return s; // relative paths resolve to our own origin by construction
} catch (e) {
return '';
}
}
export function renderAssistantMarkdown(md, sources, options) {
var opts = options || {};
var text = String(md || '');
@ -21,7 +39,13 @@ export function renderAssistantMarkdown(md, sources, options) {
var links = textProtection(text, 'links');
text = text.replace(new RegExp(
/!?\[[^\]\n]*\]\([^\n]*?\)|https?:\/\/(?:\\[^\s]|[^\s<>|\\])+|<(?:https?:\/\/|mailto:)[^\s<>]+>|<[^\s<>@]+@[^\s<>@]+>/.source + '|' + HTML_TAG_PATTERN.source,
'gi'), links.hold);
'gi'), function(match) {
if (match.charAt(0) === '!') {
var imageLink = match.match(/^!\[([^\]\n]*)\]\(([^\n]*?)\)$/);
if (imageLink && !safeImageUrl(imageLink[2])) return links.hold(escapeHtml(imageLink[1] || 'Image'), match);
}
return links.hold(match);
});
text = text.replace(/\\\[((?:\d+\s*,\s*)*\d+)\\\]/g, '[$1]');
text = renderLatexText(text, opts.katex, embedded.hold);
var limited = false;
@ -55,7 +79,7 @@ export function renderAssistantMarkdown(md, sources, options) {
function wrapTables(html) {
return String(html || '')
.replace(/<table(\s[^>]*)?>/g, '<div class="assistant-table-scroll" tabindex="0" role="region" aria-label="Scrollable table"><table$1>')
.replace(/<table(\s[^>]*)?>/g, '<div class="assistant-table-scroll" tabindex="0" role="region" aria-label="Scrollable table"><div class="assistant-table-actions"><button type="button" data-assistant-table-copy>Copy raw</button><button type="button" data-assistant-table-csv>Export CSV</button></div><table$1>')
.replace(/<\/table>/g, '</table></div>');
}
@ -369,8 +393,14 @@ export function renderLatexText(text, katex, hold) {
return hold ? hold(html, raw) : html;
}
return String(text || '')
.replace(/\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]|\$([^$\n]+?)\$|\\\((.+?)\\\)/g, function(raw, dollars, brackets, inline, parens) {
return render(raw, dollars || brackets || inline || parens, !!(dollars || brackets));
.replace(/\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]|\$([^$\n]+?)\$|\\\((.+?)\\\)|\\ce\{([^{}\n]*)\}|\\pu\{([^{}\n]*)\}/g, function(raw, dollars, brackets, inline, parens, ce, pu) {
var display = !!(dollars || brackets);
if (dollars || brackets) return render(raw, dollars || brackets, display);
if (inline !== undefined) return render(raw, inline, false);
if (parens !== undefined) return render(raw, parens, false);
if (ce !== undefined) return render(raw, ce, false);
if (pu !== undefined) return render(raw, pu, false);
return raw;
});
}

View file

@ -4,7 +4,7 @@
// server can call native MCP directly without routing through mcpo.
// ============================================================
import { EMPTY_PROMPT_SETS } from './assistant/data.js';
import { escapeAttr, escapeHtml, renderAssistantMarkdown } from './assistant/citations.js';
import { escapeAttr, escapeHtml, renderAssistantMarkdown, safeImageUrl } from './assistant/citations.js';
import { renderSourcesList } from './assistant/sources.js';
import { createAssistantExporter } from './assistant/export.js';
import { createAssistantImageStore } from './assistant/images.js';
@ -20,13 +20,13 @@ import {
openAssistantStream,
startAssistantImageJob,
saveAssistantChat,
requestAssistantHandoff,
translateAssistantMessage,
assistantAttachmentLimits,
assistantAttachmentPayload
} from './assistant/api.js';
var initialized = false;
var messages = [];
var attachments = []; // Input-only images: never persisted, never sent with handoff.
var attachments = []; // Input-only images: ride the outgoing question, then persist with the sent message.
var lastAnswer = '';
var lastSources = [];
var mermaidReady = false;
@ -38,11 +38,28 @@ import {
var activeAssistantRequest = null;
var conversationChars = null;
var STREAM_MARKDOWN_LIMIT = 3500;
var AUTOSAVE_DELAY_MS = 800;
var currentChatId = null;
var autosaveTimer = null;
var autosaveErrorShown = false;
var translateProvider = 'libretranslate';
var learningViewHtml = null;
var lastNonAssistantTab = null;
var regenerateMode = false;
var exporter = createAssistantExporter({ renderMarkdown: renderMarkdown, presentMessage: savedMessagePresentation, showToast: window.showToast });
var imageStore = createAssistantImageStore();
var TRANSLATE_LANGUAGES = [['en', 'English'], ['es', 'Español'], ['fr', 'Français'], ['de', 'Deutsch'], ['it', 'Italiano'], ['pt', 'Português'], ['zh', '中文'], ['ar', 'العربية'], ['ru', 'Русский'], ['hi', 'हिन्दी'], ['nl', 'Nederlands'], ['pl', 'Polski'], ['tr', 'Türkçe'], ['uk', 'Українська'], ['fa', 'فارسی'], ['sw', 'Kiswahili']];
var SOURCE_EXCERPT_PREVIEW = 10000;
document.addEventListener('tabChanged', function (e) {
if (e.detail && e.detail.tab === 'assistant') initIfNeeded();
if (e.detail && e.detail.tab && e.detail.tab !== 'assistant' && e.detail.tab !== 'learning') lastNonAssistantTab = e.detail.tab;
});
window.addEventListener('account-boundary', function () {
cancelAutosave();
if (initialized) closeTranslatePicker();
closeSourceModal();
});
function initIfNeeded() {
@ -59,7 +76,6 @@ import {
var form = document.getElementById('assistant-form');
var clearBtn = document.getElementById('btn-assistant-clear');
var cancelBtn = document.getElementById('btn-assistant-cancel');
var copyBtn = document.getElementById('btn-assistant-copy');
var saveBtn = document.getElementById('btn-assistant-save');
var saveConfirmBtn = document.getElementById('btn-assistant-save-confirm');
var saveCancelBtn = document.getElementById('btn-assistant-save-cancel');
@ -68,26 +84,18 @@ import {
var imageClearBtn = document.getElementById('btn-assistant-image-clear');
var attachInput = document.getElementById('assistant-attach-input');
var input = document.getElementById('assistant-input');
var goBackBtn = document.getElementById('btn-assistant-goback');
var chatViewBtn = document.getElementById('btn-assistant-chat-view');
var learningViewBtn = document.getElementById('btn-assistant-learning-view');
if (form) form.addEventListener('submit', onAsk);
if (clearBtn) clearBtn.addEventListener('click', clearConversation);
document.getElementById('btn-assistant-download-chat').addEventListener('click', downloadTranscript);
document.getElementById('btn-assistant-handoff').addEventListener('click', requestHandoff);
document.getElementById('btn-assistant-copy-handoff').addEventListener('click', function() {
navigator.clipboard.writeText(document.getElementById('assistant-handoff-text').value).catch(function() {
if (typeof showToast === 'function') showToast('Could not copy; select the summary text instead.', 'error');
});
});
function closeHandoffModal() { setHandoff(''); }
var handoffModal = document.getElementById('assistant-handoff-modal');
if (handoffModal) {
document.getElementById('btn-assistant-handoff-close').addEventListener('click', closeHandoffModal);
document.getElementById('btn-assistant-handoff-done').addEventListener('click', closeHandoffModal);
handoffModal.addEventListener('click', function(e) { if (e.target === handoffModal) closeHandoffModal(); });
}
if (goBackBtn) goBackBtn.addEventListener('click', goBackToMainMenu);
if (chatViewBtn) chatViewBtn.addEventListener('click', openChatView);
if (learningViewBtn) learningViewBtn.addEventListener('click', openLearningView);
if (input) input.addEventListener('input', updateConversationBudget);
if (cancelBtn) cancelBtn.addEventListener('click', cancelAssistantSearch);
if (copyBtn) copyBtn.addEventListener('click', copyLastAnswer);
if (saveBtn) saveBtn.addEventListener('click', showSavePanel);
if (saveConfirmBtn) saveConfirmBtn.addEventListener('click', saveCurrentChat);
if (saveCancelBtn) saveCancelBtn.addEventListener('click', hideSavePanel);
@ -96,6 +104,7 @@ import {
if (imageClearBtn) imageClearBtn.addEventListener('click', clearGeneratedImage);
if (attachInput) attachInput.addEventListener('change', onAttachFiles);
document.addEventListener('click', onAssistantDocumentClick);
document.addEventListener('keydown', onAssistantKeydown);
if (input) input.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') onAsk(e);
});
@ -112,6 +121,7 @@ import {
label.textContent = data.chatModel ? ('Chat: ' + data.chatModel) : 'Admin model';
}
conversationChars = data.success && validConversationLimit(data.conversationChars) ? data.conversationChars : null;
if (data.success && ['libretranslate', 'deepl'].includes(data.translateProvider)) translateProvider = data.translateProvider;
updateConversationBudget();
})
.catch(function () {
@ -150,13 +160,15 @@ import {
if (conversationChars !== null && conversationSize(text) > conversationChars) {
updateConversationBudget();
if (typeof showToast === 'function') showToast('Conversation limit reached. Save/download this chat, start a new chat, or explicitly request a handoff summary.', 'error');
if (typeof showToast === 'function') showToast('Conversation limit reached. Save or download this chat, then start a new chat.', 'error');
return;
}
// Only text/roles go to inference; the full source maps/images stay in the transcript.
var history = messages.map(function(m) { return { role: m.role, content: m.content }; });
var request = createAssistantRequest();
request.regenerateMode = regenerateMode;
regenerateMode = false; // consumed by this request
activeAssistantRequest = request;
setBusy(true, 'Looking up sources...');
var loading = appendLoadingMessage('Looking up sources', 'Retrieving and synthesizing references...');
@ -164,13 +176,18 @@ import {
request.accept = function() {
if (request.accepted || request.cancelled) return;
request.accepted = true;
var row = appendMessage('user', text);
if (row && loading && loading.parentNode) loading.parentNode.insertBefore(row, loading);
if (!request.regenerateMode) {
var row = appendMessage('user', text, undefined, undefined, undefined, {
attachments: attachments.map(function(a) { return { dataBase64: a.dataBase64, mimeType: a.mimeType, name: a.name || '' }; })
});
if (row && loading && loading.parentNode) loading.parentNode.insertBefore(row, loading);
}
if (input) input.value = '';
// Attached images are input-only and clear on a successful send.
// Attached images are input-only until sent, then persist with the message.
attachments = [];
renderAttachments();
exporter.invalidate();
scheduleAutosave();
updateConversationBudget();
};
@ -180,7 +197,7 @@ import {
history: history,
includeContext: !includeContext || includeContext.checked
};
// Images ride the outgoing clinical question only, never handoff or saves.
// Images ride the outgoing clinical question for inference; once sent they persist with the saved chat.
if (attachments.length) payload.images = assistantAttachmentPayload(attachments);
return streamAssistantResponse(payload, loading, request)
@ -367,6 +384,7 @@ import {
function replaceLoadingMessage(row, content, sources, suggestions, rawHtml) {
if (!row || !row.parentNode) {
appendMessage('assistant', content, sources, suggestions, rawHtml);
scheduleAutosave();
return;
}
var bubble = row.querySelector('.assistant-bubble');
@ -377,7 +395,9 @@ import {
var wrap = document.getElementById('assistant-messages');
if (wrap) wrap.scrollTop = wrap.scrollHeight;
messages.push({ role: 'assistant', content: content, sources: sources || [] });
row.dataset.messageIndex = String(messages.length - 1);
updateConversationBudget();
scheduleAutosave();
}
function renderAssistantBubbleHtml(content, sources, rawHtml, options) {
@ -386,15 +406,39 @@ import {
function fillMessageBubble(bubble, role, content, sources, suggestions, rawHtml, options) {
bubble.assistantSources = role === 'assistant' && Array.isArray(sources) ? sources : [];
bubble.innerHTML = role === 'assistant' ? renderAssistantBubbleHtml(content, sources, rawHtml, options) : escapeHtml(content);
if (role !== 'assistant' && options && options.notice) bubble.innerHTML += '<p role="note">' + escapeHtml(options.notice) + '</p>';
bubble.assistantRawContent = String(content || '');
var renderOptions = Object.assign({}, options || {}, { notice: '' });
bubble.innerHTML = role === 'assistant' ? renderAssistantBubbleHtml(content, sources, rawHtml, renderOptions) : renderMarkdown(content, [], renderOptions);
if (options && options.notice) bubble.innerHTML += '<p role="note">' + escapeHtml(options.notice) + '</p>';
if (Array.isArray(options && options.attachments) && options.attachments.length) bubble.appendChild(renderMessageAttachments(options.attachments));
if (role === 'assistant' && suggestions && suggestions.length) bubble.appendChild(renderSuggestionButtons(suggestions));
renderEmbeddedBlocks(bubble);
}
function appendMessage(role, content, sources, suggestions, rawHtml) {
var row = appendMessageNode(role, content, sources, suggestions, rawHtml);
if (row) messages.push({ role: role, content: content, sources: role === 'assistant' ? (sources || []) : [] });
function renderMessageAttachments(attachments) {
var wrap = document.createElement('div');
wrap.className = 'assistant-message-attachments';
(attachments || []).forEach(function(attachment, index) {
var item = document.createElement('figure');
item.className = 'assistant-message-attachment';
var img = document.createElement('img');
img.src = 'data:' + attachment.mimeType + ';base64,' + attachment.dataBase64;
img.alt = 'Attached image ' + (index + 1);
var caption = document.createElement('figcaption');
caption.textContent = attachment.name || ('Attachment ' + (index + 1));
item.appendChild(img);
item.appendChild(caption);
wrap.appendChild(item);
});
return wrap;
}
function appendMessage(role, content, sources, suggestions, rawHtml, extras) {
var row = appendMessageNode(role, content, sources, suggestions, rawHtml, extras);
if (row) {
messages.push({ role: role, content: content, sources: role === 'assistant' ? (sources || []) : [], ...(extras && Array.isArray(extras.attachments) && extras.attachments.length ? { attachments: extras.attachments } : {}) });
row.dataset.messageIndex = String(messages.length - 1);
}
return row;
}
@ -433,7 +477,7 @@ import {
if (typeof showToast === 'function') showToast('Could not read "' + String(file.name || 'attachment') + '".', 'error');
return;
}
attachments.push({ dataBase64: dataUrl.slice(comma + 1), mimeType: file.type, size: file.size, src: dataUrl });
attachments.push({ dataBase64: dataUrl.slice(comma + 1), mimeType: file.type, size: file.size, src: dataUrl, name: String(file.name || '') });
renderAttachments();
};
reader.onerror = function() {
@ -522,7 +566,116 @@ import {
wrap.innerHTML = renderSourcesList(sources);
}
// ── OWUI-style table actions (Copy raw / Export CSV) ────────────────
function wireTableActions(root) {
if (!root || !root.querySelectorAll) return;
root.querySelectorAll('.assistant-table-scroll').forEach(function(wrapper) {
if (wrapper.getAttribute('data-assistant-table-wired') === 'true') return;
wrapper.setAttribute('data-assistant-table-wired', 'true');
wrapper.addEventListener('click', function(event) {
if (event.target.closest('[data-assistant-table-copy]')) {
var markdown = tableToMarkdown(wrapper.querySelector('table'));
copyText(markdown, 'Table copied', 'Copy failed');
return;
}
if (event.target.closest('[data-assistant-table-csv]')) {
exportTableCsv(wrapper.querySelector('table'));
}
});
});
}
function tableToMarkdown(table) {
if (!table) return '';
var lines = [];
var rows = table.querySelectorAll('tr');
rows.forEach(function(row, rowIndex) {
var cells = row.querySelectorAll('th, td');
if (rowIndex === 1 && row.parentNode && row.parentNode.tagName === 'THEAD') return; // markdown tables carry one header row
if (rowIndex === 0) {
lines.push('| ' + Array.from(cells).map(cellToText).join(' | ') + ' |');
lines.push('| ' + Array.from(cells).map(function(cell) {
var align = cell.getAttribute('align') || (cell.style && cell.style.textAlign) || '';
if (align === 'center') return ':---:';
if (align === 'right') return '---:';
return '---';
}).join(' | ') + ' |');
return;
}
lines.push('| ' + Array.from(cells).map(cellToText).join(' | ') + ' |');
});
return lines.join('\n');
}
function cellToText(cell) {
return String(cell.textContent || '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
}
function exportTableCsv(table) {
if (!table) return;
var lines = [];
table.querySelectorAll('tr').forEach(function(row) {
lines.push(Array.from(row.querySelectorAll('th, td')).map(function(cell) {
var text = String(cell.textContent || '').trim();
return /[,"\r\n]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
}).join(','));
});
var csv = '\uFEFF' + lines.join('\r\n');
try {
var blob = new Blob([csv], { type: 'text/csv' });
var objectUrl = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = objectUrl;
link.download = 'table.csv';
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000);
} catch (e) {
if (typeof showToast === 'function') showToast('CSV export failed', 'error');
}
}
// ── OWUI-style code blocks with a Copy button ───────────────────────
function wireCodeBlocks(root) {
if (!root || !root.querySelectorAll) return;
root.querySelectorAll('pre').forEach(function(pre) {
if (pre.getAttribute('data-assistant-code-wired') === 'true') return;
pre.setAttribute('data-assistant-code-wired', 'true');
var wrapper = document.createElement('div');
wrapper.className = 'assistant-codeblock';
var button = document.createElement('button');
button.type = 'button';
button.className = 'assistant-code-copy';
button.setAttribute('data-assistant-code-copy', '');
button.textContent = 'Copy';
pre.parentNode.insertBefore(wrapper, pre);
wrapper.appendChild(pre);
wrapper.appendChild(button);
button.addEventListener('click', function() {
var code = pre.querySelector('code') || pre;
copyText(code.textContent, 'Code copied', 'Copy failed');
});
});
}
function copyText(text, successMessage, failureMessage) {
var navigatorRef = typeof navigator !== 'undefined' ? navigator : null;
if (!navigatorRef || !navigatorRef.clipboard || typeof navigatorRef.clipboard.writeText !== 'function') {
if (typeof showToast === 'function') showToast(failureMessage, 'error');
return;
}
navigatorRef.clipboard.writeText(String(text)).then(function() {
if (typeof showToast === 'function') showToast(successMessage, 'success');
}).catch(function() {
if (typeof showToast === 'function') showToast(failureMessage, 'error');
});
}
function renderEmbeddedBlocks(root) {
wireTableActions(root);
wireCodeBlocks(root);
function mermaidSource(el) {
var raw = el.getAttribute('data-mermaid') || '';
try { return decodeURIComponent(raw); } catch (e) { return raw; }
@ -598,9 +751,56 @@ import {
var citation = e.target.closest('#assistant-messages .assistant-cite');
if (citation) {
var bubble = citation.closest('.assistant-bubble');
if (bubble && Array.isArray(bubble.assistantSources)) renderSources(bubble.assistantSources);
if (bubble && Array.isArray(bubble.assistantSources)) {
renderSources(bubble.assistantSources);
var number = Number(citation.getAttribute('data-source-number'));
var source = bubble.assistantSources[number - 1];
if (source) openSourceModal(source, number);
}
return; // The native anchor navigates to the matching source in the refreshed panel.
}
var msgCopy = e.target.closest('[data-assistant-msg-copy]');
if (msgCopy) {
var msgRow = msgCopy.closest('.assistant-msg');
var msgBubble = msgRow && msgRow.querySelector('.assistant-bubble');
copyText(msgBubble && msgBubble.assistantRawContent !== undefined ? msgBubble.assistantRawContent : '', 'Message copied', 'Copy failed');
return;
}
var msgTranslate = e.target.closest('[data-assistant-msg-translate]');
if (msgTranslate) {
openTranslatePicker(msgTranslate.closest('.assistant-msg'));
return;
}
var translateLang = e.target.closest('[data-assistant-translate-lang]');
if (translateLang) {
var picker = translateLang.closest('[data-assistant-translate-pop]');
var providerButton = picker && picker.querySelector('[data-assistant-translate-provider].active');
requestMessageTranslation(picker.assistantMessageRow, translateLang.getAttribute('data-assistant-translate-lang'), providerButton ? providerButton.getAttribute('data-assistant-translate-provider') : translateProvider);
closeTranslatePicker();
return;
}
var translateProviderBtn = e.target.closest('[data-assistant-translate-provider]');
if (translateProviderBtn) {
var pop = translateProviderBtn.closest('[data-assistant-translate-pop]');
pop.querySelectorAll('[data-assistant-translate-provider]').forEach(function(b) { b.classList.remove('active'); });
translateProviderBtn.classList.add('active');
return;
}
var showOriginal = e.target.closest('[data-assistant-msg-show-original]');
if (showOriginal) {
var translatedBubble = showOriginal.closest('.assistant-bubble');
if (translatedBubble && translatedBubble.assistantOriginalHtml) {
translatedBubble.innerHTML = translatedBubble.assistantOriginalHtml;
renderEmbeddedBlocks(translatedBubble);
}
return;
}
var msgRegenerate = e.target.closest('[data-assistant-msg-regenerate]');
if (msgRegenerate) {
regenerateMessage(msgRegenerate.closest('.assistant-msg'));
return;
}
if (!e.target.closest('[data-assistant-translate-pop]')) closeTranslatePicker();
var removeAttachment = e.target.closest('[data-assistant-remove-attachment]');
if (removeAttachment) {
attachments.splice(Number(removeAttachment.getAttribute('data-assistant-remove-attachment')), 1);
@ -636,6 +836,207 @@ import {
}
}
// ── OWUI-style source modal (title, page, 10k excerpt + Show all) ──
function openSourceModal(source, number) {
closeSourceModal();
var title = source.title || source.resource || 'Source';
var page = source.page || source.page_number || source.pageNumber;
var excerpt = String(source.excerpt || '');
var modal = document.createElement('div');
modal.className = 'modal assistant-source-modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-labelledby', 'assistant-source-modal-title');
var body = '<div class="modal-content">' +
'<div class="modal-header"><h2 id="assistant-source-modal-title">Source ' + escapeHtml(number) + '</h2>' +
'<button type="button" class="modal-close" aria-label="Close source"><i class="fas fa-xmark"></i></button></div>' +
'<div class="modal-body">' +
'<div class="assistant-source-modal-meta">' + escapeHtml(title) + (page ? ' · page ' + escapeHtml(page) : '') + '</div>' +
'<div class="assistant-source-modal-excerpt" data-assistant-source-excerpt>' + escapeHtml(excerpt.slice(0, SOURCE_EXCERPT_PREVIEW)) + '</div>' +
(excerpt.length > SOURCE_EXCERPT_PREVIEW ? '<button type="button" class="btn-sm btn-ghost" data-assistant-source-show-all>Show all</button>' : '') +
'</div></div>';
modal.innerHTML = body;
modal.addEventListener('click', function(event) {
if (event.target === modal || event.target.closest('.assistant-source-modal .modal-close')) closeSourceModal();
});
var showAll = modal.querySelector('[data-assistant-source-show-all]');
if (showAll) {
showAll.addEventListener('click', function() {
modal.querySelector('[data-assistant-source-excerpt]').textContent = excerpt;
showAll.hidden = true;
});
}
document.body.appendChild(modal);
}
function closeSourceModal() {
var modal = document.querySelector('.assistant-source-modal');
if (modal) modal.remove();
}
function onAssistantKeydown(e) {
if (e.key === 'Escape') {
closeSourceModal();
closeTranslatePicker();
}
}
// ── Per-message translate with provider choice (local-first) ───────
function openTranslatePicker(row) {
if (!row) return;
closeTranslatePicker();
var pop = document.createElement('div');
pop.className = 'assistant-translate-pop';
pop.setAttribute('data-assistant-translate-pop', '');
pop.innerHTML = '<div class="assistant-translate-providers">' +
'<button type="button" data-assistant-translate-provider="libretranslate" class="' + (translateProvider === 'libretranslate' ? 'active' : '') + '">Local</button>' +
'<button type="button" data-assistant-translate-provider="deepl" class="' + (translateProvider === 'deepl' ? 'active' : '') + '">DeepL</button>' +
'</div>' +
TRANSLATE_LANGUAGES.map(function(pair) {
return '<button type="button" data-assistant-translate-lang="' + escapeAttr(pair[0]) + '">' + escapeHtml(pair[1]) + '</button>';
}).join('');
pop.assistantMessageRow = row;
row.appendChild(pop);
}
function closeTranslatePicker() {
document.querySelectorAll('[data-assistant-translate-pop]').forEach(function(pop) { pop.remove(); });
}
function requestMessageTranslation(row, target, provider) {
var bubble = row && row.querySelector('.assistant-bubble');
var content = bubble && bubble.assistantRawContent !== undefined ? bubble.assistantRawContent : '';
if (!bubble || !String(content).trim()) return;
if (!bubble.assistantOriginalHtml) bubble.assistantOriginalHtml = bubble.innerHTML;
translateAssistantMessage(content, target, provider)
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Translation failed');
bubble.innerHTML = '<p class="assistant-translated-text">' + escapeHtml(data.translated) + '</p>' +
'<button type="button" class="btn-sm btn-ghost" data-assistant-msg-show-original><i class="fas fa-undo"></i> Show original</button>';
})
.catch(function(err) {
if (typeof showToast === 'function') showToast(err && err.message ? err.message : 'Translation failed', 'error');
});
}
// ── Regenerate: replay the preceding question (latest answer only) ──
function regenerateMessage(row) {
if (assistantBusy) {
if (typeof showToast === 'function') showToast('Assistant is still finishing the current answer', 'error');
return;
}
var index = Number(row && row.getAttribute('data-message-index'));
var message = Number.isInteger(index) ? messages[index] : null;
if (!message || message.role !== 'assistant') return;
var lastAssistantIndex = -1;
for (var i = messages.length - 1; i >= 0; i--) if (messages[i].role === 'assistant') { lastAssistantIndex = i; break; }
if (index !== lastAssistantIndex) {
if (typeof showToast === 'function') showToast('Only the latest answer can be regenerated in this chat.', 'error');
return;
}
var question = messages[index - 1];
if (!question || question.role !== 'user') {
if (typeof showToast === 'function') showToast('No question precedes this answer to regenerate.', 'error');
return;
}
messages = messages.slice(0, index);
if (row && row.parentNode) row.remove();
var input = document.getElementById('assistant-input');
if (input) input.value = question.content;
regenerateMode = true;
updateConversationBudget();
onAsk();
}
// ── Workspace navigation: Go back, chat / learning view switch ─────
function goBackToMainMenu() {
if (typeof window.activateTab === 'function') {
window.activateTab(lastNonAssistantTab || 'encounter');
return;
}
if (typeof document !== 'undefined' && document.dispatchEvent) document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'encounter' } }));
}
function openChatView() {
var chat = document.getElementById('assistant-chat-view');
var learning = document.getElementById('assistant-learning-view');
var chatBtn = document.getElementById('btn-assistant-chat-view');
var learningBtn = document.getElementById('btn-assistant-learning-view');
if (chat) chat.hidden = false;
if (learning) learning.hidden = true;
if (chatBtn) chatBtn.classList.add('active');
if (learningBtn) learningBtn.classList.remove('active');
}
function openLearningView() {
var chat = document.getElementById('assistant-chat-view');
var learning = document.getElementById('assistant-learning-view');
var root = document.getElementById('assistant-learning-root');
var chatBtn = document.getElementById('btn-assistant-chat-view');
var learningBtn = document.getElementById('btn-assistant-learning-view');
if (!learning || !root) return;
if (chat) chat.hidden = true;
learning.hidden = false;
if (chatBtn) chatBtn.classList.remove('active');
if (learningBtn) learningBtn.classList.add('active');
var load = learningViewHtml ? Promise.resolve(learningViewHtml) :
(typeof fetch === 'function' ? fetch('/components/learning.html').then(function(response) {
if (!response.ok) throw new Error('Learning Hub unavailable');
return response.text();
}).then(function(html) { learningViewHtml = html; return html; }) : Promise.reject(new Error('Learning Hub unavailable')));
load.then(function(html) {
if (!root.hasChildNodes()) root.innerHTML = html;
document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'learning' } }));
}).catch(function(err) {
openChatView();
if (typeof showToast === 'function') showToast(err.message || 'Learning Hub unavailable', 'error');
});
}
// ── Debounced autosave (800ms after each completed turn/change) ────
function scheduleAutosave() {
if (typeof setTimeout !== 'function' || !messages.length) return;
autosaveErrorShown = false; // a new change retries
if (autosaveTimer && typeof clearTimeout === 'function') clearTimeout(autosaveTimer);
autosaveTimer = setTimeout(performAutosave, AUTOSAVE_DELAY_MS);
}
function cancelAutosave() {
if (autosaveTimer && typeof clearTimeout === 'function') clearTimeout(autosaveTimer);
autosaveTimer = null;
}
function autosavePayload() {
return {
title: currentChatId ? undefined : deriveChatTitle(),
messages: messages,
sources: lastSources,
lastAnswer: lastAnswer,
generatedImageJobs: generatedImageJobs,
generatedImage: lastGeneratedImageSrc || undefined,
...(currentChatId ? { id: currentChatId } : {})
};
}
function performAutosave() {
autosaveTimer = null;
if (assistantBusy || !messages.length) return;
return saveAssistantChat(autosavePayload())
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Autosave failed');
if (data.id) currentChatId = data.id;
autosaveErrorShown = false;
loadSavedChats();
})
.catch(function(err) {
// Surface once per change; never block or retry-loop the chat flow.
if (!autosaveErrorShown) {
autosaveErrorShown = true;
if (typeof showToast === 'function') showToast(err && err.message ? err.message : 'Autosave failed', 'error');
}
});
}
function clearGeneratedImage() {
lastGeneratedImageSrc = '';
generatedImageJobs = [];
@ -671,6 +1072,9 @@ import {
function performClearConversation() {
if (activeAssistantRequest) cancelAssistantSearch();
cancelAutosave();
currentChatId = null;
autosaveErrorShown = false;
messages = [];
attachments = [];
renderAttachments();
@ -685,7 +1089,6 @@ import {
}
renderSources([]);
clearGeneratedImage();
setHandoff('');
updateConversationBudget();
loadSavedChats();
}
@ -736,15 +1139,6 @@ import {
});
}
function copyLastAnswer() {
if (!lastAnswer) { if (typeof showToast === 'function') showToast('No answer to copy', 'error'); return; }
navigator.clipboard.writeText(lastAnswer).then(function () {
if (typeof showToast === 'function') showToast('Copied answer', 'success');
}).catch(function () {
if (typeof showToast === 'function') showToast('Copy failed', 'error');
});
}
function exportAnswerPdf() {
exporter.exportAnswerPdf({
messages: messages,
@ -761,17 +1155,21 @@ import {
var titleEl = document.getElementById('assistant-save-title');
var title = String(titleEl && titleEl.value || deriveChatTitle()).trim() || deriveChatTitle();
setBusy(true, 'Saving chat...');
// Sidebar image is session-only and is deliberately not saved with the chat.
// The generated sidebar image persists with the chat; a manual save also updates the autosaved row.
cancelAutosave();
return saveAssistantChat({
title: title,
messages: messages,
sources: lastSources,
lastAnswer: lastAnswer,
generatedImageJobs: generatedImageJobs
generatedImageJobs: generatedImageJobs,
generatedImage: lastGeneratedImageSrc || undefined,
...(currentChatId ? { id: currentChatId } : {})
})
.then(function (data) {
setBusy(false, 'Ready');
if (!data.success) throw new Error(data.error || 'Save failed');
if (data.id) currentChatId = data.id;
hideSavePanel();
if (typeof showToast === 'function') showToast('Saved chat', 'success');
loadSavedChats();
@ -829,6 +1227,7 @@ import {
return fetchSavedAssistantChat(id)
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Load failed');
currentChatId = data.chat ? data.chat.id : null;
restoreSavedChat(data.chat && data.chat.payload || {});
if (typeof showToast === 'function') showToast('Loaded saved chat', 'success');
})
@ -856,7 +1255,7 @@ import {
function restoreSavedChat(payload) {
messages = Array.isArray(payload.messages) ? payload.messages.map(function (m) {
var message = { role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content || ''), sources: Array.isArray(m.sources) ? m.sources : [], imageJobs: m.imageJobs || [] };
var message = { role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content || ''), sources: Array.isArray(m.sources) ? m.sources : [], imageJobs: m.imageJobs || [], attachments: Array.isArray(m.attachments) ? m.attachments : [] };
if ((payload.version !== 2 || m.legacyClipped === true) && message.content.length === 12000 && !/[\r\n]/.test(message.content)) {
message.legacyClipped = true;
if (message.role === 'assistant' && isRetainedLegacyAnswer(message.content, m.retainedAnswer)) message.retainedAnswer = m.retainedAnswer;
@ -875,9 +1274,11 @@ import {
var wrap = document.getElementById('assistant-messages');
if (wrap) {
wrap.innerHTML = '';
messages.forEach(function (m) {
messages.forEach(function (m, index) {
var display = savedMessagePresentation(m);
display.attachments = m.attachments;
var row = appendMessageNode(m.role, display.answer, m.sources && m.sources.length ? m.sources : lastSources, null, false, display);
row.dataset.messageIndex = String(index);
attachImageJobs(row, m, m.imageJobs);
});
wrap.scrollTop = wrap.scrollHeight;
@ -894,7 +1295,6 @@ import {
});
}
exporter.invalidate();
setHandoff('');
updateConversationBudget();
}
@ -930,13 +1330,19 @@ import {
row.appendChild(bubble);
wrap.appendChild(row);
fillMessageBubble(bubble, role, content, sources, suggestions, rawHtml, options);
var actions = document.createElement('div');
actions.className = 'assistant-msg-actions';
actions.innerHTML = '<button type="button" data-assistant-msg-copy title="Copy raw message"><i class="fas fa-copy"></i></button>' +
'<button type="button" data-assistant-msg-translate title="Translate"><i class="fas fa-language"></i></button>' +
(role === 'assistant' ? '<button type="button" data-assistant-msg-regenerate title="Regenerate"><i class="fas fa-rotate-right"></i></button>' : '');
row.appendChild(actions);
wrap.scrollTop = wrap.scrollHeight;
return row;
}
function deriveChatTitle() {
var first = messages.find(function (m) { return m.role === 'user' && m.content; });
return String(first && first.content || 'Clinical assistant chat').replace(/\s+/g, ' ').trim().slice(0, 80);
return String(first && first.content || 'Clinical assistant chat').replace(/\s+/g, ' ').trim().slice(0, 60);
}
function conversationSize(question) {
@ -960,13 +1366,13 @@ import {
warning.textContent = (used > conversationChars ? 'Conversation limit exceeded. Sending is blocked.' :
used === conversationChars ? 'At the conversation limit. Any additional input will exceed it.' :
'Approaching the conversation limit (90% or more used).') +
' Nothing is truncated or automatically summarized. Save or download this chat, then choose New chat or explicitly request a handoff summary. A handoff also requires the existing history to fit the budget; your unsent draft is not included in a handoff.';
' Nothing is truncated or automatically summarized. Save or download this chat, then choose New chat.';
}
}
function downloadTranscript() {
var payload = { version: 2, title: deriveChatTitle(), messages: messages, sources: lastSources,
lastAnswer: lastAnswer, generatedImageJobs: generatedImageJobs, savedAt: new Date().toISOString() };
lastAnswer: lastAnswer, generatedImageJobs: generatedImageJobs, generatedImage: lastGeneratedImageSrc || undefined, savedAt: new Date().toISOString() };
var url = URL.createObjectURL(new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }));
var link = document.createElement('a');
link.href = url;
@ -977,39 +1383,6 @@ import {
setTimeout(function() { URL.revokeObjectURL(url); }, 60000);
}
function setHandoff(summary) {
var text = String(summary || '');
document.getElementById('assistant-handoff-text').value = text; // Keep exact copy text.
var preview = document.getElementById('assistant-handoff-preview');
if (preview) {
preview.innerHTML = text ? renderMarkdown(text, []) : '';
if (text) renderEmbeddedBlocks(preview);
}
var modal = document.getElementById('assistant-handoff-modal');
if (modal) modal.classList.toggle('hidden', !text);
}
function requestHandoff() {
if (assistantBusy || !messages.length) return;
if (conversationChars !== null && conversationSize('') > conversationChars) {
updateConversationBudget();
if (typeof showToast === 'function') showToast('History exceeds the handoff budget. Save/download the full chat; nothing has been changed.', 'error');
return;
}
setBusy(true, 'Creating requested handoff...');
return requestAssistantHandoff(messages.map(function(m) { return { role: m.role, content: m.content }; }))
.then(function(data) {
if (!data.success) {
if (data.budget) conversationChars = validConversationLimit(data.budget.limit) ? data.budget.limit : null;
updateConversationBudget();
throw new Error(data.error || 'Handoff failed. Your conversation is unchanged.');
}
setHandoff(data.summary);
})
.catch(function(error) { if (typeof showToast === 'function') showToast(error.message, 'error'); })
.finally(function() { setBusy(false, 'Ready'); });
}
function lastAssistantMessage(items) {
for (var i = items.length - 1; i >= 0; i--) if (items[i].role === 'assistant') return items[i].content;
return '';
@ -1052,4 +1425,21 @@ import {
if (attachInput) attachInput.disabled = !!isBusy;
}
function sanitize(html) { return window.DOMPurify ? window.DOMPurify.sanitize(html, { ADD_ATTR: ['target'] }) : escapeHtml(html); }
var imageHookInstalled = false;
function sanitize(html) {
if (window.DOMPurify) {
// Defense in depth for the safe-image allowlist: after sanitization,
// drop any <img> whose src is not a data URI or our own origin.
if (!imageHookInstalled && typeof window.DOMPurify.addHook === 'function') {
imageHookInstalled = true;
window.DOMPurify.addHook('uponSanitizeElement', function(node, data) {
if (data.tagName === 'img') {
var src = node.getAttribute ? (node.getAttribute('src') || '') : '';
if (!safeImageUrl(src)) node.remove();
}
});
}
return window.DOMPurify.sanitize(html, { ADD_ATTR: ['target'] });
}
return escapeHtml(html);
}

View file

@ -6,6 +6,7 @@
// ============================================================
var express = require('express');
var axios = require('axios');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
@ -41,6 +42,8 @@ var {
} = require('../utils/clinicalAnswer');
var { conversationBudget, checkConversation, validateAttachments, savedChatPayload } = require('../utils/clinicalConversation');
var clinicalTranslation = require('../utils/clinicalTranslation');
var translateCache = clinicalTranslation.createTranslationCache();
var { DEFAULT_BEHAVIOR } = require('../utils/clinicalPrompts');
@ -86,6 +89,8 @@ router.get('/clinical-assistant/status', async function(req, res) {
var imageModel = await getSetting('clinical_assistant.image_model', '');
var searchLimit = clampInt(await getSetting('clinical_assistant.search_limit', '8'), 3, 20, 8);
var contextChars = clampInt(await getSetting('clinical_assistant.context_chars', '1400'), 300, 4000, 1400);
var translateProvider = String(await getSetting('clinical_assistant.translate_provider', '') || 'libretranslate').toLowerCase();
if (!clinicalTranslation.TRANSLATE_PROVIDERS.includes(translateProvider)) translateProvider = 'libretranslate';
var budget = conversationBudget(process.env);
var mcpHealth = await getMcpHealth();
res.json({
@ -99,6 +104,7 @@ router.get('/clinical-assistant/status', async function(req, res) {
conversationEnv: budget.env,
conversationSource: budget.source,
conversationMeasure: budget.measure,
translateProvider: translateProvider,
mcp: mcpHealth
});
} catch (e) {
@ -150,11 +156,30 @@ router.get('/clinical-assistant/chats/:id', async function(req, res) {
router.post('/clinical-assistant/chats', async function(req, res) {
try {
var title = cleanSavedChatTitle(req.body.title || firstUserMessage(req.body.messages) || 'Clinical assistant chat');
// Build and validate the payload first: 400/413 semantics fire before any write.
var payload = savedChatPayload(req.body);
await imageLinks.validateChat(db, payload, req.user.id);
var payloadText = JSON.stringify(payload);
var existingId = cleanSavedChatId(req.body.id);
if (existingId) {
var existing = await db.get(
'SELECT id, title FROM clinical_assistant_chats WHERE id = $1 AND user_id = $2',
[existingId, req.user.id]
);
if (!existing) return res.status(404).json({ error: 'Saved chat not found', code: 'CHAT_NOT_FOUND' });
var storedTitle = existing.title;
try { storedTitle = cryptoUtil.decryptString(storedTitle); } catch (e) {}
var title = req.body.title ? cleanSavedChatTitle(req.body.title) : cleanSavedChatTitle(storedTitle);
await db.run(
'UPDATE clinical_assistant_chats SET title = $1, payload = $2, updated_at = NOW() WHERE id = $3 AND user_id = $4',
[cryptoUtil.encryptString(title), cryptoUtil.encryptString(payloadText), existingId, req.user.id]
);
logger.audit(req.user.id, 'clinical_assistant_chat_update', 'Updated clinical assistant chat', req, { category: 'clinical' });
return res.json({ success: true, id: existingId, title: title });
}
var title = cleanSavedChatTitle(req.body.title || firstUserMessage(req.body.messages) || 'Clinical assistant chat');
var count = await db.get('SELECT COUNT(*) as cnt FROM clinical_assistant_chats WHERE user_id = $1', [req.user.id]);
if (count && Number(count.cnt) >= MAX_SAVED_CHATS_PER_USER) {
return res.status(400).json({ error: 'Maximum ' + MAX_SAVED_CHATS_PER_USER + ' saved chats per user' });
@ -183,6 +208,25 @@ router.delete('/clinical-assistant/chats/:id', async function(req, res) {
}
});
router.post('/clinical-assistant/translate', async function(req, res) {
try {
var result = await clinicalTranslation.translateMessage({
message: req.body.message,
target: req.body.target,
provider: req.body.provider,
userId: req.user.id,
env: process.env,
getSetting: getSetting,
axios: axios,
cache: translateCache
});
res.json({ success: true, translated: result.translated, provider: result.provider, target: String(req.body.target || '').toLowerCase() });
} catch (e) {
if (!e.statusCode || e.statusCode >= 500) logger.error('POST /clinical-assistant/translate', e.message);
res.status(e.statusCode || 502).json({ error: e.statusCode ? e.message : 'Translation service unavailable', code: e.code });
}
});
router.post('/clinical-assistant/chat', async function(req, res) {
var started = Date.now();
try {
@ -300,27 +344,6 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) {
}
});
router.post('/clinical-assistant/handoff', async function(req, res) {
try {
var checked = checkConversation(req.body.history, '', await getConversationLimit(), true);
if (!checked.history.length) return res.status(400).json({ error: 'There is no conversation to summarize.' });
var model = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
var ai = await callAI([
{ role: 'system', content: 'Create a concise handoff summary only because the user explicitly requested it. Preserve user-reported clinical facts, age, units, medication names/doses, corrections, contradictions and uncertainty. Distinguish user facts from prior assistant suggestions; prior AI output is not evidence. Do not add facts or clinical recommendations. Include unresolved questions. Use the heading "Clinical handoff". Do not silently resolve contradictions. This does not start or replace a chat.' },
{ role: 'user', content: checked.history.map(function(m) { return m.role.toUpperCase() + ': ' + m.content; }).join('\n\n') }
], assistantGenerationOptions({ model: model || undefined, temperature: 0, maxTokens: 1200 }));
if (!ai || !String(ai.content || '').trim()) throw new Error('No handoff summary was returned. Your conversation is unchanged.');
if (ai.finishReason !== 'stop') {
var incomplete = new Error('The handoff summary did not report a complete response and was not accepted. Your conversation is unchanged; download the full transcript instead.');
incomplete.statusCode = 503;
throw incomplete;
}
res.json({ success: true, summary: ai.content, model: ai.model || model, budget: checked.budget });
} catch (e) {
res.status(e.statusCode || 500).json({ error: assistantErrorMessage(e), code: e.code, budget: e.budget });
}
});
async function submitImage(req, res, synchronous) {
try {
const body = req.body || {};
@ -358,7 +381,7 @@ async function prepareAssistantChat(body) {
var history = checked.history;
// Image attachments are validated before any retrieval or provider call.
// The conversation budget counts the text only; images are excluded from
// the UTF-16 count and are never persisted in saved chats or transcripts.
// the UTF-16 count. Once sent, attachments persist with saved chats.
var images = validateAttachments(body.images);
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
@ -503,6 +526,18 @@ function cleanSavedChatTitle(title) {
return clip(title, MAX_SAVED_CHAT_TITLE).replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim() || 'Clinical assistant chat';
}
function cleanSavedChatId(value) {
if (value === undefined || value === null || value === '') return null;
var n = Number(value);
if (!Number.isInteger(n) || n <= 0) {
var error = new Error('Invalid saved chat id.');
error.statusCode = 400;
error.code = 'INVALID_SAVED_CHAT';
throw error;
}
return n;
}
async function getAvailableExamples() {
return promptPool.getAvailableExamples();
}

View file

@ -1,7 +1,7 @@
// Conversation text uses an exact character budget, not an estimated model token limit.
// Image attachments are excluded from this budget: UTF-16 code units count the
// text only. Attachments are validated separately below and are input-only —
// they are never persisted in saved chats or transcripts.
// text only. Attachments ride the outgoing question only for inference, and
// persist with saved chats per message once sent.
const DEFAULT_CONVERSATION_CHARS = 120000;
const MAX_SAVED_CHAT_BYTES = 8 * 1024 * 1024;
@ -50,7 +50,7 @@ function checkConversation(history, message, limit, handoff) {
const used = history.reduce((total, turn) => total + turn.content.length, message.length);
const budget = { used, limit, unit: 'characters', remaining: Math.max(0, limit - used) };
if (used > limit) {
const error = failure('Conversation limit reached. Nothing was truncated or sent to an AI provider. Save or download this chat, then start a new chat or explicitly request a handoff summary.', 413, 'CONVERSATION_LIMIT');
const error = failure('Conversation limit reached. Nothing was truncated or sent to an AI provider. Save or download this chat, then start a new chat.', 413, 'CONVERSATION_LIMIT');
error.budget = budget;
throw error;
}
@ -97,15 +97,17 @@ function savedJobs(imageJobs) {
return imageJobs.map(job => ({ jobId: job.jobId }));
}
// Image attachments accompany the outgoing clinical question only.
// Strict limits: PNG/JPEG/WebP, canonical base64, <= 4 images,
// <= 5 MiB decoded per image, <= 10 MiB decoded total. Invalid input is
// rejected before any retrieval or provider call. The conversation budget
// above counts the text only; image bytes never enter the UTF-16 count.
// Image attachments accompany the outgoing clinical question. Strict limits:
// PNG/JPEG/WebP, canonical base64, <= 4 images, <= 5 MiB decoded per image,
// <= 10 MiB decoded total. Invalid input is rejected before any retrieval or
// provider call. The conversation budget above counts the text only; image
// bytes never enter the UTF-16 count. Once a question is sent, its attachments
// persist with the saved chat (validated again below on every save).
const ATTACHMENT_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
const MAX_ATTACHMENT_IMAGES = 4;
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
const MAX_ATTACHMENT_TOTAL_BYTES = 10 * 1024 * 1024;
const MAX_ATTACHMENT_NAME = 255;
const CANONICAL_BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
function validateAttachments(images) {
@ -136,11 +138,26 @@ function validateAttachments(images) {
});
}
// Saved-chat attachments reuse the same bounded wire shape plus an optional
// display name. Extra fields are dropped, never stored.
function validateSavedAttachments(attachments) {
if (attachments === undefined || attachments === null) return [];
if (!Array.isArray(attachments)) throw failure('Saved image attachments must be a list.', 400, 'INVALID_SAVED_CHAT');
const validated = validateAttachments(attachments);
return validated.map(function (image, index) {
var original = attachments[index] || {};
var name = original.name === undefined || original.name === null ? '' : String(original.name);
name = name.replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, MAX_ATTACHMENT_NAME);
return name ? { dataBase64: image.dataBase64, mimeType: image.mimeType, name } : { dataBase64: image.dataBase64, mimeType: image.mimeType };
});
}
function savedChatPayload(body) {
const messages = validateMessages(body.messages).map(function(message, index) {
const original = body.messages[index];
const imageJobs = savedJobs(original.imageJobs);
const copy = { ...message, sources: savedSources(original.sources), ...(imageJobs.length ? { imageJobs } : {}) };
const attachments = validateSavedAttachments(original.attachments);
const copy = { ...message, sources: savedSources(original.sources), ...(imageJobs.length ? { imageJobs } : {}), ...(attachments.length ? { attachments } : {}) };
// Preserve legacy loss provenance and an existing retained raw field across
// a v2 re-save/follow-up; neither is inference history or replacement text.
if (original.legacyClipped === true && message.content.length === 12000 && !/[\r\n]/.test(message.content)) {
@ -162,12 +179,13 @@ function savedChatPayload(body) {
...(body.generatedImageJobs !== undefined ? { generatedImageJobs: savedJobs(body.generatedImageJobs) } : {}),
savedAt: new Date().toISOString()
};
// Sidebar image is session-only: validated when supplied but never stored in saved chats.
if (body.generatedImage !== undefined && body.generatedImage !== null && body.generatedImage !== '') savedImage(body.generatedImage);
// The generated sidebar image persists with the chat (supersedes the earlier
// session-only rule); validated but stored only when present.
if (body.generatedImage !== undefined && body.generatedImage !== null && body.generatedImage !== '') payload.generatedImage = savedImage(body.generatedImage);
if (Buffer.byteLength(JSON.stringify(payload), 'utf8') > MAX_SAVED_CHAT_BYTES) {
throw failure('Saved chat exceeds the 8 MiB storage limit. Nothing was saved or truncated; download the complete transcript instead.', 413, 'SAVED_CHAT_LIMIT');
}
return payload;
}
module.exports = { DEFAULT_CONVERSATION_CHARS, MAX_SAVED_CHAT_BYTES, conversationLimit, conversationBudget, checkConversation, validateAttachments, savedChatPayload };
module.exports = { DEFAULT_CONVERSATION_CHARS, MAX_SAVED_CHAT_BYTES, conversationLimit, conversationBudget, checkConversation, validateAttachments, validateSavedAttachments, savedChatPayload };

View file

@ -0,0 +1,134 @@
// Clinical Assistant message translation — local-first, owner-bound.
// Provider 1: LibreTranslate (the user's own local container, default).
// Provider 2: DeepL (admin-configured alternative; never contacted unless
// DEEPL_API_KEY is set and DEEPL_API_BASE is an allowlisted endpoint).
// No patient data ever leaves the local network unless the admin explicitly
// configures DeepL. Validation failures NEVER trigger provider fallback;
// only transient/provider-side failures fall back once to the other provider.
const crypto = require('crypto');
const axios = require('axios');
const TRANSLATE_PROVIDERS = ['libretranslate', 'deepl'];
const TRANSLATE_LANGS = ['en', 'es', 'fr', 'de', 'it', 'pt', 'zh', 'ar', 'ru', 'hi', 'nl', 'pl', 'tr', 'uk', 'fa', 'sw'];
// DeepL cannot map fa/sw (no such target codes); refusing them is validation, not a provider failure.
const DEEPL_LANGS = ['en', 'es', 'fr', 'de', 'it', 'pt', 'zh', 'ar', 'ru', 'hi', 'nl', 'pl', 'tr', 'uk'];
const DEEPL_BASES = ['https://api.deepl.com/v2', 'https://api-free.deepl.com/v2'];
const MAX_TRANSLATE_CHARS = 20000;
const PROVIDER_TIMEOUT_MS = 15000;
const DEFAULT_CACHE_MAX = 100;
function failure(message, statusCode, code) {
return Object.assign(new Error(message), { statusCode, code });
}
function createTranslationCache(max = DEFAULT_CACHE_MAX) {
return { map: new Map(), max: max };
}
function cacheKey(cache, provider, userId, target, message) {
const hash = crypto.createHash('sha256').update(String(message)).digest('hex');
return provider + '\u0000' + String(userId || '') + '\u0000' + target + '\u0000' + hash;
}
function cacheGet(cache, key) { return cache.map.get(key); }
function cacheSet(cache, key, value) {
if (cache.map.size >= cache.max) cache.map.delete(cache.map.keys().next().value); // ponytail: FIFO eviction is enough here
cache.map.set(key, value);
}
function isTransientError(err) {
const status = err && err.response && err.response.status;
return !status || status >= 500 || status === 429;
}
async function callLibreTranslate(message, target, env, http) {
const base = String(env.LIBRETRANSLATE_URL || 'http://libretranslate:5000');
if (!/^https?:\/\//.test(base)) throw failure('Local translation service is misconfigured.', 503, 'LIBRETRANSLATE_UNREACHABLE');
const response = await http.post(base + '/translate', { q: message, source: 'auto', target: target, format: 'text' }, { timeout: PROVIDER_TIMEOUT_MS });
const translated = response && response.data && response.data.translatedText;
if (!translated) throw failure('Local translation service returned no translation.', 502, 'LIBRETRANSLATE_EMPTY');
return String(translated);
}
async function callDeepL(message, target, env, http) {
const base = String(env.DEEPL_API_BASE || 'https://api.deepl.com/v2');
const key = String(env.DEEPL_API_KEY || '');
if (!key || !DEEPL_BASES.includes(base)) throw failure('DeepL is not configured for this server.', 503, 'DEEPL_NOT_CONFIGURED');
const body = 'text=' + encodeURIComponent(message) + '&target_lang=' + target.toUpperCase();
const response = await http.post(base + '/translate', body, {
headers: { Authorization: 'DeepL-Auth-Key ' + key, 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: PROVIDER_TIMEOUT_MS
});
const translated = response && response.data && Array.isArray(response.data.translations) && response.data.translations[0] && response.data.translations[0].text;
if (!translated) throw failure('DeepL returned no translation.', 502, 'DEEPL_EMPTY');
return String(translated);
}
async function attemptProvider(provider, message, target, env, http) {
if (provider === 'libretranslate') return callLibreTranslate(message, target, env, http);
return callDeepL(message, target, env, http);
}
async function translateMessage(options) {
const opts = options || {};
const message = opts.message;
const target = String(opts.target || '').toLowerCase();
const http = opts.axios || axios;
const env = opts.env || process.env || {};
var cache = opts.cache || createTranslationCache();
if (!cache.map) cache = { map: cache, max: DEFAULT_CACHE_MAX }; // accept a raw Map
if (typeof message !== 'string' || !message.trim()) throw failure('Message to translate is required.', 400, 'INVALID_TRANSLATE');
if (message.length > MAX_TRANSLATE_CHARS) throw failure('Message exceeds the 20,000 character translation limit.', 400, 'INVALID_TRANSLATE');
if (!TRANSLATE_LANGS.includes(target)) throw failure('Unsupported translation language.', 400, 'INVALID_TRANSLATE');
var provider = opts.provider;
if (provider == null || provider === '') {
var configured = null;
if (typeof opts.getSetting === 'function') {
try { configured = await opts.getSetting('clinical_assistant.translate_provider', ''); } catch (e) { configured = null; }
}
provider = String(configured || 'libretranslate').toLowerCase();
}
if (!TRANSLATE_PROVIDERS.includes(provider)) throw failure('Unsupported translation provider.', 400, 'INVALID_TRANSLATE_PROVIDER');
if (provider === 'deepl' && !DEEPL_LANGS.includes(target)) throw failure('DeepL cannot translate into this language; choose the local provider.', 400, 'TARGET_UNSUPPORTED');
const key = cacheKey(cache, provider, opts.userId, target, message);
const hit = cacheGet(cache, key);
if (hit) return { translated: hit, provider: provider };
async function run(p) {
try {
const translated = await attemptProvider(p, message, target, env, http);
cacheSet(cache, key, translated);
return { translated, provider: p };
} catch (err) {
const error = failure(err && err.message ? err.message : 'Translation service unavailable.',
(err && err.statusCode) || (isTransientError(err) ? 502 : ((err && err.response && err.response.status) || 502)),
err && err.code ? err.code : 'TRANSLATION_FAILED');
error.transient = (isTransientError(err) && !err.statusCode) || err.code === 'LIBRETRANSLATE_EMPTY' || err.code === 'DEEPL_EMPTY';
throw error;
}
}
try {
return await run(provider);
} catch (firstError) {
// Fall back once, only for transient/provider-side failures of a configured other provider.
if (!firstError.transient) throw firstError;
const other = TRANSLATE_PROVIDERS.find(p => p !== provider);
if (!other || other === 'deepl' && (!String(env.DEEPL_API_KEY || '') || !DEEPL_BASES.includes(String(env.DEEPL_API_BASE || 'https://api.deepl.com/v2')))) throw firstError;
if (other === 'deepl' && !DEEPL_LANGS.includes(target)) throw firstError; // capability is validation, not fallback material
return run(other);
}
}
module.exports = {
TRANSLATE_PROVIDERS,
TRANSLATE_LANGS,
DEEPL_LANGS,
DEEPL_BASES,
MAX_TRANSLATE_CHARS,
createTranslationCache,
translateMessage
};

View file

@ -65,9 +65,9 @@ test('native admin initializer preserves lazy navigation, assistant actions and
const writes = () => calls.filter(c => c.options.method === 'PUT');
const save = document.getElementById('btn-save-assistant-config');
save.click(); await tick();
assert.equal(writes().length, 3);
assert.equal(writes().length, 4);
assert.deepEqual(writes().map(c => c.url.split('/').pop()).sort(), [
'clinical_assistant.chat_model', 'clinical_assistant.context_chars', 'clinical_assistant.search_limit'
'clinical_assistant.chat_model', 'clinical_assistant.context_chars', 'clinical_assistant.search_limit', 'clinical_assistant.translate_provider'
]);
assert.ok(toasts.some(([message, kind]) => message === 'Assistant settings saved' && kind === 'success'));

View file

@ -0,0 +1,165 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const express = require('express');
const { JSDOM } = require('jsdom');
const { marked } = require('marked');
const policy = require('../src/utils/clinicalConversation');
const answer = require('../src/utils/clinicalAnswer');
const root = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const quiet = { log() {}, warn() {}, error() {}, info() {} };
const canonical = (length, fill = 7) => Buffer.alloc(length, fill).toString('base64');
const png = { dataBase64: canonical(16), mimeType: 'image/png', name: 'chest.png' };
const jpeg = { dataBase64: canonical(32), mimeType: 'image/jpeg', name: 'knee.jpeg' };
const asset = '/api/generated-images/12345678-1234-1234-1234-123456789abc';
function server(options = {}) {
const calls = { ai: [], search: [], writes: [], images: [] };
let saved;
const db = {
async getSetting(key) {
if (options.dbError) throw Error('private diagnostic');
if (key.endsWith('conversation_chars')) return options.legacyLimit ?? 'broken';
if (key === 'clinical_assistant.translate_provider') return 'libretranslate';
return options.model || null;
},
async get(sql, params) {
if (sql.includes('COUNT')) return { cnt: 0 };
if (sql.includes('FROM clinical_assistant_chats') && params) return saved && params[0] === 1 && params[1] === 7 ? { id: params[0], title: saved[1], payload: saved[2] } : null;
return saved && params[1] === 7 ? { id: 1, title: saved[1], payload: saved[2] } : null;
},
async run(sql, params) { calls.writes.push(sql); saved = params; return { lastInsertRowid: 1 }; },
async query(sql, params) { return { rows: (params[0] || []).map(id => ({ id })) }; }
};
const ai = async (messages, settings) => {
calls.ai.push({ messages, settings });
return { content: 'Complete supported answer. [1]', finishReason: 'stop', model: 'synthetic' };
};
const source = { number: 1, title: 'Synthetic source', excerpt: 'Synthetic reference.', page: 9 };
const mocks = {
express, axios: { async post(url, payload) { if (String(url).includes('/translate')) return { data: { translatedText: 'Synthetic translation.' } }; calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db,
'../middleware/auth': { authMiddleware() {} }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai },
'../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {} },
'../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') },
'../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) },
'../utils/generatedImages': { ...require('../src/utils/generatedImages'), service: () => ({ async get() { return { jobId: 'synthetic', status: 'done', success: true }; }, async enqueue() { return { success: true, jobId: 'synthetic', status: 'pending' }; } }) },
'../utils/generatedImageLinks': require('../src/utils/generatedImageLinks'),
'../utils/imageTool': require('../src/utils/imageTool'),
'../utils/clinicalPrompts': require('../src/utils/clinicalPrompts'),
'../utils/clinicalMcpClient': {
async semanticSearch(query) { calls.search.push(query); return {}; }, async getMcpHealth() { calls.health = calls.health || []; calls.health.push('health'); return {}; }
},
'../utils/clinicalRetrieval': {
cleanSourceExcerpt: require('../src/utils/clinicalRetrieval').cleanSourceExcerpt,
normalizeMcpSearchResponse: () => options.noSources ? [] : [source],
normalizeMcpMultimodalResponse: () => [], dedupeSources: value => value,
isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => []
},
'../utils/clinicalAnswer': answer, '../utils/clinicalConversation': policy,
'../utils/clinicalTranslation': require('../src/utils/clinicalTranslation')
};
const module = { exports: {} };
vm.runInNewContext(read('src/routes/clinicalAssistant.js'), {
module, exports: module.exports, console: quiet, Buffer, Map, TextEncoder,
process: { env: { CLINICAL_ASSISTANT_MCP_WARMUP: 'false', CLINICAL_ASSISTANT_CONVERSATION_CHARS: options.limit, LITELLM_API_BASE: 'http://synthetic.invalid' } }, setTimeout() {},
require(name) { assert.ok(Object.hasOwn(mocks, name), 'Unexpected import: ' + name); return mocks[name]; }
});
async function request(method, endpoint, body, userId = 7) {
const handler = module.exports.stack.find(layer => layer.route && layer.route.path === endpoint && layer.route.methods[method]).route.stack.find(layer => layer.method === method).handle;
const res = {
statusCode: 200, headers: {}, events: '', body: null,
status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; },
setHeader(k, v) { this.headers[k] = v; }, write(text) { this.events += text; }, end() {}, flushHeaders() {}
};
await handler({ body, params: { id: 1 }, user: { id: userId } }, res);
return res;
}
return { calls, request };
}
test('savedChatPayload persists bounded attachments and the generated image; invalid input is rejected before any write', () => {
const messages = [{ role: 'user', content: 'Question', attachments: [png, jpeg] }, { role: 'assistant', content: 'Answer [1]', sources: [{ number: 1, title: 'S' }] }];
const payload = policy.savedChatPayload({ messages, lastAnswer: 'Answer [1]', sources: [], generatedImage: asset });
assert.deepEqual(payload.messages[0].attachments, [png, jpeg], 'normalized roundtrip incl. name');
assert.equal(payload.generatedImage, asset, 'sidebar image now persists');
assert.equal(payload.messages[1].attachments, undefined, 'absent attachments stay absent');
const invalid = [
[{ dataBase64: png.dataBase64, mimeType: 'image/svg+xml' }],
[{ dataBase64: 'not base64!!!', mimeType: 'image/png' }],
[{ dataBase64: canonical(5 * 1024 * 1024 + 1), mimeType: 'image/png' }],
[png, jpeg, png, jpeg, png],
'nope'
];
for (const attachments of invalid) {
assert.throws(() => policy.savedChatPayload({ messages: [{ role: 'user', content: 'Q', attachments }] }), error => error.statusCode === 400);
}
const longName = { dataBase64: png.dataBase64, mimeType: png.mimeType, name: 'n'.repeat(400) };
const trimmed = policy.savedChatPayload({ messages: [{ role: 'user', content: 'Q', attachments: [longName] }] });
assert.equal(trimmed.messages[0].attachments[0].name.length, 255, 'names clip at 255 characters');
for (const generatedImage of ['data:image/svg+xml;base64,PHN2Zz4=', 'javascript:alert(1)']) {
assert.throws(() => policy.savedChatPayload({ messages: [], generatedImage }), error => error.statusCode === 400);
}
assert.throws(() => policy.savedChatPayload({ messages: [{ role: 'user', content: 'x'.repeat(policy.MAX_SAVED_CHAT_BYTES) }] }), error => error.statusCode === 413);
});
test('POST /chats stores and reopens attachments; the same id updates in place instead of duplicating', async () => {
const app = server();
const body = { messages: [{ role: 'user', content: 'Question with image', attachments: [png] }, { role: 'assistant', content: 'Answer [1]' }], lastAnswer: 'Answer [1]', sources: [] };
const saved = await app.request('post', '/clinical-assistant/chats', body);
assert.equal(saved.statusCode, 200);
const reopened = await app.request('get', '/clinical-assistant/chats/:id', {});
assert.deepEqual(JSON.parse(JSON.stringify(reopened.body.chat.payload.messages[0].attachments)), [png]);
const updated = await app.request('post', '/clinical-assistant/chats', { id: 1, messages: [{ role: 'user', content: 'Edited', attachments: [jpeg] }], lastAnswer: '' });
assert.equal(updated.statusCode, 200);
assert.equal(updated.body.id, 1);
assert.ok(app.calls.writes.some(sql => /UPDATE clinical_assistant_chats/.test(sql)), 'update path used');
const foreign = await app.request('post', '/clinical-assistant/chats', { id: 1, messages: [{ role: 'user', content: 'x' }] }, 8);
assert.equal(foreign.statusCode, 404, 'updates are owner-bound');
const missing = await app.request('post', '/clinical-assistant/chats', { id: 99, messages: [{ role: 'user', content: 'x' }] });
assert.equal(missing.statusCode, 404);
const badId = await app.request('post', '/clinical-assistant/chats', { id: 'not-a-number', messages: [{ role: 'user', content: 'x' }] });
assert.equal(badId.statusCode, 400);
});
test('client restores attachments as thumbnails and saves them plus the generated image', t => {
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
const window = dom.window;
window.eval(read('public/js/accountBoundary.js'));
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
window.marked = marked;
window.DOMPurify = require('dompurify')(window);
window.matchMedia = () => ({ matches: true });
const saves = [];
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
setTimeout() {}, clearTimeout() {}, showToast() {}, EMPTY_PROMPT_SETS: [[]],
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: src => '<img src="' + src + '">' }),
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
saveAssistantChat: async body => { saves.push(JSON.parse(JSON.stringify(policy.savedChatPayload(body)))); return { success: true, id: 1 }; } };
vm.createContext(context);
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
}
t.after(() => window.close());
context.appendMessage('user', 'Question with image', [], [], false, { attachments: [png] });
let thumbs = window.document.querySelector('.assistant-msg.user .assistant-message-attachments');
assert.ok(thumbs, 'thumbnail strip rendered');
assert.ok(thumbs.querySelector('img').getAttribute('src').startsWith('data:image/png;base64,'));
assert.match(thumbs.textContent, /chest\.png/);
context.appendMessage('assistant', 'Answer [1].', [{ number: 1, title: 'S', page: 2 }]);
context.lastAnswer = 'Answer [1].';
context.lastGeneratedImageSrc = asset;
return context.saveCurrentChat().then(() => {
assert.deepEqual(saves[0].messages[0].attachments, [png]);
assert.equal(saves[0].generatedImage, asset);
context.restoreSavedChat(saves[0]);
thumbs = window.document.querySelector('.assistant-msg.user .assistant-message-attachments');
assert.ok(thumbs, 'attachments restored after reload');
assert.equal(window.document.querySelector('#assistant-visual-output img').getAttribute('src'), asset, 'generated image restored into the visual output');
assert.equal(context.messages[0].content, 'Question with image', 'raw transcript canonical');
});
});

View file

@ -0,0 +1,163 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const { marked } = require('marked');
const { webcrypto } = require('node:crypto');
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
function fakeTimers() {
var nextId = 1;
var pending = new Map();
return {
pending: pending,
setTimeout(fn, ms) { var id = nextId++; pending.set(id, { fn, ms }); return id; },
clearTimeout(id) { pending.delete(id); },
count() { return pending.size; },
delay() { return pending.size ? Array.from(pending.values()).map(e => e.ms).join(',') : ''; },
async flush() { while (pending.size) { var entries = Array.from(pending.values()); pending.clear(); for (var e of entries) await e.fn(); } }
};
}
function ui(t, options = {}) {
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
const window = dom.window;
window.eval(read('public/js/accountBoundary.js'));
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
window.marked = marked;
window.DOMPurify = require('dompurify')(window);
window.matchMedia = () => ({ matches: true });
const timers = fakeTimers();
const toasts = [];
const saves = [];
var nextSaveId = 1;
const chats = [];
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController, crypto: webcrypto,
setTimeout: timers.setTimeout.bind(timers), clearTimeout: timers.clearTimeout.bind(timers),
showToast: (...args) => toasts.push(args), EMPTY_PROMPT_SETS: [[]],
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
fetchAssistantStatus: async () => ({ success: true }),
fetchAssistantExamples: async () => ({ success: true, examples: [] }),
fetchSavedAssistantChats: async () => ({ success: true, chats }),
fetchSavedAssistantChat: async id => ({ success: true, chat: chats.find(c => c.id === id) || { id, title: 'Stored', payload: { version: 2, messages: [], sources: [], lastAnswer: '' } } }),
deleteSavedAssistantChat: async () => ({ success: true }),
saveAssistantChat: async body => {
saves.push(JSON.parse(JSON.stringify(body)));
if (options.saveError) throw new Error(options.saveError);
if (body.id) {
var existing = chats.find(c => c.id === body.id);
if (existing) { existing.payload = JSON.parse(JSON.stringify({ version: 2, messages: body.messages, sources: body.sources, lastAnswer: body.lastAnswer })); return { success: true, id: body.id }; }
}
var id = nextSaveId++;
chats.push({ id, title: body.title || 'Derived', payload: { version: 2, messages: body.messages, sources: body.sources, lastAnswer: body.lastAnswer } });
return { success: true, id };
},
openAssistantStream: async () => new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: 'Complete answer.', sources: [] }) + '\n\n') };
vm.createContext(context);
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
}
t.after(() => window.close());
return { context, document: window.document, window, timers, toasts, saves };
}
async function ask(app, text) {
app.document.getElementById('assistant-input').value = text;
await app.context.onAsk();
}
test('autosave debounces completed turns into one save with a 60-character derived title, then updates in place', async t => {
const app = ui(t);
const c = app.context;
const question = 'Synthetic pediatric asthma management question that is deliberately longer than sixty characters to verify title derivation';
await ask(app, question);
await ask(app, 'Follow-up question about monitoring');
assert.equal(app.timers.count(), 1, 'two turns debounce into one pending save');
assert.equal(app.timers.delay(), '800');
await app.timers.flush();
assert.equal(app.saves.length, 1);
assert.equal(app.saves[0].title, question.slice(0, 60), 'title derived from the first user message');
assert.equal(app.saves[0].id, undefined, 'first autosave creates the chat');
assert.equal(app.saves[0].generatedImage, undefined);
assert.deepEqual(app.saves[0].messages.map(m => [m.role, m.content]), [
['user', question], ['assistant', 'Complete answer.'], ['user', 'Follow-up question about monitoring'], ['assistant', 'Complete answer.']
], 'raw transcript stored intact');
assert.equal(c.currentChatId, 1);
await ask(app, 'Third question');
await app.timers.flush();
assert.equal(app.saves.length, 2);
assert.equal(app.saves[1].id, 1, 'follow-up autosave updates the same chat');
assert.equal(app.saves[1].title, undefined, 'updates never overwrite the stored title');
assert.equal(app.saves[1].messages.at(-1).content, 'Complete answer.');
});
test('autosave failure surfaces exactly once and retries only on the next change', async t => {
const app = ui(t, { saveError: 'Saved chat exceeds the 8 MiB storage limit. Nothing was saved or truncated.' });
const c = app.context;
await ask(app, 'A question that produces a too-large saved chat');
await app.timers.flush();
assert.equal(app.saves.length, 1);
assert.equal(app.toasts.length, 1);
assert.match(app.toasts[0][0], /8 MiB/);
assert.equal(app.timers.count(), 0, 'no automatic retry loop');
await app.timers.flush();
assert.equal(app.saves.length, 1, 'flushing an empty timer queue does not resend');
c.appendMessage('user', 'Another change');
c.scheduleAutosave();
await app.timers.flush();
assert.equal(app.saves.length, 2, 'the next change retries');
assert.equal(app.toasts.length, 2, 'the retry failure surfaces once more');
assert.equal(c.messages.length, 3, 'failures never touch the live chat');
});
test('loading a saved chat binds autosave updates to that chat id', async t => {
const app = ui(t);
const c = app.context;
c.chats = undefined;
await c.loadSavedChat(7); // fetchSavedAssistantChat stub returns a stored payload for id 7
assert.equal(c.currentChatId, 7);
await ask(app, 'Continuing the loaded chat');
await app.timers.flush();
assert.equal(app.saves.length, 1);
assert.equal(app.saves[0].id, 7);
});
test('new chat clears the autosave identity and pending timer; manual save adopts the custom title and id', async t => {
const app = ui(t);
const c = app.context;
c.currentChatId = 5;
c.appendMessage('user', 'Question');
c.scheduleAutosave();
assert.equal(app.timers.count(), 1);
c.performClearConversation();
assert.equal(c.currentChatId, null);
assert.equal(app.timers.count(), 0, 'pending autosave cancelled');
c.appendMessage('user', 'Fresh question');
c.appendMessage('assistant', 'Fresh answer.', []);
c.lastAnswer = 'Fresh answer.';
app.document.getElementById('assistant-save-title').value = 'Manual title';
await c.saveCurrentChat();
assert.equal(app.saves.length, 1);
assert.equal(app.saves[0].title, 'Manual title');
assert.equal(app.saves[0].id, undefined);
assert.equal(c.currentChatId, 1);
await c.saveCurrentChat();
assert.equal(app.saves[1].id, 1, 'manual re-save updates the autosaved chat');
assert.equal(app.saves[1].title, 'Manual title');
});
test('oversized conversations are sent whole and never client-truncated', async t => {
const app = ui(t);
const c = app.context;
const big = 'X'.repeat(2000);
c.appendMessage('user', 'Long question');
c.appendMessage('assistant', big, []);
c.lastAnswer = big;
c.scheduleAutosave();
await app.timers.flush();
assert.equal(app.saves[0].messages[1].content, big, 'content sent exactly');
assert.equal(c.messages[1].content, big);
});

View file

@ -0,0 +1,112 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const { marked } = require('marked');
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
function ui(t) {
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
const window = dom.window;
window.eval(read('public/js/accountBoundary.js'));
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
window.marked = marked;
window.DOMPurify = require('dompurify')(window);
window.matchMedia = () => ({ matches: true });
const toasts = [];
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
setTimeout() {}, clearTimeout() {}, showToast: (...args) => toasts.push(args), EMPTY_PROMPT_SETS: [[]],
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
saveAssistantChat: async () => ({ success: true }) };
vm.createContext(context);
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
}
appBinding(t, context);
t.after(() => window.close());
return { context, document: window.document, window, toasts };
}
// The production page binds delegated clicks in bindEvents; the vm harness mirrors it.
function appBinding(t, context) {
context.bindEvents();
}
const longExcerpt = 'Synthetic excerpt. ' + 'x'.repeat(15000);
function openModal(app, n = 1) {
const cite = app.document.querySelector('.assistant-cite[data-source-number="' + n + '"]');
assert.ok(cite, 'citation chip exists');
cite.click();
return app.document.querySelector('.assistant-source-modal');
}
test('citation chips open an OWUI-style source modal with title, page and 10k excerpt preview', t => {
const app = ui(t);
const sources = [{ number: 1, title: 'Nelson Textbook of Pediatrics', page: 7, excerpt: longExcerpt }];
app.context.restoreSavedChat({ version: 2, messages: [
{ role: 'user', content: 'Question' },
{ role: 'assistant', content: 'Answer [1].', sources }
], lastAnswer: 'Answer [1].', sources });
const modal = openModal(app);
assert.ok(modal, 'modal opens');
assert.equal(modal.getAttribute('role'), 'dialog');
assert.match(modal.textContent, /Nelson Textbook of Pediatrics/);
assert.match(modal.textContent, /page 7/);
const excerpt = modal.querySelector('[data-assistant-source-excerpt]');
assert.ok(excerpt);
assert.ok(excerpt.textContent.length >= 10000 && excerpt.textContent.length <= 10200, '10k preview shown');
const showAll = modal.querySelector('[data-assistant-source-show-all]');
assert.ok(showAll, 'Show all offered for long excerpts');
showAll.click();
assert.ok(excerpt.textContent.length >= 15000, 'Show all reveals the full excerpt');
assert.equal(showAll.hidden, true, 'toggle hides once expanded');
// Sidebar switching is preserved alongside the modal.
assert.match(app.document.querySelector('#assistant-source-1').textContent, /Nelson Textbook of Pediatrics.*page 7/is);
});
test('source modal closes via button, backdrop and Escape', t => {
const app = ui(t);
const sources = [{ number: 1, title: 'Synthetic source', page: 3, excerpt: 'Short excerpt.' }];
app.context.restoreSavedChat({ version: 2, messages: [
{ role: 'user', content: 'Question' },
{ role: 'assistant', content: 'Answer [1].', sources }
], lastAnswer: 'Answer [1].', sources });
let modal = openModal(app);
assert.equal(modal.querySelector('[data-assistant-source-show-all]'), null, 'no Show all for short excerpts');
modal.querySelector('.modal-close').click();
assert.equal(app.document.querySelector('.assistant-source-modal'), null);
modal = openModal(app);
modal.click(); // backdrop is the modal element itself
assert.equal(app.document.querySelector('.assistant-source-modal'), null);
modal = openModal(app);
app.window.document.dispatchEvent(new app.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
assert.equal(app.document.querySelector('.assistant-source-modal'), null);
});
test('per-turn source maps select the correct source for reused citation numbers', t => {
const app = ui(t);
const first = [{ number: 1, title: 'First turn reference', page: 11, excerpt: 'First turn context.' }];
const second = [{ number: 1, title: 'Second turn reference', page: 23, excerpt: 'Second turn context.' }];
app.context.restoreSavedChat({ version: 2, messages: [
{ role: 'user', content: 'First question' },
{ role: 'assistant', content: 'First answer. [1]', sources: first },
{ role: 'user', content: 'Second question' },
{ role: 'assistant', content: 'Second answer. [1]', sources: second }
], lastAnswer: 'Second answer. [1]', sources: second });
const citations = app.document.querySelectorAll('#assistant-messages .assistant-cite');
assert.equal(citations.length, 2);
citations[0].click();
assert.match(app.document.querySelector('.assistant-source-modal').textContent, /First turn reference/);
assert.match(app.document.querySelector('.assistant-source-modal').textContent, /page 11/);
app.document.querySelector('.assistant-source-modal .modal-close').click();
citations[1].click();
const modal = app.document.querySelector('.assistant-source-modal');
assert.match(modal.textContent, /Second turn reference/);
assert.match(modal.textContent, /page 23/);
assert.doesNotMatch(modal.textContent, /First turn reference/);
});

View file

@ -38,7 +38,7 @@ function server(options = {}) {
};
const source = { number: 1, title: 'Synthetic source', excerpt: 'Synthetic reference.', page: 9 };
const mocks = {
express, axios: { async post(url, payload) { calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db,
express, axios: { async post(url, payload) { if (String(url).includes('/translate')) return { data: { translatedText: 'Synthetic translation.' } }; calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db,
'../middleware/auth': { authMiddleware() {} }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai },
'../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {} },
'../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') },
@ -56,7 +56,8 @@ function server(options = {}) {
normalizeMcpMultimodalResponse: () => [], dedupeSources: value => value,
isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => []
},
'../utils/clinicalAnswer': answer, '../utils/clinicalConversation': policy
'../utils/clinicalAnswer': answer, '../utils/clinicalConversation': policy,
'../utils/clinicalTranslation': require('../src/utils/clinicalTranslation')
};
const module = { exports: {} };
vm.runInNewContext(read('src/routes/clinicalAssistant.js'), {
@ -142,14 +143,10 @@ test('greeting and retrieval-empty direct responses validate attachments before
assert.equal(good.calls.ai.length + good.calls.search.length, 0, 'greeting never reaches providers');
});
test('handoff ignores image attachments and never forwards them', async () => {
test('handoff route is gone: images were never part of it and the layer no longer exists', async () => {
const app = server();
const result = await app.request('post', '/clinical-assistant/handoff', { history: [{ role: 'user', content: 'Known facts.' }], images: [{ dataBase64: canonical(8), mimeType: 'image/png' }] });
assert.equal(result.statusCode, 200);
assert.equal(app.calls.ai.length, 1);
assert.ok(app.calls.ai[0].messages[1].content.includes('Known facts.'));
assert.equal(app.calls.ai[0].settings.images, undefined);
assert.doesNotMatch(JSON.stringify(app.calls.ai[0].messages), /base64/);
await assert.rejects(app.request('post', '/clinical-assistant/handoff', { history: [{ role: 'user', content: 'Known facts.' }], images: [{ dataBase64: canonical(8), mimeType: 'image/png' }] }), /route/);
assert.equal(app.calls.ai.length + app.calls.search.length, 0);
});
function openaiAdapter() {
@ -267,7 +264,7 @@ function browserUI(options = {}) {
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', {
url: 'https://example.test/', runScripts: 'outside-only'
});
const calls = { stream: [], handoff: [], save: [] };
const calls = { stream: [], save: [] };
const toasts = [];
const escapeHtml = text => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const context = {
@ -287,8 +284,7 @@ function browserUI(options = {}) {
return new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: 'Complete response.', sources: [] }) + '\n\n');
},
fetchAssistantChat: async payload => ({ success: true, answer: 'Fallback answer.' }),
saveAssistantChat: async payload => { calls.save.push(payload); return { success: true }; },
requestAssistantHandoff: async history => { calls.handoff.push(history); return { success: true, summary: 'Explicit handoff.' }; }
saveAssistantChat: async payload => { calls.save.push(payload); return { success: true }; }
};
vm.createContext(context);
vm.runInContext(read('public/js/clinicalAssistant.js').replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, ''), context);
@ -351,7 +347,7 @@ test('UI attaches only PNG/JPEG/WebP with thumbnails, removal and client-side li
ui.dom.window.close();
});
test('UI sends images only with the question, clears them on success, and keeps them out of handoff, saves and the budget label', async () => {
test('UI sends images only with the question, clears them on success, persists them in saved chats, and keeps the budget label text-only', async () => {
const api = await realApiHelpers();
const ui = browserUI({ limit: 1000 });
ui.context.assistantAttachmentLimits = api.assistantAttachmentLimits;
@ -369,11 +365,7 @@ test('UI sends images only with the question, clears them on success, and keeps
assert.equal(label.textContent, '1,000 / 1,000 conversation characters (UTF-16 code units).', 'budget label stays text-only');
assert.doesNotMatch(label.textContent, /image/i);
// Handoff and saved chats never carry attachments.
await ui.context.requestHandoff();
assert.equal(ui.calls.handoff.length, 1);
assert.equal(ui.calls.handoff[0].images, undefined);
assert.equal(JSON.stringify(ui.calls.handoff[0]).includes('dataBase64'), false);
// Pending attachments stay input-only until the question is sent.
await ui.context.saveCurrentChat();
assert.equal(ui.calls.save[0].images, undefined);
assert.equal(JSON.stringify(ui.calls.save[0]).includes('dataBase64'), false);
@ -385,6 +377,12 @@ test('UI sends images only with the question, clears them on success, and keeps
assert.equal(ui.document.getElementById('assistant-attachments').hidden, true);
assert.equal(ui.context.messages.length, 3, 'question and answer appended');
// The sent question now persists its attachments with the saved chat.
await ui.context.saveCurrentChat();
assert.ok(JSON.stringify(ui.calls.save.at(-1)).includes('dataBase64'));
const savedUser = ui.calls.save.at(-1).messages.filter(m => m.role === 'user').at(-1);
assert.deepEqual(JSON.parse(JSON.stringify(savedUser.attachments)), [{ dataBase64: expected[0].dataBase64, mimeType: 'image/png', name: 'photo.png' }]);
// New chat clears any later attachments.
attachFile(ui, 'again.png', 'image/png', bytes);
await tick();

View file

@ -0,0 +1,45 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
let modulePromise;
async function loadCitationModule() {
if (!modulePromise) {
modulePromise = fs.readFile(path.join(__dirname, '..', 'public', 'js', 'assistant', 'citations.js'), 'utf8')
.then((source) => import('data:text/javascript;charset=utf-8,' + encodeURIComponent(source)));
}
return modulePromise;
}
test('mhchem inline delimiters render as KaTeX without eating ordinary backslash prose', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const expressions = [];
const katex = {
renderToString(expr, opts) {
expressions.push({ expr, display: opts.displayMode });
return '<span class="katex">' + expr + '</span>';
}
};
const html = renderAssistantMarkdown('Base deficit \\ce{HCO3-} and \\pu{37 C}; a plain \\ce without braces stays text.', [], { katex });
assert.deepEqual(expressions, [{ expr: 'HCO3-', display: false }, { expr: '37 C', display: false }]);
assert.match(html, /<span class="katex">HCO3-<\/span>/);
assert.match(html, /<span class="katex">37 C<\/span>/);
assert.match(html, /without braces stays text/);
});
test('without KaTeX the mhchem source stays literal and escaped', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Base deficit \\ce{HCO3-}.', []);
assert.match(html, /\\ce\{HCO3-\}/);
assert.doesNotMatch(html, /katex/);
});
test('mhchem inside fenced code blocks is never rendered', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const katex = { renderToString() { throw new Error('code block sent to KaTeX'); } };
const html = renderAssistantMarkdown('```md\n\\ce{H2O} stays literal\n```', [], { katex });
assert.match(html, /\\ce\{H2O\}/);
assert.doesNotMatch(html, /katex/);
});

View file

@ -0,0 +1,112 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const { marked } = require('marked');
const { webcrypto } = require('node:crypto');
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
function ui(t, options = {}) {
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
const window = dom.window;
window.eval(read('public/js/accountBoundary.js'));
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
window.marked = marked;
window.DOMPurify = require('dompurify')(window);
window.matchMedia = () => ({ matches: true });
const copies = [];
const toasts = [];
const saves = [];
var nextSaveId = 1;
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController, crypto: webcrypto,
navigator: { clipboard: { writeText: async text => { copies.push(text); } } },
setTimeout() {}, clearTimeout() {}, showToast: (...args) => toasts.push(args), EMPTY_PROMPT_SETS: [[]],
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
fetchAssistantStatus: async () => ({ success: true }),
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
saveAssistantChat: async body => { saves.push(JSON.parse(JSON.stringify(body))); return { success: true, id: nextSaveId++ }; },
openAssistantStream: async () => new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: 'Regenerated answer.', sources: [] }) + '\n\n') };
vm.createContext(context);
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
}
appBinding(context);
t.after(() => window.close());
return { context, document: window.document, window, copies, toasts, saves };
}
// The production page binds delegated clicks in bindEvents; the vm harness mirrors it.
function appBinding(context) {
context.bindEvents();
}
function rows(app) { return app.document.querySelectorAll('#assistant-messages .assistant-msg'); }
test('every message offers Copy (raw) and Translate; assistant messages also offer Regenerate', t => {
const app = ui(t);
const c = app.context;
c.appendMessage('user', '**Question** with `code`');
c.appendMessage('assistant', 'Answer.', []);
const userRow = rows(app)[0];
const assistantRow = rows(app)[1];
assert.ok(userRow.querySelector('[data-assistant-msg-copy]'));
assert.ok(userRow.querySelector('[data-assistant-msg-translate]'));
assert.equal(userRow.querySelector('[data-assistant-msg-regenerate]'), null, 'regenerate is assistant-only');
assert.ok(assistantRow.querySelector('[data-assistant-msg-copy]'));
assert.ok(assistantRow.querySelector('[data-assistant-msg-translate]'));
assert.ok(assistantRow.querySelector('[data-assistant-msg-regenerate]'));
userRow.querySelector('[data-assistant-msg-copy]').click();
assert.equal(app.copies.length, 1);
assert.equal(app.copies[0], '**Question** with `code`', 'copies the raw markdown, not the rendered HTML');
assistantRow.querySelector('[data-assistant-msg-copy]').click();
assert.equal(app.copies[1], 'Answer.');
});
test('regenerate replays the preceding question for the latest answer and never touches earlier turns', async t => {
const app = ui(t);
const c = app.context;
c.appendMessage('user', 'First question');
c.appendMessage('assistant', 'First answer.', []);
c.appendMessage('user', 'Second question');
c.appendMessage('assistant', 'Second answer.', []);
assert.equal(rows(app).length, 4);
const secondRow = rows(app)[3];
secondRow.querySelector('[data-assistant-msg-regenerate]').click();
assert.equal(app.document.getElementById('assistant-input').value, 'Second question', 'input refilled with the question');
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
assert.equal(c.messages.length, 4, 'old answer replaced by the regenerated one');
assert.equal(c.messages[3].content, 'Regenerated answer.');
assert.equal(c.messages[0].content, 'First question', 'earlier turns untouched');
assert.equal(c.messages[1].content, 'First answer.');
assert.match(rows(app)[3].textContent, /Regenerated answer/);
});
test('regenerate on a non-latest answer refuses honestly without touching the chat', t => {
const app = ui(t);
const c = app.context;
c.appendMessage('user', 'First question');
c.appendMessage('assistant', 'First answer.', []);
c.appendMessage('user', 'Second question');
c.appendMessage('assistant', 'Second answer.', []);
const firstRow = rows(app)[1];
firstRow.querySelector('[data-assistant-msg-regenerate]').click();
assert.equal(app.toasts.length, 1);
assert.match(app.toasts[0][0], /latest answer/);
assert.equal(c.messages.length, 4);
assert.equal(app.copies.length, 0);
});
test('regenerate is refused while the assistant is busy', async t => {
const app = ui(t);
const c = app.context;
c.appendMessage('user', 'Question');
c.appendMessage('assistant', 'Answer.', []);
c.assistantBusy = true;
rows(app)[1].querySelector('[data-assistant-msg-regenerate]').click();
assert.equal(app.toasts.length, 1);
assert.match(app.toasts[0][0], /still finishing/i);
assert.equal(c.messages.length, 2);
});

View file

@ -0,0 +1,102 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const { marked } = require('marked');
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
function ui(t) {
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
const window = dom.window;
window.eval(read('public/js/accountBoundary.js'));
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
const style = window.document.createElement('style');
style.textContent = read('public/css/assistant.css');
window.document.head.appendChild(style);
window.marked = marked;
window.DOMPurify = require('dompurify')(window);
window.matchMedia = () => ({ matches: true });
const copies = [];
const downloads = [];
const context = { window, document: window.document, console, Blob, TextDecoder, AbortController,
URL: Object.assign(URL, { createObjectURL: () => 'blob:synthetic', revokeObjectURL() {} }),
navigator: { clipboard: { writeText: async text => { copies.push(text); } } },
setTimeout() {}, clearTimeout() {}, showToast() {}, EMPTY_PROMPT_SETS: [[]],
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
saveAssistantChat: async () => ({ success: true }) };
vm.createContext(context);
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
}
window.HTMLAnchorElement.prototype.click = function() { downloads.push({ href: this.href, download: this.download }); };
t.after(() => window.close());
return { context, document: window.document, window, copies, downloads };
}
const table = '| Item | Value |\n| :--- | ---: |\n| Alpha | 1.25 |\n| Beta | 2-4 |';
function bubble(app) { return app.document.querySelector('.assistant-msg.assistant .assistant-bubble'); }
test('tables carry OWUI copy-raw and export-csv behavioral hooks outside the table element', t => {
const app = ui(t);
app.context.appendMessage('assistant', table, []);
const wrapper = bubble(app).querySelector('.assistant-table-scroll');
assert.ok(wrapper, 'wrapper present');
const copy = wrapper.querySelector('[data-assistant-table-copy]');
const csv = wrapper.querySelector('[data-assistant-table-csv]');
assert.ok(copy && csv, 'both action buttons present');
assert.equal(wrapper.querySelector('table [data-assistant-table-copy], table [data-assistant-table-csv]'), null, 'buttons live outside the table');
copy.click();
assert.equal(app.copies.length, 1);
assert.match(app.copies[0], /\| Item \| Value \|/);
assert.match(app.copies[0], /\| :?-{3} \| ---: \|/, 'alignment survives the raw reconstruction');
assert.match(app.copies[0], /\| Alpha \| 1\.25 \|/);
assert.match(app.copies[0], /\| Beta \| 2-4 \|/);
csv.click();
assert.equal(app.downloads.length, 1);
assert.equal(app.downloads[0].href, 'blob:synthetic');
assert.equal(app.downloads[0].download, 'table.csv');
});
test('CSV export serializes rendered cells with a BOM', t => {
const app = ui(t);
const created = [];
const OriginalBlob = app.context.Blob;
app.context.Blob = class extends OriginalBlob { constructor(parts, opts) { super(parts, opts); created.push({ parts, opts }); } };
app.context.appendMessage('assistant', table, []);
bubble(app).querySelector('[data-assistant-table-csv]').click();
assert.equal(created.length, 1);
assert.equal(created[0].opts.type, 'text/csv');
const text = String(created[0].parts[0]);
assert.ok(text.startsWith('\uFEFF'), 'CSV starts with a BOM');
assert.match(text, /Item,Value/);
assert.match(text, /Alpha,1\.25/);
});
test('code blocks gain a copy button that copies decoded code text', t => {
const app = ui(t);
app.context.appendMessage('assistant', '```js\nconst ref = "[1]";\n```', []);
const block = bubble(app).querySelector('.assistant-codeblock');
assert.ok(block, 'code block wrapper present');
const button = block.querySelector('[data-assistant-code-copy]');
assert.ok(button);
assert.equal(block.querySelector('code').textContent, 'const ref = "[1]";');
button.click();
assert.equal(app.copies.length, 1);
assert.equal(app.copies[0], 'const ref = "[1]";');
});
test('copy failures never crash the chat flow', t => {
const app = ui(t);
app.context.navigator = { clipboard: { writeText: async () => { throw new Error('denied'); } } };
app.context.appendMessage('assistant', '```md\nx\n```', []);
bubble(app).querySelector('[data-assistant-code-copy]').click();
app.context.appendMessage('assistant', table, []);
var bubbles = app.document.querySelectorAll('.assistant-msg.assistant .assistant-bubble');
bubbles[bubbles.length - 1].querySelector('[data-assistant-table-copy]').click();
assert.equal(bubbles[bubbles.length - 1].querySelectorAll('table').length, 1, 'chat keeps rendering');
});

View file

@ -0,0 +1,258 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const express = require('express');
const { JSDOM } = require('jsdom');
const { marked } = require('marked');
const answer = require('../src/utils/clinicalAnswer');
const root = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const quiet = { log() {}, warn() {}, error() {}, info() {} };
async function translate(options) {
const { translateMessage } = require('../src/utils/clinicalTranslation');
return translateMessage(options);
}
function baseOptions(overrides = {}) {
return {
message: 'Chest pain in a four year old.',
target: 'es',
userId: 7,
env: { LIBRETRANSLATE_URL: 'http://libretranslate:5000', DEEPL_API_KEY: 'synthetic-key', DEEPL_API_BASE: 'https://api.deepl.com/v2' },
getSetting: async () => null,
cache: new Map(),
...overrides
};
}
function axiosStub(behavior) {
const calls = [];
return {
calls,
axios: { post: async (url, payload, opts) => { calls.push({ url: String(url), payload, opts }); return behavior({ url: String(url), payload, opts }, calls.length); } }
};
}
test('translation validates message, target and provider before any provider contact', async () => {
for (const target of ['xx', '', 'xy', 'javascript']) {
const stub = axiosStub(() => { throw new Error('must not be called'); });
await assert.rejects(translate(baseOptions({ target, axios: stub.axios })), e => e.statusCode === 400);
assert.equal(stub.calls.length, 0);
}
await assert.rejects(translate(baseOptions({ message: '' })), e => e.statusCode === 400);
await assert.rejects(translate(baseOptions({ message: 'x'.repeat(20001) })), e => e.statusCode === 400);
const stub = axiosStub(() => { throw new Error('must not be called'); });
await assert.rejects(translate(baseOptions({ provider: 'google', axios: stub.axios })), e => e.statusCode === 400);
assert.equal(stub.calls.length, 0);
});
test('libretranslate is the local-first default and caches per provider+message+lang', async () => {
const stub = axiosStub(() => ({ data: { translatedText: 'Synthetic Spanish.' } }));
const cache = new Map();
const opts = baseOptions({ axios: stub.axios, cache });
const first = await translate(opts);
assert.equal(first.translated, 'Synthetic Spanish.');
assert.equal(first.provider, 'libretranslate');
assert.equal(stub.calls.length, 1);
assert.match(stub.calls[0].url, /^http:\/\/libretranslate:5000\/translate$/);
assert.deepEqual(stub.calls[0].payload, { q: 'Chest pain in a four year old.', source: 'auto', target: 'es', format: 'text' });
await translate(opts);
assert.equal(stub.calls.length, 1, 'cache hit');
// a different language or provider is a separate cache entry
await translate(baseOptions({ axios: stub.axios, cache, target: 'fr' }));
assert.equal(stub.calls.length, 2);
});
test('deepL posts form-encoded with the auth key header and uppercase target', async () => {
const stub = axiosStub(({ url, payload, opts }) => {
assert.equal(url, 'https://api.deepl.com/v2/translate');
assert.equal(opts.headers.Authorization, 'DeepL-Auth-Key synthetic-key');
assert.match(payload, /^text=Chest%20pain/);
assert.match(payload, /target_lang=ES$/);
return { data: { translations: [{ text: 'Dolor de pecho.' }] } };
});
const result = await translate(baseOptions({ provider: 'deepl', axios: stub.axios }));
assert.equal(result.translated, 'Dolor de pecho.');
assert.equal(result.provider, 'deepl');
});
test('deepl refuses languages it cannot map and is never contacted for them', async () => {
const stub = axiosStub(() => { throw new Error('must not be called'); });
await assert.rejects(translate(baseOptions({ provider: 'deepl', target: 'fa', axios: stub.axios })), e => e.statusCode === 400);
assert.equal(stub.calls.length, 0);
// libretranslate still accepts the same language
const ok = axiosStub(() => ({ data: { translatedText: 'ترجمه' } }));
const result = await translate(baseOptions({ target: 'fa', axios: ok.axios }));
assert.equal(result.provider, 'libretranslate');
});
test('transient provider failure falls back once; validation and 4xx never fall back', async () => {
const stub = axiosStub(({ url }) => {
if (url.startsWith('http://libretranslate')) throw new Error('ECONNREFUSED');
return { data: { translations: [{ text: 'DeepL fallback.' }] } };
});
const result = await translate(baseOptions({ axios: stub.axios }));
assert.equal(result.translated, 'DeepL fallback.');
assert.equal(result.provider, 'deepl');
assert.equal(stub.calls.length, 2);
const fourxx = axiosStub(({ url }) => {
if (url.startsWith('http://libretranslate')) { const err = new Error('Bad request'); err.response = { status: 400 }; throw err; }
throw new Error('must not fall back');
});
await assert.rejects(translate(baseOptions({ axios: fourxx.axios })), e => e.statusCode === 400 && /Bad request/.test(e.message));
assert.equal(fourxx.calls.length, 1, 'no fallback on provider 4xx');
const deepL403 = axiosStub(({ url }) => {
if (url.startsWith('https://api.deepl')) { const err = new Error('DeepL auth failed'); err.response = { status: 403 }; throw err; }
throw new Error('must not fall back');
});
await assert.rejects(translate(baseOptions({ provider: 'deepl', axios: deepL403.axios })), e => e.statusCode === 403);
assert.equal(deepL403.calls.length, 1, 'no fallback on DeepL 403');
// deepl down (429) falls back to local libretranslate
const quota = axiosStub(({ url }) => {
if (url.startsWith('https://api.deepl')) { const err = new Error('Too many requests'); err.response = { status: 429 }; throw err; }
return { data: { translatedText: 'Local fallback.' } };
});
const back = await translate(baseOptions({ provider: 'deepl', axios: quota.axios }));
assert.equal(back.provider, 'libretranslate');
assert.equal(back.translated, 'Local fallback.');
});
test('deepl without configuration surfaces 503 and never falls back when explicitly requested', async () => {
const stub = axiosStub(() => { throw new Error('must not be called'); });
await assert.rejects(translate(baseOptions({ provider: 'deepl', env: { LIBRETRANSLATE_URL: 'http://libretranslate:5000' }, axios: stub.axios })), e => e.statusCode === 503 && e.code === 'DEEPL_NOT_CONFIGURED');
assert.equal(stub.calls.length, 0);
// default libretranslate down + deepl unconfigured → provider-side 502
const down = axiosStub(() => { throw new Error('ECONNREFUSED'); });
await assert.rejects(translate(baseOptions({ env: { LIBRETRANSLATE_URL: 'http://libretranslate:5000' }, axios: down.axios })), e => e.statusCode === 502);
});
test('translate route is owner-bound, validated and cached; admin default provider is honored', async () => {
const module = { exports: {} };
const calls = [];
const db = { async getSetting(key) { return key === 'clinical_assistant.translate_provider' ? 'deepl' : null; }, async get() { return null; }, async run() { return { lastInsertRowid: 1 }; }, async query() { return { rows: [] }; } };
const mocks = {
express, axios: { async post(url, payload) { calls.push(String(url)); return { data: { translations: [{ text: 'Traducción sintética.' }] } }; } }, crypto: require('node:crypto'),
'../db/database': db, '../middleware/auth': { authMiddleware() {} }, '../utils/ai': { callAI: async () => ({}), callAIStream: async () => ({}) },
'../utils/errors': { gatewayUrl: p => 'http://synthetic.invalid' + p }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {} },
'../utils/crypto': { encryptString: v => 'encrypted:' + v, decryptString: v => v.replace(/^encrypted:/, '') },
'../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) },
'../utils/generatedImages': { ...require('../src/utils/generatedImages'), service: () => ({ async get() { return {}; }, async enqueue() { return {}; } }) },
'../utils/generatedImageLinks': require('../src/utils/generatedImageLinks'),
'../utils/imageTool': require('../src/utils/imageTool'),
'../utils/clinicalPrompts': require('../src/utils/clinicalPrompts'),
'../utils/clinicalMcpClient': { async semanticSearch() { return {}; }, async getMcpHealth() { return {}; } },
'../utils/clinicalRetrieval': { cleanSourceExcerpt: require('../src/utils/clinicalRetrieval').cleanSourceExcerpt, normalizeMcpSearchResponse: () => [], normalizeMcpMultimodalResponse: () => [], dedupeSources: v => v, isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => [] },
'../utils/clinicalAnswer': answer, '../utils/clinicalConversation': require('../src/utils/clinicalConversation'),
'../utils/clinicalTranslation': require('../src/utils/clinicalTranslation')
};
vm.runInNewContext(read('src/routes/clinicalAssistant.js'), {
module, exports: module.exports, console: quiet, Buffer, Map, TextEncoder,
process: { env: { CLINICAL_ASSISTANT_MCP_WARMUP: 'false', DEEPL_API_KEY: 'synthetic-key', DEEPL_API_BASE: 'https://api.deepl.com/v2' } }, setTimeout() {},
require(name) { assert.ok(Object.hasOwn(mocks, name), 'Unexpected import: ' + name); return mocks[name]; }
});
async function request(body) {
const handler = module.exports.stack.find(layer => layer.route && layer.route.path === '/clinical-assistant/translate' && layer.route.methods.post).route.stack.find(layer => layer.method === 'post').handle;
const res = { statusCode: 200, headers: {}, body: null, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; }, setHeader() {}, write() {}, end() {}, flushHeaders() {} };
await handler({ body, user: { id: 7 } }, res);
return res;
}
const ok = await request({ message: 'Chest pain.', target: 'es' });
assert.equal(ok.statusCode, 200);
assert.equal(ok.body.translated, 'Traducción sintética.');
assert.equal(ok.body.provider, 'deepl', 'admin default provider honored');
assert.match(calls[0], /api\.deepl\.com\/v2\/translate$/);
const cached = await request({ message: 'Chest pain.', target: 'es' });
assert.equal(calls.length, 1, 'route cache hit');
const bad = await request({ message: 'Chest pain.', target: 'nope' });
assert.equal(bad.statusCode, 400);
assert.equal(calls.length, 1, 'validation never contacts a provider');
});
function client(t, options = {}) {
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
const window = dom.window;
window.eval(read('public/js/accountBoundary.js'));
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
window.marked = marked;
window.DOMPurify = require('dompurify')(window);
window.matchMedia = () => ({ matches: true });
const calls = [];
const toasts = [];
const apiFetch = async (url, options) => {
calls.push({ url, options });
if (url === '/api/clinical-assistant/translate') {
const body = JSON.parse(options.body);
if (options.failEs || body.target === 'bad') return new Response(JSON.stringify({ error: 'Unsupported translation language.' }), { status: 400 });
return new Response(JSON.stringify({ success: true, translated: 'Traducción de "' + body.message + '"', provider: body.provider }));
}
return new Response(JSON.stringify({ success: true, chats: [] }));
};
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
setTimeout() {}, clearTimeout() {}, showToast: (...args) => toasts.push(args), EMPTY_PROMPT_SETS: [[]],
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
saveAssistantChat: async () => ({ success: true }),
fetchAssistantStatus: async () => ({ success: true, translateProvider: 'libretranslate' }),
translateAssistantMessage: (message, target, provider) => apiFetch('/api/clinical-assistant/translate', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, target, provider }), failEs: options.failEs
}).then(function(r) { return r.json(); }),
fetch: apiFetch };
vm.createContext(context);
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
}
context.bindEvents();
t.after(() => window.close());
return { context, document: window.document, window, calls, toasts };
}
test('per-message translate picker offers a provider choice and leaves the raw transcript untouched', async t => {
const app = client(t);
const c = app.context;
c.appendMessage('assistant', 'Chest pain in a four year old.', []);
const row = app.document.querySelector('.assistant-msg.assistant');
const translateBtn = row.querySelector('[data-assistant-msg-translate]');
translateBtn.click();
const pop = app.document.querySelector('[data-assistant-translate-pop]');
assert.ok(pop, 'language picker opens');
assert.ok(pop.querySelector('[data-assistant-translate-provider="libretranslate"]'));
const deepl = pop.querySelector('[data-assistant-translate-provider="deepl"]');
assert.ok(deepl);
deepl.click();
const es = pop.querySelector('[data-assistant-translate-lang="es"]');
assert.ok(es);
es.click();
await new Promise(r => setImmediate(r));
assert.equal(app.calls.length, 1);
assert.equal(app.calls[0].url, '/api/clinical-assistant/translate');
assert.deepEqual(JSON.parse(app.calls[0].options.body), { message: 'Chest pain in a four year old.', target: 'es', provider: 'deepl' });
const bubble = row.querySelector('.assistant-bubble');
assert.match(bubble.textContent, /Traducción de "Chest pain in a four year old\."/);
assert.equal(c.messages[0].content, 'Chest pain in a four year old.', 'canonical transcript unchanged');
const showOriginal = bubble.querySelector('[data-assistant-msg-show-original]');
assert.ok(showOriginal);
showOriginal.click();
assert.match(bubble.innerHTML, /Chest pain in a four year old\./);
assert.equal(bubble.querySelector('[data-assistant-msg-show-original]'), null, 'back to the original view');
});
test('translation failures toast once and keep the original rendering', async t => {
const app = client(t, { failEs: true });
const c = app.context;
c.appendMessage('assistant', 'Original content.', []);
const row = app.document.querySelector('.assistant-msg.assistant');
row.querySelector('[data-assistant-msg-translate]').click();
const pop = app.document.querySelector('[data-assistant-translate-pop]');
pop.querySelector('[data-assistant-translate-lang="es"]').click();
await new Promise(r => setImmediate(r));
assert.equal(app.toasts.length, 1);
assert.match(app.toasts[0][0], /Unsupported translation language/);
assert.match(row.querySelector('.assistant-bubble').innerHTML, /Original content\./);
assert.equal(c.messages[0].content, 'Original content.');
});

View file

@ -0,0 +1,89 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const { marked } = require('marked');
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
function ui(t) {
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
const window = dom.window;
window.eval(read('public/js/accountBoundary.js'));
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
const style = window.document.createElement('style');
style.textContent = read('public/css/assistant.css');
window.document.head.appendChild(style);
window.marked = marked;
window.DOMPurify = require('dompurify')(window);
window.matchMedia = () => ({ matches: true });
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
setTimeout() {}, clearTimeout() {}, showToast() {}, EMPTY_PROMPT_SETS: [[]],
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
saveAssistantChat: async () => ({ success: true }) };
vm.createContext(context);
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
}
t.after(() => window.close());
return { context, document: window.document, window };
}
function userBubble(app) {
return app.document.querySelector('.assistant-msg.user .assistant-bubble');
}
test('user messages render markdown while the raw transcript stays canonical', t => {
const app = ui(t);
const md = '**Bold** plan\n\n- item one\n- item two\n\n`inline code` and 2+2=4';
app.context.appendMessage('user', md);
const bubble = userBubble(app);
assert.match(bubble.innerHTML, /<strong>Bold<\/strong>/);
assert.match(bubble.innerHTML, /<li>item one<\/li>/);
assert.match(bubble.innerHTML, /<li>item two<\/li>/);
assert.match(bubble.innerHTML, /<code>inline code<\/code>/);
assert.equal(app.context.messages[0].content, md, 'raw user text is stored unchanged');
assert.equal(app.context.messages[0].role, 'user');
assert.deepEqual(JSON.parse(JSON.stringify(app.context.messages[0].sources)), [], 'user turns carry no source map');
assert.equal(bubble.querySelector('.assistant-cite'), null, 'user text cannot create citation links');
});
test('user tables render with the OWUI wrapper and keyboard-scroll accessibility attributes', t => {
const app = ui(t);
app.context.appendMessage('user', '| Item | Dose |\n| :--- | ---: |\n| Alpha | 2 |');
const scroll = userBubble(app).querySelector('.assistant-table-scroll');
assert.ok(scroll, 'user table gets the shared wrapper');
assert.equal(scroll.getAttribute('tabindex'), '0');
assert.equal(scroll.getAttribute('role'), 'region');
assert.equal(scroll.getAttribute('aria-label'), 'Scrollable table');
assert.equal(scroll.querySelectorAll('tbody tr').length, 1);
});
test('inline markdown images respect the safe allowlist: own assets and data URIs only', t => {
const app = ui(t);
const id = '12345678-1234-1234-1234-123456789abc';
app.context.appendMessage('user', '![chart](/api/generated-images/' + id + ') and ![inline](data:image/png;base64,iVBORw0KGgo=)');
const imgs = userBubble(app).querySelectorAll('img');
assert.equal(imgs.length, 2);
assert.ok(imgs[0].getAttribute('src').endsWith('/api/generated-images/' + id));
assert.ok(imgs[1].getAttribute('src').startsWith('data:image/png;base64,'));
app.context.appendMessage('user', '![evil](https://evil.example/x.png) and a normal [link](https://example.test/page)');
const bubbles = app.document.querySelectorAll('.assistant-msg.user .assistant-bubble');
assert.equal(bubbles[1].querySelectorAll('img').length, 0, 'external image URLs never render as images');
assert.match(bubbles[1].textContent, /evil/);
assert.equal(bubbles[1].querySelector('a[href="https://example.test/page"]') !== null, true, 'ordinary links stay links');
});
test('assistant markdown rendering and citation chips are unchanged by user-markdown mode', t => {
const app = ui(t);
const sources = [{ title: 'Synthetic A', page: 7 }];
app.context.appendMessage('user', 'What is the dose [1]?');
app.context.appendMessage('assistant', 'Dose [1].', sources);
const assistantBubble = app.document.querySelector('.assistant-msg.assistant .assistant-bubble');
assert.ok(assistantBubble.querySelector('.assistant-cite[data-source-number="1"]'));
assert.equal(userBubble(app).querySelector('.assistant-cite'), null);
assert.deepEqual(app.context.messages[1].sources, sources, 'per-turn source maps preserved');
});

View file

@ -0,0 +1,104 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const { marked } = require('marked');
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
function workspace(t) {
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
const window = dom.window;
window.eval(read('public/js/accountBoundary.js'));
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
window.marked = marked;
window.DOMPurify = require('dompurify')(window);
window.matchMedia = () => ({ matches: true });
const fetched = [];
const activated = [];
const events = [];
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
setTimeout() {}, clearTimeout() {}, showToast() {}, EMPTY_PROMPT_SETS: [[]],
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
fetchAssistantStatus: async () => ({ success: true }),
fetchAssistantExamples: async () => ({ success: true, examples: [] }),
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
saveAssistantChat: async () => ({ success: true }),
fetch: async url => { fetched.push(url); return new Response(read('public/components/learning.html')); } };
vm.createContext(context);
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
}
context.bindEvents();
window.activateTab = tab => activated.push(tab);
window.document.addEventListener('tabChanged', e => events.push(e.detail.tab));
t.after(() => window.close());
return { context, document: window.document, window, fetched, activated, events };
}
test('assistant area is an OWUI-style three-column workspace with a slim go-back top bar', t => {
const app = workspace(t);
const layout = app.document.querySelector('.assistant-layout');
assert.ok(layout);
const columns = [...layout.children].map(el => el.className.split(' ')[0]);
assert.ok(columns.includes('assistant-history'), 'left history rail');
assert.ok(columns.includes('assistant-main'), 'center chat column');
assert.ok(columns.includes('assistant-side'), 'right image/sources column');
const left = app.document.querySelector('.assistant-history');
assert.ok(left.querySelector('#assistant-saved-chats'), 'saved chats live in the left rail');
assert.equal(left.querySelector('#btn-assistant-clear') !== null, true, 'New chat sits at the top of the rail');
assert.equal(left.firstElementChild.id, 'btn-assistant-clear', 'New chat is the first rail element');
const right = app.document.querySelector('.assistant-side');
assert.ok(right.querySelector('#assistant-visual-output'), 'image controls in the right column');
assert.ok(right.querySelector('#assistant-sources'), 'sources panel in the right column');
assert.equal(right.querySelector('#assistant-saved-chats'), null, 'saved chats moved out of the right column');
const topbar = app.document.querySelector('.assistant-topbar');
assert.ok(topbar);
assert.ok(topbar.querySelector('#btn-assistant-goback'), 'Go back control present');
const actions = app.document.querySelector('.assistant-toolbar-actions');
const buttons = [...actions.querySelectorAll('button')].map(b => b.id);
assert.deepEqual(buttons, ['btn-assistant-export-pdf', 'btn-assistant-download-chat'], 'top bar keeps only Export PDF and Download transcript');
const examples = app.document.querySelectorAll('.assistant-empty .assistant-examples button, [data-assistant-example]');
assert.ok(examples.length >= 3, 'the generated example questions stay on the empty chat screen');
});
test('learning hub opens inside the assistant center column and keeps the right tools column visible', async t => {
const app = workspace(t);
const c = app.context;
app.document.getElementById('btn-assistant-learning-view').click();
await new Promise(r => setImmediate(r));
assert.ok(app.fetched.includes('/components/learning.html'), 'learning component fetched once');
assert.equal(app.document.getElementById('assistant-chat-view').hidden, true, 'chat view hidden');
assert.equal(app.document.getElementById('assistant-learning-view').hidden, false, 'learning view visible');
assert.ok(app.document.querySelector('#assistant-learning-root #lh-search'), 'learning markup injected');
assert.deepEqual(app.events, ['learning'], 'learning tabChanged dispatched for learningHub init');
const right = app.document.querySelector('.assistant-side');
assert.equal(right.hidden, false, 'right tools column stays visible in the learning view');
assert.equal(app.document.getElementById('assistant-visual-output').closest('.assistant-layout') !== null, true, 'image controls remain mounted');
app.document.getElementById('btn-assistant-chat-view').click();
assert.equal(app.document.getElementById('assistant-chat-view').hidden, false);
assert.equal(app.document.getElementById('assistant-learning-view').hidden, true);
// Reopening reuses the cached fetch and re-dispatches init.
app.document.getElementById('btn-assistant-learning-view').click();
await new Promise(r => setImmediate(r));
assert.equal(app.fetched.filter(url => url === '/components/learning.html').length, 1, 'component fetched exactly once');
assert.equal(app.events.filter(e => e === 'learning').length, 2);
});
test('go back leaves the assistant workspace for the last non-assistant tab', t => {
const app = workspace(t);
app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'notes' } }));
app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
app.document.getElementById('btn-assistant-goback').click();
assert.deepEqual(app.activated, ['notes'], 'returns to the previous main-menu tab');
});
test('learning hub has no top-level menu entry and its component section is gone from index.html', () => {
const indexHtml = read('public/index.html');
assert.doesNotMatch(indexHtml, /<button class="tab-btn[^"]*" data-tab="learning">/, 'no Learning Hub sidebar button');
assert.doesNotMatch(indexHtml, /<section id="learning-tab"/, 'no separate learning tab section');
assert.match(indexHtml, /<script src="\/vendor\/katex\/katex\.min\.js" defer><\/script>[\s\S]*<script src="\/vendor\/katex\/contrib\/mhchem\.min\.js" defer><\/script>/, 'mhchem loads right after katex');
assert.match(indexHtml, /<section id="assistant-tab" class="tab-content" data-component="assistant"><\/section>/);
});

View file

@ -35,7 +35,7 @@ function server(options = {}) {
};
const source = { number: 1, title: 'Synthetic source', excerpt: 'Synthetic reference.', page: 9 };
const mocks = {
express, axios: { async post(url, payload) { calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db,
express, axios: { async post(url, payload) { if (String(url).includes('/translate')) return { data: { translatedText: 'Synthetic translation.' } }; calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db,
'../middleware/auth': { authMiddleware() {} }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai },
'../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {} },
'../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') },
@ -56,7 +56,8 @@ function server(options = {}) {
normalizeMcpMultimodalResponse: () => [], dedupeSources: value => value,
isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => []
},
'../utils/clinicalAnswer': answer, '../utils/clinicalConversation': policy
'../utils/clinicalAnswer': answer, '../utils/clinicalConversation': policy,
'../utils/clinicalTranslation': require('../src/utils/clinicalTranslation')
};
const module = { exports: {} };
vm.runInNewContext(read('src/routes/clinicalAssistant.js'), {
@ -88,8 +89,8 @@ test('conversation budget validates exact boundaries, Unicode, malformed roles a
}
});
test('real chat, stream and handoff endpoints reject before all paid/retrieval calls; invalid ENV closes access', async () => {
for (const endpoint of ['/clinical-assistant/chat', '/clinical-assistant/chat/stream', '/clinical-assistant/handoff']) {
test('real chat and stream endpoints reject before all paid/retrieval calls; invalid ENV closes access', async () => {
for (const endpoint of ['/clinical-assistant/chat', '/clinical-assistant/chat/stream']) {
const app = server({ limit: '1000' });
const result = await app.request('post', endpoint, { message: 'hello', history: [{ role: 'user', content: 'x'.repeat(1001) }] });
assert.equal(result.statusCode, 413);
@ -126,7 +127,7 @@ test('real accepted route retains early turns and late corrections in rewrite an
assert.equal(long.statusCode, 200, 'The old independent 4,000-character clipping boundary is gone');
});
test('real save/reopen keeps 101 turns, Markdown, Unicode and full source metadata; the sidebar image is not stored', async () => {
test('real save/reopen keeps 101 turns, Markdown, Unicode and full source metadata; the sidebar image persists', async () => {
const app = server();
const content = ' Preserve indent\n\n| Item | Unit |\n|---|---|\n| 0.25 | mg/kg |\n' + '保留'.repeat(6500);
const sources = Array.from({ length: 31 }, (_, index) => ({ number: index + 1, title: 'Title ' + index, excerpt: content, resource: 'source-' + index, page: index + 1 }));
@ -137,7 +138,7 @@ test('real save/reopen keeps 101 turns, Markdown, Unicode and full source metada
const reopened = await app.request('get', '/clinical-assistant/chats/:id', {});
assert.deepEqual(JSON.parse(JSON.stringify(reopened.body.chat.payload.messages)), messages);
assert.equal(reopened.body.chat.payload.lastAnswer, content);
assert.equal(reopened.body.chat.payload.generatedImage, undefined, 'The sidebar image is session-only');
assert.equal(reopened.body.chat.payload.generatedImage, png, 'The sidebar image persists with the chat');
assert.equal(reopened.body.chat.payload.sources.length, 31);
assert.equal((await app.request('get', '/clinical-assistant/chats/:id', {}, 8)).statusCode, 404);
const oversized = await app.request('post', '/clinical-assistant/chats', { messages: [{ role: 'user', content: 'x'.repeat(policy.MAX_SAVED_CHAT_BYTES) }] });
@ -150,18 +151,10 @@ test('real save/reopen keeps 101 turns, Markdown, Unicode and full source metada
assert.equal(app.calls.writes.length, 1, 'Rejected saves must not insert truncated records');
});
test('explicit handoff uses full context, does not retrieve/replace a chat, and reports empty model responses', async () => {
test('handoff route is gone; save/download wording covers the budget limit', async () => {
const app = server();
const history = [{ role: 'user', content: 'Known facts.\n' + 'x'.repeat(1500) + '\nCorrection: dose 0.25 mg/kg.' }];
const result = await app.request('post', '/clinical-assistant/handoff', { history });
assert.equal(result.statusCode, 200);
assert.ok(app.calls.ai[0].messages[1].content.includes(history[0].content));
assert.match(app.calls.ai[0].messages[0].content, /prior AI output is not evidence/);
assert.match(app.calls.ai[0].messages[0].content, /heading.*Clinical handoff/);
assert.doesNotMatch(app.calls.ai[0].messages[0].content, /Label this as conversation context, not a verified clinical source/);
assert.equal(app.calls.search.length + app.calls.writes.length, 0);
assert.equal((await server({ emptyAI: true }).request('post', '/clinical-assistant/handoff', { history })).statusCode, 500);
assert.equal((await server({ finishReason: 'length' }).request('post', '/clinical-assistant/handoff', { history })).statusCode, 503);
await assert.rejects(app.request('post', '/clinical-assistant/handoff', { history: [{ role: 'user', content: 'Known facts.' }] }), /route/, 'route layer no longer exists');
assert.throws(() => policy.checkConversation([{ role: 'user', content: 'x' }], 'y'.repeat(1000), 1000), error => error.statusCode === 413 && !/handoff/i.test(error.message));
});
function browserUI(options = {}) {
@ -172,7 +165,7 @@ function browserUI(options = {}) {
return new Response(read('public/css/assistant.css'), { headers: { 'Content-Type': 'text/css' } });
})] }
});
const calls = { stream: [], save: [], handoff: [] };
const calls = { stream: [], save: [] };
const escapeHtml = text => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const context = {
window: dom.window, document: dom.window.document, navigator: dom.window.navigator,
@ -189,8 +182,7 @@ function browserUI(options = {}) {
if (options.stream) return options.stream(payload);
return new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: 'Complete response.', sources: [] }) + '\n\n');
},
saveAssistantChat: async payload => { calls.save.push(payload); return { success: true }; },
requestAssistantHandoff: async history => { calls.handoff.push(history); return options.handoffError ? { error: 'Summary failed.' } : { success: true, summary: 'Explicit handoff.' }; }
saveAssistantChat: async payload => { calls.save.push(payload); return { success: true }; }
};
vm.createContext(context);
vm.runInContext(read('public/js/clinicalAssistant.js').replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, ''), context);
@ -199,19 +191,19 @@ function browserUI(options = {}) {
return { context, document: dom.window.document, calls, dom };
}
test('actual UI blocks over-budget input without clearing draft/history or automatically requesting a summary', async () => {
test('actual UI blocks over-budget input without clearing draft/history', async () => {
const ui = browserUI({ limit: 1000 });
ui.context.restoreSavedChat({ messages: [{ role: 'user', content: 'x'.repeat(999) }] });
ui.document.getElementById('assistant-input').value = 'xx';
await ui.context.onAsk();
assert.equal(ui.calls.stream.length + ui.calls.handoff.length, 0);
assert.equal(ui.calls.stream.length, 0);
assert.equal(ui.context.messages.length, 1);
assert.equal(ui.document.getElementById('assistant-input').value, 'xx');
assert.equal(ui.document.getElementById('assistant-context-warning').hidden, false);
ui.dom.window.close();
});
test('actual UI sends complete prior history exactly once, does not save the sidebar image, and offers explicit handoff', async () => {
test('actual UI sends complete prior history exactly once and saves the sidebar image', async () => {
const ui = browserUI();
const messages = Array.from({ length: 20 }, (_, i) => ({ role: i % 2 ? 'assistant' : 'user', content: 'Turn ' + i + (i === 0 ? 'x'.repeat(1500) : ''), sources: [] }));
ui.context.restoreSavedChat({ messages, lastAnswer: 'Previous answer.', generatedImage: png });
@ -223,12 +215,9 @@ test('actual UI sends complete prior history exactly once, does not save the sid
assert.equal(ui.context.messages[20].content, 'New question');
assert.equal(ui.document.getElementById('assistant-input').value, '');
await ui.context.saveCurrentChat();
assert.equal(ui.calls.save[0].generatedImage, undefined);
assert.equal(ui.calls.save[0].generatedImage, png);
assert.equal(ui.calls.save[0].messages.length, 22);
await ui.context.requestHandoff();
assert.equal(ui.calls.handoff.length, 1);
assert.equal(ui.context.messages.length, 22, 'Handoff must not replace the conversation');
assert.equal(ui.document.getElementById('assistant-handoff-text').value, 'Explicit handoff.');
assert.equal(ui.context.messages.length, 22);
ui.dom.window.close();
});
@ -348,7 +337,7 @@ function directAdapter(provider, mode, reason) {
};
}
test('actual direct AI adapters propagate completion status through the actual handoff route (fake SDK only)', async () => {
test('actual direct AI adapters propagate completion status without any route (fake SDK only)', async () => {
for (const [provider, mode, model] of [
['bedrock', 'invoke', 'anthropic.claude-synthetic'],
['bedrock', 'converse', 'amazon/nova-lite'],
@ -361,22 +350,11 @@ test('actual direct AI adapters propagate completion status through the actual h
['future_status', 'future_status'], ['', ''], [null, null], [undefined, null]
]) {
const adapter = directAdapter(provider, mode, reason);
const app = server({ ai: adapter, model });
const result = await app.request('post', '/clinical-assistant/handoff', { history: [{ role: 'user', content: 'Synthetic facts.' }] });
await adapter.callAI([{ role: 'user', content: 'Synthetic facts.' }], { model });
assert.equal(adapter.sdkCalls.length, 1, provider + '/' + mode + '/' + reason);
assert.equal(adapter.responses[0].finishReason, normalized);
assert.equal(result.statusCode, normalized === 'stop' ? 200 : 503);
if (normalized !== 'stop') {
assert.equal(result.body.summary, undefined);
assert.match(result.body.error, /not accepted.*unchanged/);
} else assert.equal(result.body.summary, 'Synthetic handoff.');
assert.equal(app.calls.search.length + app.calls.writes.length, 0);
}
}
for (const finishReason of [null, undefined, 'tool_calls', 'unknown']) {
const result = await server({ finishReason }).request('post', '/clinical-assistant/handoff', { history: [{ role: 'user', content: 'Facts.' }] });
assert.equal(result.statusCode, 503, 'Unknown OpenAI-compatible status cannot be accepted either');
}
});
test('ENV/default metadata and exact UTF16 boundary ignore legacy DB budget and DB failures', async () => {

View file

@ -83,7 +83,7 @@ test('native admin and assistant modules retain budget, table/source identity an
document.getElementById('btn-save-assistant-config').click();
await tick();
assert.equal(limit, 2000);
assert.equal(calls.filter(call => call.options.method === 'PUT').length, 3, 'one native admin initializer; prompts and ENV budget are not generic setting saves');
assert.equal(calls.filter(call => call.options.method === 'PUT').length, 4, 'one native admin initializer; prompts and ENV budget are not generic setting saves');
assert.equal(calls.some(call => call.url.endsWith('/config/clinical_assistant.conversation_chars')), false);
document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
await tick();

View file

@ -36,7 +36,11 @@ async function browser(t, module, handler) {
const originals = Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(global, key)]);
Object.assign(global, values);
Object.assign(window, { getAuthHeaders: values.getAuthHeaders, showToast: values.showToast, confirm: () => true });
t.after(() => {
t.after(async () => {
// Let pending debounced autosaves settle against THIS fetch mock before
// globals are restored; otherwise a late 800ms autosave timer would post
// through the next test's fetch and pollute its call log.
await new Promise(resolve => setTimeout(resolve, 900));
for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(global, key, descriptor); else delete global[key]; }
window.close();
});
@ -207,11 +211,10 @@ test('native conversation UI counts UTF-16 history + draft, warns at exactly 90%
const input = enter(ui, 'x'.repeat(102));
await ask(ui);
assert.equal(ui.calls.filter(c => c.url.endsWith('/chat/stream')).length, 0);
assert.equal(ui.calls.filter(c => c.url.endsWith('/handoff')).length, 0);
assert.equal(input.value.length, 102);
assert.equal(ui.document.querySelectorAll('.assistant-msg').length, 2);
assert.match(warning.textContent, /Sending is blocked/);
for (const id of ['btn-assistant-save', 'btn-assistant-download-chat', 'btn-assistant-export-pdf', 'btn-assistant-copy']) assert.equal(ui.document.getElementById(id).disabled, false);
for (const id of ['btn-assistant-save', 'btn-assistant-download-chat', 'btn-assistant-export-pdf']) assert.equal(ui.document.getElementById(id).disabled, false);
enter(ui, 'x'.repeat(101)); await ask(ui);
const request = ui.calls.find(c => c.url.endsWith('/chat/stream'));
assert.deepEqual(request.body.history, history);
@ -219,16 +222,13 @@ test('native conversation UI counts UTF-16 history + draft, warns at exactly 90%
assert.equal(input.value, '');
});
test('over-cap saved history remains viewable/saveable/exportable; explicit handoff refuses before transport', async t => {
test('over-cap saved history remains viewable/saveable/exportable', async t => {
const history = [{ role: 'user', content: 'x'.repeat(1001) }, { role: 'assistant', content: 'Full saved answer' }];
const ui = await browser(t, 'assistant', (url, options) => {
if (url === '/api/clinical-assistant/chats' && !options.method) return json({ success: true, chats: [{ id: 1 }] });
if (url === '/api/clinical-assistant/chats/1') return json({ success: true, chat: { payload: { version: 2, messages: history } } });
});
await loadChat(ui); enter(ui, 'Unsent draft');
ui.document.getElementById('btn-assistant-handoff').click(); await tick();
assert.equal(ui.calls.filter(c => c.url.endsWith('/handoff')).length, 0);
assert.match(ui.toasts.at(-1)[0], /History exceeds/);
ui.document.getElementById('btn-assistant-save-confirm').click(); await tick();
assert.deepEqual(ui.calls.find(c => c.url.endsWith('/chats') && c.options.method === 'POST').body.messages.map(({ role, content }) => ({ role, content })), history);
assert.equal(ui.document.getElementById('assistant-input').value, 'Unsent draft');
@ -238,49 +238,19 @@ test('over-cap saved history remains viewable/saveable/exportable; explicit hand
assert.equal(ui.document.getElementById('btn-assistant-download-chat').disabled, false);
});
test('explicit handoff at the exact cap excludes but preserves the draft, and keeps full history', async t => {
test('handoff controls are gone: the picker, modal and textarea no longer exist', async t => {
const history = [{ role: 'user', content: '😀'.repeat(500) }];
const ui = await browser(t, 'assistant', (url, options) => {
if (url === '/api/clinical-assistant/chats') return json({ success: true, chats: [{ id: 1 }] });
if (url === '/api/clinical-assistant/chats/1') return json({ success: true, chat: { payload: { messages: history } } });
if (url.endsWith('/handoff')) return json({ success: true, summary: 'Explicit synthetic handoff' });
});
await loadChat(ui); enter(ui, 'Draft not included in handoff');
ui.document.getElementById('btn-assistant-handoff').click(); await tick();
assert.deepEqual(ui.calls.find(c => c.url.endsWith('/handoff')).body, { history });
assert.equal(ui.document.getElementById('assistant-input').value, 'Draft not included in handoff');
await loadChat(ui); enter(ui, 'Draft stays');
assert.equal(ui.document.getElementById('btn-assistant-handoff'), null);
assert.equal(ui.document.getElementById('assistant-handoff-modal'), null);
assert.equal(ui.document.getElementById('assistant-handoff-text'), null);
assert.equal(ui.calls.filter(c => c.url.endsWith('/handoff')).length, 0);
assert.equal(ui.document.getElementById('assistant-input').value, 'Draft stays');
assert.equal(ui.document.querySelectorAll('.assistant-msg').length, 1);
assert.equal(ui.document.getElementById('assistant-handoff-text').value, 'Explicit synthetic handoff');
});
test('handoff renders Markdown while preserving exact copy text and clearing on saved-chat load', async t => {
const summary = '## Clinical handoff\n\n**Plan**\n\n- Follow up\n\n| Item | Value |\n| --- | --- |\n| Synthetic | 1.25 mg |\n';
const history = [{ role: 'user', content: 'Synthetic facts.' }];
const ui = await browser(t, 'assistant', url => {
if (url === '/api/clinical-assistant/chats') return json({ success: true, chats: [{ id: 1 }] });
if (url === '/api/clinical-assistant/chats/1') return json({ success: true, chat: { payload: { messages: history } } });
if (url.endsWith('/handoff')) return json({ success: true, summary });
});
ui.window.DOMPurify = require('dompurify')(ui.window);
ui.window.marked = await import('marked');
await loadChat(ui);
ui.document.getElementById('btn-assistant-handoff').click(); await tick();
const raw = ui.document.getElementById('assistant-handoff-text');
const preview = ui.document.getElementById('assistant-handoff-preview');
assert.equal(raw.value, summary);
assert.equal(raw.hidden, true);
assert.equal(preview.querySelector('h2').textContent, 'Clinical handoff');
assert.equal(preview.querySelector('strong').textContent, 'Plan');
assert.equal(preview.querySelector('li').textContent, 'Follow up');
assert.equal(preview.querySelector('tbody td:last-child').textContent, '1.25 mg');
assert.doesNotMatch(ui.document.getElementById('assistant-handoff-modal').textContent, /not.*verified clinical/i);
assert.equal(ui.document.getElementById('assistant-handoff-modal').classList.contains('hidden'), false, 'modal visible');
ui.document.getElementById('btn-assistant-handoff-done').click(); await tick();
assert.equal(ui.document.getElementById('assistant-handoff-modal').classList.contains('hidden'), true, 'Close returns to the chat');
assert.equal(ui.document.querySelectorAll('.assistant-msg').length, 1, 'chat state untouched');
await loadChat(ui);
assert.equal(raw.value, '');
assert.equal(preview.textContent, '');
});
test('missing/invalid metadata never fabricates a cap; authoritative refusal keeps the draft and updates the counter', async t => {
@ -359,7 +329,8 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves
ui.document.getElementById('btn-save-assistant-config').click(); await tick();
assert.deepEqual(writes(ui).map(c => [decodeURIComponent(c.url.split('/').pop()), c.body.value]), [
['clinical_assistant.chat_model', 'saved-chat'],
['clinical_assistant.search_limit', '19'], ['clinical_assistant.context_chars', '2300']
['clinical_assistant.search_limit', '19'], ['clinical_assistant.context_chars', '2300'],
['clinical_assistant.translate_provider', 'libretranslate']
]);
});

View file

@ -81,6 +81,7 @@ function route(file, ai, jobs) {
'../utils/clinicalPrompts':require('../src/utils/clinicalPrompts'),
'../utils/clinicalConversation':require('../src/utils/clinicalConversation'),
'../utils/clinicalAnswer':require('../src/utils/clinicalAnswer'),
'../utils/clinicalTranslation':require('../src/utils/clinicalTranslation'),
'../utils/generatedImages':realImages, '../utils/generatedImageLinks':require('../src/utils/generatedImageLinks'),
'../utils/imageTool':{tools:imageTool.tools,dispatch:(value,options)=>imageTool.dispatch(value,{...options,images:{enqueue:async(...args)=>{jobs.push(args);return {jobId:id,status:'pending'};}}})},
'../utils/clinicalMcpClient':{semanticSearch:async()=>({})},

View file

@ -127,7 +127,7 @@ test('selected sidebar B survives A-done -> B-queued -> save/reopen/export, with
const transcript=[{role:'assistant',content:'Original [3, 1].',sources:[{number:3,page:19}],imageJobs:[{jobId:id}]}];
ui.context.restoreSavedChat({messages:transcript,lastAnswer:'Original [3, 1].',generatedImage:src});
ui.context.generateImage('B request');await new Promise(r=>setImmediate(r));await ui.context.saveCurrentChat();
assert.equal(saved.generatedImage, undefined, 'offbox image is session-only and never saved');assert.equal(saved.generatedImageJobs[0].jobId,b);assert.equal(saved.messages[0].imageJobs[0].jobId,id);
assert.equal(saved.generatedImage, undefined, 'an empty sidebar reference is omitted while a job is queued');assert.equal(saved.generatedImageJobs[0].jobId,b);assert.equal(saved.messages[0].imageJobs[0].jobId,id);
ui.context.restoreSavedChat(saved);await new Promise(r=>setImmediate(r));assert.equal(ui.context.lastGeneratedImageSrc,'');
vm.runInContext(fs.readFileSync('public/js/assistant/export.js','utf8').replace(/^import .*;\n/gm,'').replace(/^export /gm,''),ui.context);
ui.context.imageDataUrl=async url=>'data-for:'+url;
@ -140,7 +140,7 @@ test('selected sidebar B survives A-done -> B-queued -> save/reopen/export, with
assert.equal(ui.context.lastGeneratedImageSrc,'/api/generated-images/'+b);
const exported=await ui.context.preparePrivateExport(state(),exportOwner);assert.equal(exported.lastGeneratedImageSrc,'data-for:/api/generated-images/'+b);
assert.equal(exported.messages[0].images[0],'data-for:'+src);assert.equal(exported.messages[0].content,transcript[0].content);
await ui.context.saveCurrentChat();assert.equal(saved.generatedImage,undefined,'offbox image stays session-only even after job completes');assert.equal(saved.generatedImageJobs[0].jobId,b);ui.dom.window.close();
await ui.context.saveCurrentChat();assert.equal(saved.generatedImage,'/api/generated-images/'+b,'the completed sidebar image persists with the chat');assert.equal(saved.generatedImageJobs[0].jobId,b);ui.dom.window.close();
});
test('actual CMS presentation image controls are visible and insertion/save/reopen preserve Markdown; preserved refinement never resets editor', async () => {
const ui=client();ui.dom.window.document.body.innerHTML='<style>.hidden{display:none!important}</style>'+fs.readFileSync('public/components/cms.html','utf8');