summaryrefslogtreecommitdiff
path: root/htroot/yacychat.html
diff options
context:
space:
mode:
authorMichael Peter Christen <mc@yacy.net>2025-11-25 19:55:55 +0100
committerMichael Peter Christen <mc@yacy.net>2025-11-25 19:55:55 +0100
commitc030a194842a7b3762b75b4795ba10ca64a8538a (patch)
tree7a3c8a22f3ded9c2098fcb9e4396c96cdf33de3b /htroot/yacychat.html
parent0375dc34771b3215475572f9a42b4c6ec08641f8 (diff)
added copy and edit buttons in chat history
Diffstat (limited to 'htroot/yacychat.html')
-rw-r--r--htroot/yacychat.html181
1 files changed, 157 insertions, 24 deletions
diff --git a/htroot/yacychat.html b/htroot/yacychat.html
index cdeb5c68d..14d63934a 100644
--- a/htroot/yacychat.html
+++ b/htroot/yacychat.html
@@ -149,7 +149,9 @@
display: none;
}
- .attachment-button {
+ .attachment-button,
+ .copy-button,
+ .user-trim-button {
width: 24px;
height: 24px;
border: none;
@@ -165,7 +167,9 @@
transition: background 0.15s, border-color 0.15s;
}
- .attachment-button:hover {
+ .attachment-button:hover,
+ .copy-button:hover,
+ .user-trim-button:hover {
background: #c3cbd9;
}
@@ -197,6 +201,13 @@
background: #c3cbd9;
}
+ .copy-button,
+ .user-trim-button {
+ position: absolute;
+ top: -6px;
+ right: 8px;
+ }
+
.hidden-file-input {
position: absolute;
left: -9999px;
@@ -269,7 +280,7 @@
<fieldset class="chat-turn user">
<legend>User</legend>
<div class="composer">
- <div class="composer-main">
+ <div class="composer-main" id="composerMain">
<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">
@@ -279,7 +290,7 @@
<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"/>
+ <input type="file" id="fileInput" class="hidden-file-input" accept="image/*,text/plain,text/markdown"/>
</div>
</div>
<input type="submit" class="btn btn-primary" id="sendButton" value="Send"/>
@@ -342,7 +353,8 @@
downloadChatButton: document.getElementById('downloadChatButton'),
uploadChatButton: document.getElementById('uploadChatButton'),
uploadChatInput: document.getElementById('uploadChatInput'),
- toggleSystemButton: document.getElementById('toggleSystemButton')
+ toggleSystemButton: document.getElementById('toggleSystemButton'),
+ composerMain: document.getElementById('composerMain')
};
function scrollToBottom(smooth = true) {
@@ -372,6 +384,9 @@
const { skipScroll = false, skipVisibilityUpdate = false } = opts;
const entry = document.createElement('fieldset');
entry.className = `chat-turn ${role}`;
+ if (role === 'assistant' || role === 'user') {
+ entry.style.position = 'relative';
+ }
const legend = document.createElement('legend');
if (role === 'assistant') {
legend.textContent = 'Assistant';
@@ -388,6 +403,27 @@
entry.appendChild(body);
dom.messages.appendChild(entry);
+ if (typeof opts.messageIndex === 'number') {
+ entry.dataset.messageIndex = String(opts.messageIndex);
+ }
+ if (role === 'assistant') {
+ const copyBtn = document.createElement('button');
+ copyBtn.type = 'button';
+ copyBtn.className = 'copy-button';
+ copyBtn.title = 'Copy answer';
+ copyBtn.innerHTML = '<span class="glyphicon glyphicon-copy" aria-hidden="true"></span>';
+ copyBtn.addEventListener('click', () => copyAssistant(body.textContent));
+ entry.appendChild(copyBtn);
+ }
+ if (role === 'user' && typeof opts.messageIndex === 'number') {
+ const trimBtn = document.createElement('button');
+ trimBtn.type = 'button';
+ trimBtn.className = 'user-trim-button';
+ trimBtn.title = 'Reuse from here';
+ trimBtn.innerHTML = '<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>';
+ trimBtn.addEventListener('click', () => trimConversationFromIndex(opts.messageIndex, body.textContent));
+ entry.appendChild(trimBtn);
+ }
if (!skipScroll) {
scrollToBottom();
}
@@ -405,6 +441,38 @@
dom.attachmentRow.classList.remove('has-attachment');
}
+ async function copyAssistant(text) {
+ try {
+ await navigator.clipboard.writeText(text || '');
+ } catch (err) {
+ console.warn('Clipboard copy failed', err);
+ }
+ }
+
+ function trimConversationFromIndex(index, reuseText) {
+ if (typeof index !== 'number' || index < 0) return;
+ state.messages = state.messages.slice(0, index);
+ state.assistantSeen = state.messages.some(m => m.role === 'assistant');
+ state.showSystem = false;
+ renderConversation();
+ persistConversation();
+ updateClearChatVisibility();
+ updateSystemToggleButton();
+ clearAttachment();
+ dom.input.value = reuseText || '';
+ resizeComposer();
+ showComposer();
+ }
+
+ function hideComposer() {
+ if (dom.form) dom.form.style.display = 'none';
+ }
+
+ function showComposer() {
+ if (dom.form) dom.form.style.display = '';
+ if (dom.input) dom.input.focus();
+ }
+
function updateClearChatVisibility() {
if (!dom.clearChatRow) return;
dom.clearChatRow.style.display = state.assistantSeen ? 'block' : 'none';
@@ -475,7 +543,8 @@
}
}
- async function streamChat(userMessage) {
+ async function streamChat(userMessage, assistantNode, options = {}) {
+ const { onFirstToken } = options;
ensureSystemMessage();
const payload = {
model: state.config.model,
@@ -497,7 +566,7 @@
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let assistantText = '';
- const assistantNode = appendMessage('assistant', '...');
+ let sawFirstToken = false;
const parseChunk = chunk => {
const segments = chunk
.split(/\n+/)
@@ -514,6 +583,11 @@
const parsed = JSON.parse(clean);
const delta = parsed?.choices?.[0]?.delta?.content;
if (delta) {
+ if (!sawFirstToken && typeof onFirstToken === 'function') {
+ sawFirstToken = true;
+ assistantNode.textContent = '';
+ onFirstToken();
+ }
assistantText += delta;
assistantNode.textContent = assistantText;
scrollToBottom(false);
@@ -531,9 +605,13 @@
const chunk = decoder.decode(value, { stream: true });
done = parseChunk(chunk);
}
+ if (!sawFirstToken && typeof onFirstToken === 'function') {
+ onFirstToken();
+ }
state.messages.push(userMessage);
state.messages.push({ role: 'assistant', content: assistantText });
persistConversation();
+ renderConversation();
}
function resizeComposer() {
@@ -544,13 +622,16 @@
}
function buildUserMessage(promptText) {
- if (state.attachment?.dataUrl) {
+ if (state.attachment) {
+ const parts = [{ type: 'text', text: promptText }];
+ if (state.attachment.kind === 'image' && state.attachment.dataUrl) {
+ parts.push({ type: 'image_url', image_url: { url: state.attachment.dataUrl } });
+ } else if (state.attachment.kind === 'text' && state.attachment.textContent) {
+ parts.push({ type: 'text', text: state.attachment.textContent });
+ }
return {
role: 'user',
- content: [
- { type: 'text', text: promptText },
- { type: 'image_url', image_url: { url: state.attachment.dataUrl } }
- ]
+ content: parts
};
}
return { role: 'user', content: promptText };
@@ -594,9 +675,10 @@
function renderConversation() {
dom.messages.innerHTML = '';
state.assistantSeen = state.messages.some(m => m.role === 'assistant');
- for (const msg of state.messages) {
+ for (let i = 0; i < state.messages.length; i++) {
+ const msg = state.messages[i];
if (msg.role === 'system' && !state.showSystem) continue;
- appendMessage(msg.role, messageText(msg.content), { skipScroll: true, skipVisibilityUpdate: true });
+ appendMessage(msg.role, messageText(msg.content), { skipScroll: true, skipVisibilityUpdate: true, messageIndex: i });
}
updateClearChatVisibility();
updateSystemToggleButton();
@@ -634,26 +716,27 @@
function formatUserPreview(promptText) {
if (state.attachment?.name) {
- return `${promptText}\n[Image attached: ${state.attachment.name}]`;
+ return `${promptText}\n[Attachment: ${state.attachment.name}]`;
}
return promptText;
}
async function handleFileChange(event) {
const file = event.target.files && event.target.files[0];
+ dom.fileInput.value = '';
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;
+ const attachment = await buildAttachment(file);
+ if (!attachment) {
+ appendMessage('system', 'Please upload an image, .txt, or .md file.');
+ clearAttachment();
+ return;
+ }
+ state.attachment = attachment;
+ dom.attachmentFilename.textContent = attachment.name;
dom.attachmentRow.classList.add('has-attachment');
} catch (err) {
appendMessage('system', `Failed to read file: ${err.message}`);
@@ -670,6 +753,31 @@
});
}
+ function readFileAsText(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsText(file);
+ });
+ }
+
+ async function buildAttachment(file) {
+ const name = file.name || 'attachment';
+ const mime = (file.type || '').toLowerCase();
+ const isImage = mime.startsWith('image/');
+ const isText = mime.startsWith('text/') || name.endsWith('.txt') || name.endsWith('.md');
+ if (isImage) {
+ const dataUrl = await readFileAsDataURL(file);
+ return { kind: 'image', name, dataUrl };
+ }
+ if (isText) {
+ const textContent = await readFileAsText(file);
+ return { kind: 'text', name, textContent };
+ }
+ return null;
+ }
+
dom.form.addEventListener('submit', async event => {
event.preventDefault();
if (state.busy) return;
@@ -683,13 +791,17 @@
dom.input.value = '';
clearAttachment();
resizeComposer();
+ const assistantNode = appendMessage('assistant', 'waiting for an answer...');
+ hideComposer();
try {
- await streamChat(userMessage);
+ await streamChat(userMessage, assistantNode, { onFirstToken: showComposer });
} catch (err) {
appendMessage('system', `Error: ${err.message}`);
+ showComposer();
} finally {
state.busy = false;
dom.sendButton.disabled = false;
+ showComposer();
}
});
@@ -719,6 +831,27 @@
}
}
});
+ dom.composerMain?.addEventListener('dragover', event => {
+ event.preventDefault();
+ });
+ dom.composerMain?.addEventListener('drop', async event => {
+ event.preventDefault();
+ const file = event.dataTransfer?.files && event.dataTransfer.files[0];
+ if (!file) return;
+ try {
+ const attachment = await buildAttachment(file);
+ if (!attachment) {
+ appendMessage('system', 'Please drop an image, .txt, or .md file.');
+ return;
+ }
+ state.attachment = attachment;
+ dom.attachmentFilename.textContent = attachment.name;
+ dom.attachmentRow.classList.add('has-attachment');
+ } catch (err) {
+ appendMessage('system', `Failed to read file: ${err.message}`);
+ clearAttachment();
+ }
+ });
window.addEventListener('resize', resizeComposer);
hydrateConversation();