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

+91
View File
@@ -262,6 +262,97 @@
margin-inline: auto;
}
/* Reply Button */
.msg {
position: relative;
}
.msg-reply-btn {
position: absolute;
top: var(--badge-spacing);
right: var(--badge-spacing);
opacity: 0;
transition: opacity .15s;
padding: .15em .35em;
font-size: var(--font-size);
background: var(--f2-bg);
border: var(--border-width) solid var(--f2-border);
border-radius: var(--border-radius);
}
.msg:hover .msg-reply-btn {
opacity: var(--semi-transparent);
}
.msg-reply-btn:hover {
opacity: 1 !important;
}
/* Reply Quote in User Bubble */
.reply-quote {
border-left: 3px solid var(--primary);
padding: var(--badge-padding);
padding-left: var(--box-spacing);
margin-bottom: var(--box-spacing);
opacity: var(--semi-transparent);
white-space: normal;
}
.reply-quote-meta {
font-size: var(--font-size);
margin-bottom: .2em;
color: var(--primary);
}
.reply-quote-text {
font-size: var(--font-size);
}
/* Reply Preview */
.reply-preview {
padding: var(--box-padding);
padding-bottom: 0;
}
.reply-preview:not([hidden]) {
display: flex;
}
.reply-preview-item {
display: flex;
align-items: center;
padding: var(--badge-padding);
gap: var(--badge-spacing);
background: var(--f2-bg);
border: var(--border-width) solid var(--f2-border);
border-radius: var(--border-radius);
border-left: 3px solid var(--primary);
overflow: hidden;
flex: 1;
}
.reply-icon {
flex-shrink: 0;
color: var(--primary);
}
.reply-meta {
flex-shrink: 0;
font-weight: bold;
font-size: var(--font-size);
}
.reply-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: var(--semi-transparent);
}
/* Workspace Refs */
.ref-preview {
+1
View File
@@ -163,6 +163,7 @@
<div class="meta-bar" id="meta"></div>
<div id="filePreview" class="file-preview" hidden></div>
<div id="refPreview" class="ref-preview" hidden></div>
<div id="replyPreview" class="reply-preview" hidden></div>
<div class="input-area">
<input type="file" id="fileInput" accept="image/jpeg,image/png,image/gif,image/webp,application/pdf" multiple hidden>
<button id="fileBtn" class="icon-btn" title="Datei anhängen"><i class="fa fa-paperclip"></i></button>
+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;