feat: Ältere Nachrichten nachladen per Button (500er Batches)
- Backend: beforeId-Parameter + GetActiveMessagesBeforeId + GetActiveMessageCountBeforeId - Frontend: Button 'X ältere Nachrichten laden' statt Info-Text - Archiv-Button erscheint erst wenn alle älteren Nachrichten geladen - Scroll-Position bleibt beim Nachladen erhalten - Fix: wwwroot Quell-Dateien mit publish synchronisiert
This commit is contained in:
1 parent
38bdb4408c
commit
575e5c1d44
7 files changed
+233
-18
No files matched your search
@@ -34,9 +34,26 @@ public static class HistoryEndpoint
|
|||||||
}
|
}
|
||||||
|
|
||||||
const int messageLimit = 500;
|
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 totalCount = await db.GetActiveMessageCount(sessionId);
|
||||||
var messages = await db.GetLatestActiveMessages(sessionId, messageLimit);
|
|
||||||
|
List<MessageRecord> 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)
|
// Nach Chain gruppieren (Reihenfolge beibehalten)
|
||||||
var chainIds = messages.Select(m => m.ChainId).Distinct().ToList();
|
var chainIds = messages.Select(m => m.ChainId).Distinct().ToList();
|
||||||
@@ -54,7 +71,8 @@ public static class HistoryEndpoint
|
|||||||
await ctx.Response.WriteAsJsonAsync(new
|
await ctx.Response.WriteAsJsonAsync(new
|
||||||
{
|
{
|
||||||
entries = result,
|
entries = result,
|
||||||
hasMore = totalCount > messageLimit,
|
hasMore = remainingOlder > 0,
|
||||||
|
remainingCount = remainingOlder,
|
||||||
totalCount,
|
totalCount,
|
||||||
loadedCount = messages.Count
|
loadedCount = messages.Count
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -514,6 +514,28 @@ public class DatabaseService
|
|||||||
return messages.Reverse().ToList();
|
return messages.Reverse().ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetActiveMessageCountBeforeId(string sessionId, long beforeId)
|
||||||
|
{
|
||||||
|
using var conn = CreateConnection();
|
||||||
|
return await conn.ExecuteScalarAsync<int>(@"
|
||||||
|
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<List<MessageRecord>> GetActiveMessagesBeforeId(string sessionId, long beforeId, int limit)
|
||||||
|
{
|
||||||
|
using var conn = CreateConnection();
|
||||||
|
var messages = await conn.QueryAsync<MessageRecord>(@"
|
||||||
|
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<IEnumerable<MessageRecord>> GetActiveMessagesAfterId(string sessionId, long afterId, int limit = 200)
|
public async Task<IEnumerable<MessageRecord>> GetActiveMessagesAfterId(string sessionId, long afterId, int limit = 200)
|
||||||
{
|
{
|
||||||
using var conn = CreateConnection();
|
using var conn = CreateConnection();
|
||||||
|
|||||||
+4
-2
@@ -43,8 +43,10 @@ async function apiDeleteAgent(agentId) {
|
|||||||
return fetch(`/api/agents/${agentId}`, { method: 'DELETE' });
|
return fetch(`/api/agents/${agentId}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiHistory(sessionId) {
|
async function apiHistory(sessionId, beforeId) {
|
||||||
const resp = await fetch(`/api/history?sessionId=${sessionId}`);
|
let url = `/api/history?sessionId=${sessionId}`;
|
||||||
|
if (beforeId) url += `&beforeId=${beforeId}`;
|
||||||
|
const resp = await fetch(url);
|
||||||
return resp.json();
|
return resp.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ async function clearChat() {
|
|||||||
chatEl().innerHTML = '';
|
chatEl().innerHTML = '';
|
||||||
lastKnownMessageId = 0;
|
lastKnownMessageId = 0;
|
||||||
loadedArchiveCount = 0;
|
loadedArchiveCount = 0;
|
||||||
|
_oldestLoadedMessageId = 0;
|
||||||
|
_hasMoreOlderMessages = false;
|
||||||
clearReply();
|
clearReply();
|
||||||
|
|
||||||
setMeta(`Archiviert: ${archive.chainCount} Chains, ${archive.messageCount} Messages`);
|
setMeta(`Archiviert: ${archive.chainCount} Chains, ${archive.messageCount} Messages`);
|
||||||
|
|||||||
+157
-9
@@ -1,26 +1,30 @@
|
|||||||
// ── History Loading ──
|
// ── History Loading ──
|
||||||
|
|
||||||
let lastKnownMessageId = 0;
|
let lastKnownMessageId = 0;
|
||||||
|
let _oldestLoadedMessageId = 0;
|
||||||
|
let _hasMoreOlderMessages = false;
|
||||||
let historyPollInterval = null;
|
let historyPollInterval = null;
|
||||||
let loadedArchiveCount = 0;
|
let loadedArchiveCount = 0;
|
||||||
|
|
||||||
async function loadHistory() {
|
async function loadHistory() {
|
||||||
try {
|
try {
|
||||||
const sid = getCurrentSessionId();
|
const sid = getCurrentSessionId();
|
||||||
const archives = await apiListArchives(sid);
|
|
||||||
if (archives.length > 0) {
|
|
||||||
showArchiveLoadButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await apiHistory(sid);
|
const data = await apiHistory(sid);
|
||||||
const entries = data.entries || [];
|
const entries = data.entries || [];
|
||||||
|
|
||||||
|
// Älteste geladene Message-ID merken für Nachladen
|
||||||
|
_hasMoreOlderMessages = data.hasMore;
|
||||||
|
_oldestLoadedMessageId = _findOldestMessageId(entries);
|
||||||
|
|
||||||
if (data.hasMore) {
|
if (data.hasMore) {
|
||||||
const skipped = data.totalCount - data.loadedCount;
|
showOlderMessagesButton(data.remainingCount);
|
||||||
const info = document.createElement('div');
|
} else {
|
||||||
info.className = 'msg system-info';
|
// Keine älteren Nachrichten mehr → Archiv-Button anzeigen falls vorhanden
|
||||||
info.innerHTML = `<i class="fa fa-info-circle"></i> ${skipped} ältere Nachrichten nicht geladen`;
|
const archives = await apiListArchives(sid);
|
||||||
chatEl().appendChild(info);
|
if (archives.length > 0) {
|
||||||
|
showArchiveLoadButton();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
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 = '<i class="fa fa-hourglass-half"></i> 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 => '<span class="chain-meta-badge">' + esc(b) + '</span>').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 = `<i class="fa fa-paperclip"></i> ${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) {
|
function renderMessages(msgs, addToConversation) {
|
||||||
for (const msg of msgs) {
|
for (const msg of msgs) {
|
||||||
if (msg.id > lastKnownMessageId) lastKnownMessageId = msg.id;
|
if (msg.id > lastKnownMessageId) lastKnownMessageId = msg.id;
|
||||||
|
|||||||
@@ -239,6 +239,8 @@ registerPage('chat', {
|
|||||||
messages.length = 0;
|
messages.length = 0;
|
||||||
lastKnownMessageId = 0;
|
lastKnownMessageId = 0;
|
||||||
loadedArchiveCount = 0;
|
loadedArchiveCount = 0;
|
||||||
|
_oldestLoadedMessageId = 0;
|
||||||
|
_hasMoreOlderMessages = false;
|
||||||
isStreaming = false;
|
isStreaming = false;
|
||||||
|
|
||||||
await loadHistory();
|
await loadHistory();
|
||||||
|
|||||||
+26
-5
@@ -650,10 +650,26 @@ function setMetaHtml(html) {
|
|||||||
metaEl().innerHTML = 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 = '<i class="fa fa-arrow-up"></i> ' + 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 ──
|
// ── Archive Button ──
|
||||||
|
|
||||||
function showArchiveLoadButton() {
|
function showArchiveLoadButton() {
|
||||||
const existing = chatEl().querySelector('.archive-load-btn');
|
const existing = chatEl().querySelector('.archive-load-btn:not(.older-messages-btn)');
|
||||||
if (existing) existing.remove();
|
if (existing) existing.remove();
|
||||||
|
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
@@ -661,11 +677,16 @@ function showArchiveLoadButton() {
|
|||||||
btn.innerHTML = '<i class="fa fa-archive"></i> Archivierte Nachrichten laden';
|
btn.innerHTML = '<i class="fa fa-archive"></i> Archivierte Nachrichten laden';
|
||||||
btn.addEventListener('click', loadLatestArchive);
|
btn.addEventListener('click', loadLatestArchive);
|
||||||
|
|
||||||
// Immer ganz oben im Chat
|
// Immer ganz oben im Chat (aber nach dem older-messages-btn falls vorhanden)
|
||||||
chatEl().prepend(btn);
|
const olderBtn = chatEl().querySelector('.older-messages-btn');
|
||||||
|
if (olderBtn) {
|
||||||
|
olderBtn.after(btn);
|
||||||
|
} else {
|
||||||
|
chatEl().prepend(btn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeArchiveLoadButton() {
|
function removeArchiveLoadButton() {
|
||||||
const btn = chatEl().querySelector('.archive-load-btn');
|
const btns = chatEl().querySelectorAll('.archive-load-btn:not(.older-messages-btn)');
|
||||||
if (btn) btn.remove();
|
btns.forEach(b => b.remove());
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user