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:
1 parent
7c0e5d180a
commit
57b4b5931b
6 files changed
+224
-6
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -297,5 +297,6 @@ registerPage('chat', {
|
||||
isStreaming = false;
|
||||
clearPendingFiles();
|
||||
clearPendingRefs();
|
||||
clearReply();
|
||||
}
|
||||
});
|
||||
+62
-2
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user