diff --git a/Endpoints/HistoryEndpoint.cs b/Endpoints/HistoryEndpoint.cs index bd0c1c7..2eebd60 100644 --- a/Endpoints/HistoryEndpoint.cs +++ b/Endpoints/HistoryEndpoint.cs @@ -15,19 +15,20 @@ public static class HistoryEndpoint { app.MapGet("/api/history", HandleHistory); app.MapGet("/api/history/poll", HandlePoll); + app.MapPost("/api/archive", HandleArchive); + app.MapGet("/api/archives", HandleListArchives); + app.MapGet("/api/archive/{archiveId}", HandleGetArchive); } private static async Task HandleHistory(HttpContext ctx, DatabaseService db) { var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault(); - var limitStr = ctx.Request.Query["limit"].FirstOrDefault(); - var limit = int.TryParse(limitStr, out var l) ? l : 50; IEnumerable chains; if (string.IsNullOrEmpty(sessionId) || sessionId == "null") - chains = await db.GetRecentChains(limit); + chains = await db.GetActiveChains(); else - chains = await db.GetSessionChains(sessionId, limit); + chains = await db.GetSessionChains(sessionId); var result = new List(); foreach (var chain in chains) @@ -44,7 +45,44 @@ public static class HistoryEndpoint var afterStr = ctx.Request.Query["afterId"].FirstOrDefault(); var afterId = long.TryParse(afterStr, out var a) ? a : 0; - var messages = await db.GetMessagesAfterId(afterId); + var messages = await db.GetActiveMessagesAfterId(afterId); await ctx.Response.WriteAsJsonAsync(messages); } + + private static async Task HandleArchive(HttpContext ctx, DatabaseService db) + { + var archive = await db.CreateArchive(); + if (archive == null) + { + ctx.Response.StatusCode = 204; + return; + } + await ctx.Response.WriteAsJsonAsync(archive); + } + + private static async Task HandleListArchives(HttpContext ctx, DatabaseService db) + { + var archives = await db.GetArchives(); + await ctx.Response.WriteAsJsonAsync(archives); + } + + private static async Task HandleGetArchive(HttpContext ctx, DatabaseService db) + { + var archiveId = ctx.Request.RouteValues["archiveId"]?.ToString(); + if (string.IsNullOrEmpty(archiveId)) + { + ctx.Response.StatusCode = 400; + return; + } + + var chains = await db.GetArchiveChains(archiveId); + var result = new List(); + foreach (var chain in chains) + { + var messages = await db.GetChainMessages(chain.ChainId); + result.Add(new { chain, messages }); + } + + await ctx.Response.WriteAsJsonAsync(result); + } } diff --git a/Models/ArchiveRecord.cs b/Models/ArchiveRecord.cs new file mode 100644 index 0000000..02f00ce --- /dev/null +++ b/Models/ArchiveRecord.cs @@ -0,0 +1,11 @@ +namespace Gott.Models; + +public class ArchiveRecord +{ + public long Id { get; set; } + public string ArchiveId { get; set; } + public string Name { get; set; } + public int ChainCount { get; set; } + public int MessageCount { get; set; } + public string CreatedAt { get; set; } +} diff --git a/Models/ChainRecord.cs b/Models/ChainRecord.cs index b3f590c..d6ba6de 100644 --- a/Models/ChainRecord.cs +++ b/Models/ChainRecord.cs @@ -12,5 +12,6 @@ public class ChainRecord public int Iterations { get; set; } public string StopReason { get; set; } public long DurationMs { get; set; } + public string ArchiveId { get; set; } public string CreatedAt { get; set; } } diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index a67a376..853bebb 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -28,13 +28,23 @@ public class DatabaseService { using var conn = CreateConnection(); - // Dapper: snake_case → PascalCase Mapping Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; + conn.Execute(@" + CREATE TABLE IF NOT EXISTS archives ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + archive_id TEXT NOT NULL UNIQUE, + name TEXT, + chain_count INTEGER NOT NULL DEFAULT 0, + message_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS chains ( id INTEGER PRIMARY KEY AUTOINCREMENT, chain_id TEXT NOT NULL UNIQUE, session_id TEXT, + archive_id TEXT, model TEXT NOT NULL, system_prompt TEXT, input_tokens INTEGER NOT NULL DEFAULT 0, @@ -60,10 +70,63 @@ public class DatabaseService ); CREATE INDEX IF NOT EXISTS idx_chains_session ON chains(session_id); + CREATE INDEX IF NOT EXISTS idx_chains_archive ON chains(archive_id); CREATE INDEX IF NOT EXISTS idx_messages_chain ON messages(chain_id); "); } + // ── Archives ── + + public async Task CreateArchive(string name = null) + { + using var conn = CreateConnection(); + + // Zähle aktive Chains und Messages + var chainCount = await conn.ExecuteScalarAsync( + "SELECT COUNT(*) FROM chains WHERE archive_id IS NULL"); + if (chainCount == 0) return null; + + var messageCount = await conn.ExecuteScalarAsync(@" + SELECT COUNT(*) FROM messages + WHERE chain_id IN (SELECT chain_id FROM chains WHERE archive_id IS NULL)"); + + var archive = new ArchiveRecord + { + ArchiveId = Guid.NewGuid().ToString("N"), + Name = name, + ChainCount = chainCount, + MessageCount = messageCount, + CreatedAt = DateTime.UtcNow.ToString("o") + }; + + await conn.ExecuteAsync(@" + INSERT INTO archives (archive_id, name, chain_count, message_count, created_at) + VALUES (@ArchiveId, @Name, @ChainCount, @MessageCount, @CreatedAt) + ", archive); + + // Alle aktiven Chains diesem Archiv zuweisen + await conn.ExecuteAsync( + "UPDATE chains SET archive_id = @archiveId WHERE archive_id IS NULL", + new { archiveId = archive.ArchiveId }); + + return archive; + } + + public async Task> GetArchives(int limit = 20) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM archives ORDER BY id DESC LIMIT @limit", new { limit }); + } + + public async Task> GetArchiveChains(string archiveId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM chains WHERE archive_id = @archiveId ORDER BY id", + new { archiveId }); + } + // ── Chains ── public async Task CreateChain(ChainRecord chain) @@ -71,8 +134,8 @@ public class DatabaseService using var conn = CreateConnection(); chain.CreatedAt = DateTime.UtcNow.ToString("o"); await conn.ExecuteAsync(@" - INSERT INTO chains (chain_id, session_id, model, system_prompt, input_tokens, output_tokens, iterations, stop_reason, duration_ms, created_at) - VALUES (@ChainId, @SessionId, @Model, @SystemPrompt, @InputTokens, @OutputTokens, @Iterations, @StopReason, @DurationMs, @CreatedAt) + INSERT INTO chains (chain_id, session_id, archive_id, model, system_prompt, input_tokens, output_tokens, iterations, stop_reason, duration_ms, created_at) + VALUES (@ChainId, @SessionId, @ArchiveId, @Model, @SystemPrompt, @InputTokens, @OutputTokens, @Iterations, @StopReason, @DurationMs, @CreatedAt) ", chain); } @@ -98,21 +161,19 @@ public class DatabaseService ); } - public async Task> GetRecentChains(int limit = 50) + public async Task> GetActiveChains() { using var conn = CreateConnection(); return await conn.QueryAsync( - "SELECT * FROM chains ORDER BY id DESC LIMIT @limit", new { limit } - ); + "SELECT * FROM chains WHERE archive_id IS NULL ORDER BY id DESC"); } - public async Task> GetSessionChains(string sessionId, int limit = 100) + public async Task> GetSessionChains(string sessionId) { using var conn = CreateConnection(); return await conn.QueryAsync( - "SELECT * FROM chains WHERE session_id = @sessionId ORDER BY id DESC LIMIT @limit", - new { sessionId, limit } - ); + "SELECT * FROM chains WHERE session_id = @sessionId AND archive_id IS NULL ORDER BY id DESC", + new { sessionId }); } // ── Messages ── @@ -136,12 +197,14 @@ public class DatabaseService ); } - public async Task> GetMessagesAfterId(long afterId, int limit = 200) + public async Task> GetActiveMessagesAfterId(long afterId, int limit = 200) { using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT * FROM messages WHERE id > @afterId ORDER BY id LIMIT @limit", - new { afterId, limit } - ); + return await conn.QueryAsync(@" + SELECT m.* FROM messages m + JOIN chains c ON m.chain_id = c.chain_id + WHERE m.id > @afterId AND c.archive_id IS NULL + ORDER BY m.id LIMIT @limit", + new { afterId, limit }); } } diff --git a/wwwroot/css/style.css b/wwwroot/css/style.css index 01a2538..214cf9b 100644 --- a/wwwroot/css/style.css +++ b/wwwroot/css/style.css @@ -1,78 +1,71 @@ * { margin: 0; padding: 0; box-sizing: border-box; } body { - font-family: monospace; - background: #1a1a2e; - color: #e0e0e0; + background: #222; + color: #eee; display: flex; flex-direction: column; height: 100vh; } +body, input, select, button, textarea { + font-family: monospace; + font-size: 1em; + line-height: 1.5; + color: #eee; +} + /* ── Header ── */ header { - padding: 12px 20px; - border-bottom: 1px solid #333; + padding: 1em 2em; + border-bottom: 1px solid #444; display: flex; align-items: center; - gap: 12px; + gap: 1em; flex-shrink: 0; } header h1 { - color: #7c83ff; - font-size: 1.2em; + font-size: 1em; } -select { - font-family: monospace; - padding: 6px 10px; - border: 1px solid #333; - border-radius: 4px; - background: #16213e; - color: #e0e0e0; -} - -.clear-btn { - padding: 6px 10px; +button, select, textarea { + padding: .5em; + border: 1px solid #444; background: #333; - color: #aaa; - border: none; - border-radius: 4px; - cursor: pointer; - font-family: monospace; - font-size: 0.85em; + outline: none; } -.clear-btn:hover { background: #444; } +button:hover, select:hover, input:hover, textarea:hover { + border-color: #666; +} + +button { + cursor: pointer; +} + +textarea { + height: 8em; + min-height: 8em; +} .system-toggle { display: flex; align-items: center; - gap: 6px; + gap: .5em; margin-left: auto; } .system-toggle label { - font-size: 0.85em; - color: #888; cursor: pointer; } #systemPrompt { display: none; - width: calc(100% - 40px); - font-family: monospace; - padding: 8px; - border: 1px solid #333; - border-radius: 4px; - background: #0a0a1a; - color: #e0e0e0; - resize: vertical; - font-size: 12px; - min-height: 40px; - margin: 8px 20px 0; + width: calc(100% - 2em); + margin: 1em; + padding: 1em; } #systemPrompt.visible { display: block; } @@ -80,13 +73,11 @@ select { /* ── Meta Bar ── */ .meta-bar { - padding: 4px 20px; - background: #0a0a1a; - border-bottom: 1px solid #222; - font-size: 0.8em; - color: #666; + padding: .5em; + background: #111; + border-bottom: 1px solid #333; flex-shrink: 0; - min-height: 24px; + min-height: 2em; } /* ── Chat ── */ @@ -94,53 +85,47 @@ select { #chat { flex: 1; overflow-y: auto; - padding: 16px 20px; + padding: 1em; display: flex; flex-direction: column; - gap: 12px; + gap: 1em; } .msg { max-width: 85%; - padding: 10px 14px; - border-radius: 8px; + padding: 1em; white-space: pre-wrap; word-break: break-word; - line-height: 1.5; } .msg.user { align-self: flex-end; - background: #16213e; - border: 1px solid #333; + background: #333; + border: 1px solid #444; } .msg.assistant { align-self: flex-start; - background: #0f3460; - border: 1px solid #1a4a7a; + background: #111; + border: 1px solid #333; } .msg.system-info { align-self: center; - background: #1a1a2e; - border: 1px solid #333; - color: #888; - font-size: 0.85em; + border: 1px solid #444; text-align: center; - max-width: 95%; } /* ── Cursor ── */ .msg .cursor { display: inline-block; - width: 2px; + width: .125em; height: 1em; - background: #7c83ff; + background: #fff; animation: blink 0.8s infinite; vertical-align: text-bottom; - margin-left: 1px; + margin-left: .125em; } @keyframes blink { @@ -152,55 +137,44 @@ select { .tool-box { align-self: center; - max-width: 95%; - padding: 8px 12px; - border-radius: 6px; - background: #0a1e3d; - border: 1px solid #1a3a5c; - font-size: 0.85em; + padding: .5em; + background: #333; + border: 1px solid #444; } -.tool-name { color: #7c83ff; font-weight: bold; } -.tool-input { color: #888; margin-left: 6px; font-size: 0.85em; } -.tool-result { color: #6bff8a; font-size: 0.8em; margin-top: 4px; max-height: 100px; overflow-y: auto; } -.tool-error { color: #ff6b6b; } -.tool-spinner { color: #7c83ff; } - /* ── Input Area ── */ .input-area { - padding: 12px 20px; border-top: 1px solid #333; display: flex; - gap: 8px; flex-shrink: 0; + gap: 1em; + padding: 1em; } .input-area textarea { flex: 1; - font-family: monospace; - padding: 10px; - border: 1px solid #333; - border-radius: 4px; - background: #0f3460; - color: #e0e0e0; - resize: none; - font-size: 14px; - min-height: 60px; - max-height: 150px; } .input-area button { - padding: 10px 20px; - background: #7c83ff; - color: #fff; - border: none; - border-radius: 4px; - font-family: monospace; - font-weight: bold; - cursor: pointer; align-self: flex-end; } -.input-area button:hover { background: #5a63e0; } .input-area button:disabled { opacity: 0.5; cursor: not-allowed; } + +/* ── Archive ── */ + +.archive-container { + display: flex; + flex-direction: column; + gap: 1em; +} + +.archived { opacity: 0.6; } + +/* ── Archive Button ── */ + +.archive-load-btn { + display: block; + margin-inline: auto; +} diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index c55798d..b1eba09 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -8,8 +8,8 @@ async function apiChat(model, messages, systemPrompt) { }); } -async function apiHistory(sessionId = 'null', limit = 50) { - const resp = await fetch(`/api/history?sessionId=${sessionId}&limit=${limit}`); +async function apiHistory(sessionId = 'null') { + const resp = await fetch(`/api/history?sessionId=${sessionId}`); return resp.json(); } @@ -17,3 +17,19 @@ async function apiHistoryPoll(afterId) { const resp = await fetch(`/api/history/poll?afterId=${afterId}`); return resp.json(); } + +async function apiArchive() { + const resp = await fetch('/api/archive', { method: 'POST' }); + if (resp.status === 204) return null; + return resp.json(); +} + +async function apiListArchives() { + const resp = await fetch('/api/archives'); + return resp.json(); +} + +async function apiGetArchive(archiveId) { + const resp = await fetch(`/api/archive/${archiveId}`); + return resp.json(); +} diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index 41656b1..c1f7456 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -3,10 +3,18 @@ const messages = []; let isStreaming = false; -function clearChat() { +async function clearChat() { + const archive = await apiArchive(); messages.length = 0; chatEl().innerHTML = ''; - setMeta(''); + lastKnownMessageId = 0; + + if (archive) { + setMeta(`Archiviert: ${archive.chainCount} Chains, ${archive.messageCount} Messages`); + showArchiveLoadButton(); + } else { + setMeta(''); + } } // ── Send Message ── @@ -20,7 +28,7 @@ async function sendMessage() { if (!text) return; messages.push({ role: 'user', content: text }); - addUserBubble(text); + addUserBubble(text, true); input.value = ''; input.style.height = 'auto'; @@ -96,10 +104,6 @@ async function sendMessage() { } } - if (fullText) { - messages.push({ role: 'assistant', content: fullText }); - } - } catch (e) { endStreamBubble(); addErrorBubble('Netzwerk-Fehler: ' + e.message); @@ -107,5 +111,9 @@ async function sendMessage() { btn.disabled = false; isStreaming = false; endStreamBubble(); + + // Erst neue aus DB laden, dann gestreamte Elemente ersetzen + await pollHistory(); + removeStreamedElements(); } } diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index 5f942b9..6d0fffb 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -2,11 +2,17 @@ let lastKnownMessageId = 0; let historyPollInterval = null; +let loadedArchiveCount = 0; async function loadHistory() { try { + const archives = await apiListArchives(); + if (archives.length > 0) { + showArchiveLoadButton(); + } + const data = await apiHistory('null'); - data.reverse(); // DESC → chronologisch + data.reverse(); for (const entry of data) { renderMessages(entry.messages, true); @@ -49,6 +55,96 @@ function renderMessages(msgs, addToConversation) { } } +// ── Archive Loading ── + +function getOrCreateArchiveContainer() { + let container = chatEl().querySelector('.archive-container'); + if (!container) { + container = document.createElement('div'); + container.className = 'archive-container'; + chatEl().prepend(container); + } + return container; +} + +function getOrCreateArchiveDivider() { + let divider = chatEl().querySelector('.archive-divider'); + if (!divider) { + divider = document.createElement('div'); + divider.className = 'msg system-info archive-divider'; + divider.textContent = '── Aktuelle Konversation ──'; + // Nach dem Container, vor den aktiven Messages + const container = getOrCreateArchiveContainer(); + container.after(divider); + } + return divider; +} + +async function loadLatestArchive() { + try { + const archives = await apiListArchives(); + if (archives.length <= loadedArchiveCount) return; + + const latest = archives[loadedArchiveCount]; + loadedArchiveCount++; + + removeArchiveLoadButton(); + + const data = await apiGetArchive(latest.archiveId); + const container = getOrCreateArchiveContainer(); + getOrCreateArchiveDivider(); + + // Archiv-Block zusammenbauen + const fragment = document.createDocumentFragment(); + + const sep = document.createElement('div'); + sep.className = 'msg system-info'; + sep.textContent = `📦 Archiv vom ${new Date(latest.createdAt).toLocaleString('de-AT')} (${latest.chainCount} Chains, ${latest.messageCount} Messages)`; + fragment.appendChild(sep); + + for (const entry of data) { + for (const msg of entry.messages) { + const div = document.createElement('div'); + switch (msg.type) { + case 'text': + div.className = msg.role === 'user' ? 'msg user archived' : 'msg assistant archived'; + div.textContent = msg.content; + break; + case 'tool_use': + div.className = 'tool-box archived'; + div.innerHTML = '' + esc(msg.toolName) + '' + + '' + esc(truncate(msg.content, 120)) + ''; + break; + case 'tool_result': + div.className = 'tool-box archived'; + div.innerHTML = '
' + + esc(truncate(msg.content, 300)) + '
'; + break; + case 'error': + div.className = 'msg system-info archived'; + div.style.color = '#ff6b6b'; + div.textContent = msg.content; + break; + default: + continue; + } + fragment.appendChild(div); + } + } + + // Älteres Archiv vor dem bisherigen Inhalt einfügen + container.prepend(fragment); + + // Button ganz oben, falls es noch ältere Archive gibt + if (archives.length > loadedArchiveCount) { + showArchiveLoadButton(); + } + + } catch (e) { + console.error('Archiv laden fehlgeschlagen:', e); + } +} + // ── Polling ── function startHistoryPolling(intervalMs = 3000) { diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index 2136379..281f627 100644 --- a/wwwroot/js/ui.js +++ b/wwwroot/js/ui.js @@ -21,9 +21,10 @@ function truncate(s, max) { // ── Message Bubbles ── -function addUserBubble(text) { +function addUserBubble(text, streamed = false) { const div = document.createElement('div'); div.className = 'msg user'; + if (streamed) div.classList.add('streamed'); div.textContent = text; chatEl().appendChild(div); scrollDown(); @@ -54,7 +55,7 @@ let _streamText = ''; function startStreamBubble() { _streamDiv = document.createElement('div'); - _streamDiv.className = 'msg assistant'; + _streamDiv.className = 'msg assistant streamed'; _streamDiv.innerHTML = ''; _streamText = ''; chatEl().appendChild(_streamDiv); @@ -85,7 +86,7 @@ function endStreamBubble() { function addToolBoxLoading(name) { const div = document.createElement('div'); - div.className = 'tool-box'; + div.className = 'tool-box streamed'; div.innerHTML = '' + esc(name) + ' ⏳'; chatEl().appendChild(div); scrollDown(); @@ -114,6 +115,12 @@ function addToolBoxStatic(name, input, result, isError) { scrollDown(); } +// ── Streamed Elements ── + +function removeStreamedElements() { + chatEl().querySelectorAll('.streamed').forEach(el => el.remove()); +} + // ── Meta Bar ── function setMeta(text) { @@ -123,3 +130,23 @@ function setMeta(text) { function setMetaHtml(html) { metaEl().innerHTML = html; } + +// ── Archive Button ── + +function showArchiveLoadButton() { + const existing = chatEl().querySelector('.archive-load-btn'); + if (existing) existing.remove(); + + const btn = document.createElement('button'); + btn.className = 'archive-load-btn'; + btn.textContent = '📦 Archivierte Nachrichten laden'; + btn.addEventListener('click', loadLatestArchive); + + // Immer ganz oben im Chat + chatEl().prepend(btn); +} + +function removeArchiveLoadButton() { + const btn = chatEl().querySelector('.archive-load-btn'); + if (btn) btn.remove(); +}