feat: Reply/Zitat-Feature für Chat-Nachrichten

- Reply-Button (Hover) auf jeder User/Assistant-Nachricht
- Reply-Preview-Leiste über dem Input-Feld (mit Timestamp, Rolle, Text-Preview)
- Beim Senden: Zitat als Kontext-Block vorangestellt (> [Zitat: HH:MM, Role]:)
- User-Nachrichten parsen Zitat-Blöcke als visuelle Quote-Elemente (blaue Borderlinie)
- Timestamp-basierte Referenzierung (HH:MM oder DD.MM. HH:MM)
- clearReply() bei Chat-Clear, Navigation und Destroy
This commit is contained in:
Meleki committed 2026-03-01 01:06:23 +01:00
1 parent 7c0e5d180a
commit 57b4b5931b
6 files changed
+224 -6

No files matched your search

+67 -2
View File
@@ -32,6 +32,7 @@ const messages = [];
let isStreaming = false;
let pendingFiles = []; // [{ name, type, base64 }]
let pendingRefs = []; // [{ path, from, to }] - Workspace-Dateireferenzen
let pendingReply = null; // { index, role, text } - Zitierte Nachricht
let _lastDoneChainData = null; // Chain-Daten vom letzten done-Event
let _chatAbandoned = false; // true wenn User weg-navigiert während Stream läuft
@@ -43,6 +44,7 @@ async function clearChat() {
chatEl().innerHTML = '';
lastKnownMessageId = 0;
loadedArchiveCount = 0;
clearReply();
setMeta(`Archiviert: ${archive.chainCount} Chains, ${archive.messageCount} Messages`);
showArchiveLoadButton();
@@ -60,14 +62,24 @@ async function sendMessage() {
let content;
let bubbleText = text;
// Refs in den Text einbauen
// Reply-Zitat einbauen
let msgText = text;
if (pendingReply) {
const roleLabel = pendingReply.role === 'user' ? 'User' : 'Assistant';
const timeLabel = _formatReplyTimestamp(pendingReply.timestamp);
const quoteText = pendingReply.text.split('\n').map(l => '> ' + l).join('\n');
const quoteHeader = `> [Zitat: ${timeLabel}, ${roleLabel}]:`;
msgText = quoteHeader + '\n' + quoteText + '\n\n' + (text || '');
clearReply();
}
// Refs in den Text einbauen
if (pendingRefs.length > 0) {
const refTags = pendingRefs.map(r => {
if (r.from && r.to) return `[Datei: ${r.path}:${r.from}-${r.to}]`;
return `[Datei: ${r.path}]`;
});
msgText = refTags.join('\n') + (text ? '\n' + text : '');
msgText = refTags.join('\n') + (msgText ? '\n' + msgText : '');
clearPendingRefs();
}
@@ -324,3 +336,56 @@ function clearPendingFiles() {
const fileInput = $('#fileInput');
if (fileInput) fileInput.value = '';
}
// ── Reply / Quote ──
function _formatReplyTimestamp(ts) {
if (!ts) return 'Jetzt';
try {
const d = new Date(ts);
const now = new Date();
const hh = d.getHours().toString().padStart(2, '0');
const mm = d.getMinutes().toString().padStart(2, '0');
// Gleiches Datum → nur Uhrzeit, sonst Datum + Uhrzeit
if (d.toDateString() === now.toDateString()) {
return hh + ':' + mm;
}
const dd = d.getDate().toString().padStart(2, '0');
const mo = (d.getMonth() + 1).toString().padStart(2, '0');
return dd + '.' + mo + '. ' + hh + ':' + mm;
} catch { return 'Jetzt'; }
}
function setReply(reply) {
pendingReply = reply;
renderReplyPreview();
document.getElementById('input')?.focus();
}
function clearReply() {
pendingReply = null;
renderReplyPreview();
}
function renderReplyPreview() {
const container = $('#replyPreview');
if (!container) return;
if (!pendingReply) {
container.innerHTML = '';
container.hidden = true;
return;
}
const roleLabel = pendingReply.role === 'user' ? 'User' : 'Assistant';
const timeLabel = _formatReplyTimestamp(pendingReply.timestamp);
const preview = truncate(pendingReply.text, 150);
container.innerHTML = `<div class="reply-preview-item">
<span class="reply-icon"><i class="fa fa-reply"></i></span>
<span class="reply-meta">${esc(timeLabel)}, ${esc(roleLabel)}</span>
<span class="reply-text">${esc(preview)}</span>
<button class="file-remove" onclick="clearReply()"><i class="fa fa-times"></i></button>
</div>`;
container.hidden = false;
}
+2 -2
View File
@@ -35,11 +35,11 @@ function renderMessages(msgs, addToConversation) {
switch (msg.type) {
case 'text':
if (msg.role === 'user') {
addUserBubble(msg.content);
addUserBubble(msg.content, false, msg.createdAt);
if (msg.attachmentCount > 0) addAttachmentIndicator(msg.id, msg.attachmentCount, msg.attachmentNames);
if (addToConversation) messages.push({ role: 'user', content: msg.content });
} else if (msg.role === 'assistant') {
addAssistantBubble(msg.content);
addAssistantBubble(msg.content, msg.createdAt);
if (addToConversation) messages.push({ role: 'assistant', content: msg.content });
}
break;
+1
View File
@@ -297,5 +297,6 @@ registerPage('chat', {
isStreaming = false;
clearPendingFiles();
clearPendingRefs();
clearReply();
}
});
+62 -2
View File
@@ -33,10 +33,64 @@ function truncate(s, max) {
return s && s.length > max ? s.substring(0, max) + '...' : (s || '');
}
// ── Reply Button ──
function _addReplyButton(div) {
const btn = document.createElement('button');
btn.className = 'msg-reply-btn icon-btn';
btn.innerHTML = '<i class="fa fa-reply"></i>';
btn.title = 'Antworten';
btn.addEventListener('click', (e) => {
e.stopPropagation();
// Nur den eigentlichen Nachrichten-Text holen (ohne Reply-Button-Text)
const clone = div.cloneNode(true);
clone.querySelectorAll('.msg-reply-btn, .attachment-indicator, .reply-quote').forEach(el => el.remove());
const text = clone.textContent.trim();
setReply({
role: div.dataset.role,
timestamp: div.dataset.timestamp || '',
text
});
});
div.appendChild(btn);
}
// ── Message Bubbles ──
function renderUserContent(container, text) {
const raw = (text || '').trim();
// Reply-Zitat erkennen und als visuellen Block rendern
// Format: > [Zitat: HH:MM, Role]:\n> text...\n\nrest
const quoteMatch = raw.match(/^> \[Zitat: ([^\]]+)\]:\n((?:> .*\n?)*)\n*([\s\S]*)$/);
if (quoteMatch) {
const quoteMeta = quoteMatch[1]; // z.B. "14:32, Assistant"
const quoteBody = quoteMatch[2].replace(/^> /gm, '').trim();
const restText = quoteMatch[3].trim();
const quoteEl = document.createElement('div');
quoteEl.className = 'reply-quote';
const metaEl = document.createElement('div');
metaEl.className = 'reply-quote-meta';
metaEl.innerHTML = '<i class="fa fa-reply"></i> ' + esc(quoteMeta);
quoteEl.appendChild(metaEl);
const bodyEl = document.createElement('div');
bodyEl.className = 'reply-quote-text';
bodyEl.textContent = truncate(quoteBody, 200);
quoteEl.appendChild(bodyEl);
container.appendChild(quoteEl);
// Rest als normalen Text weiterverarbeiten
if (restText) {
_renderUserTextContent(container, restText);
}
return;
}
_renderUserTextContent(container, raw);
}
function _renderUserTextContent(container, raw) {
// [Datei: path] oder [Datei: path:1-10] erkennen
const pattern = /\[Datei: ([^\]]+)\]/g;
let last = 0;
@@ -75,19 +129,25 @@ function renderUserContent(container, text) {
container.appendChild(frag);
}
function addUserBubble(text, streamed = false) {
function addUserBubble(text, streamed = false, timestamp = '') {
const div = document.createElement('div');
div.className = 'msg user';
div.dataset.role = 'user';
div.dataset.timestamp = timestamp || new Date().toISOString();
if (streamed) div.classList.add('streamed');
renderUserContent(div, text);
_addReplyButton(div);
chatEl().appendChild(div);
scrollDown();
}
function addAssistantBubble(text) {
function addAssistantBubble(text, timestamp = '') {
const div = document.createElement('div');
div.className = 'msg assistant markdown';
div.dataset.role = 'assistant';
div.dataset.timestamp = timestamp || new Date().toISOString();
div.innerHTML = renderMarkdown(text);
_addReplyButton(div);
chatEl().appendChild(div);
scrollDown();
return div;