mirror of
https://github.com/yacy/yacy_search_server.git
synced 2026-10-25 05:54:15 +00:00
added chat front-end (stub, no connection to search yet)
This commit is contained in:
1 parent
f1d17d9460
commit
5f06b5b482
5 files changed
+420
-3
No files matched your search
@@ -7,7 +7,7 @@
|
||||
</head>
|
||||
<body id="IndexControl">
|
||||
#%env/templates/header.template%#
|
||||
#%env/templates/submenuIndexImport.template%#
|
||||
#%env/templates/submenuAI.template%#
|
||||
<script>
|
||||
let availableModels = [];
|
||||
|
||||
|
||||
+7
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user