diff --git a/layouts/partials/chatbot.html b/layouts/partials/chatbot.html index f256a02c331..a26705d5818 100644 --- a/layouts/partials/chatbot.html +++ b/layouts/partials/chatbot.html @@ -2,7 +2,6 @@ #just-os-widget { position: fixed; top: 100px; - /* Below navbar */ right: 20px; bottom: auto; left: auto; @@ -10,7 +9,6 @@ display: flex; flex-direction: column; align-items: flex-end; - /* Right align */ font-family: sans-serif; } @@ -29,7 +27,6 @@ transform: scale(1.05); } - /* The icon */ #just-os-img { width: 80px; height: auto; @@ -37,11 +34,9 @@ filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1)); } - /* The speech bubble */ #just-os-bubble { position: absolute; right: 90px; - /* To the left of the button */ left: auto; top: 20px; background: white; @@ -64,39 +59,73 @@ transform: translateX(0); } - /* Initial state: User image shows it visible. */ .bubble-visible { opacity: 1 !important; transform: translateX(0) !important; } - /* The chat window */ + /* The chat window — default: docked right panel */ #just-os-window { display: none; - width: 360px; - height: 500px; + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: auto; + width: 33vw; + min-width: 320px; background: white; - border: 2px solid #333; - box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2); - border-radius: 12px; + border-left: 2px solid #333; + box-shadow: -4px 0 20px rgba(0, 0, 0, 0.15); overflow: hidden; flex-direction: column; - margin-top: 15px; - /* space between button and window */ - margin-bottom: 0; - animation: slideIn 0.3s ease-out forwards; + z-index: 10000; } - @keyframes slideIn { - from { - opacity: 0; - transform: translateY(-20px); - } + /* Floating mode — centered, resizable */ + #just-os-window.floating { + top: 5vh; + right: auto; + bottom: auto; + left: 50%; + transform: translateX(-50%); + width: 60vw; + height: 80vh; + min-height: 300px; + border: 2px solid #333; + border-radius: 12px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); + } - to { - opacity: 1; - transform: translateY(0); - } + /* Custom resize handle (bottom-left corner, floating only) */ + .just-os-resize-handle { + display: none; + position: absolute; + bottom: 0; + left: 0; + width: 18px; + height: 18px; + cursor: nesw-resize; + z-index: 10; + } + + #just-os-window.floating .just-os-resize-handle { + display: block; + } + + .just-os-resize-handle::after { + content: ''; + position: absolute; + bottom: 3px; + left: 3px; + width: 10px; + height: 10px; + border-left: 2px solid #bbb; + border-bottom: 2px solid #bbb; + } + + .just-os-resize-handle:hover::after { + border-color: #888; } .chat-header { @@ -127,49 +156,216 @@ color: #000; } - /* --- NEW: Large mode (docked right panel) --- */ - #just-os-window.large { + /* ---- Chat UI styles ---- */ + .just-os-messages { + flex: 1; + overflow-y: auto; + padding: 12px; display: flex; - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: auto; + flex-direction: column; + gap: 10px; + min-height: 0; + } - width: 33vw; - height: 100vh; + .just-os-msg { + max-width: 85%; + position: relative; + } - margin: 0; - border-radius: 0; - animation: none; + .just-os-msg.user { + align-self: flex-end; + } - z-index: 10000; + .just-os-msg.bot { + align-self: flex-start; + } + + .just-os-bubble-content { + padding: 8px 12px; + border-radius: 12px; + line-height: 1.45; + font-size: 0.9rem; + word-wrap: break-word; + overflow-wrap: break-word; + } + + .just-os-msg.user .just-os-bubble-content { + background: #0d3c74; + color: white; + border-bottom-right-radius: 4px; + } + + .just-os-msg.bot .just-os-bubble-content { + background: #f0f2f5; + color: #1a1a1a; + border-bottom-left-radius: 4px; + } + + .just-os-msg.bot .just-os-bubble-content p { + margin: 0 0 8px; + } + + .just-os-msg.bot .just-os-bubble-content p:last-child { + margin-bottom: 0; + } + + .just-os-msg.bot .just-os-bubble-content a { + color: #0d3c74; + text-decoration: underline; + } + + /* Inline citation links rendered as plain text */ + .just-os-msg.bot .just-os-bubble-content a.reference-link { + color: inherit; + text-decoration: none; + pointer-events: none; + cursor: default; + font-size: 0.85em; + } + + .just-os-copy-btn { + display: block; + margin-top: 4px; + margin-left: auto; + background: none; + border: none; + cursor: pointer; + font-size: 0.85rem; + padding: 2px 4px; + color: #aaa; + border-radius: 4px; + opacity: 0; + transition: opacity 0.2s; + line-height: 1; + } + + .just-os-msg.bot:hover .just-os-copy-btn { + opacity: 1; + } + + .just-os-copy-btn:hover { + color: #555; + } + + /* References list below bot message */ + .just-os-references { + margin-top: 8px; + padding: 8px 10px; + background: #f8f9fb; + border-radius: 8px; + border: 1px solid #e8eaed; + font-size: 0.8rem; + line-height: 1.4; + } + + .just-os-references summary { + cursor: pointer; + font-weight: 600; + color: #555; + font-size: 0.8rem; + user-select: none; + } + + .just-os-references ol { + margin: 6px 0 0; + padding-left: 18px; + } + + .just-os-references li { + margin-bottom: 4px; + color: #555; } - #just-os-window.large .chat-frame-wrapper { + .just-os-references li a { + color: #0d3c74; + text-decoration: none; + } + + .just-os-references li a:hover { + text-decoration: underline; + } + + .just-os-typing { + display: none; + padding: 6px 12px; + color: #888; + font-size: 0.85rem; + font-style: italic; + } + + .just-os-error { + color: #c0392b; + font-style: italic; + } + + .just-os-input-area { + display: flex; + align-items: flex-end; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid #eee; + background: #fafafa; + } + + .just-os-input { flex: 1; - min-height: 0; + border: 1px solid #ccc; + border-radius: 8px; + padding: 8px 10px; + font-size: 0.9rem; + resize: none; + max-height: 120px; + font-family: inherit; + line-height: 1.4; } - /* --- NEW: Resizable mode --- */ - #just-os-window.resizable { - resize: both; - overflow: hidden; - min-width: 320px; - min-height: 360px; - max-width: 90vw; - max-height: 95vh; + .just-os-input:focus { + outline: none; + border-color: #0d3c74; + } + + .just-os-send-btn { + background: #0d3c74; + color: white; + border: none; + border-radius: 8px; + padding: 8px 14px; + cursor: pointer; + font-size: 0.9rem; + white-space: nowrap; + flex-shrink: 0; + } + + .just-os-send-btn:hover { + background: #0a2f5c; + } + + .just-os-send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* New Chat button */ + .just-os-new-chat-btn { + background: none; + border: none; + cursor: pointer; + font-size: 0.8rem; + color: #666; + padding: 4px 8px; + border-radius: 4px; } - /* Responsive tweak */ + .just-os-new-chat-btn:hover { + background: #eee; + color: #333; + } + + /* Responsive */ @media (max-width: 1100px) { #just-os-widget { top: 150px; } - - #just-os-window { - top: 150px; - } } @media (max-width: 768px) { @@ -178,22 +374,13 @@ } #just-os-window { - width: 90vw; - height: 60vh; - top: 150px; - right: 5vw; - left: auto; - position: fixed; + width: 100vw; } - /* On small screens, large mode should just behave like full screen */ - #just-os-window.large { - width: 100vw; - height: 100vh; - right: 0; - top: 0; - bottom: 0; - border-radius: 0; + #just-os-window.floating { + width: 95vw; + height: 80vh; + top: 5vh; } } @@ -201,7 +388,7 @@ {{ if ne .RelPermalink "/just_os_chatbot/" }}
' + WELCOME_MESSAGE + '
' }); + messagesEl.appendChild(welcomeEl); + } + + function renderAllMessages() { + const container = getContainer(); + if (!container) return; + const messagesEl = getMessagesEl(container); + if (!messagesEl) return; + + messagesEl.innerHTML = ''; + state.messages.forEach(function (msg) { + messagesEl.appendChild(createMessageEl(msg)); + }); + scrollToBottom(messagesEl); + } + + function showTyping(container, text) { + let typing = container.querySelector('.just-os-typing'); + if (!typing) { + typing = document.createElement('div'); + typing.className = 'just-os-typing'; + const messagesEl = getMessagesEl(container); + if (messagesEl) messagesEl.appendChild(typing); + } + typing.textContent = text || 'Thinking…'; + typing.style.display = 'block'; + scrollToBottom(getMessagesEl(container)); + } + + function hideTyping(container) { + const typing = container.querySelector('.just-os-typing'); + if (typing) typing.remove(); + } + + /* -------------------------------------------------- */ + /* API interaction */ + /* -------------------------------------------------- */ + + async function sendMessage(text) { + if (state.streaming || !text.trim()) return; + + const container = getContainer(); + if (!container) return; + + const messagesEl = getMessagesEl(container); + if (!messagesEl) return; + + // Ensure we have a chatId + if (!state.chatId) state.chatId = generateId(); + + // Add user message + const userMsg = { role: 'user', content: text.trim() }; + state.messages.push(userMsg); + messagesEl.appendChild(createMessageEl(userMsg)); + scrollToBottom(messagesEl); + + // Disable input + state.streaming = true; + updateInputState(container); + + showTyping(container, 'Thinking…'); + + let botContent = ''; + + try { + const resp = await fetch(API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: text.trim(), chat_id: state.chatId }), + }); + + if (!resp.ok) throw new Error('API returned ' + resp.status); + + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); // keep incomplete line in buffer + + for (const line of lines) { + if (!line.trim()) continue; + try { + const data = JSON.parse(line); + if (data.status === 'complete' && data.message) { + botContent = data.message; + } else if (data.status === 'in-progress' && data.message) { + showTyping(container, data.message); + } + } catch (_) { /* non-JSON line – ignore */ } + } + } + + // Process any remaining buffer + if (buffer.trim()) { + try { + const data = JSON.parse(buffer); + if (data.status === 'complete' && data.message) { + botContent = data.message; + } + } catch (_) { /* ignore */ } + } + + if (!botContent) throw new Error('No response received'); + + hideTyping(container); + + const botMsg = { role: 'bot', content: botContent }; + state.messages.push(botMsg); + messagesEl.appendChild(createMessageEl(botMsg)); + scrollToBottom(messagesEl); + saveState(); + + } catch (err) { + hideTyping(container); + + const errorMsg = { role: 'bot', content: 'Sorry, something went wrong. Please try again.
' }; + state.messages.push(errorMsg); + messagesEl.appendChild(createMessageEl(errorMsg)); + scrollToBottom(messagesEl); + saveState(); + console.error('JUST-OS chat error:', err); + } finally { + state.streaming = false; + updateInputState(container); + } + } + + /* -------------------------------------------------- */ + /* Input handling */ + /* -------------------------------------------------- */ + + function updateInputState(container) { + const input = getInputEl(container); + const sendBtn = getSendBtn(container); + if (input) input.disabled = state.streaming; + if (sendBtn) sendBtn.disabled = state.streaming; + } + + function handleSend(container) { + const input = getInputEl(container); + if (!input) return; + const text = input.value; + if (!text.trim() || state.streaming) return; + input.value = ''; + input.style.height = 'auto'; + sendMessage(text); + } + + function bindInput(container) { + const input = getInputEl(container); + const sendBtn = getSendBtn(container); + if (!input) return; + + input.addEventListener('keydown', function (e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(container); + } + }); + + // Auto-resize textarea + input.addEventListener('input', function () { + this.style.height = 'auto'; + this.style.height = Math.min(this.scrollHeight, 120) + 'px'; + }); + + if (sendBtn) { + sendBtn.addEventListener('click', function () { + handleSend(container); + }); + } + } + + /* -------------------------------------------------- */ + /* New Chat */ + /* -------------------------------------------------- */ + + function newChat() { + state.chatId = generateId(); + state.messages = []; + state.streaming = false; + sessionStorage.removeItem(STORAGE_KEY); + renderAllMessages(); + const container = getContainer(); + if (container) { + showWelcome(container); + updateInputState(container); + const input = getInputEl(container); + if (input) input.focus(); + } + } + + /* -------------------------------------------------- */ + /* Copy response */ + /* -------------------------------------------------- */ + + function copyResponse(btn, bubble) { + const text = bubble.innerText; + navigator.clipboard.writeText(text).then(function () { + const orig = btn.innerHTML; + btn.innerHTML = '✅'; + setTimeout(function () { btn.innerHTML = orig; }, 1500); + }).catch(function () { + // Fallback + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + const orig = btn.innerHTML; + btn.innerHTML = '✅'; + setTimeout(function () { btn.innerHTML = orig; }, 1500); + }); + } + + /* -------------------------------------------------- */ + /* Reference list */ + /* -------------------------------------------------- */ + + function buildReferenceList(bubble) { + const links = bubble.querySelectorAll('a[data-reference]'); + if (!links.length) return null; + + // Deduplicate references by URL + const seen = new Set(); + const refs = []; + links.forEach(function (link) { + try { + const ref = JSON.parse(link.getAttribute('data-reference')); + if (!ref || !ref.url || seen.has(ref.url)) return; + seen.add(ref.url); + refs.push(ref); + } catch (_) { /* skip bad JSON */ } + }); + + if (!refs.length) return null; + + const details = document.createElement('details'); + details.className = 'just-os-references'; + details.open = true; + + const summary = document.createElement('summary'); + summary.textContent = 'References (' + refs.length + ')'; + details.appendChild(summary); + + const ol = document.createElement('ol'); + refs.forEach(function (ref) { + const li = document.createElement('li'); + const parts = []; + + if (ref.authors) { + const authSpan = document.createElement('span'); + // Truncate long author lists + var authors = ref.authors.trim(); + if (authors.length > 80) authors = authors.substring(0, 80) + '…'; + authSpan.textContent = authors + ' '; + li.appendChild(authSpan); + } + + if (ref.title) { + const a = document.createElement('a'); + a.href = ref.url || '#'; + a.target = '_blank'; + a.rel = 'noopener'; + a.textContent = ref.title; + li.appendChild(a); + } + + ol.appendChild(li); + }); + details.appendChild(ol); + return details; + } + + function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + /* -------------------------------------------------- */ + /* Widget toggle functions (global) */ + /* -------------------------------------------------- */ + + window.toggleJustOS = function () { + const win = document.getElementById('just-os-window'); + const bubble = document.getElementById('just-os-bubble'); + if (!win || !bubble) return; + + if (win.style.display === 'flex') { + win.style.display = 'none'; + bubble.classList.add('bubble-visible'); + win.classList.remove('floating'); + // Reset any drag-resize inline styles + win.style.width = ''; + win.style.height = ''; + updateFloatIcon(win); + } else { + win.style.display = 'flex'; + bubble.classList.remove('bubble-visible'); + var input = getInputEl(win); + if (input) setTimeout(function () { input.focus(); }, 100); + } + }; + + function updateFloatIcon(win) { + var btn = document.getElementById('just-os-float-btn'); + if (!btn) return; + var isFloating = win.classList.contains('floating'); + btn.textContent = isFloating ? '⤡' : '⤢'; + btn.title = isFloating ? 'Dock to side' : 'Pop out'; + btn.setAttribute('aria-label', isFloating ? 'Dock to side' : 'Pop out'); + } + + window.toggleJustOSFloat = function () { + var win = document.getElementById('just-os-window'); + if (!win) return; + if (win.style.display !== 'flex') window.toggleJustOS(); + win.classList.toggle('floating'); + // Reset inline sizing when switching modes + win.style.width = ''; + win.style.height = ''; + updateFloatIcon(win); + }; + + window.newJustOSChat = newChat; + + /* -------------------------------------------------- */ + /* Custom drag-resize (bottom-left handle) */ + /* -------------------------------------------------- */ + + function initResize() { + var handle = document.querySelector('#just-os-window .just-os-resize-handle'); + var win = document.getElementById('just-os-window'); + if (!handle || !win) return; + + var startX, startY, startW, startH; + + function applyResize(dx, dy) { + // Bottom-left handle: drag left = widen (×2 because centered), drag down = taller + var newW = Math.max(320, Math.min(startW - dx * 2, window.innerWidth * 0.95)); + var newH = Math.max(300, Math.min(startH + dy, window.innerHeight * 0.9)); + win.style.width = newW + 'px'; + win.style.height = newH + 'px'; + } + + handle.addEventListener('mousedown', function (e) { + e.preventDefault(); + startX = e.clientX; startY = e.clientY; + startW = win.offsetWidth; startH = win.offsetHeight; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + applyResize(e.clientX - startX, e.clientY - startY); + } + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + } + + handle.addEventListener('touchstart', function (e) { + var t = e.touches[0]; + startX = t.clientX; startY = t.clientY; + startW = win.offsetWidth; startH = win.offsetHeight; + document.addEventListener('touchmove', onTouchMove, { passive: false }); + document.addEventListener('touchend', onTouchEnd); + }, { passive: true }); + + function onTouchMove(e) { + e.preventDefault(); + var t = e.touches[0]; + applyResize(t.clientX - startX, t.clientY - startY); + } + function onTouchEnd() { + document.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('touchend', onTouchEnd); + } + } + + /* -------------------------------------------------- */ + /* Initialization */ + /* -------------------------------------------------- */ + + function init() { + const container = getContainer(); + if (!container) return; + + const restored = restoreState(); + if (!state.chatId) state.chatId = generateId(); + + bindInput(container); + initResize(); + + if (restored && state.messages.length > 0) { + renderAllMessages(); + } else { + showWelcome(container); + } + } + + // Save state before leaving + window.addEventListener('beforeunload', saveState); + + // Auto-toggle helper bubble every 10 seconds (widget only) + setInterval(function () { + const bubble = document.getElementById('just-os-bubble'); + const win = document.getElementById('just-os-window'); + if (bubble && win && win.style.display !== 'flex') { + bubble.classList.toggle('bubble-visible'); + } + }, 10000); + + // Init on DOM ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})();