diff --git a/public/components/assistant.html b/public/components/assistant.html index 37e7e39..8d01f41 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -90,10 +90,11 @@ .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-main { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 190px); } +.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-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; overflow-y:auto; background:linear-gradient(180deg,#fff,var(--g50)); } +.assistant-messages { padding:16px; overflow-y:auto; overflow-x:hidden; background:linear-gradient(180deg,#fff,var(--g50)); min-width:0; } .assistant-empty { max-width:680px; margin:50px auto; text-align:center; color:var(--g500); } .assistant-empty i { font-size:34px; color:var(--purple); margin-bottom:10px; } .assistant-empty h3 { color:var(--g800); font-size:18px; margin-bottom:6px; } @@ -102,10 +103,10 @@ .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; margin:0 0 14px; display:grid; gap:6px; } +.assistant-msg { max-width:900px; min-width:0; margin:0 0 14px; display:grid; gap:6px; } .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-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; } +.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 { 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); } .assistant-bubble h1:first-child, .assistant-bubble h2:first-child, .assistant-bubble h3:first-child { margin-top:0; } @@ -117,8 +118,9 @@ .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-bubble table { width:100%; border-collapse:separate; border-spacing:0; margin:12px 0; overflow:hidden; border:1px solid var(--g200); border-radius:10px; font-size:12px; } +.assistant-bubble table { width:100%; max-width:100%; border-collapse:separate; border-spacing:0; margin:12px 0; overflow:hidden; border:1px solid var(--g200); border-radius:10px; font-size:12px; } .assistant-bubble th, .assistant-bubble td { padding:8px 10px; border-bottom:1px solid var(--g200); vertical-align:top; text-align:left; } +.assistant-bubble th, .assistant-bubble td { overflow-wrap:anywhere; } .assistant-bubble th { background:var(--g50); font-weight:700; color:var(--g800); } .assistant-bubble tr:last-child td { border-bottom:0; } .assistant-bubble code { background:var(--g100); border-radius:4px; padding:1px 4px; } @@ -188,7 +190,7 @@ .assistant-messages { padding:10px; } .assistant-msg, .assistant-msg.user { max-width:100%; } .assistant-bubble { font-size:13px; padding:11px 12px; } - .assistant-bubble table { display:block; overflow-x:auto; white-space:nowrap; max-width:100%; } + .assistant-bubble table { display:block; overflow-x:auto; -webkit-overflow-scrolling:touch; } .assistant-composer { position:sticky; bottom:0; z-index:3; } .assistant-composer-footer { flex-direction:column; align-items:stretch; } .assistant-composer-footer .btn-generate { width:100%; } diff --git a/public/js/assistant/api.js b/public/js/assistant/api.js index 8769fc6..bd5d7cf 100644 --- a/public/js/assistant/api.js +++ b/public/js/assistant/api.js @@ -46,6 +46,22 @@ export function requestAssistantImage(prompt) { }).then(function(r) { return r.json(); }); } +export function startAssistantImageJob(prompt) { + return fetch('/api/clinical-assistant/image/jobs', { + method: 'POST', + headers: authHeaders(), + credentials: 'same-origin', + body: JSON.stringify({ prompt: prompt }) + }).then(function(r) { return r.json(); }); +} + +export function fetchAssistantImageJob(jobId) { + return fetch('/api/clinical-assistant/image/jobs/' + encodeURIComponent(jobId), { + headers: authHeaders(), + credentials: 'same-origin' + }).then(function(r) { return r.json(); }); +} + export function saveAssistantChat(payload) { return fetch('/api/clinical-assistant/chats', { method: 'POST', diff --git a/public/js/assistant/export.js b/public/js/assistant/export.js index d202f20..15458c2 100644 --- a/public/js/assistant/export.js +++ b/public/js/assistant/export.js @@ -18,18 +18,26 @@ export function createAssistantExporter(options) { } var exportItems = collectExportItems(state.messages || [], state.lastAnswer, state.lastSources || []); var cacheKey = buildExportCacheKey(exportItems, state.lastGeneratedImageSrc || ''); + if (exportCacheKey === cacheKey && exportCacheItems) { + showPrintableExport(exportCacheItems, state.lastGeneratedImageSrc || ''); + return; + } + exportCacheKey = cacheKey; + exportCacheItems = exportItems; + showPrintableExport(exportItems, state.lastGeneratedImageSrc || ''); + } + + function showPrintableExport(items, imageSrc) { + if (shouldUseInlineExport()) { + writeInlineChatExport(items, imageSrc); + return; + } var doc = openExportWindow(); if (!doc) { if (typeof options.showToast === 'function') options.showToast('Allow popups to export PDF', 'error'); return; } - if (exportCacheKey === cacheKey && exportCacheItems) { - writePrintableChatExport(doc, exportCacheItems, state.lastGeneratedImageSrc || ''); - return; - } - exportCacheKey = cacheKey; - exportCacheItems = exportItems; - writePrintableChatExport(doc, exportItems, state.lastGeneratedImageSrc || ''); + writePrintableChatExport(doc, items, imageSrc); } function openExportWindow() { @@ -43,6 +51,50 @@ export function createAssistantExporter(options) { function writePrintableChatExport(doc, items, imageSrc) { if (!doc || doc.closed) return; + var html = buildPrintableChatHtml(items, imageSrc, false); + doc.document.open(); + doc.document.write(html); + doc.document.close(); + try { + var printBtn = doc.document.getElementById('assistant-export-print'); + if (printBtn) printBtn.addEventListener('click', function () { doc.focus(); doc.print(); }); + var closeBtn = doc.document.getElementById('assistant-export-close'); + if (closeBtn) closeBtn.addEventListener('click', function () { try { doc.close(); } catch (e) { doc.location.href = '/'; } }); + } catch (e) {} + setTimeout(function () { try { doc.focus(); doc.print(); } catch (e) {} }, 500); + } + + function writeInlineChatExport(items, imageSrc) { + closeInlineExport(); + var modal = document.createElement('div'); + modal.id = 'assistant-export-modal'; + modal.innerHTML = '
Generating image...
'; - requestAssistantImage(prompt) + startAssistantImageJob(prompt) + .then(function (data) { + if (!data.success || !data.jobId) throw new Error(data.error || 'Image generation failed'); + return waitForImageJob(data.jobId, function (status) { + if (out && status === 'running') out.innerHTML = 'Generating image... You can leave the app open or return in a moment.
'; + }); + }) .then(function (data) { if (!data.success) throw new Error(data.error || 'Image generation failed'); var src = data.imageUrl || data.url || (data.base64 ? ('data:image/png;base64,' + data.base64) : ''); @@ -453,6 +460,28 @@ import { }); } + function waitForImageJob(jobId, onStatus) { + var started = Date.now(); + var delay = 1200; + return new Promise(function (resolve, reject) { + function poll() { + fetchAssistantImageJob(jobId).then(function (data) { + if (!data.success) throw new Error(data.error || 'Image generation failed'); + if (data.status === 'done') { resolve(data); return; } + if (data.status === 'error') { reject(new Error(data.error || 'Image generation failed')); return; } + if (typeof onStatus === 'function') onStatus(data.status || 'pending'); + if (Date.now() - started > 180000) { reject(new Error('Image generation timed out. Please try again.')); return; } + delay = Math.min(delay + 300, 3500); + setTimeout(poll, delay); + }).catch(function (err) { + if (Date.now() - started > 180000) { reject(err); return; } + setTimeout(poll, delay); + }); + } + poll(); + }); + } + function onAssistantDocumentClick(e) { var loadBtn = e.target.closest('[data-assistant-load-chat]'); if (loadBtn) { @@ -472,6 +501,12 @@ import { imageStore.openImagePreview(openBtn.getAttribute('data-assistant-open-image')); return; } + var downloadBtn = e.target.closest('[data-assistant-download-image]'); + if (downloadBtn) { + e.preventDefault(); + imageStore.downloadImage(downloadBtn.getAttribute('data-assistant-download-image')); + return; + } if (e.target.closest('.assistant-image-modal-close') || e.target.classList.contains('assistant-image-modal')) { imageStore.closeImagePreview(); } diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index d9d7ccd..892ce20 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -7,6 +7,7 @@ var express = require('express'); var axios = require('axios'); +var crypto = require('crypto'); var router = express.Router(); var db = require('../db/database'); var { authMiddleware } = require('../middleware/auth'); @@ -45,6 +46,8 @@ var GREETING_RE = /^(hi|hello|hey|yo|good\s+(morning|afternoon|evening)|thanks|t var MAX_SAVED_CHATS_PER_USER = 100; var MAX_SAVED_CHAT_PAYLOAD = 250000; var MAX_SAVED_CHAT_TITLE = 160; +var IMAGE_JOB_TTL_SECONDS = 15 * 60; +var imageJobs = new Map(); var promptPool = createClinicalPromptPool({ redisCache: redisCache, callAI: callAI, @@ -287,6 +290,46 @@ router.post('/clinical-assistant/image', async function(req, res) { } }); +router.post('/clinical-assistant/image/jobs', async function(req, res) { + try { + var prompt = String(req.body.prompt || '').trim(); + if (!prompt) return res.status(400).json({ error: 'Prompt is required' }); + if (prompt.length > 5000) prompt = prompt.substring(0, 5000); + + var model = await getSetting('clinical_assistant.image_model', '') || process.env.CLINICAL_ASSISTANT_IMAGE_MODEL || 'openai-gpt-image-1'; + var id = crypto.randomBytes(16).toString('hex'); + var job = { id: id, userId: req.user.id, status: 'pending', model: model, createdAt: Date.now(), updatedAt: Date.now() }; + await setImageJob(job); + res.json({ success: true, jobId: id, status: 'pending' }); + + runImageJob(id, req.user.id, prompt, model, req).catch(function(e) { + console.warn('[clinical-assistant image job]', e.message); + }); + } catch (e) { + var detail = e.response && e.response.data ? JSON.stringify(e.response.data).substring(0, 300) : e.message; + res.status(500).json({ error: detail || 'Image job failed' }); + } +}); + +router.get('/clinical-assistant/image/jobs/:id', async function(req, res) { + try { + var job = await getImageJob(req.params.id); + if (!job || String(job.userId) !== String(req.user.id)) return res.status(404).json({ error: 'Image job not found' }); + res.json({ + success: true, + jobId: job.id, + status: job.status, + model: job.model || null, + imageUrl: job.imageUrl || null, + url: job.url || null, + base64: job.base64 || null, + error: job.error || null + }); + } catch (e) { + res.status(500).json({ error: 'Image job status failed' }); + } +}); + async function prepareAssistantChat(body) { body = body || {}; var message = String(body.message || '').trim(); @@ -563,6 +606,49 @@ async function generateImage(prompt, model) { return { imageUrl: item.url || null, base64: item.b64_json || null, raw: (!item.url && !item.b64_json) ? resp.data : undefined }; } +async function runImageJob(id, userId, prompt, model, req) { + await updateImageJob(id, { status: 'running', updatedAt: Date.now() }); + try { + var image = await generateImage(prompt, model); + await updateImageJob(id, Object.assign({ status: 'done', updatedAt: Date.now() }, image)); + logger.audit(userId, 'clinical_assistant_image', 'Generated clinical assistant image', req, { category: 'clinical', model: model, async: true }); + } catch (e) { + var detail = e.response && e.response.data ? JSON.stringify(e.response.data).substring(0, 300) : e.message; + await updateImageJob(id, { status: 'error', error: detail || 'Image generation failed', updatedAt: Date.now() }); + } +} + +async function getImageJob(id) { + var key = imageJobKey(id); + var job = await redisCache.getJson(key); + if (job) return job; + return imageJobs.get(key) || null; +} + +async function setImageJob(job) { + var key = imageJobKey(job.id); + imageJobs.set(key, job); + trimImageJobs(); + await redisCache.setJson(key, job, IMAGE_JOB_TTL_SECONDS).catch(function() { return false; }); +} + +async function updateImageJob(id, patch) { + var existing = await getImageJob(id); + if (!existing) return; + await setImageJob(Object.assign({}, existing, patch)); +} + +function imageJobKey(id) { + return 'clinical-assistant:image-job:' + String(id || '').replace(/[^a-f0-9]/g, '').slice(0, 64); +} + +function trimImageJobs() { + var cutoff = Date.now() - IMAGE_JOB_TTL_SECONDS * 1000; + imageJobs.forEach(function(job, key) { + if (!job || (job.updatedAt || job.createdAt || 0) < cutoff) imageJobs.delete(key); + }); +} + function generateImageRequest(model, prompt, size, headers) { return axios.post(gatewayUrl('/images/generations'), { model: model,