diff options
| author | Michael Peter Christen <mc@yacy.net> | 2025-11-24 23:41:57 +0100 |
|---|---|---|
| committer | Michael Peter Christen <mc@yacy.net> | 2025-11-24 23:41:57 +0100 |
| commit | 15a9260db0aeaea5172dac38dca5cac541c326fc (patch) | |
| tree | 7bcc08a5a5dc39fc855d3e9ff611e4776807d49f /htroot/yacychat.html | |
| parent | 9aeffad62bfdd7a7f5546d8255e951569527587a (diff) | |
added image attachments, local storage, clear/download/upload/show-system buttons to chat
Diffstat (limited to 'htroot/yacychat.html')
| -rw-r--r-- | htroot/yacychat.html | 428 |
1 files changed, 408 insertions, 20 deletions
diff --git a/htroot/yacychat.html b/htroot/yacychat.html index 0ef3a2856..cdeb5c68d 100644 --- a/htroot/yacychat.html +++ b/htroot/yacychat.html @@ -8,7 +8,7 @@ .chat-panel { width: 100%; color: #333333; - padding: 0; + padding: 0 0 32px 0; } /* 2. Layout Structure */ @@ -116,14 +116,94 @@ color: #2c3e50; } - .composer textarea { + .composer-main { flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + } + + .composer textarea { + flex: 0 0 auto; resize: none; border: none; background: transparent; padding: 0; margin: 0; outline: none; + overflow-y: hidden; + } + + .attachment-row { + display: flex; + align-items: center; + gap: 10px; + font-size: 0.95rem; + color: #2c3e50; + } + + .attachment-row.has-attachment .attachment-clear { + display: inline-flex; + } + .attachment-row.has-attachment .attachment-button { + display: none; + } + + .attachment-button { + width: 24px; + height: 24px; + border: none; + border-radius: 4px; + background: #d1d9e6; + color: #2c3e50; + font-weight: 800; + font-size: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + } + + .attachment-button:hover { + background: #c3cbd9; + } + + .attachment-filename { + font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace; + font-size: 0.95rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .attachment-clear { + display: none; + width: 24px; + height: 24px; + border: none; + border-radius: 4px; + background: #d1d9e6; + color: #2c3e50; + font-weight: 800; + font-size: 1.25rem; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; + } + + .attachment-clear:hover { + background: #c3cbd9; + } + + .hidden-file-input { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; } /* 7. The Button - Industrial Action */ @@ -160,6 +240,19 @@ border: 2px solid #555; } } + + .clear-chat-row { + display: none; + margin-top: 12px; + gap: 10px; + align-items: center; + } + + .clear-chat-row .btn { + display: inline-flex; + align-items: center; + gap: 6px; + } </style> </head> <body id="IndexControl"> @@ -176,11 +269,42 @@ <fieldset class="chat-turn user"> <legend>User</legend> <div class="composer"> - <textarea class="chat-body" id="userInput" rows="3" placeholder="Ask me anything..." required="required"></textarea> + <div class="composer-main"> + <textarea class="chat-body" id="userInput" rows="3" placeholder="Ask me anything..." required="required"></textarea> + <div class="attachment-row" id="attachmentRow"> + <button type="button" class="attachment-button" id="addFileButton" aria-label="Attach an image"> + <span class="glyphicon glyphicon-paperclip" aria-hidden="true"></span> + </button> + <button type="button" class="attachment-clear" id="clearFileButton" aria-label="Remove attached image"> + <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> + </button> + <span class="attachment-filename" id="attachmentFilename">Attach a PNG or JPG</span> + <input type="file" id="fileInput" class="hidden-file-input" accept="image/png,image/jpeg"/> + </div> + </div> <input type="submit" class="btn btn-primary" id="sendButton" value="Send"/> </div> </fieldset> </form> + <div class="clear-chat-row" id="clearChatRow"> + <button type="button" class="btn btn-inverse label-warning" id="clearChatButton" aria-label="Clear chat"> + <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> + Clear Chat + </button> + <button type="button" class="btn btn-inverse label-success" id="downloadChatButton" aria-label="Download chat"> + <span class="glyphicon glyphicon-download" aria-hidden="true"></span> + Download Chat + </button> + <button type="button" class="btn btn-inverse label-primary" id="uploadChatButton" aria-label="Upload chat"> + <span class="glyphicon glyphicon-upload" aria-hidden="true"></span> + Upload Chat + </button> + <input type="file" id="uploadChatInput" class="hidden-file-input" accept="application/json"/> + <button type="button" class="btn btn-inverse label-info" id="toggleSystemButton" aria-label="Show system prompt"> + <span class="glyphicon glyphicon-star" aria-hidden="true"></span> + <span class="toggle-label">Show System</span> + </button> + </div> </div> </div> @@ -188,6 +312,8 @@ const SYSTEM_PROMPT = 'You are a smart and helpful chatbot. If possible, use friendly emojies.'; const defaultApiHost = ''; + const STORAGE_KEY = 'yacychat_recent_pairs'; + const state = { messages: [], config: { @@ -195,17 +321,55 @@ model: 'chat', systemPrompt: SYSTEM_PROMPT }, - busy: false + busy: false, + attachment: null, + assistantSeen: false, + showSystem: false }; const dom = { messages: document.getElementById('chatMessages'), form: document.getElementById('chatForm'), input: document.getElementById('userInput'), - sendButton: document.getElementById('sendButton') + sendButton: document.getElementById('sendButton'), + fileInput: document.getElementById('fileInput'), + addFileButton: document.getElementById('addFileButton'), + attachmentFilename: document.getElementById('attachmentFilename'), + clearFileButton: document.getElementById('clearFileButton'), + attachmentRow: document.getElementById('attachmentRow'), + clearChatRow: document.getElementById('clearChatRow'), + clearChatButton: document.getElementById('clearChatButton'), + downloadChatButton: document.getElementById('downloadChatButton'), + uploadChatButton: document.getElementById('uploadChatButton'), + uploadChatInput: document.getElementById('uploadChatInput'), + toggleSystemButton: document.getElementById('toggleSystemButton') }; - function appendMessage(role, text) { + function scrollToBottom(smooth = true) { + window.requestAnimationFrame(() => { + const behavior = smooth ? 'smooth' : 'auto'; + window.scrollTo({ top: document.documentElement.scrollHeight, behavior }); + }); + } + + function formatTimestamp() { + const now = new Date(); + const pad = (n, len = 2) => n.toString().padStart(len, '0'); + return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + } + + function updateSystemToggleButton() { + if (!dom.toggleSystemButton) return; + const icon = dom.toggleSystemButton.querySelector('.glyphicon'); + if (icon) { + icon.className = `glyphicon ${state.showSystem ? 'glyphicon-star-empty' : 'glyphicon-star'}`; + } + const label = state.showSystem ? 'Hide System' : 'Show System'; + dom.toggleSystemButton.querySelector('.toggle-label').textContent = label; + } + + function appendMessage(role, text, opts = {}) { + const { skipScroll = false, skipVisibilityUpdate = false } = opts; const entry = document.createElement('fieldset'); entry.className = `chat-turn ${role}`; const legend = document.createElement('legend'); @@ -224,24 +388,98 @@ entry.appendChild(body); dom.messages.appendChild(entry); - entry.scrollIntoView({ behavior: 'smooth', block: 'end' }); - if (dom.form) { - dom.form.scrollIntoView({ behavior: 'smooth', block: 'end' }); + if (!skipScroll) { + scrollToBottom(); + } + if (!skipVisibilityUpdate && role === 'assistant') { + state.assistantSeen = true; + updateClearChatVisibility(); } return body; } + function clearAttachment() { + state.attachment = null; + dom.fileInput.value = ''; + dom.attachmentFilename.textContent = 'Attach a PNG or JPG'; + dom.attachmentRow.classList.remove('has-attachment'); + } + + function updateClearChatVisibility() { + if (!dom.clearChatRow) return; + dom.clearChatRow.style.display = state.assistantSeen ? 'block' : 'none'; + if (state.assistantSeen) { + scrollToBottom(); + } + } + + function clearChatHistory() { + dom.messages.innerHTML = ''; + state.messages = []; + state.assistantSeen = false; + state.showSystem = false; + localStorage.removeItem(STORAGE_KEY); + updateClearChatVisibility(); + updateSystemToggleButton(); + dom.input.value = ''; + clearAttachment(); + resizeComposer(); + } + + function downloadChat() { + const payload = buildRequestPayload(); + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `yacychat-${formatTimestamp()}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + + function applyUploadedChat(payload) { + if (!payload || !Array.isArray(payload.messages)) { + appendMessage('system', 'Invalid chat file.'); + return; + } + clearChatHistory(); + if (payload.model) { + state.config.model = payload.model; + } + for (const msg of payload.messages) { + if (!msg?.role || msg.content === undefined) continue; + state.messages.push({ role: msg.role, content: msg.content }); + } + renderConversation(); + persistConversation(); + } + + async function handleUploadChatFile(event) { + const file = event.target.files && event.target.files[0]; + dom.uploadChatInput.value = ''; + if (!file) return; + try { + const text = await file.text(); + const parsed = JSON.parse(text); + applyUploadedChat(parsed); + } catch (err) { + appendMessage('system', `Failed to load chat file: ${err.message}`); + } + } + function ensureSystemMessage() { if (!state.messages.length || state.messages[0].role !== 'system') { state.messages.unshift({ role: 'system', content: state.config.systemPrompt }); } } - async function streamChat(prompt) { + async function streamChat(userMessage) { ensureSystemMessage(); const payload = { model: state.config.model, - messages: [...state.messages, { role: 'user', content: prompt }], + messages: [...state.messages, userMessage], stream: true }; const headers = { 'Content-Type': 'application/json' }; @@ -261,8 +499,12 @@ let assistantText = ''; const assistantNode = appendMessage('assistant', '...'); const parseChunk = chunk => { - const lines = chunk.split('\n').map(line => line.trim()).filter(Boolean); - for (const line of lines) { + const segments = chunk + .split(/\n+/) + .flatMap(line => line.split(/(?=data:)/)) + .map(line => line.trim()) + .filter(Boolean); + for (const line of segments) { if (line === 'data: [DONE]' || line === '[DONE]') { return true; } @@ -274,7 +516,7 @@ if (delta) { assistantText += delta; assistantNode.textContent = assistantText; - dom.form?.scrollIntoView({ behavior: 'smooth', block: 'end' }); + scrollToBottom(false); } } catch (err) { console.warn('Failed to parse line', line, err); @@ -289,17 +531,143 @@ const chunk = decoder.decode(value, { stream: true }); done = parseChunk(chunk); } - state.messages.push({ role: 'user', content: prompt }); + state.messages.push(userMessage); state.messages.push({ role: 'assistant', content: assistantText }); + persistConversation(); } function resizeComposer() { const minHeight = 24; dom.input.style.height = 'auto'; - const maxHeight = Math.max(window.innerHeight * 0.5, 220); const measured = dom.input.scrollHeight || minHeight; - const newHeight = Math.max(minHeight, Math.min(measured, maxHeight)); - dom.input.style.height = `${newHeight}px`; + dom.input.style.height = `${Math.max(minHeight, measured)}px`; + } + + function buildUserMessage(promptText) { + if (state.attachment?.dataUrl) { + return { + role: 'user', + content: [ + { type: 'text', text: promptText }, + { type: 'image_url', image_url: { url: state.attachment.dataUrl } } + ] + }; + } + return { role: 'user', content: promptText }; + } + + function buildRequestPayload() { + ensureSystemMessage(); + return { + model: state.config.model, + messages: state.messages.map(m => ({ role: m.role, content: m.content })), + stream: true + }; + } + + function messageText(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + const textPart = content.find(part => part?.type === 'text'); + return textPart?.text || ''; + } + if (content && typeof content === 'object' && typeof content.text === 'string') { + return content.text; + } + return ''; + } + + function collectRecentPairs() { + const pairs = []; + let pendingUser = null; + for (const msg of state.messages) { + if (msg.role === 'user') { + pendingUser = messageText(msg.content); + } else if (msg.role === 'assistant' && pendingUser !== null) { + pairs.push({ user: pendingUser, assistant: messageText(msg.content) }); + pendingUser = null; + } + } + return pairs.slice(-3); + } + + function renderConversation() { + dom.messages.innerHTML = ''; + state.assistantSeen = state.messages.some(m => m.role === 'assistant'); + for (const msg of state.messages) { + if (msg.role === 'system' && !state.showSystem) continue; + appendMessage(msg.role, messageText(msg.content), { skipScroll: true, skipVisibilityUpdate: true }); + } + updateClearChatVisibility(); + updateSystemToggleButton(); + scrollToBottom(); + } + + function persistConversation() { + try { + const pairs = collectRecentPairs(); + localStorage.setItem(STORAGE_KEY, JSON.stringify(pairs)); + } catch (err) { + console.warn('Failed to persist conversation', err); + } + } + + function hydrateConversation() { + let stored = null; + try { + stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); + } catch (err) { + console.warn('Failed to parse stored conversation', err); + } + if (!Array.isArray(stored) || !stored.length) return; + for (const pair of stored) { + if (pair?.user) { + state.messages.push({ role: 'user', content: pair.user }); + } + if (pair?.assistant) { + state.messages.push({ role: 'assistant', content: pair.assistant }); + } + } + ensureSystemMessage(); + renderConversation(); + } + + function formatUserPreview(promptText) { + if (state.attachment?.name) { + return `${promptText}\n[Image attached: ${state.attachment.name}]`; + } + return promptText; + } + + async function handleFileChange(event) { + const file = event.target.files && event.target.files[0]; + if (!file) { + clearAttachment(); + return; + } + if (!file.type.startsWith('image/')) { + appendMessage('system', 'Please upload a PNG or JPG image.'); + clearAttachment(); + return; + } + try { + const dataUrl = await readFileAsDataURL(file); + state.attachment = { name: file.name, dataUrl }; + dom.attachmentFilename.textContent = file.name; + dom.attachmentRow.classList.add('has-attachment'); + } catch (err) { + appendMessage('system', `Failed to read file: ${err.message}`); + clearAttachment(); + } + } + + function readFileAsDataURL(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); } dom.form.addEventListener('submit', async event => { @@ -307,13 +675,16 @@ if (state.busy) return; const prompt = dom.input.value.trim(); if (!prompt) return; + const userMessage = buildUserMessage(prompt); + const preview = formatUserPreview(prompt); state.busy = true; dom.sendButton.disabled = true; - appendMessage('user', prompt); + appendMessage('user', preview); dom.input.value = ''; + clearAttachment(); resizeComposer(); try { - await streamChat(prompt); + await streamChat(userMessage); } catch (err) { appendMessage('system', `Error: ${err.message}`); } finally { @@ -322,6 +693,20 @@ } }); + dom.addFileButton.addEventListener('click', () => { + dom.fileInput.click(); + }); + dom.fileInput.addEventListener('change', handleFileChange); + dom.clearFileButton.addEventListener('click', clearAttachment); + dom.clearChatButton?.addEventListener('click', clearChatHistory); + dom.downloadChatButton?.addEventListener('click', downloadChat); + dom.uploadChatButton?.addEventListener('click', () => dom.uploadChatInput?.click()); + dom.uploadChatInput?.addEventListener('change', handleUploadChatFile); + dom.toggleSystemButton?.addEventListener('click', () => { + state.showSystem = !state.showSystem; + renderConversation(); + }); + dom.input.addEventListener('input', resizeComposer); dom.input.addEventListener('keydown', event => { if (event.isComposing) return; @@ -336,7 +721,10 @@ }); window.addEventListener('resize', resizeComposer); + hydrateConversation(); + updateSystemToggleButton(); resizeComposer(); + updateClearChatVisibility(); </script> #%env/templates/footer.template%# |
