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 | |
| parent | 9aeffad62bfdd7a7f5546d8255e951569527587a (diff) | |
added image attachments, local storage, clear/download/upload/show-system buttons to chat
| -rw-r--r-- | htroot/yacychat.html | 428 | ||||
| -rw-r--r-- | source/net/yacy/http/servlets/RAGProxyServlet.java | 92 |
2 files changed, 495 insertions, 25 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%# diff --git a/source/net/yacy/http/servlets/RAGProxyServlet.java b/source/net/yacy/http/servlets/RAGProxyServlet.java index 0545b3d40..439b5aa21 100644 --- a/source/net/yacy/http/servlets/RAGProxyServlet.java +++ b/source/net/yacy/http/servlets/RAGProxyServlet.java @@ -29,7 +29,9 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.util.ArrayList; +import java.util.Base64; import java.util.Iterator; +import java.util.List; import javax.servlet.ServletException; import javax.servlet.ServletOutputStream; @@ -143,9 +145,10 @@ public class RAGProxyServlet extends HttpServlet { JSONArray messages = bodyObject.optJSONArray("messages"); //JSONObject systemObject = messages.getJSONObject(0); //String system = systemObject.optString("content", ""); // the system prompt - JSONObject userObject = messages.getJSONObject(messages.length() - 1); - String user = userObject.optString("content", ""); // this is the latest prompt - + UserObject userObject = new UserObject(messages.getJSONObject(messages.length() - 1)); + String user = userObject.getContentText(); // this is the latest prompt + //List<DataURL> data_urls = userObject.getContentAttachments(); + // RAG if (rag) { // modify system and user prompt here in bodyObject to enable RAG @@ -153,8 +156,7 @@ public class RAGProxyServlet extends HttpServlet { String searchResultMarkdown = searchResultsAsMarkdown(query, 4); user += LLM_USER_PREFIX; user += searchResultMarkdown; - userObject.put("content", user); - + userObject.setContentText(user); } // write back modified bodyMap to body @@ -197,7 +199,87 @@ public class RAGProxyServlet extends HttpServlet { throw new IOException(e.getMessage()); } } + + public final static class DataURL { + private String mimetype; + private byte[] data; + public DataURL(String data_url) { + if (data_url == null || !data_url.startsWith("data:")) { + throw new IllegalArgumentException("data url not valid: it must start with 'data:'"); + } + int commaIndex = data_url.indexOf(','); + if (commaIndex == -1) { + throw new IllegalArgumentException("data url not valid: it must contain a comma"); + } + String header = data_url.substring(5, commaIndex); // "image/jpeg;base64" + String base64Data = data_url.substring(commaIndex + 1); // "/9j/4AAQSkZJRgABAQEASAB..." + String[] headerParts = header.split(";"); + this.mimetype = headerParts[0]; // i.e. "image/jpeg" + this.data = Base64.getDecoder().decode(base64Data); + } + public String getMimetype() { + return this.mimetype; + } + public byte[] getData() { + return this.data; + } + } + public final static class UserObject { + private JSONObject userObject; + + public UserObject(JSONObject userObject) { + this.userObject = userObject; + } + + public String getContentText() { + Object content = this.userObject.opt("content"); + if (content instanceof JSONArray) { + JSONArray array = (JSONArray) content; + for (int i = 0; i < array.length(); i++) { + JSONObject j = array.optJSONObject(i); + String ctype = j.optString("type"); + if (ctype != null && ctype.equals("text")) { + String text = j.optString("text", ""); + return text; + } + } + return ""; + } + assert content instanceof String; + return (String) content; + } + + public List<DataURL> getContentAttachments() { + ArrayList<DataURL> list = new ArrayList<>(); + Object content = this.userObject.opt("content"); + if (content instanceof JSONArray) { + JSONArray array = (JSONArray) content; + for (int i = 0; i < array.length(); i++) { + JSONObject j = array.optJSONObject(i); + String ctype = j.optString("type"); + if (ctype != null && ctype.equals("image_url")) { + JSONObject image_url = j.optJSONObject("image_url"); + if (image_url != null) { + String data_url = image_url.optString("url", ""); + if (data_url.length() > 0) { + DataURL dataurl = new DataURL(data_url); + list.add(dataurl); + } + } + } + } + } + return list; + } + + public void setContentText(String text) { + try { + this.userObject.put("content", text); + } catch (JSONException e) {} + } + } + public static JSONArray searchResults(String query, int count, final boolean includeSnippet) { final JSONArray results = new JSONArray(); if (query == null || query.length() == 0 || count == 0) return results; |
