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:
parent
b9ac93ddc6
commit
982a089fcb
7 changed files with 57 additions and 123 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue