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;