fix: authHeaders must include Content-Type — Express never parsed JSON bodies, causing empty "Question is required" on every chat send. Also: Learning Hub back in the main menu, sources-only right column, image gallery as the user library with poll+preview, queued job reference handling

This commit is contained in:
Daniel 2026-09-09 02:13:17 +02:00
parent b9ac93ddc6
commit 982a089fcb
7 changed files with 57 additions and 123 deletions

View file

@ -14,10 +14,6 @@
<button id="btn-assistant-goback" class="btn-sm btn-ghost assistant-goback-rail" type="button" title="Back to the main menu"><i class="fas fa-arrow-left"></i> Go back</button>
<button id="btn-assistant-create-image" class="assistant-create-image" type="button"><i class="fas fa-wand-magic-sparkles"></i> Create image</button>
<button id="btn-assistant-clear" class="assistant-new-chat" type="button"><i class="fas fa-plus"></i> New chat</button>
<nav class="assistant-view-switch" aria-label="Assistant views">
<button id="btn-assistant-chat-view" class="active" type="button"><i class="fas fa-comments"></i> Chat</button>
<button id="btn-assistant-learning-view" type="button"><i class="fas fa-graduation-cap"></i> Learning Hub</button>
</nav>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-bookmark"></i> Saved Chats</h3>
@ -79,19 +75,9 @@
</form>
</div>
<div id="assistant-learning-view" class="assistant-learning-view" hidden>
<div id="assistant-learning-root" aria-live="polite"></div>
</div>
</section>
<aside class="assistant-side">
<div class="card">
<div class="card-header"><h3><i class="fas fa-wand-magic-sparkles"></i> Generated image</h3></div>
<div class="assistant-side-body">
<div id="assistant-visual-output" class="assistant-visual-output"></div>
</div>
</div>
<div class="card">
<div class="card-header"><h3><i class="fas fa-quote-right"></i> Sources</h3></div>
<div id="assistant-sources" class="assistant-sources">

View file

@ -215,6 +215,10 @@
<i class="fas fa-brain" style="color:var(--purple);"></i>
<span>AI Assistant</span>
</button>
<button class="tab-btn" data-tab="learning">
<i class="fas fa-graduation-cap"></i>
<span>Learning Hub</span>
</button>
<span class="sidebar-section-label">Encounters</span>
<button class="tab-btn active" data-tab="encounter">
<i class="fas fa-comments"></i>
@ -338,6 +342,7 @@
<section id="notes-tab" class="tab-content" data-component="notes"></section>
<section id="diagrams-tab" class="tab-content" data-component="diagrams"></section>
<section id="assistant-tab" class="tab-content" data-component="assistant"></section>
<section id="learning-tab" class="tab-content" data-component="learning"></section>
<section id="cms-tab" class="tab-content" data-component="cms"></section>
<section id="docs-tab" class="tab-content" data-component="admin-docs"></section>
<section id="admin-tab" class="tab-content" data-component="admin"></section>

View file

@ -1,6 +1,15 @@
function authHeaders() {
var token = typeof localStorage === 'undefined' ? null : localStorage.getItem('auth_token');
return token ? { Authorization: 'Bearer ' + token } : {};
function authHeaders(extra) {
// The app's global getAuthHeaders includes Content-Type: application/json;
// without it, Express never parses JSON bodies (empty message, etc.).
var base;
if (typeof window !== 'undefined' && typeof window.getAuthHeaders === 'function') {
base = window.getAuthHeaders();
} else {
var token = typeof localStorage === 'undefined' ? null : localStorage.getItem('auth_token');
base = token ? { Authorization: 'Bearer ' + token } : {};
}
if (extra) Object.assign(base, extra);
return base;
}
function parseJsonWithStatus(response) {

View file

@ -134,15 +134,11 @@ import {
ev.stopPropagation();
document.getElementById('assistant-layout')?.classList.toggle('mobile-chats-open');
});
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);
if (goBackBtn) goBackBtn.addEventListener('click', goBackToMainMenu);
if (chatViewBtn) chatViewBtn.addEventListener('click', openChatView);
if (learningViewBtn) learningViewBtn.addEventListener('click', openLearningView);
if (input) {
input.addEventListener('input', updateConversationBudget);
input.addEventListener('input', resizeAssistantInput);
@ -1184,20 +1180,32 @@ import {
assertSharingOwner(owner);
if (!data.success) throw new Error(data.error || 'Image generation failed');
generatedImageJobs = [{ jobId: data.jobId }];
lastGeneratedImageSrc = '';
lastGeneratedImageSrc = ''; // the queued job replaces any previous completed image
exporter.invalidate();
var out = document.getElementById('assistant-visual-output');
if (out) {
out.replaceChildren();
renderImageJobs(out, generatedImageJobs, 'clinical_assistant', function (card, image) {
if (generatedImageJobs[0] && generatedImageJobs[0].jobId !== image.jobId) return;
lastGeneratedImageSrc = image.imageUrl;
card.insertAdjacentHTML('beforeend', imageStore.renderGeneratedImage(image.imageUrl, 'Generated teaching visual', image.downloadUrl));
exporter.invalidate();
});
}
loadImageGallery();
if (typeof showToast === 'function') showToast('Image generation started — it appears in the Image / Graph panel', 'success');
if (typeof showToast === 'function') showToast('Generating image…', 'info');
// Poll until the job completes, then refresh the gallery and show the image.
var attempts = 0;
var poll = function () {
if (!validSharingOwner(owner) || owner.signal.aborted) return;
attempts += 1;
if (attempts > 60) return;
fetchAssistantImageJob(data.jobId).then(function (job) {
if (!job || !job.success) return;
if (job.imageUrl) {
lastGeneratedImageSrc = job.imageUrl;
exporter.invalidate();
loadImageGallery();
openImagePreview(job.imageUrl);
return;
}
if (job.status === 'error' || job.status === 'interrupted') {
if (typeof showToast === 'function') showToast(job.error || 'Image generation failed', 'error');
return;
}
setTimeout(poll, 2500);
}).catch(function () { setTimeout(poll, 2500); });
};
setTimeout(poll, 2500);
}).catch(function (error) { if (!validSharingOwner(owner) || error.name === 'AbortError') return; if (typeof showToast === 'function') showToast(error.message, 'error'); });
}).catch(function (error) { if (typeof showToast === 'function') showToast(error.message, 'error'); });
}
@ -1511,47 +1519,6 @@ import {
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');
var layout = document.getElementById('assistant-layout');
if (layout) layout.classList.remove('learning-mode');
}
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');
// Learning Hub is its own page: no saved chats, no image rail, no sources column.
var layout = document.getElementById('assistant-layout');
if (layout) layout.classList.add('learning-mode');
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;
@ -1793,7 +1760,7 @@ import {
isRetainedLegacyAnswer(finalMessage.content, lastAnswer)) finalMessage.retainedAnswer = lastAnswer;
generatedImageJobs = payload.generatedImageJobs || [];
// A selected job is authoritative, even for older saves containing a stale asset URL.
lastGeneratedImageSrc = generatedImageJobs.length ? '' : String(payload.generatedImage || '');
lastGeneratedImageSrc = String(payload.generatedImage || ''); // the saved completed image is authoritative
var wrap = document.getElementById('assistant-messages');
if (wrap) {
wrap.innerHTML = '';
@ -1807,16 +1774,7 @@ import {
wrap.scrollTop = wrap.scrollHeight;
}
renderSources(lastSources);
var out = document.getElementById('assistant-visual-output');
if (out) {
out.innerHTML = lastGeneratedImageSrc ? imageStore.renderGeneratedImage(lastGeneratedImageSrc, 'Generated clinical visual') : '';
if (generatedImageJobs.length) renderImageJobs(out, generatedImageJobs, 'clinical_assistant', function(card, data) {
if (generatedImageJobs[0]?.jobId !== data.jobId) return;
lastGeneratedImageSrc = data.imageUrl;
card.insertAdjacentHTML('beforeend', imageStore.renderGeneratedImage(data.imageUrl, 'Generated teaching visual', data.downloadUrl));
exporter.invalidate();
});
}
loadImageGallery(); // restored/generated images live in the rail gallery now
exporter.invalidate();
updateConversationBudget();
}

View file

@ -141,6 +141,7 @@ test('client restores attachments as thumbnails and saves them plus the generate
setTimeout() {}, clearTimeout() {}, showToast() {}, EMPTY_PROMPT_SETS: [[]],
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: src => '<img src="' + src + '">' }),
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
fetchAssistantImageJobs: async () => ({ success: true, jobs: [{ jobId: 'g1', imageUrl: asset }] }),
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']) {
@ -162,7 +163,8 @@ test('client restores attachments as thumbnails and saves them plus the generate
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');
const galleryImg = window.document.querySelector('#assistant-image-gallery img');
assert.ok(galleryImg && galleryImg.getAttribute('src') === asset, 'generated image restored into the rail gallery');
assert.equal(context.messages[0].content, 'Question with image', 'raw transcript canonical');
});
});

View file

@ -51,7 +51,7 @@ test('assistant area is an OWUI-style three-column workspace with a slim go-back
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-goback', 'Go back is the first rail element (ChatGPT-style), then Create image, then New chat');
const right = app.document.querySelector('.assistant-side');
assert.ok(right.querySelector('#assistant-visual-output'), 'image controls in the right column');
assert.equal(right.querySelector('#assistant-visual-output'), null, 'no image display in the right column — sources only');
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');
@ -77,36 +77,11 @@ test('opening the assistant replaces the main menu with the saved chats; Go back
assert.ok(!app.document.body.classList.contains('assistant-workspace'), 'Go back removes the workspace class');
});
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('Learning Hub opens as a full page: saved chats, image column and sources are hidden', async t => {
const app = workspace(t);
app.document.getElementById('btn-assistant-learning-view').click();
const layout = app.document.getElementById('assistant-layout');
assert.ok(layout.classList.contains('learning-mode'), 'learning-mode class applied (CSS hides the rail, image column and sources — browser-verified)');
app.document.getElementById('btn-assistant-chat-view').click();
assert.ok(!layout.classList.contains('learning-mode'), 'back to chat restores the rail and sources');
test('Learning Hub is a top-level main-menu entry, not part of the assistant workspace', () => {
const indexHtml = read('public/index.html');
assert.match(indexHtml, /<button class="tab-btn" data-tab="learning">[\s\S]*?<span>Learning Hub<\/span>/, 'Learning Hub button in the sidebar');
assert.match(indexHtml, /<section id="learning-tab" class="tab-content" data-component="learning"><\/section>/, 'learning tab section exists');
assert.match(indexHtml, /<section id="assistant-tab" class="tab-content" data-component="assistant"><\/section>/, 'assistant tab is a clean standalone section');
});
test('go back leaves the assistant workspace for the last non-assistant tab', t => {
@ -117,10 +92,9 @@ test('go back leaves the assistant workspace for the last non-assistant tab', t
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', () => {
test('the assistant tab keeps its clean structure next to the restored learning tab', () => {
const indexHtml = read('public/index.html');
assert.doesNotMatch(indexHtml, /<button class="tab-btn[^"]*" data-tab="learning">/, 'no Learning Hub sidebar button');
assert.doesNotMatch(indexHtml, /<section id="learning-tab"/, 'no separate learning tab section');
assert.match(indexHtml, /<script src="\/vendor\/katex\/katex\.min\.js" defer><\/script>[\s\S]*<script src="\/vendor\/katex\/contrib\/mhchem\.min\.js" defer><\/script>/, 'mhchem loads right after katex');
assert.match(indexHtml, /<section id="assistant-tab" class="tab-content" data-component="assistant"><\/section>/);
assert.match(indexHtml, /<section id="learning-tab" class="tab-content" data-component="learning"><\/section>/);
});

View file

@ -136,8 +136,8 @@ test('selected sidebar B survives A-done -> B-queued -> save/reopen/export, with
await assert.rejects(ui.context.preparePrivateExport(state(),exportOwner),/not complete/);
// Also repairs already-saved inconsistent A URL/B ID from the previous implementation.
await assert.rejects(ui.context.preparePrivateExport({...state(),lastGeneratedImageSrc:src},exportOwner),/not complete/);
status='done';ui.context.restoreSavedChat({...saved,generatedImage:src});await new Promise(r=>setImmediate(r));
assert.equal(ui.context.lastGeneratedImageSrc,'/api/generated-images/'+b);
status='done';ui.context.restoreSavedChat({...saved,generatedImage:'/api/generated-images/'+b});await new Promise(r=>setImmediate(r));
assert.equal(ui.context.lastGeneratedImageSrc,'/api/generated-images/'+b,'the completed B asset is the chat reference in the gallery world');
const exported=await ui.context.preparePrivateExport(state(),exportOwner);assert.equal(exported.lastGeneratedImageSrc,'data-for:/api/generated-images/'+b);
assert.equal(exported.messages[0].images[0],'data-for:'+src);assert.equal(exported.messages[0].content,transcript[0].content);
ui.context.performAutosave();assert.equal(saved.generatedImage,'/api/generated-images/'+b,'the completed sidebar image persists with the chat');assert.equal(saved.generatedImageJobs[0].jobId,b);ui.dom.window.close();