+
diff --git a/public/css/assistant.css b/public/css/assistant.css
index 79b294b7..16d532df 100644
--- a/public/css/assistant.css
+++ b/public/css/assistant.css
@@ -2,7 +2,7 @@
.assistant-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; }
.assistant-mobile-menu { display:none; }
.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 { flex:1; min-width:0; position:relative; }
.assistant-topbar-title h2 { font-size:15px; margin:0; color:var(--g800); }
/* Inside the assistant workspace the page IS the assistant; naming it again in
the topbar just costs a row of space. Kept in the DOM for screen readers. */
@@ -139,6 +139,18 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
.assistant-sources { padding:10px 12px; display:grid; gap:8px; flex:1 1 auto; min-height:0; overflow-y:auto; }
.assistant-saved-chats { flex:1 1 auto; overflow-y:auto; min-height:0; padding:10px 12px; display:flex; flex-direction:column; gap:8px; }
/* Open WebUI-style recency headings above each run of chats. */
+/* Workspace section in the rail: the app's own menu, one level down, the way
+ Claude's sidebar carries both its chats and its top-level navigation. */
+.assistant-rail-workspace { display:flex; flex-direction:column; gap:1px; margin-top:8px; padding-top:8px; border-top:1px solid var(--g100); }
+.assistant-rail-section { display:flex; align-items:center; gap:8px; width:100%; border:none; background:none; cursor:pointer; padding:6px 10px; border-radius:8px; font-size:11px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--g400); text-align:left; }
+.assistant-rail-section:hover { background:var(--g100); color:var(--g600); }
+.assistant-rail-caret { transition:transform .15s ease; font-size:9px; }
+.assistant-rail-section.is-open .assistant-rail-caret { transform:rotate(90deg); }
+.assistant-rail-links { display:flex; flex-direction:column; gap:1px; }
+.assistant-rail-link { display:flex; align-items:center; gap:10px; width:100%; border:none; background:none; cursor:pointer; padding:8px 10px; border-radius:8px; font-size:13px; color:var(--g700); text-align:left; }
+.assistant-rail-link:hover { background:var(--g100); color:var(--g900); }
+.assistant-rail-link i { width:16px; text-align:center; color:var(--g400); font-size:12px; }
+.assistant-rail-link:hover i { color:var(--purple); }
.assistant-saved-chat-group { display:block; }
.assistant-saved-chat-group + .assistant-saved-chat-group { margin-top:10px; }
.assistant-saved-chat-heading { margin:0 0 2px; padding:0 10px; font-size:11px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--g400); }
@@ -276,6 +288,12 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
/* Full-screen Open WebUI workspace: the assistant replaces the app chrome */
body.assistant-workspace .assistant-layout { height: calc(100vh - 64px); min-height: 0; grid-template-rows: minmax(0, 1fr); overflow: hidden; }
+/* Phones: workspace mode hides .app-header, so there is no 64px to subtract and
+ the mobile flex layout must survive the more specific workspace selectors. */
+@media (max-width: 640px) {
+ body.assistant-workspace .assistant-layout { height:100dvh; display:flex; flex-direction:column; grid-template-rows:none; overflow:hidden; }
+ body.assistant-workspace .assistant-learning-view { height:100dvh; }
+}
/* Desktop: the saved-chats rail collapses like ChatGPT's sidebar */
.assistant-toggle-history { border:none; background:none; color:var(--g600); padding:6px 8px; border-radius:8px; cursor:pointer; line-height:1; }
.assistant-toggle-history:hover { background:var(--g100); color:var(--g800); }
diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js
index 62e27a2e..ef0dccb0 100644
--- a/public/js/clinicalAssistant.js
+++ b/public/js/clinicalAssistant.js
@@ -161,6 +161,50 @@ import {
btn.classList.toggle('is-collapsed', !!collapsed);
};
+ // The rail mirrors the app's real tab list rather than restating it, so a tab
+ // added, renamed or hidden in index.html shows up here with no extra work.
+ var renderWorkspaceLinks = function() {
+ var host = document.getElementById('assistant-workspace-links');
+ if (!host) return;
+ host.innerHTML = '';
+ Array.prototype.forEach.call(document.querySelectorAll('.tab-btn'), function(tab) {
+ var name = tab.getAttribute('data-tab');
+ if (!name || name === 'assistant' || tab.classList.contains('hidden')) return;
+ var icon = tab.querySelector('i');
+ var label = tab.querySelector('span');
+ var link = document.createElement('button');
+ link.type = 'button';
+ link.className = 'assistant-rail-link';
+ link.setAttribute('data-assistant-workspace-tab', name);
+ link.innerHTML = '' +
+ '' + escapeHtml(label ? label.textContent : name) + '';
+ host.appendChild(link);
+ });
+ };
+
+ var workspaceToggle = document.getElementById('btn-assistant-workspace-toggle');
+ if (workspaceToggle) workspaceToggle.addEventListener('click', function() {
+ var host = document.getElementById('assistant-workspace-links');
+ if (!host) return;
+ var open = host.hidden;
+ host.hidden = !open;
+ workspaceToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
+ workspaceToggle.classList.toggle('is-open', open);
+ try { localStorage.setItem('ped_assistant_workspace_open', open ? '1' : '0'); } catch (e) {}
+ if (open) renderWorkspaceLinks();
+ });
+ document.addEventListener('click', function(event) {
+ var link = event.target.closest && event.target.closest('[data-assistant-workspace-tab]');
+ if (!link) return;
+ var layout = document.getElementById('assistant-layout');
+ if (layout) layout.classList.remove('mobile-chats-open');
+ document.body.classList.remove('assistant-workspace');
+ if (typeof window.activateTab === 'function') window.activateTab(link.getAttribute('data-assistant-workspace-tab'));
+ });
+ try {
+ if (localStorage.getItem('ped_assistant_workspace_open') === '1' && workspaceToggle) workspaceToggle.click();
+ } catch (e) {}
+
var closeDrawer = function() {
var layout = document.getElementById('assistant-layout');
if (layout) layout.classList.remove('mobile-chats-open');
diff --git a/src/utils/clinicalPrompts.js b/src/utils/clinicalPrompts.js
index 9de5580d..de8b326f 100644
--- a/src/utils/clinicalPrompts.js
+++ b/src/utils/clinicalPrompts.js
@@ -1,5 +1,5 @@
// Global Clinical Assistant instructions, separate from the Scribe catalogue.
-const DEFAULT_BEHAVIOR = 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user\'s latest message is only a greeting, an acknowledgement, or thanks — in any language, for example "ok", "nice", "thanks", "\u041e\u043a\u0435\u0439" — do not answer a clinical question and do not repeat your previous answer; reply in one short sentence asking what they would like you to look up. Synthesize across sources and cite factual claims with the exact provided source numbers like [1]. Do not invent, renumber, merge, or move citations. Call the generate_image tool ONLY when the user\'s latest message itself asks for a picture, or asks you to change one you just made. An acknowledgement of an image you already produced is not a request for another one, and you must never repeat a previous turn\'s answer in order to make a second image. When it is a real request, call the tool with a self-contained prompt and briefly say you are preparing the image; the image itself will appear automatically. Do not merely write an image prompt.';
+const DEFAULT_BEHAVIOR = 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims; do not answer from your own knowledge. Respond to the user\'s latest message, not to an earlier one. If it carries no question, say so briefly and ask what they would like you to look up. Never repeat a previous answer. Synthesize across sources and cite factual claims with the exact provided source numbers like [1]. Do not invent, renumber, merge, or move citations. You have a generate_image tool. Use it when the latest message asks for a picture, or for a change to one you just made, and not otherwise. Call it with a self-contained prompt built from the clinical context and briefly say you are preparing the image; it appears automatically. Do not write an image prompt as your answer.';
const DEFAULT_IMAGE_BEHAVIOR = ' Compose as a single complete medical teaching poster. Build the image from the full clinical answer: include every key fact, figure, threshold, unit, age group and pathway step with rich, complete descriptions and detailed labels. Keep every element fully inside the canvas with a 10% safe margin on all sides. Do not crop boxes, arrows, labels, legends, or body parts. Use fewer words per box, large readable type, and generous spacing. Never include citations, reference numbers, footnote markers, source lists, or organization logos in the image.';
function imagePromptForCanvas(prompt, behavior = DEFAULT_IMAGE_BEHAVIOR) {
diff --git a/src/utils/imageTool.js b/src/utils/imageTool.js
index 7187f867..c59472c0 100644
--- a/src/utils/imageTool.js
+++ b/src/utils/imageTool.js
@@ -1,6 +1,6 @@
const { service, args, requestKey, failure } = require('./generatedImages');
const tools = [{ type: 'function', function: { name: 'generate_image',
- description: 'Generate one medical educational image. Call this ONLY when the user\'s latest message itself asks for a picture, or asks you to change one you just made. Do NOT call it because an earlier turn produced an image: an acknowledgement of that image ("ok", "nice", "thanks", or the same in any other language) is not a request for another one. Supply a self-contained prompt grounded in the current content. The server selects model, credentials and workflow. Return the educational answer separately; never put image markup or invented asset URLs in it.',
+ description: 'Generate one medical educational image. Use it when the user\'s latest message asks for a picture, or for a change to one you just made, and not otherwise. Supply a self-contained prompt grounded in the current clinical content. The server selects model, credentials and workflow. Return the educational answer separately; never put image markup or invented asset URLs in it.',
parameters: { type: 'object', additionalProperties: false, properties: {
prompt: { type: 'string', minLength: 1, maxLength: 32000 },
layout: { type: 'string', enum: ['auto', 'portrait', 'landscape', 'square'] }
diff --git a/test/assistant-image-intent.test.js b/test/assistant-image-intent.test.js
index bf1acc82..0101f1cc 100644
--- a/test/assistant-image-intent.test.js
+++ b/test/assistant-image-intent.test.js
@@ -24,20 +24,25 @@ test('assistant image intent still handles explicit visual requests', async () =
// replayed it verbatim for "Окей" and "Nice". The fix is not a word list in the
// route — the model already has the tool, the prompt and the conversation, so the
// policy lives where the model reads it.
-test('the system prompt tells the model to decide, in the user\'s own language', () => {
+test('the prompt states principles, not an enumerated list of cases', () => {
const { DEFAULT_BEHAVIOR } = require('../src/utils/clinicalPrompts');
- assert.match(DEFAULT_BEHAVIOR, /in any language/i, 'acknowledgements are recognised by the model, not by a list');
- assert.match(DEFAULT_BEHAVIOR, /ONLY when the user's latest message itself asks for a picture/);
- assert.match(DEFAULT_BEHAVIOR, /never repeat a previous turn's answer/);
- assert.match(DEFAULT_BEHAVIOR, /ask what they would like you to look up|asking what they would like you to look up/i);
+ assert.match(DEFAULT_BEHAVIOR, /Respond to the user's latest message, not to an earlier one/);
+ assert.match(DEFAULT_BEHAVIOR, /If it carries no question/, 'covers acknowledgements without naming any');
+ assert.match(DEFAULT_BEHAVIOR, /Never repeat a previous answer/);
+ assert.match(DEFAULT_BEHAVIOR, /do not answer from your own knowledge/, 'grounded in retrieved context');
+ // No word list anywhere: the model reads the message in whatever language it
+ // arrives in, so no vocabulary needs maintaining here.
+ for (const word of ['ok', 'nice', 'thanks', 'okay']) {
+ assert.doesNotMatch(DEFAULT_BEHAVIOR, new RegExp('"' + word + '"', 'i'), 'no example word list');
+ }
});
-test('the tool description itself says when not to call it', () => {
+test('the tool description says when to use it and when not', () => {
const { tools } = require('../src/utils/imageTool');
const description = tools[0].function.description;
- assert.match(description, /ONLY when the user's latest message itself asks for a picture/);
- assert.match(description, /is not a request for another one/);
- assert.match(description, /any other language/);
+ assert.match(description, /asks for a picture, or for a change to one you just made/);
+ assert.match(description, /and not otherwise/);
+ assert.match(description, /grounded in the current clinical content/);
});
test('no hand-maintained acknowledgement or language list is left in the route', () => {
diff --git a/test/assistant-workspace-layout.test.js b/test/assistant-workspace-layout.test.js
index 980dd539..0d554963 100644
--- a/test/assistant-workspace-layout.test.js
+++ b/test/assistant-workspace-layout.test.js
@@ -162,3 +162,39 @@ test('the assistant workspace does not repeat its own name in the topbar', () =>
const html = fs.readFileSync(path.join(__dirname, '..', 'public/components/assistant.html'), 'utf8');
assert.match(html, /AI Clinical Assistant/, 'and still present in the DOM');
});
+
+test('workspace mode does not override the mobile layout with a desktop header offset', () => {
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/assistant.css'), 'utf8');
+ // body.assistant-workspace .assistant-layout is both later in the file and more
+ // specific than the .assistant-layout rule inside @media (max-width:640px), so
+ // without a matching-specificity override phones inherit calc(100vh - 64px) —
+ // an offset for the app header that workspace mode has already hidden.
+ const override = css.indexOf('body.assistant-workspace .assistant-layout { height:100dvh;');
+ const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: calc(100vh - 64px)');
+ assert.ok(desktop > 0, 'the desktop workspace rule exists');
+ assert.ok(override > desktop, 'the mobile override comes after it, so it wins');
+ const rule = css.slice(override, css.indexOf('}', override));
+ assert.match(rule, /display:flex/, 'the mobile flex layout is restored');
+ assert.match(rule, /grid-template-rows:none/, 'and the grid rows do not linger on a flex box');
+});
+
+test('the assistant rail carries the workspace menu without restating it', () => {
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const root = path.join(__dirname, '..');
+ const html = fs.readFileSync(path.join(root, 'public/components/assistant.html'), 'utf8');
+ assert.match(html, /assistant-rail-workspace/, 'the rail has a Workspace section');
+ assert.match(html, /id="assistant-workspace-links"[^>]*hidden/, 'collapsed until asked for');
+ assert.match(html, /aria-controls="assistant-workspace-links"/);
+
+ const js = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8');
+ // Built from the app's real tabs, so renaming or hiding one in index.html is
+ // reflected here automatically instead of drifting out of sync.
+ assert.match(js, /querySelectorAll\('\.tab-btn'\)/, 'sourced from the real tab list');
+ assert.match(js, /name === 'assistant'/, "the assistant does not link to itself");
+ assert.match(js, /tab\.classList\.contains\('hidden'\)/, 'hidden admin tabs stay hidden');
+ assert.match(js, /data-assistant-workspace-tab/, 'each entry activates its tab');
+ assert.match(js, /classList\.remove\('assistant-workspace'\)/, 'leaving the assistant restores the app chrome');
+});
diff --git a/test/prompt-administration.test.js b/test/prompt-administration.test.js
index fadcf849..81d651c4 100644
--- a/test/prompt-administration.test.js
+++ b/test/prompt-administration.test.js
@@ -314,10 +314,11 @@ test('approved inherited Scribe and clinical default bytes remain unchanged', ()
const hash = value => require('node:crypto').createHash('sha256').update(value).digest('hex');
const svc = services();
// Hashes captured from the protected input patch, before overrides or helpers.
- // DEFAULT_BEHAVIOR changed deliberately: acknowledgement handling and the
- // "when to call generate_image" rule moved out of route heuristics and into
- // the prompt, where the model applies them in the user's own language.
+ // DEFAULT_BEHAVIOR changed deliberately: the acknowledgement and image rules
+ // moved out of route heuristics into the prompt, then were rewritten as
+ // principles ("respond to the latest message", "never repeat a previous
+ // answer") rather than an enumerated list of words and cases.
assert.equal(hash(JSON.stringify(svc.prompts.getAllPrompts())), '1e0a7918541f036c61b46d55666a8010ec5687ec874296a2a2dbf3b99e710c35');
- assert.equal(hash(svc.clinical.DEFAULT_BEHAVIOR), '749bd6b8df7cb2700337224eeaecc191167895582ceb13023690f3c491950bb8');
+ assert.equal(hash(svc.clinical.DEFAULT_BEHAVIOR), '183a4e8faa467242938951365153cdc9055435cc48ae47afe4160ac760bb412b');
assert.equal(hash(svc.clinical.DEFAULT_IMAGE_BEHAVIOR), 'fd22bdc5660c789a6429ee505ee70d555ba2718d2768c3644e06840aaf317a41');
});