From 8b072496e2654ab5012ab9f3ba90f7533de187ea Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 8 Sep 2026 18:52:35 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Open=20WebUI-style=20assistant=20worksp?= =?UTF-8?q?ace=20=E2=80=94=203-column=20layout,=20markdown/math/code/table?= =?UTF-8?q?s,=20autosave=20with=20images,=20translation=20(LibreTranslate+?= =?UTF-8?q?DeepL),=20citation=20modal,=20Learning=20Hub=20moved=20in,=20ha?= =?UTF-8?q?ndoff=20removed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 6 + docs/CLINICAL_ASSISTANT.md | 20 +- public/components/admin.html | 7 + public/components/assistant.html | 137 +++-- public/css/assistant.css | 64 ++- public/index.html | 6 +- public/js/admin/clinicalAssistant.js | 4 +- public/js/assistant/api.js | 23 +- public/js/assistant/citations.js | 38 +- public/js/clinicalAssistant.js | 554 ++++++++++++++++--- src/routes/clinicalAssistant.js | 81 ++- src/utils/clinicalConversation.js | 42 +- src/utils/clinicalTranslation.js | 134 +++++ test/admin-clinical-assistant-wiring.test.js | 4 +- test/assistant-attachment-roundtrip.test.js | 165 ++++++ test/assistant-autosave.test.js | 163 ++++++ test/assistant-citation-modal.test.js | 112 ++++ test/assistant-image-attachments.test.js | 34 +- test/assistant-math-mhchem.test.js | 45 ++ test/assistant-message-actions.test.js | 112 ++++ test/assistant-table-actions.test.js | 102 ++++ test/assistant-translate.test.js | 258 +++++++++ test/assistant-user-markdown.test.js | 89 +++ test/assistant-workspace-layout.test.js | 104 ++++ test/clinical-conversation.test.js | 60 +- test/clinical-release-integration.test.js | 2 +- test/frontend-prompt-env.test.js | 61 +- test/generated-image-tools.test.js | 1 + test/generated-images-ui.test.js | 4 +- 29 files changed, 2106 insertions(+), 326 deletions(-) create mode 100644 src/utils/clinicalTranslation.js create mode 100644 test/assistant-attachment-roundtrip.test.js create mode 100644 test/assistant-autosave.test.js create mode 100644 test/assistant-citation-modal.test.js create mode 100644 test/assistant-math-mhchem.test.js create mode 100644 test/assistant-message-actions.test.js create mode 100644 test/assistant-table-actions.test.js create mode 100644 test/assistant-translate.test.js create mode 100644 test/assistant-user-markdown.test.js create mode 100644 test/assistant-workspace-layout.test.js diff --git a/docker-compose.yml b/docker-compose.yml index 0b7e515c..f91a95f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/CLINICAL_ASSISTANT.md b/docs/CLINICAL_ASSISTANT.md index 9b09f9f1..3a4a24c6 100644 --- a/docs/CLINICAL_ASSISTANT.md +++ b/docs/CLINICAL_ASSISTANT.md @@ -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. diff --git a/public/components/admin.html b/public/components/admin.html index df7600d1..fdb227f6 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -313,6 +313,13 @@ +
+ + +
Conversation input budget

Loading server budget metadata...

diff --git a/public/components/assistant.html b/public/components/assistant.html index c20dbc52..7a2742f1 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -1,5 +1,6 @@ -
-
+
+ +

AI Clinical Assistant

@@ -9,50 +10,76 @@
-
-
-
- Clinical Question - Admin model + -
- - -

Loading conversation limit; history and draft are counted in UTF-16 code units. The server validates each request.

- - - -
-
-

Saved Chats

-
-

Saved chats appear here after you click Save chat.

-
- -
-

Sources

@@ -92,21 +104,4 @@
- - - diff --git a/public/css/assistant.css b/public/css/assistant.css index efac0797..8fcd03ea 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -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; } diff --git a/public/index.html b/public/index.html index 4e5768e7..afc73186 100644 --- a/public/index.html +++ b/public/index.html @@ -21,6 +21,7 @@ + @@ -281,10 +282,6 @@ AI Assistant -
') .replace(/<\/table>/g, '
'); } @@ -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; }); } diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 8db34353..776f0be4 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -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 += '

' + escapeHtml(options.notice) + '

'; + 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 += '

' + escapeHtml(options.notice) + '

'; + 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 = ''; + 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 = '
' + + '' + + '' + + '
' + + TRANSLATE_LANGUAGES.map(function(pair) { + return ''; + }).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 = '

' + escapeHtml(data.translated) + '

' + + ''; + }) + .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 = '' + + '' + + (role === 'assistant' ? '' : ''); + 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 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); + } diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index aedbd523..c4e450d9 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -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(); } diff --git a/src/utils/clinicalConversation.js b/src/utils/clinicalConversation.js index 852c4062..987cb907 100644 --- a/src/utils/clinicalConversation.js +++ b/src/utils/clinicalConversation.js @@ -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 }; diff --git a/src/utils/clinicalTranslation.js b/src/utils/clinicalTranslation.js new file mode 100644 index 00000000..044469ba --- /dev/null +++ b/src/utils/clinicalTranslation.js @@ -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 +}; diff --git a/test/admin-clinical-assistant-wiring.test.js b/test/admin-clinical-assistant-wiring.test.js index 7f7ab3a3..2783fb1d 100644 --- a/test/admin-clinical-assistant-wiring.test.js +++ b/test/admin-clinical-assistant-wiring.test.js @@ -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')); diff --git a/test/assistant-attachment-roundtrip.test.js b/test/assistant-attachment-roundtrip.test.js new file mode 100644 index 00000000..fd319c77 --- /dev/null +++ b/test/assistant-attachment-roundtrip.test.js @@ -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('
' + read('public/components/assistant.html') + '
', { 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 => '' }), + 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'); + }); +}); diff --git a/test/assistant-autosave.test.js b/test/assistant-autosave.test.js new file mode 100644 index 00000000..bbfa248a --- /dev/null +++ b/test/assistant-autosave.test.js @@ -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('
' + read('public/components/assistant.html') + '
', { 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); +}); diff --git a/test/assistant-citation-modal.test.js b/test/assistant-citation-modal.test.js new file mode 100644 index 00000000..b31cc3bc --- /dev/null +++ b/test/assistant-citation-modal.test.js @@ -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('
' + read('public/components/assistant.html') + '
', { 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/); +}); diff --git a/test/assistant-image-attachments.test.js b/test/assistant-image-attachments.test.js index 4afbb2f3..86822f65 100644 --- a/test/assistant-image-attachments.test.js +++ b/test/assistant-image-attachments.test.js @@ -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('
' + read('public/components/assistant.html') + '
', { 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, '&').replace(//g, '>'); 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(); diff --git a/test/assistant-math-mhchem.test.js b/test/assistant-math-mhchem.test.js new file mode 100644 index 00000000..43a3b922 --- /dev/null +++ b/test/assistant-math-mhchem.test.js @@ -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 '' + expr + ''; + } + }; + 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, /HCO3-<\/span>/); + assert.match(html, /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/); +}); diff --git a/test/assistant-message-actions.test.js b/test/assistant-message-actions.test.js new file mode 100644 index 00000000..953e9179 --- /dev/null +++ b/test/assistant-message-actions.test.js @@ -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('
' + read('public/components/assistant.html') + '
', { 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); +}); diff --git a/test/assistant-table-actions.test.js b/test/assistant-table-actions.test.js new file mode 100644 index 00000000..fa61759b --- /dev/null +++ b/test/assistant-table-actions.test.js @@ -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('
' + read('public/components/assistant.html') + '
', { 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'); +}); diff --git a/test/assistant-translate.test.js b/test/assistant-translate.test.js new file mode 100644 index 00000000..d6ceb014 --- /dev/null +++ b/test/assistant-translate.test.js @@ -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('
' + read('public/components/assistant.html') + '
', { 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.'); +}); diff --git a/test/assistant-user-markdown.test.js b/test/assistant-user-markdown.test.js new file mode 100644 index 00000000..7dbae8b1 --- /dev/null +++ b/test/assistant-user-markdown.test.js @@ -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('
' + read('public/components/assistant.html') + '
', { 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, /Bold<\/strong>/); + assert.match(bubble.innerHTML, /
  • item one<\/li>/); + assert.match(bubble.innerHTML, /
  • item two<\/li>/); + assert.match(bubble.innerHTML, /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'); +}); diff --git a/test/assistant-workspace-layout.test.js b/test/assistant-workspace-layout.test.js new file mode 100644 index 00000000..505dc8d0 --- /dev/null +++ b/test/assistant-workspace-layout.test.js @@ -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('
    ' + read('public/components/assistant.html') + '
    ', { 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, /