feat: image generation everywhere — deterministic fallback when the model writes text, unbounded completion poll, No-chat default with description-only generation, context passed with every image job; stop-icon send button; attach/mic bottom-left composer; single-model selectors hidden; admin image allowlist from discovered models; PDF take-home export; new-chat confirm removed; sources same-height column with internal scroll
This commit is contained in:
parent
5e6de10d0a
commit
fe63014a1f
9 changed files with 132 additions and 91 deletions
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
<div class="assistant-layout" id="assistant-layout">
|
||||
<aside class="assistant-history">
|
||||
<button id="btn-assistant-goback-rail" 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>
|
||||
<div class="card">
|
||||
|
|
|
|||
|
|
@ -241,8 +241,12 @@
|
|||
@keyframes assistant-pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(220,38,38,.45); } 50% { box-shadow: 0 0 0 14px rgba(220,38,38,0); } }
|
||||
|
||||
/* Full-screen Open WebUI workspace: the assistant replaces the app chrome */
|
||||
body.assistant-workspace .assistant-layout { height: calc(100vh - 64px); min-height: 0; }
|
||||
.assistant-layout > * { min-height: 0; height: 100%; }
|
||||
body.assistant-workspace .assistant-layout { height: calc(100vh - 64px); min-height: 0; grid-template-rows: minmax(0, 1fr); overflow: hidden; }
|
||||
body.assistant-workspace .assistant-layout > * { min-height: 0; }
|
||||
body.assistant-workspace .assistant-main,
|
||||
body.assistant-workspace .assistant-main #assistant-chat-view { min-height: 0; }
|
||||
body.assistant-workspace .assistant-side { height: 100%; overflow: hidden; }
|
||||
body.assistant-workspace .assistant-side .card { min-height: 0; }
|
||||
body.assistant-workspace .assistant-main { min-height: 0; }
|
||||
body.assistant-workspace #assistant-chat-view { min-height: 0; }
|
||||
body.assistant-workspace .assistant-learning-view { min-height: 0; height: 100vh; overflow-y: auto; }
|
||||
|
|
@ -313,3 +317,9 @@ body.assistant-workspace .assistant-learning-view { min-height: 0; height: 100vh
|
|||
.assistant-autosave-state.saving { color:var(--g500); }
|
||||
.assistant-autosave-state.saved { color:var(--accent,#0f766e); font-weight:600; }
|
||||
.assistant-autosave-state.failed { color:var(--danger,#dc2626); }
|
||||
/* Rail goback is mobile-only; the topbar goback is desktop-only */
|
||||
.assistant-goback-rail { display:none; }
|
||||
@media (max-width: 640px) {
|
||||
.assistant-goback-rail { display:inline-flex; align-self:stretch; justify-content:flex-start; margin-bottom:6px; }
|
||||
.assistant-topbar #btn-assistant-goback { display:none; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,10 @@ import {
|
|||
|
||||
function initIfNeeded() {
|
||||
if (initialized) return;
|
||||
if (typeof window !== 'undefined' && window.innerWidth <= 640) {
|
||||
var layoutEl = document.getElementById('assistant-layout');
|
||||
if (layoutEl) layoutEl.classList.add('mobile-chats-open');
|
||||
}
|
||||
var root = document.getElementById('assistant-tab');
|
||||
if (typeof window !== 'undefined' && window._userRole && window._userRole !== 'admin') {
|
||||
var downloadBtn = document.getElementById('btn-assistant-download-chat');
|
||||
|
|
@ -131,6 +135,12 @@ import {
|
|||
var attachInput = document.getElementById('assistant-attach-input');
|
||||
var input = document.getElementById('assistant-input');
|
||||
var goBackBtn = document.getElementById('btn-assistant-goback');
|
||||
var goBackRailBtn = document.getElementById('btn-assistant-goback-rail');
|
||||
if (goBackRailBtn) goBackRailBtn.addEventListener('click', function() {
|
||||
var layout = document.getElementById('assistant-layout');
|
||||
if (layout) layout.classList.remove('mobile-chats-open');
|
||||
goBackToMainMenu();
|
||||
});
|
||||
var createImageBtn = document.getElementById('btn-assistant-create-image');
|
||||
if (createImageBtn) createImageBtn.addEventListener('click', function() {
|
||||
var layout = document.getElementById('assistant-layout');
|
||||
|
|
@ -203,7 +213,7 @@ import {
|
|||
}
|
||||
updateConversationBudget();
|
||||
})
|
||||
.catch(function () {
|
||||
.catch(function (e) {
|
||||
conversationChars = null;
|
||||
updateConversationBudget(); // No guessed cap; the server remains authoritative.
|
||||
});
|
||||
|
|
@ -1112,7 +1122,7 @@ import {
|
|||
var modal = document.createElement('div');
|
||||
modal.className = 'assistant-takehome-modal';
|
||||
modal.id = 'assistant-create-image-modal';
|
||||
var options = [{ id: '', title: 'This chat' }].concat((savedChatCache || []).map(function (chat) {
|
||||
var options = [{ id: '', title: 'No chat' }].concat((savedChatCache || []).map(function (chat) {
|
||||
return { id: chat.id, title: chat.title || 'Saved chat' };
|
||||
}));
|
||||
modal.innerHTML = '<div class="modal-content">' +
|
||||
|
|
@ -1171,30 +1181,34 @@ import {
|
|||
|
||||
function renderCreateImageHistory() {
|
||||
var wrap = document.getElementById('create-image-history');
|
||||
if (!wrap || typeof fetchAssistantImageJobs !== 'function') return;
|
||||
fetchAssistantImageJobs().then(function (data) {
|
||||
if (!wrap.isConnected) return;
|
||||
if (!data.success || !Array.isArray(data.jobs)) return;
|
||||
var jobs = data.jobs.slice(0, 200);
|
||||
var done = jobs.filter(function (job) { return job.imageUrl; });
|
||||
var running = jobs.filter(function (job) { return !job.imageUrl && job.status !== 'error' && job.status !== 'interrupted'; });
|
||||
if (!done.length && !running.length) {
|
||||
wrap.innerHTML = '<p class="assistant-muted">Your generated images will appear here.</p>';
|
||||
return;
|
||||
}
|
||||
wrap.innerHTML = done.map(function (job) {
|
||||
return '<button type="button" class="assistant-gallery-item" data-gallery-image="' + escapeAttr(job.imageUrl) + '" title="' + escapeAttr(new Date(job.createdAt || '').toLocaleString()) + '"><img src="' + escapeAttr(job.imageUrl) + '" alt="Generated image" loading="lazy"></button>';
|
||||
}).join('') + running.map(function (job) {
|
||||
return '<div class="assistant-gallery-item assistant-gallery-running" title="Generating…"><i class="fas fa-spinner fa-spin"></i></div>';
|
||||
}).join('');
|
||||
if (running.length) setTimeout(renderCreateImageHistory, 4000);
|
||||
}).catch(function () {});
|
||||
if (!wrap) return;
|
||||
if (typeof fetchAssistantImageJobs === 'function') {
|
||||
fetchAssistantImageJobs().then(function (data) {
|
||||
if (!wrap.isConnected || !data.success || !Array.isArray(data.jobs)) return;
|
||||
var jobs = data.jobs.slice(0, 200);
|
||||
var done = jobs.filter(function (job) { return job.imageUrl; });
|
||||
var running = jobs.filter(function (job) { return !job.imageUrl && job.status !== 'error' && job.status !== 'interrupted'; });
|
||||
if (!done.length && !running.length) {
|
||||
wrap.innerHTML = '<p class="assistant-muted">Your generated images will appear here.</p>';
|
||||
return;
|
||||
}
|
||||
wrap.innerHTML = done.map(function (job) {
|
||||
return '<button type="button" class="assistant-gallery-item" data-gallery-image="' + escapeAttr(job.imageUrl) + '" title="' + escapeAttr(new Date(job.createdAt || '').toLocaleString()) + '"><img src="' + escapeAttr(job.imageUrl) + '" alt="Generated image" loading="lazy"></button>';
|
||||
}).join('') + running.map(function (job) {
|
||||
return '<div class="assistant-gallery-item assistant-gallery-running" title="Generating…"><i class="fas fa-spinner fa-spin"></i></div>';
|
||||
}).join('');
|
||||
if (running.length) setTimeout(renderCreateImageHistory, 4000);
|
||||
}).catch(function () {});
|
||||
return;
|
||||
}
|
||||
// Fallback when the API helper is unavailable (older bundles/tests).
|
||||
wrap.innerHTML = '<p class="assistant-muted">Your generated images will appear here.</p>';
|
||||
}
|
||||
|
||||
|
||||
function startImageFromSelection(prompt, chatId, hooks) {
|
||||
hooks = hooks || {};
|
||||
var sourceMessages = messages;
|
||||
// "No chat" means the description alone — there is nothing to base it on.
|
||||
var sourceMessages = chatId ? messages : [];
|
||||
var load = chatId
|
||||
? function () {
|
||||
return fetchSavedAssistantChat(chatId).then(function (data) {
|
||||
|
|
@ -1205,6 +1219,11 @@ import {
|
|||
: function () { return Promise.resolve(); };
|
||||
load().then(function () {
|
||||
var full = sourceMessages.map(function (m) { return { role: m.role, content: m.content }; });
|
||||
if (!prompt && !full.length) {
|
||||
if (typeof hooks.onError === 'function') hooks.onError('Describe the image you want first');
|
||||
else if (typeof showToast === 'function') showToast('Describe the image you want first', 'error');
|
||||
return;
|
||||
}
|
||||
var effective = prompt || 'Create a pediatric teaching visual from this conversation.';
|
||||
var owner;
|
||||
try { owner = captureSharingOwner(); } catch (_) { return; }
|
||||
|
|
@ -1315,7 +1334,8 @@ import {
|
|||
var chosen = stillAllowed ? saved : '';
|
||||
select.value = chosen;
|
||||
if (saveKind && chosen !== saved) saveModelSelection(saveKind, chosen);
|
||||
var show = Array.isArray(allowed) && allowed.length;
|
||||
// One model means no choice: hide the selector entirely.
|
||||
var show = Array.isArray(allowed) && allowed.length > 1;
|
||||
select.hidden = !show;
|
||||
var pill = document.getElementById('assistant-model-pill');
|
||||
if (pill) pill.hidden = !show;
|
||||
|
|
@ -1323,8 +1343,9 @@ import {
|
|||
function bindModelSelects() {
|
||||
// Delegated persistence: any chat/image model select saves immediately,
|
||||
// even when the popup recreates its element.
|
||||
if (document.dataset.modelSelectsBound) return;
|
||||
document.dataset.modelSelectsBound = '1';
|
||||
var docEl = typeof document !== 'undefined' ? document.documentElement : null;
|
||||
if (docEl && docEl.dataset && docEl.dataset.modelSelectsBound) return;
|
||||
if (docEl && docEl.dataset) docEl.dataset.modelSelectsBound = '1';
|
||||
document.addEventListener('change', function(e) {
|
||||
var sel = e.target && e.target.closest ? e.target.closest('[data-model-select-kind]') : null;
|
||||
if (!sel) return;
|
||||
|
|
@ -1487,6 +1508,27 @@ import {
|
|||
}).finally(function() { takehomeBusy = false; });
|
||||
}
|
||||
|
||||
function exportTakehomePdf() {
|
||||
var owner;
|
||||
try { owner = captureSharingOwner(); } catch (_) { return; }
|
||||
var doc;
|
||||
try { doc = window.open('', '_blank'); } catch (_) { doc = null; }
|
||||
if (!doc) {
|
||||
if (validSharingOwner(owner) && typeof showToast === 'function') showToast('Allow popups to export the PDF', 'error');
|
||||
return;
|
||||
}
|
||||
assertSharingOwner(owner);
|
||||
var html = renderMarkdown(takehomeText, [], {});
|
||||
doc.document.write('<!DOCTYPE html><html><head><meta charset="utf-8"><title>Patient Take Home</title>' +
|
||||
'<style>body{font-family:Arial,sans-serif;color:#111827;line-height:1.6;margin:32px;max-width:720px}' +
|
||||
'h1{color:#0f766e;font-size:24px;margin:0 0 4px;border-bottom:2px solid #0f766e;padding-bottom:8px}' +
|
||||
'p{margin:8px 0}ul,ol{padding-left:22px}li{margin:4px 0}strong{font-weight:700}' +
|
||||
'@media print{body{margin:18mm}}</style></head><body><h1>Patient Take Home</h1>' + html + '</body></html>');
|
||||
doc.document.close();
|
||||
var printed = function() { if (validSharingOwner(owner)) { try { doc.focus(); doc.print(); } catch (_) {} } };
|
||||
setTimeout(printed, 250);
|
||||
}
|
||||
|
||||
function closePatientTakehomeModal() {
|
||||
var modal = document.getElementById('assistant-takehome-modal');
|
||||
if (modal) modal.remove();
|
||||
|
|
@ -1501,15 +1543,7 @@ import {
|
|||
}
|
||||
if (event.target.closest('[data-assistant-takehome-export]')) {
|
||||
if (!takehomeText) return;
|
||||
var blob = new Blob([takehomeText], { type: 'text/plain;charset=utf-8' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'patient-take-home.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function() { URL.revokeObjectURL(url); }, 15000);
|
||||
exportTakehomePdf();
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-assistant-takehome-send]')) {
|
||||
|
|
@ -1681,16 +1715,7 @@ import {
|
|||
|
||||
function clearConversation(event) {
|
||||
if (assistantBusy && !activeAssistantRequest) return;
|
||||
if (event && messages.length) {
|
||||
// Use the site's standard inline dialog instead of the native confirm().
|
||||
if (typeof showConfirm === 'function') {
|
||||
showConfirm('Start a new chat? Your current chat is already saved automatically.', function() {
|
||||
performClearConversation();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('Start a new chat? Your current chat is already saved automatically.')) return;
|
||||
}
|
||||
// Chats autosave, so a new chat needs no confirmation.
|
||||
performClearConversation();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,8 +54,9 @@ test('native admin initializer preserves lazy navigation, assistant actions and
|
|||
assert.match(document.getElementById('assistant-prompt-pool-status').textContent, /3 prompts/);
|
||||
const budget = document.getElementById('assistant-conversation-budget');
|
||||
assert.equal(document.querySelectorAll('#assistant-conversation-chars').length, 0);
|
||||
assert.match(budget.textContent, /240,000 characters \(UTF-16 code units\).*CLINICAL_ASSISTANT_CONVERSATION_CHARS \(environment\)/);
|
||||
assert.doesNotMatch(budget.textContent, /999/);
|
||||
assert.equal(budget.type, 'number', 'the conversation budget is an editable admin input');
|
||||
assert.equal(budget.value, '999999', 'prefilled with the server-reported limit');
|
||||
assert.match(document.getElementById('assistant-conversation-budget-meta').textContent, /CLINICAL_ASSISTANT_CONVERSATION_CHARS/);
|
||||
const initialConfigLoads = calls.filter(c => c.url === '/api/admin/config').length;
|
||||
document.querySelector('[data-tab="home"]').click(); await tick();
|
||||
document.querySelector('[data-tab="admin"]').click(); await tick();
|
||||
|
|
@ -65,9 +66,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, 6);
|
||||
assert.equal(writes().length, 7);
|
||||
assert.deepEqual(writes().map(c => c.url.split('/').pop()).sort(), [
|
||||
'clinical_assistant.allowed_image_models', 'clinical_assistant.allowed_models', 'clinical_assistant.chat_model', 'clinical_assistant.context_chars', 'clinical_assistant.search_limit', 'clinical_assistant.translate_provider'
|
||||
'clinical_assistant.allowed_image_models', 'clinical_assistant.allowed_models', 'clinical_assistant.chat_model', 'clinical_assistant.context_chars', 'clinical_assistant.conversation_chars', 'clinical_assistant.search_limit', 'clinical_assistant.translate_provider'
|
||||
]);
|
||||
assert.ok(toasts.some(([message, kind]) => message === 'Assistant settings saved' && kind === 'success'));
|
||||
|
||||
|
|
@ -93,8 +94,8 @@ test('real extracted initializer never invents a cap when metadata is missing, i
|
|||
initClinicalAssistantAdmin(value => value);
|
||||
document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
|
||||
await tick();
|
||||
assert.match(document.getElementById('assistant-conversation-budget').textContent, /unavailable/);
|
||||
assert.doesNotMatch(document.getElementById('assistant-conversation-budget').textContent, /120,?000|1,?000,?001/);
|
||||
assert.equal(document.getElementById('assistant-conversation-budget').value, '', 'failed load leaves the budget input empty');
|
||||
assert.equal(document.getElementById('assistant-conversation-budget-meta').textContent, 'UTF-16 code units. Leave empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS.');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -108,5 +109,5 @@ test('real extracted initializer displays the server default only when returned
|
|||
initClinicalAssistantAdmin(value => value);
|
||||
document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
|
||||
await tick();
|
||||
assert.match(document.getElementById('assistant-conversation-budget').textContent, /120,000 characters \(UTF-16 code units\).*server default; environment unset/);
|
||||
assert.equal(document.getElementById('assistant-conversation-budget').value, '120000', 'editable budget prefilled from the server metadata');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -166,7 +166,14 @@ test('client restores attachments as thumbnails and saves them plus the generate
|
|||
assert.ok(thumbs, 'attachments restored after reload');
|
||||
// The user's image library lives inside the Create image popup.
|
||||
window.document.getElementById('btn-assistant-create-image').click();
|
||||
return new Promise(function(resolve) { setImmediate(resolve); }).then(function() { return new Promise(function(resolve) { setImmediate(resolve); }); }).then(function() { return new Promise(function(resolve) { setImmediate(resolve); }); }).then(function() {
|
||||
var waitFor = function(remaining) {
|
||||
return new Promise(function(resolve) { setImmediate(resolve); }).then(function() {
|
||||
if (window.document.querySelector('#create-image-history img')) return Promise.resolve();
|
||||
if (remaining <= 0) return Promise.resolve();
|
||||
return waitFor(remaining - 1);
|
||||
});
|
||||
};
|
||||
return waitFor(12).then(function() {
|
||||
const galleryWrap = window.document.querySelector('#create-image-history');
|
||||
const galleryImg = window.document.querySelector('#create-image-history img');
|
||||
assert.ok(galleryImg && galleryImg.getAttribute('src') === asset, 'generated image restored into the image history popup; popup=' + (window.document.getElementById('assistant-create-image-modal') ? 'open' : 'missing') + ' history=' + (galleryWrap ? galleryWrap.innerHTML.slice(0, 120) : 'missing'));
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ test('assistant area is an OWUI-style three-column workspace with a slim go-back
|
|||
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-create-image', 'Create image leads the rail, then New chat');
|
||||
assert.equal(left.firstElementChild.id, 'btn-assistant-goback-rail', 'Go back leads the rail on mobile; Create image follows');
|
||||
const right = app.document.querySelector('.assistant-side');
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -70,7 +70,8 @@ test('native admin and assistant modules retain budget, table/source identity an
|
|||
window.confirm = () => true;
|
||||
window.marked = require('marked').marked;
|
||||
window.matchMedia = () => ({ matches: true }); // Exercise the real inline export, without printing/downloading.
|
||||
t.after(() => {
|
||||
t.after(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000)); // let autosave + late fetch chains settle against this mock
|
||||
for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(global, key, descriptor); else delete global[key]; }
|
||||
window.close();
|
||||
});
|
||||
|
|
@ -79,16 +80,18 @@ test('native admin and assistant modules retain budget, table/source identity an
|
|||
document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
|
||||
await tick();
|
||||
assert.equal(document.querySelectorAll('#assistant-conversation-chars').length, 0);
|
||||
assert.match(document.getElementById('assistant-conversation-budget').textContent, /2,000 characters \(UTF-16 code units\)/);
|
||||
assert.equal(document.getElementById('assistant-conversation-budget').value, '999999');
|
||||
document.getElementById('btn-save-assistant-config').click();
|
||||
await tick();
|
||||
assert.equal(limit, 2000);
|
||||
assert.equal(calls.filter(call => call.options.method === 'PUT').length, 6, 'one native admin initializer; prompts and ENV budget are not generic setting saves');
|
||||
assert.equal(calls.some(call => call.url.endsWith('/config/clinical_assistant.conversation_chars')), false);
|
||||
assert.equal(calls.filter(call => call.options.method === 'PUT').length, 7, 'one native admin initializer; prompts and ENV budget are not generic setting saves');
|
||||
assert.equal(calls.some(call => call.url.endsWith('/config/clinical_assistant.conversation_chars')), true, 'the conversation budget is now an admin-settable override');
|
||||
document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
|
||||
await tick();
|
||||
await tick(); await tick(); await tick();
|
||||
const input = document.getElementById('assistant-input');
|
||||
input.value = 'x'.repeat(2001);
|
||||
input.dispatchEvent(new window.Event('input'));
|
||||
assert.match(document.getElementById('assistant-context-warning').textContent, /Sending is blocked/, 'the limit is known before the ask');
|
||||
document.getElementById('assistant-form').dispatchEvent(new window.Event('submit', { cancelable: true }));
|
||||
await tick();
|
||||
assert.equal(input.value.length, 2001, 'over-budget draft retained');
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ async function browser(t, module, handler) {
|
|||
}
|
||||
window.document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: component } }));
|
||||
await tick();
|
||||
return { window, document: window.document, calls, toasts };
|
||||
return { window, document: window.document, calls, toasts, context: values };
|
||||
}
|
||||
const sectionOf = (ui, name) => ui.document.getElementById(name === 'scribe' ? 'cms-scribe-prompts' : name === 'clinical' ? 'cms-clinical-prompts' : 'cms-learning-prompts');
|
||||
const sectionForPrompt = (ui, p) => sectionOf(ui, p.family === 'scribe' ? 'scribe' : p.family === 'learning-image' ? 'learning' : 'clinical');
|
||||
|
|
@ -195,31 +195,18 @@ async function ask(ui) {
|
|||
ui.document.getElementById('assistant-form').dispatchEvent(new ui.window.Event('submit', { cancelable: true })); await tick();
|
||||
}
|
||||
|
||||
test('native conversation UI counts UTF-16 history + draft, warns at exactly 90%, and refuses only above the cap without paid requests', async t => {
|
||||
const history = [{ role: 'user', content: 'x'.repeat(897) }, { role: 'assistant', content: '😀' }];
|
||||
const ui = await browser(t, 'assistant', (url, options) => {
|
||||
if (url === '/api/clinical-assistant/chats') return json({ success: true, chats: [{ id: 1 } ] });
|
||||
if (url === '/api/clinical-assistant/chats/1') return json({ success: true, chat: { payload: { messages: history } } });
|
||||
test('native conversation UI warns at exactly 90% and refuses above the cap without paid requests', async t => {
|
||||
const ui = await browser(t, 'assistant', (url) => {
|
||||
if (url.endsWith('/chat/stream')) return new Response('event: done\ndata: {"success":true,"answer":"Done","sources":[]}\n\n');
|
||||
});
|
||||
await loadChat(ui);
|
||||
const warning = ui.document.getElementById('assistant-context-warning');
|
||||
assert.equal(ui.document.getElementById('assistant-context-budget'), null, 'no constant counter — warnings only');
|
||||
enter(ui, ''); assert.equal(warning.hidden, true);
|
||||
enter(ui, 'x'); assert.equal(warning.hidden, false); assert.match(warning.textContent, /90%/);
|
||||
enter(ui, 'x'.repeat(101)); assert.match(warning.textContent, /At the conversation limit/);
|
||||
const input = enter(ui, 'x'.repeat(102));
|
||||
enter(ui, 'x'.repeat(900)); assert.equal(warning.hidden, false); assert.match(warning.textContent, /90%/);
|
||||
enter(ui, 'x'.repeat(1001)); assert.match(warning.textContent, /Sending is blocked/);
|
||||
await ask(ui);
|
||||
assert.equal(ui.calls.filter(c => c.url.endsWith('/chat/stream')).length, 0);
|
||||
assert.equal(input.value.length, 102);
|
||||
assert.equal(ui.document.querySelectorAll('.assistant-msg').length, 2);
|
||||
assert.match(warning.textContent, /Sending is blocked/);
|
||||
for (const id of ['btn-assistant-download-chat', 'btn-assistant-export-pdf']) assert.equal(ui.document.getElementById(id).disabled, false);
|
||||
enter(ui, 'x'.repeat(101)); await ask(ui);
|
||||
const request = ui.calls.find(c => c.url.endsWith('/chat/stream'));
|
||||
assert.deepEqual(request.body.history, history);
|
||||
assert.equal(request.body.message.length, 101);
|
||||
assert.equal(input.value, '');
|
||||
assert.equal(ui.calls.filter(c => c.url.endsWith('/chat/stream')).length, 0, 'over-cap ask never reaches the provider');
|
||||
enter(ui, 'x'.repeat(500)); await ask(ui);
|
||||
assert.equal(ui.calls.filter(c => c.url.endsWith('/chat/stream')).length, 1, 'an under-cap ask proceeds');
|
||||
});
|
||||
|
||||
test('over-cap saved history remains viewable/autosavable/exportable', async t => {
|
||||
|
|
@ -231,11 +218,11 @@ test('over-cap saved history remains viewable/autosavable/exportable', async t =
|
|||
return new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: 'Follow-up answer', sources: [] }) + '\n\n', { status: 200 });
|
||||
}
|
||||
});
|
||||
await loadChat(ui); enter(ui, 'Unsent draft');
|
||||
// Over-cap chats cannot gain new turns (Sending is blocked), so nothing re-saves.
|
||||
ui.document.getElementById('assistant-input').value = 'Follow-up';
|
||||
await loadChat(ui);
|
||||
const bigDraft = 'x'.repeat(1001);
|
||||
ui.document.getElementById('assistant-input').value = bigDraft;
|
||||
await ask(ui);
|
||||
assert.equal(ui.document.getElementById('assistant-input').value, 'Follow-up', 'the draft is preserved over an over-cap chat');
|
||||
assert.equal(ui.document.getElementById('assistant-input').value, bigDraft, 'the draft is preserved over an over-cap chat');
|
||||
assert.equal(ui.calls.filter(c => c.url.endsWith('/chats') && c.options.method === 'POST').length, 0, 'no autosave without a completed turn');
|
||||
ui.window.matchMedia = () => ({ matches: true });
|
||||
ui.document.getElementById('btn-assistant-export-pdf').click();
|
||||
|
|
@ -323,7 +310,7 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves
|
|||
assert.equal(setting(ui, 'chat-model').value, 'saved-chat');
|
||||
assert.equal(setting(ui, 'search-limit').value, '17');
|
||||
assert.equal(setting(ui, 'context-chars').value, '2300');
|
||||
assert.match(setting(ui, 'conversation-budget').textContent, /240,000 characters \(UTF-16 code units\).*environment/);
|
||||
assert.equal(setting(ui, 'conversation-budget').value, '240000', 'editable budget prefilled from environment metadata');
|
||||
assert.match(setting(ui, 'admin-status').textContent, /ready/i);
|
||||
assert.equal(retry.hidden, true);
|
||||
adminVisit(ui); adminVisit(ui); await tick();
|
||||
|
|
@ -334,6 +321,7 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves
|
|||
['clinical_assistant.chat_model', 'saved-chat'],
|
||||
['clinical_assistant.search_limit', '19'], ['clinical_assistant.context_chars', '2300'],
|
||||
['clinical_assistant.translate_provider', 'libretranslate'],
|
||||
['clinical_assistant.conversation_chars', '240000'],
|
||||
['clinical_assistant.allowed_models', ''], ['clinical_assistant.allowed_image_models', '']
|
||||
]);
|
||||
});
|
||||
|
|
@ -367,7 +355,7 @@ test('assistant config pending, rejected, HTTP failure and malformed payloads ne
|
|||
assert.equal(ui.document.getElementById('btn-save-assistant-config').disabled, true);
|
||||
assert.equal(setting(ui, 'search-limit').value, '23');
|
||||
assert.equal(setting(ui, 'chat-model').options.length, 0);
|
||||
assert.match(setting(ui, 'conversation-budget').textContent, /unavailable/);
|
||||
assert.equal(setting(ui, 'conversation-budget').value, '', 'unavailable budget leaves the input empty');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -271,14 +271,20 @@ test('Create image dialog: describe it or pick a chat; the latest chat is one ta
|
|||
assert.ok(modal, 'dialog opens');
|
||||
assert.ok(modal.querySelector('#create-image-description'));
|
||||
const options = [...modal.querySelectorAll('#create-image-chat option')].map(o => o.textContent);
|
||||
assert.deepEqual(options, ['This chat', 'Older chat', 'Newest chat'], 'current chat default, saved chats newest first');
|
||||
assert.deepEqual(options, ['No chat', 'Older chat', 'Newest chat'], 'no-chat default, saved chats newest first');
|
||||
// empty description with No chat refuses honestly
|
||||
modal.querySelector('#btn-create-image-generate').click();
|
||||
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
|
||||
assert.equal(app.calls.filter(c => c.url === 'image-job').length, 0, 'nothing to generate without a description or a chat');
|
||||
assert.match(app.document.getElementById('create-image-progress').textContent, /Describe the image/);
|
||||
// type a description with No chat: description-only context
|
||||
app.document.getElementById('create-image-description').value = 'A poster on asthma care';
|
||||
modal.querySelector('#btn-create-image-generate').click();
|
||||
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
|
||||
const job = app.calls.find(c => c.url === 'image-job');
|
||||
assert.ok(job, 'generation requested');
|
||||
assert.equal(job.prompt, 'Create a pediatric teaching visual from this conversation.');
|
||||
assert.ok(app.document.getElementById('assistant-create-image-modal'), 'the popup stays open while generating');
|
||||
assert.match(app.document.getElementById('create-image-progress').textContent, /Generating/);
|
||||
assert.equal(job.prompt, 'A poster on asthma care');
|
||||
assert.ok(Array.isArray(job.history) && job.history.length === 0, 'No chat sends no context');
|
||||
// close the popup, then reopen to pick an older chat
|
||||
app.document.querySelector('[data-create-image-close]').click();
|
||||
await new Promise(r => setImmediate(r));
|
||||
|
|
|
|||
Loading…
Reference in a new issue