diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index eb54ded..0df4d8f 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -50,16 +50,27 @@ public static class ChatEndpoint res.Headers["Cache-Control"] = "no-cache"; res.Headers["Connection"] = "keep-alive"; + var sessionId = body.SessionId; + if (string.IsNullOrEmpty(sessionId)) + { + res.StatusCode = 400; + await res.WriteAsJsonAsync(new { error = "sessionId required" }); + return; + } + var chainId = Guid.NewGuid().ToString("N"); - // Chain zuerst anlegen (FK für messages) await db.CreateChain(new ChainRecord { ChainId = chainId, + SessionId = sessionId, Model = modelId, SystemPrompt = body.SystemPrompt }); + // Session-Timestamp aktualisieren + await db.UpdateSessionTimestamp(sessionId); + var messages = new List(); foreach (var msg in body.Messages) messages.Add(new { role = msg.Role, content = msg.Content }); diff --git a/Endpoints/HistoryEndpoint.cs b/Endpoints/HistoryEndpoint.cs index 2eebd60..dcfd933 100644 --- a/Endpoints/HistoryEndpoint.cs +++ b/Endpoints/HistoryEndpoint.cs @@ -23,12 +23,14 @@ public static class HistoryEndpoint private static async Task HandleHistory(HttpContext ctx, DatabaseService db) { var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault(); + if (string.IsNullOrEmpty(sessionId)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "sessionId required" }); + return; + } - IEnumerable chains; - if (string.IsNullOrEmpty(sessionId) || sessionId == "null") - chains = await db.GetActiveChains(); - else - chains = await db.GetSessionChains(sessionId); + var chains = await db.GetActiveChains(sessionId); var result = new List(); foreach (var chain in chains) @@ -42,16 +44,30 @@ public static class HistoryEndpoint private static async Task HandlePoll(HttpContext ctx, DatabaseService db) { + var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault(); var afterStr = ctx.Request.Query["afterId"].FirstOrDefault(); var afterId = long.TryParse(afterStr, out var a) ? a : 0; - var messages = await db.GetActiveMessagesAfterId(afterId); + if (string.IsNullOrEmpty(sessionId)) + { + ctx.Response.StatusCode = 400; + return; + } + + var messages = await db.GetActiveMessagesAfterId(sessionId, afterId); await ctx.Response.WriteAsJsonAsync(messages); } private static async Task HandleArchive(HttpContext ctx, DatabaseService db) { - var archive = await db.CreateArchive(); + var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault(); + if (string.IsNullOrEmpty(sessionId)) + { + ctx.Response.StatusCode = 400; + return; + } + + var archive = await db.CreateArchive(sessionId); if (archive == null) { ctx.Response.StatusCode = 204; @@ -62,7 +78,14 @@ public static class HistoryEndpoint private static async Task HandleListArchives(HttpContext ctx, DatabaseService db) { - var archives = await db.GetArchives(); + var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault(); + if (string.IsNullOrEmpty(sessionId)) + { + ctx.Response.StatusCode = 400; + return; + } + + var archives = await db.GetArchives(sessionId); await ctx.Response.WriteAsJsonAsync(archives); } diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs new file mode 100644 index 0000000..a807ec7 --- /dev/null +++ b/Endpoints/SessionEndpoint.cs @@ -0,0 +1,50 @@ +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Gott.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Gott.Endpoints; + +public static class SessionEndpoint +{ + public static void MapSessionEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/sessions", HandleList); + app.MapPost("/api/sessions", HandleCreate); + app.MapPut("/api/sessions/{sessionId}", HandleUpdate); + } + + private static async Task HandleList(HttpContext ctx, DatabaseService db) + { + var sessions = await db.GetSessions(); + await ctx.Response.WriteAsJsonAsync(sessions); + } + + private static async Task HandleCreate(HttpContext ctx, DatabaseService db) + { + var session = await db.CreateSession("Neue Session"); + await ctx.Response.WriteAsJsonAsync(session); + } + + private static async Task HandleUpdate(HttpContext ctx, DatabaseService db) + { + var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString(); + if (string.IsNullOrEmpty(sessionId)) + { + ctx.Response.StatusCode = 400; + return; + } + + var body = await JsonSerializer.DeserializeAsync(ctx.Request.Body); + if (body.TryGetProperty("name", out var nameEl)) + { + await db.UpdateSessionName(sessionId, nameEl.GetString()); + } + + var session = await db.GetSession(sessionId); + await ctx.Response.WriteAsJsonAsync(session); + } +} diff --git a/Models/ArchiveRecord.cs b/Models/ArchiveRecord.cs index 02f00ce..2e66f1e 100644 --- a/Models/ArchiveRecord.cs +++ b/Models/ArchiveRecord.cs @@ -4,6 +4,7 @@ public class ArchiveRecord { public long Id { get; set; } public string ArchiveId { get; set; } + public string SessionId { get; set; } public string Name { get; set; } public int ChainCount { get; set; } public int MessageCount { get; set; } diff --git a/Models/ChatModels.cs b/Models/ChatModels.cs index 1319178..545147e 100644 --- a/Models/ChatModels.cs +++ b/Models/ChatModels.cs @@ -22,4 +22,7 @@ public class ChatRequest [JsonPropertyName("systemPrompt")] public string SystemPrompt { get; set; } = ""; + + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = ""; } diff --git a/Models/SessionRecord.cs b/Models/SessionRecord.cs new file mode 100644 index 0000000..cdf2e68 --- /dev/null +++ b/Models/SessionRecord.cs @@ -0,0 +1,10 @@ +namespace Gott.Models; + +public class SessionRecord +{ + public long Id { get; set; } + public string SessionId { get; set; } + public string Name { get; set; } + public string CreatedAt { get; set; } + public string UpdatedAt { get; set; } +} diff --git a/Program.cs b/Program.cs index 32029f7..2167ef2 100644 --- a/Program.cs +++ b/Program.cs @@ -21,5 +21,6 @@ app.UseStaticFiles(); app.MapChatEndpoint(); app.MapHistoryEndpoint(); +app.MapSessionEndpoints(); app.Run("http://0.0.0.0:19000"); diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index 853bebb..9abf314 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -31,9 +31,18 @@ public class DatabaseService Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; conn.Execute(@" + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL UNIQUE, + name TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS archives ( id INTEGER PRIMARY KEY AUTOINCREMENT, archive_id TEXT NOT NULL UNIQUE, + session_id TEXT NOT NULL, name TEXT, chain_count INTEGER NOT NULL DEFAULT 0, message_count INTEGER NOT NULL DEFAULT 0, @@ -75,24 +84,76 @@ public class DatabaseService "); } + // ── Sessions ── + + public async Task CreateSession(string name = null) + { + using var conn = CreateConnection(); + var now = DateTime.UtcNow.ToString("o"); + var session = new SessionRecord + { + SessionId = Guid.NewGuid().ToString("N"), + Name = name, + CreatedAt = now, + UpdatedAt = now + }; + await conn.ExecuteAsync(@" + INSERT INTO sessions (session_id, name, created_at, updated_at) + VALUES (@SessionId, @Name, @CreatedAt, @UpdatedAt) + ", session); + return session; + } + + public async Task> GetSessions() + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM sessions ORDER BY updated_at DESC"); + } + + public async Task GetSession(string sessionId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM sessions WHERE session_id = @sessionId", new { sessionId }); + } + + public async Task UpdateSessionTimestamp(string sessionId) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "UPDATE sessions SET updated_at = @now WHERE session_id = @sessionId", + new { sessionId, now = DateTime.UtcNow.ToString("o") }); + } + + public async Task UpdateSessionName(string sessionId, string name) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "UPDATE sessions SET name = @name, updated_at = @now WHERE session_id = @sessionId", + new { sessionId, name, now = DateTime.UtcNow.ToString("o") }); + } + // ── Archives ── - public async Task CreateArchive(string name = null) + public async Task CreateArchive(string sessionId, 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"); + "SELECT COUNT(*) FROM chains WHERE archive_id IS NULL AND session_id = @sessionId", + new { sessionId }); 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)"); + WHERE chain_id IN (SELECT chain_id FROM chains WHERE archive_id IS NULL AND session_id = @sessionId)", + new { sessionId }); var archive = new ArchiveRecord { ArchiveId = Guid.NewGuid().ToString("N"), + SessionId = sessionId, Name = name, ChainCount = chainCount, MessageCount = messageCount, @@ -100,23 +161,23 @@ public class DatabaseService }; await conn.ExecuteAsync(@" - INSERT INTO archives (archive_id, name, chain_count, message_count, created_at) - VALUES (@ArchiveId, @Name, @ChainCount, @MessageCount, @CreatedAt) + INSERT INTO archives (archive_id, session_id, name, chain_count, message_count, created_at) + VALUES (@ArchiveId, @SessionId, @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 }); + "UPDATE chains SET archive_id = @archiveId WHERE archive_id IS NULL AND session_id = @sessionId", + new { archiveId = archive.ArchiveId, sessionId }); return archive; } - public async Task> GetArchives(int limit = 20) + public async Task> GetArchives(string sessionId) { using var conn = CreateConnection(); return await conn.QueryAsync( - "SELECT * FROM archives ORDER BY id DESC LIMIT @limit", new { limit }); + "SELECT * FROM archives WHERE session_id = @sessionId ORDER BY id DESC", + new { sessionId }); } public async Task> GetArchiveChains(string archiveId) @@ -161,14 +222,7 @@ public class DatabaseService ); } - public async Task> GetActiveChains() - { - using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT * FROM chains WHERE archive_id IS NULL ORDER BY id DESC"); - } - - public async Task> GetSessionChains(string sessionId) + public async Task> GetActiveChains(string sessionId) { using var conn = CreateConnection(); return await conn.QueryAsync( @@ -197,14 +251,14 @@ public class DatabaseService ); } - public async Task> GetActiveMessagesAfterId(long afterId, int limit = 200) + public async Task> GetActiveMessagesAfterId(string sessionId, long afterId, int limit = 200) { using var conn = CreateConnection(); 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 + WHERE m.id > @afterId AND c.archive_id IS NULL AND c.session_id = @sessionId ORDER BY m.id LIMIT @limit", - new { afterId, limit }); + new { sessionId, afterId, limit }); } } diff --git a/wwwroot/css/style.css b/wwwroot/css/style.css index 7aa35f0..2befe50 100644 --- a/wwwroot/css/style.css +++ b/wwwroot/css/style.css @@ -4,10 +4,16 @@ body { background: #222; color: #eee; display: flex; - flex-direction: column; height: 100vh; } +main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + body, input, select, button, textarea { font-family: monospace; font-size: 1em; @@ -28,6 +34,7 @@ button:hover, select:hover, input:hover, textarea:hover { button { cursor: pointer; + line-height: 1.25; } textarea { @@ -46,6 +53,68 @@ textarea { cursor: pointer; } +/* ── Sidebar ── */ + +#sidebar { + width: 260px; + background: #1a1a1a; + border-right: 1px solid #444; + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow: hidden; +} + +.sidebar-header { + padding: 1em; + border-bottom: 1px solid #444; + display: flex; + align-items: center; + justify-content: space-between; +} + +.sidebar-header h2 { + font-size: 1em; +} + +#sessionList { + flex: 1; + overflow-y: auto; +} + +.session-item { + padding: 1em; + cursor: pointer; + display: flex; + flex-direction: column; + border-left: 3px solid transparent; +} + +.session-item:hover { background: #333; } + +.session-item.active { + background: #333; + border-left-color: #eee; +} + +.session-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border: 1px solid transparent; +} + +.session-date { + color: #888; +} + +.session-rename { + background: #222; + border: 1px solid #888; + outline: none; + width: 100%; +} + /* ── Header ── */ header { @@ -79,7 +148,7 @@ header h1 { background: #111; border-bottom: 1px solid #333; flex-shrink: 0; - min-height: 2em; + min-height: 2.5em; } /* ── Chat ── */ diff --git a/wwwroot/index.html b/wwwroot/index.html index 775a39c..514388d 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -8,30 +8,41 @@ -
-

// gott

- - -
- - +
+
+ - -
-
+
+
+

// gott

+ + +
+ + +
+
-
- - -
+ +
+
+ +
+ + +
+
+ diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index b1eba09..18bda82 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -1,31 +1,31 @@ // ── API Calls ── -async function apiChat(model, messages, systemPrompt) { +async function apiChat(model, messages, systemPrompt, sessionId) { return fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model, messages, systemPrompt }) + body: JSON.stringify({ model, messages, systemPrompt, sessionId }) }); } -async function apiHistory(sessionId = 'null') { +async function apiHistory(sessionId) { const resp = await fetch(`/api/history?sessionId=${sessionId}`); return resp.json(); } -async function apiHistoryPoll(afterId) { - const resp = await fetch(`/api/history/poll?afterId=${afterId}`); +async function apiHistoryPoll(sessionId, afterId) { + const resp = await fetch(`/api/history/poll?sessionId=${sessionId}&afterId=${afterId}`); return resp.json(); } -async function apiArchive() { - const resp = await fetch('/api/archive', { method: 'POST' }); +async function apiArchive(sessionId) { + const resp = await fetch(`/api/archive?sessionId=${sessionId}`, { method: 'POST' }); if (resp.status === 204) return null; return resp.json(); } -async function apiListArchives() { - const resp = await fetch('/api/archives'); +async function apiListArchives(sessionId) { + const resp = await fetch(`/api/archives?sessionId=${sessionId}`); return resp.json(); } @@ -33,3 +33,24 @@ async function apiGetArchive(archiveId) { const resp = await fetch(`/api/archive/${archiveId}`); return resp.json(); } + +// ── Session API ── + +async function apiGetSessions() { + const resp = await fetch('/api/sessions'); + return resp.json(); +} + +async function apiCreateSession() { + const resp = await fetch('/api/sessions', { method: 'POST' }); + return resp.json(); +} + +async function apiUpdateSession(sessionId, data) { + const resp = await fetch(`/api/sessions/${sessionId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + return resp.json(); +} diff --git a/wwwroot/js/app.js b/wwwroot/js/app.js index 2694ffc..4f391b8 100644 --- a/wwwroot/js/app.js +++ b/wwwroot/js/app.js @@ -1,13 +1,18 @@ // ── App Init ── -document.addEventListener('DOMContentLoaded', () => { +document.addEventListener('DOMContentLoaded', async () => { + // Session initialisieren (vor allem anderen) + await initSession(); + // System Prompt Toggle $('#showSystem').addEventListener('change', () => { $('#systemPrompt').classList.toggle('visible', $('#showSystem').checked); }); - // Send Button + // Buttons $('#send').addEventListener('click', sendMessage); + $('#clearBtn').addEventListener('click', clearChat); + $('#newSessionBtn').addEventListener('click', createNewSession); // Ctrl+Enter $('#input').addEventListener('keydown', (e) => { @@ -20,9 +25,6 @@ document.addEventListener('DOMContentLoaded', () => { this.style.height = Math.min(this.scrollHeight, 150) + 'px'; }); - // Clear Button - $('#clearBtn').addEventListener('click', clearChat); - // History laden - loadHistory(); + await loadHistory(); }); diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index c1f7456..8c48f3a 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -4,17 +4,16 @@ const messages = []; let isStreaming = false; async function clearChat() { - const archive = await apiArchive(); + const archive = await apiArchive(getCurrentSessionId()); + if (!archive) return; + messages.length = 0; chatEl().innerHTML = ''; lastKnownMessageId = 0; + loadedArchiveCount = 0; - if (archive) { - setMeta(`Archiviert: ${archive.chainCount} Chains, ${archive.messageCount} Messages`); - showArchiveLoadButton(); - } else { - setMeta(''); - } + setMeta(`Archiviert: ${archive.chainCount} Chains, ${archive.messageCount} Messages`); + showArchiveLoadButton(); } // ── Send Message ── @@ -40,7 +39,7 @@ async function sendMessage() { let currentToolBox = null; try { - const resp = await apiChat(model, messages, systemPrompt); + const resp = await apiChat(model, messages, systemPrompt, getCurrentSessionId()); const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index 6d0fffb..b63edd1 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -6,12 +6,13 @@ let loadedArchiveCount = 0; async function loadHistory() { try { - const archives = await apiListArchives(); + const sid = getCurrentSessionId(); + const archives = await apiListArchives(sid); if (archives.length > 0) { showArchiveLoadButton(); } - const data = await apiHistory('null'); + const data = await apiHistory(sid); data.reverse(); for (const entry of data) { @@ -82,7 +83,7 @@ function getOrCreateArchiveDivider() { async function loadLatestArchive() { try { - const archives = await apiListArchives(); + const archives = await apiListArchives(getCurrentSessionId()); if (archives.length <= loadedArchiveCount) return; const latest = archives[loadedArchiveCount]; @@ -163,7 +164,7 @@ async function pollHistory() { if (isStreaming) return; try { - const newMessages = await apiHistoryPoll(lastKnownMessageId); + const newMessages = await apiHistoryPoll(getCurrentSessionId(), lastKnownMessageId); if (newMessages.length > 0) { renderMessages(newMessages, true); scrollDown(); diff --git a/wwwroot/js/session.js b/wwwroot/js/session.js new file mode 100644 index 0000000..e0f3135 --- /dev/null +++ b/wwwroot/js/session.js @@ -0,0 +1,108 @@ +// ── Session State ── + +let currentSessionId = null; + +function getCurrentSessionId() { + return currentSessionId; +} + +async function initSession() { + // SessionId aus localStorage oder neue erstellen + const stored = localStorage.getItem('sessionId'); + if (stored) { + currentSessionId = stored; + } else { + const session = await apiCreateSession(); + currentSessionId = session.sessionId; + localStorage.setItem('sessionId', currentSessionId); + } + + await refreshSessionList(); +} + +async function createNewSession() { + const session = await apiCreateSession(); + currentSessionId = session.sessionId; + localStorage.setItem('sessionId', currentSessionId); + + // Chat leeren und neu laden + messages.length = 0; + chatEl().innerHTML = ''; + lastKnownMessageId = 0; + loadedArchiveCount = 0; + setMeta(''); + + await refreshSessionList(); + await loadHistory(); +} + +async function switchSession(sessionId) { + if (sessionId === currentSessionId) return; + + currentSessionId = sessionId; + localStorage.setItem('sessionId', currentSessionId); + + // Chat leeren und Session laden + messages.length = 0; + chatEl().innerHTML = ''; + lastKnownMessageId = 0; + loadedArchiveCount = 0; + setMeta(''); + + await refreshSessionList(); + await loadHistory(); +} + +async function refreshSessionList() { + const sessions = await apiGetSessions(); + const list = $('#sessionList'); + list.innerHTML = ''; + + for (const s of sessions) { + const div = document.createElement('div'); + div.className = 'session-item' + (s.sessionId === currentSessionId ? ' active' : ''); + div.dataset.sessionId = s.sessionId; + + const name = document.createElement('span'); + name.className = 'session-name'; + name.textContent = s.name || 'Unbenannt'; + name.addEventListener('dblclick', () => renameSession(s.sessionId, name)); + + const date = document.createElement('span'); + date.className = 'session-date'; + date.textContent = new Date(s.updatedAt).toLocaleString('de-AT', { + day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' + }); + + div.appendChild(name); + div.appendChild(date); + div.addEventListener('click', () => switchSession(s.sessionId)); + + list.appendChild(div); + } +} + +async function renameSession(sessionId, nameEl) { + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'session-rename'; + input.value = nameEl.textContent; + + nameEl.replaceWith(input); + input.focus(); + input.select(); + + const save = async () => { + const newName = input.value.trim(); + if (newName) { + await apiUpdateSession(sessionId, { name: newName }); + } + await refreshSessionList(); + }; + + input.addEventListener('blur', save); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') input.blur(); + if (e.key === 'Escape') { input.value = nameEl.textContent; input.blur(); } + }); +}