diff options
| author | Michael Peter Christen <mc@yacy.net> | 2025-11-23 18:34:49 +0100 |
|---|---|---|
| committer | Michael Peter Christen <mc@yacy.net> | 2025-11-23 18:34:49 +0100 |
| commit | 5f06b5b4820c70545640148bc81199c29a67f8ad (patch) | |
| tree | 23abdcbaf7a65d868a7fa59a4cc73a261ee675ce | |
| parent | f1d17d94608ae50d9f3139a6f63c9435abd7d6ea (diff) | |
added chat front-end (stub, no connection to search yet)
| -rw-r--r-- | htroot/LLMSelection_p.html | 2 | ||||
| -rw-r--r-- | htroot/env/templates/submenuAI.template | 7 | ||||
| -rw-r--r-- | htroot/yacychat.html | 368 | ||||
| -rw-r--r-- | source/net/yacy/htroot/yacychat.java | 33 | ||||
| -rw-r--r-- | source/net/yacy/http/servlets/RAGProxyServlet.java | 13 |
5 files changed, 420 insertions, 3 deletions
diff --git a/htroot/LLMSelection_p.html b/htroot/LLMSelection_p.html index 8179abd59..4150549e3 100644 --- a/htroot/LLMSelection_p.html +++ b/htroot/LLMSelection_p.html @@ -7,7 +7,7 @@ </head> <body id="IndexControl"> #%env/templates/header.template%# - #%env/templates/submenuIndexImport.template%# + #%env/templates/submenuAI.template%# <script> let availableModels = []; diff --git a/htroot/env/templates/submenuAI.template b/htroot/env/templates/submenuAI.template new file mode 100644 index 000000000..c9c933661 --- /dev/null +++ b/htroot/env/templates/submenuAI.template @@ -0,0 +1,7 @@ +<div class="SubMenu"> + <h3>AI Lab</h3> + <ul class="SubMenu"> + <li><a href="LLMSelection_p.html" class="MenuItemLink #(authorized)#lock::unlock#(/authorized)#">LLM Selection</a></li> + <li><a href="yacychat.html" class="MenuItemLink">Chat</a></li> + </ul> +</div> diff --git a/htroot/yacychat.html b/htroot/yacychat.html new file mode 100644 index 000000000..52fcfa12b --- /dev/null +++ b/htroot/yacychat.html @@ -0,0 +1,368 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "DTD/xhtml1-transitional.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <title>YaCy '#[clientname]#': Chat</title> + #%env/templates/metas.template%# + <style type="text/css"> + /* 1. Global Container */ + .chat-panel { + width: 100%; + color: #333333; + padding: 0; + } + + /* 2. Layout Structure */ + .chat-flow { + display: flex; + flex-direction: column; + gap: 15px; + } + + .chat-messages { + display: flex; + flex-direction: column; + gap: 10px; + } + + /* 3. The Message Blocks - Structural Look */ + .chat-turn { + position: relative; + border: 1px solid #e0e0e0; + border-radius: 0px; + padding-top: 2px; + padding-bottom: 12px; + padding-left: 20px; + padding-right: 20px; + margin: 0; + box-shadow: 2px 2px 0px rgba(0,0,0,0.1); + transition: all 0.2s; + } + .chat-turn:hover { + box-shadow: 3px 3px 0px rgba(0,0,0,0.15); + } + + /* 4. Labels (User/Assistant Headers) - Headline Font Consistency */ + .chat-turn legend { + font-family: inherit; + padding: 4px 10px; + font-size: 0.95rem; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; + border: 1px solid transparent; + margin-bottom: 8px; /* Increased spacing */ + } + + /* --- USER: Lighter Grey Input --- */ + .chat-turn.user { + background-color: #ECF1F8; + border-left: 5px solid #2c3e50; + border-color: #d1d9e6; + color: #2c3e50; + } + + .chat-turn.user legend { + background: #5092CF; + color: #ffffff; + border: 1px solid #5092CF; + } + + /* --- ASSISTANT: Darker Grey Output --- */ + .chat-turn.assistant { + background-color: #DEE7F3; + border-left: 5px solid #0056b3; + border-color: #bbccdd; + color: #000; + } + + .chat-turn.assistant legend { + background: #2c3e50; + color: #ffffff; + border: 1px solid #2c3e50; + } + + /* --- SYSTEM: Log Entry Style --- */ + .chat-turn.system { + background: #fff3cd; + border: 1px solid #ffeeba; + border-left: 5px solid #ffc107; + color: #856404; + font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace; + font-size: 1.2rem; + } + + /* 5. Typography - All Chat Content is MONOSPACE */ + .chat-body { + white-space: pre-wrap; + line-height: 1.6; + font-size: 1.2rem; /* Larger font size */ + font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace; + } + + /* 6. The Composer (Input Area) */ + .composer { + display: flex; + gap: 0; + align-items: stretch; + border-top: none; + } + + .composer fieldset { + flex: 1; + margin: 0; + padding: 0; + border: 2px solid #555; + background: #fff; + border-radius: 0px; + border-right: none; + } + + .composer fieldset:focus-within { + background-color: #ECF1F8; + border-left: 5px solid #2c3e50; + border-color: #d1d9e6; + color: #2c3e50; + } + + .composer legend { + display: none; + } + + .composer textarea { + width: 100%; + resize: none; + border: none; + background: transparent; + padding-top: 12px; + padding-bottom: 12px; + padding-left: 20px; + padding-right: 20px; + margin: 0; + outline: none; + font-family: 'Roboto Mono', 'Menlo', monospace; /* Monospace for coding */ + color: #333; + font-size: 1.2rem; /* Larger font size */ + } + + .composer textarea::placeholder { + color: #999; + font-style: normal; + } + + /* 7. The Button - Industrial Action */ + .composer .btn { + align-self: stretch; + padding: 0 30px; + min-height: 50px; + border: 2px solid #555; + background: #333; + color: #fff; + font-family: 'Roboto Mono', monospace; + font-weight: bold; + text-transform: uppercase; + cursor: pointer; + transition: all 0.2s; + } + + .composer .btn:hover { + background: #0056b3; + border-color: #0056b3; + } + + .composer .btn:active { + background: #004494; + } + + @media (max-width: 900px) { + .composer { + flex-direction: column; + gap: 10px; + } + .composer fieldset { + border-right: 2px solid #555; + } + .composer .btn { + width: 100%; + border: 2px solid #555; + } + } + </style> + </head> + <body id="IndexControl"> + #%env/templates/header.template%# + #%env/templates/submenuAI.template%# + + <h2>YaCy Chat</h2> + <p>Chat with your configured LLM using the local YaCy settings. Requests are sent to the same host that served this page.</p> + + <div class="chat-panel"> + <div class="chat-flow"> + <div class="chat-messages" id="chatMessages" aria-live="polite"></div> + <form class="composer" id="chatForm"> + <fieldset class="chat-turn user"> + <legend>User</legend> + <textarea id="userInput" rows="3" placeholder="Ask me anything..." required="required"></textarea> + </fieldset> + <input type="submit" class="btn btn-primary" id="sendButton" value="Send"/> + </form> + </div> + </div> + + <script type="text/javascript"> + const SYSTEM_PROMPT = 'You are a smart and helpful chatbot. If possible, use friendly emojies.'; + const defaultApiHost = ''; + + const state = { + messages: [], + config: { + apiHost: defaultApiHost, + model: 'chat', + systemPrompt: SYSTEM_PROMPT + }, + busy: false + }; + + const dom = { + messages: document.getElementById('chatMessages'), + form: document.getElementById('chatForm'), + input: document.getElementById('userInput'), + sendButton: document.getElementById('sendButton') + }; + + function appendMessage(role, text) { + const entry = document.createElement('fieldset'); + entry.className = `chat-turn ${role}`; + const legend = document.createElement('legend'); + if (role === 'assistant') { + legend.textContent = 'Assistant'; + } else if (role === 'user') { + legend.textContent = 'User'; + } else { + legend.textContent = 'System'; + } + entry.appendChild(legend); + + const body = document.createElement('div'); + body.className = 'chat-body'; + body.textContent = text; + entry.appendChild(body); + + dom.messages.appendChild(entry); + entry.scrollIntoView({ behavior: 'smooth', block: 'end' }); + if (dom.form) { + dom.form.scrollIntoView({ behavior: 'smooth', block: 'end' }); + } + return body; + } + + function ensureSystemMessage() { + if (!state.messages.length || state.messages[0].role !== 'system') { + state.messages.unshift({ role: 'system', content: state.config.systemPrompt }); + } + } + + async function streamChat(prompt) { + ensureSystemMessage(); + const payload = { + model: state.config.model, + messages: [...state.messages, { role: 'user', content: prompt }], + stream: true + }; + const headers = { 'Content-Type': 'application/json' }; + const response = await fetch(state.config.apiHost.replace(/\/$/, '') + '/v1/chat/completions', { + method: 'POST', + headers, + body: JSON.stringify(payload) + }); + if (!response.ok) { + throw new Error(`Request failed: ${response.status} ${response.statusText}`); + } + if (!response.body) { + throw new Error('Streaming not supported by this browser.'); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + 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) { + if (line === 'data: [DONE]' || line === '[DONE]') { + return true; + } + const clean = line.replace(/^data:\s*/i, ''); + if (!clean) continue; + try { + const parsed = JSON.parse(clean); + const delta = parsed?.choices?.[0]?.delta?.content; + if (delta) { + assistantText += delta; + assistantNode.textContent = assistantText; + dom.form?.scrollIntoView({ behavior: 'smooth', block: 'end' }); + } + } catch (err) { + console.warn('Failed to parse line', line, err); + } + } + return false; + }; + let done = false; + while (!done) { + const { value, done: readerDone } = await reader.read(); + if (readerDone) break; + const chunk = decoder.decode(value, { stream: true }); + done = parseChunk(chunk); + } + state.messages.push({ role: 'user', content: prompt }); + state.messages.push({ role: 'assistant', content: assistantText }); + } + + function resizeComposer() { + const minHeight = 44; + 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.form.addEventListener('submit', async event => { + event.preventDefault(); + if (state.busy) return; + const prompt = dom.input.value.trim(); + if (!prompt) return; + state.busy = true; + dom.sendButton.disabled = true; + appendMessage('user', prompt); + dom.input.value = ''; + resizeComposer(); + try { + await streamChat(prompt); + } catch (err) { + appendMessage('system', `Error: ${err.message}`); + } finally { + state.busy = false; + dom.sendButton.disabled = false; + } + }); + + dom.input.addEventListener('input', resizeComposer); + dom.input.addEventListener('keydown', event => { + if (event.isComposing) return; + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + if (typeof dom.form.requestSubmit === 'function') { + dom.form.requestSubmit(dom.sendButton); + } else { + dom.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + } + } + }); + window.addEventListener('resize', resizeComposer); + + resizeComposer(); + </script> + + #%env/templates/footer.template%# + </body> +</html> diff --git a/source/net/yacy/htroot/yacychat.java b/source/net/yacy/htroot/yacychat.java new file mode 100644 index 000000000..db9d14eee --- /dev/null +++ b/source/net/yacy/htroot/yacychat.java @@ -0,0 +1,33 @@ +package net.yacy.htroot; + +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; + +import net.yacy.cora.protocol.RequestHeader; +import net.yacy.search.Switchboard; +import net.yacy.server.serverObjects; +import net.yacy.server.serverSwitch; + +public class yacychat { + + public static serverObjects respond(@SuppressWarnings("unused") final RequestHeader header, final serverObjects post, final serverSwitch env) { + // return variable that accumulates replacements + final Switchboard sb = (Switchboard) env; + + final serverObjects prop = new serverObjects(); + String body = post == null ? "" : post.get("BODY", ""); + JSONObject bodyj = new JSONObject(); + if (body.length() > 0) { + try { + bodyj = new JSONObject(new JSONTokener(body)); + } catch (JSONException e) { + // silently catch this + } + } + + // return rewrite properties + return prop; + } + +}
\ No newline at end of file diff --git a/source/net/yacy/http/servlets/RAGProxyServlet.java b/source/net/yacy/http/servlets/RAGProxyServlet.java index 5553b75f1..cc1c14514 100644 --- a/source/net/yacy/http/servlets/RAGProxyServlet.java +++ b/source/net/yacy/http/servlets/RAGProxyServlet.java @@ -51,6 +51,7 @@ import org.json.JSONObject; import net.yacy.ai.LLM; import net.yacy.cora.federate.solr.SolrType; import net.yacy.cora.federate.solr.connector.EmbeddedSolrConnector; +import net.yacy.cora.protocol.Domains; import net.yacy.search.Switchboard; import net.yacy.search.schema.CollectionSchema; @@ -88,6 +89,13 @@ public class RAGProxyServlet extends HttpServlet { hresponse.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE"); hresponse.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + final String clientIP = hrequest.getRemoteAddr(); + final boolean localhostAccess = Domains.isLocalhost(clientIP); + if (!localhostAccess) { + // we will introduce a rate limit for non-localhost later, for now we just don't allow it + hresponse.sendError(HttpServletResponse.SC_FORBIDDEN); + } + final Method reqMethod = Method.getMethod(hrequest.getMethod()); if (reqMethod == Method.OTHER) { // required to handle CORS @@ -157,9 +165,10 @@ public class RAGProxyServlet extends HttpServlet { systemObject.put("content", system); userObject.put("content", user); - // write back modified bodyMap to body - body = bodyObject.toString(); } + + // write back modified bodyMap to body + body = bodyObject.toString(); // Open request to back-end service URL url = new URI(llm4Chat.llm.hoststub + "/v1/chat/completions").toURL(); |
