diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css
index b524ad9..cedcb20 100644
--- a/wwwroot/css/chat.css
+++ b/wwwroot/css/chat.css
@@ -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 {
diff --git a/wwwroot/index.html b/wwwroot/index.html
index ba64dd5..fad370d 100644
--- a/wwwroot/index.html
+++ b/wwwroot/index.html
@@ -163,6 +163,7 @@
diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js
index 034f660..8860c9d 100644
--- a/wwwroot/js/chat.js
+++ b/wwwroot/js/chat.js
@@ -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 = `
+
+ ${esc(timeLabel)}, ${esc(roleLabel)}
+ ${esc(preview)}
+
+
`;
+ container.hidden = false;
+}
diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js
index 6f1297a..edcb361 100644
--- a/wwwroot/js/history.js
+++ b/wwwroot/js/history.js
@@ -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;
diff --git a/wwwroot/js/pages/chat-page.js b/wwwroot/js/pages/chat-page.js
index 2ab2550..aa82fba 100644
--- a/wwwroot/js/pages/chat-page.js
+++ b/wwwroot/js/pages/chat-page.js
@@ -297,5 +297,6 @@ registerPage('chat', {
isStreaming = false;
clearPendingFiles();
clearPendingRefs();
+ clearReply();
}
});
diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js
index b7f38cc..8174b75 100644
--- a/wwwroot/js/ui.js
+++ b/wwwroot/js/ui.js
@@ -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 = '
';
+ 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 = '
' + 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;