diff --git a/Endpoints/HistoryEndpoint.cs b/Endpoints/HistoryEndpoint.cs index 99ab069..fa3a348 100644 --- a/Endpoints/HistoryEndpoint.cs +++ b/Endpoints/HistoryEndpoint.cs @@ -34,9 +34,26 @@ public static class HistoryEndpoint } const int messageLimit = 500; + var beforeStr = ctx.Request.Query["beforeId"].FirstOrDefault(); + var beforeId = long.TryParse(beforeStr, out var b) ? b : 0; var totalCount = await db.GetActiveMessageCount(sessionId); - var messages = await db.GetLatestActiveMessages(sessionId, messageLimit); + + List messages; + if (beforeId > 0) + { + messages = await db.GetActiveMessagesBeforeId(sessionId, beforeId, messageLimit); + } + else + { + messages = await db.GetLatestActiveMessages(sessionId, messageLimit); + } + + // Zähle wie viele Nachrichten es VOR der ältesten geladenen noch gibt + var oldestLoadedId = messages.Count > 0 ? messages[0].Id : 0; + var remainingOlder = oldestLoadedId > 0 + ? await db.GetActiveMessageCountBeforeId(sessionId, oldestLoadedId) + : 0; // Nach Chain gruppieren (Reihenfolge beibehalten) var chainIds = messages.Select(m => m.ChainId).Distinct().ToList(); @@ -54,7 +71,8 @@ public static class HistoryEndpoint await ctx.Response.WriteAsJsonAsync(new { entries = result, - hasMore = totalCount > messageLimit, + hasMore = remainingOlder > 0, + remainingCount = remainingOlder, totalCount, loadedCount = messages.Count }); diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index 9e988ea..6a74998 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -514,6 +514,28 @@ public class DatabaseService return messages.Reverse().ToList(); } + public async Task GetActiveMessageCountBeforeId(string sessionId, long beforeId) + { + using var conn = CreateConnection(); + return await conn.ExecuteScalarAsync(@" + SELECT COUNT(*) FROM messages m + JOIN chains c ON m.chain_id = c.chain_id + WHERE m.id < @beforeId AND c.archive_id IS NULL AND c.session_id = @sessionId", + new { sessionId, beforeId }); + } + + public async Task> GetActiveMessagesBeforeId(string sessionId, long beforeId, int limit) + { + using var conn = CreateConnection(); + var messages = await conn.QueryAsync(@" + SELECT m.* FROM messages m + JOIN chains c ON m.chain_id = c.chain_id + WHERE m.id < @beforeId AND c.archive_id IS NULL AND c.session_id = @sessionId + ORDER BY m.id DESC LIMIT @limit", + new { sessionId, beforeId, limit }); + return messages.Reverse().ToList(); + } + public async Task> GetActiveMessagesAfterId(string sessionId, long afterId, int limit = 200) { using var conn = CreateConnection(); diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index 9ea1bcb..76cca49 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -43,8 +43,10 @@ async function apiDeleteAgent(agentId) { return fetch(`/api/agents/${agentId}`, { method: 'DELETE' }); } -async function apiHistory(sessionId) { - const resp = await fetch(`/api/history?sessionId=${sessionId}`); +async function apiHistory(sessionId, beforeId) { + let url = `/api/history?sessionId=${sessionId}`; + if (beforeId) url += `&beforeId=${beforeId}`; + const resp = await fetch(url); return resp.json(); } diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index 7f3df8f..3cf74f1 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -62,6 +62,8 @@ async function clearChat() { chatEl().innerHTML = ''; lastKnownMessageId = 0; loadedArchiveCount = 0; + _oldestLoadedMessageId = 0; + _hasMoreOlderMessages = false; clearReply(); setMeta(`Archiviert: ${archive.chainCount} Chains, ${archive.messageCount} Messages`); diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index 82b0528..7027c03 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -1,26 +1,30 @@ // ── History Loading ── let lastKnownMessageId = 0; +let _oldestLoadedMessageId = 0; +let _hasMoreOlderMessages = false; let historyPollInterval = null; let loadedArchiveCount = 0; async function loadHistory() { try { const sid = getCurrentSessionId(); - const archives = await apiListArchives(sid); - if (archives.length > 0) { - showArchiveLoadButton(); - } const data = await apiHistory(sid); const entries = data.entries || []; + // Älteste geladene Message-ID merken für Nachladen + _hasMoreOlderMessages = data.hasMore; + _oldestLoadedMessageId = _findOldestMessageId(entries); + if (data.hasMore) { - const skipped = data.totalCount - data.loadedCount; - const info = document.createElement('div'); - info.className = 'msg system-info'; - info.innerHTML = ` ${skipped} ältere Nachrichten nicht geladen`; - chatEl().appendChild(info); + showOlderMessagesButton(data.remainingCount); + } else { + // Keine älteren Nachrichten mehr → Archiv-Button anzeigen falls vorhanden + const archives = await apiListArchives(sid); + if (archives.length > 0) { + showArchiveLoadButton(); + } } for (const entry of entries) { @@ -36,6 +40,150 @@ async function loadHistory() { } } +async function loadOlderMessages() { + if (!_hasMoreOlderMessages || !_oldestLoadedMessageId) return; + + const btn = chatEl().querySelector('.older-messages-btn'); + if (btn) { + btn.disabled = true; + btn.innerHTML = ' Wird geladen...'; + } + + try { + const sid = getCurrentSessionId(); + const data = await apiHistory(sid, _oldestLoadedMessageId); + const entries = data.entries || []; + + _hasMoreOlderMessages = data.hasMore; + const newOldest = _findOldestMessageId(entries); + if (newOldest > 0) _oldestLoadedMessageId = newOldest; + + // Button entfernen + removeOlderMessagesButton(); + + // Scroll-Position merken + const chat = chatEl(); + const oldScrollHeight = chat.scrollHeight; + + // Nachrichten oben einfügen (vor dem ersten .msg Element) + const fragment = document.createDocumentFragment(); + for (const entry of entries) { + _renderMessagesIntoFragment(fragment, entry.messages); + if (entry.chain && isChainMetaVisible(entry.chain)) { + const badges = buildMetaBadges(entry.chain); + const metaDiv = document.createElement('div'); + metaDiv.className = 'chain-meta'; + metaDiv.innerHTML = badges.map(b => '' + esc(b) + '').join(''); + fragment.appendChild(metaDiv); + } + } + + // Einfügen: vor dem ersten .msg oder .chain-meta Element + const firstMsg = chat.querySelector('.msg, .chain-meta, .tool-box'); + if (firstMsg) { + chat.insertBefore(fragment, firstMsg); + } else { + chat.appendChild(fragment); + } + + // Neuen Button oder Archiv-Button anzeigen + if (data.hasMore) { + showOlderMessagesButton(data.remainingCount); + } else { + const archives = await apiListArchives(sid); + if (archives.length > 0) { + showArchiveLoadButton(); + } + } + + // Scroll-Position beibehalten + const newScrollHeight = chat.scrollHeight; + chat.scrollTop += (newScrollHeight - oldScrollHeight); + + } catch (e) { + console.error('Ältere Nachrichten laden fehlgeschlagen:', e); + if (_hasMoreOlderMessages) { + showOlderMessagesButton(0); + } + } +} + +function _findOldestMessageId(entries) { + for (const entry of entries) { + if (entry.messages && entry.messages.length > 0) { + return entry.messages[0].id; + } + } + return 0; +} + +function _renderMessagesIntoFragment(fragment, msgs) { + for (const msg of msgs) { + const div = document.createElement('div'); + switch (msg.type) { + case 'text': + if (msg.role === 'user') { + div.className = 'msg user' + (msg.isMarked ? ' marked' : ''); + div.dataset.role = 'user'; + div.dataset.timestamp = msg.createdAt || ''; + if (msg.id) div.dataset.messageId = msg.id; + renderUserContent(div, msg.content); + _addReplyButton(div); + _addMarkButton(div, msg.id, msg.isMarked); + fragment.appendChild(div); + if (msg.attachmentCount > 0) { + let names = []; + try { if (msg.attachmentNames) names = JSON.parse(msg.attachmentNames); } catch {} + const nameList = names.filter(n => n).join(', '); + const ind = document.createElement('div'); + ind.className = 'attachment-indicator'; + ind.innerHTML = ` ${msg.attachmentCount} Datei${msg.attachmentCount > 1 ? 'en' : ''}` + (nameList ? `: ${esc(nameList)}` : ''); + ind.style.cursor = 'pointer'; + ind.title = 'Klicken zum Anzeigen'; + ind.addEventListener('click', () => loadAndShowAttachments(msg.id, ind)); + div.appendChild(ind); + } + } else if (msg.role === 'assistant') { + div.className = 'msg assistant markdown' + (msg.isMarked ? ' marked' : ''); + div.dataset.role = 'assistant'; + div.dataset.timestamp = msg.createdAt || ''; + if (msg.id) div.dataset.messageId = msg.id; + div.innerHTML = renderMarkdown(msg.content); + _addReplyButton(div); + _addMarkButton(div, msg.id, msg.isMarked); + fragment.appendChild(div); + } + break; + case 'tool_use': + if (!isToolVisible(msg.toolName)) continue; + div.className = 'tool-box'; + renderToolBoxContent(div, msg.toolName, msg.content, null, false); + fragment.appendChild(div); + break; + case 'tool_result': { + const prevBoxes = fragment.querySelectorAll('.tool-box'); + const lastToolBox = prevBoxes[prevBoxes.length - 1]; + if (lastToolBox && !lastToolBox.querySelector('.tool-result')) { + const rd = document.createElement('div'); + rd.className = 'tool-result' + (msg.isError ? ' tool-error' : ''); + rd.textContent = truncate(msg.content || '', 300); + rd.hidden = true; + lastToolBox.appendChild(rd); + } + continue; + } + case 'error': + div.className = 'msg system-info'; + div.style.color = 'var(--dangerous)'; + div.textContent = (msg.content || '').trim(); + fragment.appendChild(div); + break; + default: + continue; + } + } +} + function renderMessages(msgs, addToConversation) { for (const msg of msgs) { if (msg.id > lastKnownMessageId) lastKnownMessageId = msg.id; diff --git a/wwwroot/js/pages/chat-page.js b/wwwroot/js/pages/chat-page.js index 6fbbb51..beff8a1 100644 --- a/wwwroot/js/pages/chat-page.js +++ b/wwwroot/js/pages/chat-page.js @@ -239,6 +239,8 @@ registerPage('chat', { messages.length = 0; lastKnownMessageId = 0; loadedArchiveCount = 0; + _oldestLoadedMessageId = 0; + _hasMoreOlderMessages = false; isStreaming = false; await loadHistory(); diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index 2070b53..acb7884 100644 --- a/wwwroot/js/ui.js +++ b/wwwroot/js/ui.js @@ -650,10 +650,26 @@ function setMetaHtml(html) { metaEl().innerHTML = html; } +// ── Older Messages Button ── + +function showOlderMessagesButton(remainingCount) { + removeOlderMessagesButton(); + const btn = document.createElement('button'); + btn.className = 'archive-load-btn older-messages-btn'; + btn.innerHTML = ' ' + remainingCount + ' ältere Nachrichten laden'; + btn.addEventListener('click', () => loadOlderMessages()); + chatEl().prepend(btn); +} + +function removeOlderMessagesButton() { + const btn = chatEl().querySelector('.older-messages-btn'); + if (btn) btn.remove(); +} + // ── Archive Button ── function showArchiveLoadButton() { - const existing = chatEl().querySelector('.archive-load-btn'); + const existing = chatEl().querySelector('.archive-load-btn:not(.older-messages-btn)'); if (existing) existing.remove(); const btn = document.createElement('button'); @@ -661,11 +677,16 @@ function showArchiveLoadButton() { btn.innerHTML = ' Archivierte Nachrichten laden'; btn.addEventListener('click', loadLatestArchive); - // Immer ganz oben im Chat - chatEl().prepend(btn); + // Immer ganz oben im Chat (aber nach dem older-messages-btn falls vorhanden) + const olderBtn = chatEl().querySelector('.older-messages-btn'); + if (olderBtn) { + olderBtn.after(btn); + } else { + chatEl().prepend(btn); + } } function removeArchiveLoadButton() { - const btn = chatEl().querySelector('.archive-load-btn'); - if (btn) btn.remove(); + const btns = chatEl().querySelectorAll('.archive-load-btn:not(.older-messages-btn)'); + btns.forEach(b => b.remove()); }