From 9f2f3fee1f4256539cd4af692e3e24857d27c91a Mon Sep 17 00:00:00 2001 From: Meleki Date: Thu, 26 Feb 2026 15:21:34 +0100 Subject: [PATCH] Persistenz: SQLite + Dapper, chains/messages Tabellen, History-Endpoint, Frontend History-Load --- Endpoints/ChatEndpoint.cs | 32 +++++++- Endpoints/HistoryEndpoint.cs | 40 ++++++++++ Models/ChainRecord.cs | 16 ++++ Models/MessageRecord.cs | 15 ++++ Program.cs | 2 + Services/AnthropicService.cs | 85 ++++++++++++++++++++- Services/DatabaseService.cs | 138 +++++++++++++++++++++++++++++++++++ gott.csproj | 2 + wwwroot/index.html | 46 ++++++++++++ 9 files changed, 370 insertions(+), 6 deletions(-) create mode 100644 Endpoints/HistoryEndpoint.cs create mode 100644 Models/ChainRecord.cs create mode 100644 Models/MessageRecord.cs create mode 100644 Services/DatabaseService.cs diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index 0945aa1..eb54ded 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Text.Json; using System.Threading.Tasks; @@ -19,7 +21,7 @@ public static class ChatEndpoint app.MapPost("/api/chat", HandleChat); } - private static async Task HandleChat(HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config, ToolService toolService) + private static async Task HandleChat(HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config, ToolService toolService, DatabaseService db) { var body = await JsonSerializer.DeserializeAsync(req.Body); if (body == null || body.Messages == null || body.Messages.Count == 0) @@ -48,10 +50,34 @@ public static class ChatEndpoint res.Headers["Cache-Control"] = "no-cache"; res.Headers["Connection"] = "keep-alive"; + var chainId = Guid.NewGuid().ToString("N"); + + // Chain zuerst anlegen (FK für messages) + await db.CreateChain(new ChainRecord + { + ChainId = chainId, + Model = modelId, + SystemPrompt = body.SystemPrompt + }); + var messages = new List(); foreach (var msg in body.Messages) messages.Add(new { role = msg.Role, content = msg.Content }); + // Nur die letzte User-Message speichern (= die neue) + var lastUserMsg = body.Messages.LastOrDefault(m => m.Role == "user"); + if (lastUserMsg != null) + { + await db.SaveMessage(new MessageRecord + { + ChainId = chainId, + Iteration = 0, + Role = "user", + Type = "text", + Content = lastUserMsg.Content + }); + } + var systemPrompt = new List { new { type = "text", text = "You are Claude Code, Anthropic's official CLI for Claude." } @@ -59,7 +85,7 @@ public static class ChatEndpoint if (!string.IsNullOrWhiteSpace(body.SystemPrompt)) systemPrompt.Add(new { type = "text", text = body.SystemPrompt }); - var anthropic = new AnthropicService(httpFactory, token, toolService); - await anthropic.StreamChat(res, modelId, systemPrompt, messages); + var anthropic = new AnthropicService(httpFactory, token, toolService, db); + await anthropic.StreamChat(res, modelId, systemPrompt, messages, chainId); } } diff --git a/Endpoints/HistoryEndpoint.cs b/Endpoints/HistoryEndpoint.cs new file mode 100644 index 0000000..8894cfa --- /dev/null +++ b/Endpoints/HistoryEndpoint.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Gott.Models; +using Gott.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Gott.Endpoints; + +public static class HistoryEndpoint +{ + public static void MapHistoryEndpoint(this IEndpointRouteBuilder app) + { + app.MapGet("/api/history", HandleHistory); + } + + 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); + else + chains = await db.GetSessionChains(sessionId, limit); + + 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/ChainRecord.cs b/Models/ChainRecord.cs new file mode 100644 index 0000000..b3f590c --- /dev/null +++ b/Models/ChainRecord.cs @@ -0,0 +1,16 @@ +namespace Gott.Models; + +public class ChainRecord +{ + public long Id { get; set; } + public string ChainId { get; set; } + public string SessionId { get; set; } + public string Model { get; set; } + public string SystemPrompt { get; set; } + public long InputTokens { get; set; } + public long OutputTokens { get; set; } + public int Iterations { get; set; } + public string StopReason { get; set; } + public long DurationMs { get; set; } + public string CreatedAt { get; set; } +} diff --git a/Models/MessageRecord.cs b/Models/MessageRecord.cs new file mode 100644 index 0000000..7f5cbe4 --- /dev/null +++ b/Models/MessageRecord.cs @@ -0,0 +1,15 @@ +namespace Gott.Models; + +public class MessageRecord +{ + public long Id { get; set; } + public string ChainId { get; set; } + public string Role { get; set; } // user, assistant + public string Type { get; set; } // text, tool_use, tool_result + public string Content { get; set; } // Text oder JSON + public string ToolName { get; set; } // null bei text + public string ToolCallId { get; set; } // tool_use id + public bool IsError { get; set; } + public int Iteration { get; set; } // welche Loop-Iteration + public string CreatedAt { get; set; } +} diff --git a/Program.cs b/Program.cs index 624f2a0..32029f7 100644 --- a/Program.cs +++ b/Program.cs @@ -13,11 +13,13 @@ var workspaceRoot = Path.Combine(dataDir, "workspace"); Directory.CreateDirectory(workspaceRoot); builder.Services.AddSingleton(new ToolService(workspaceRoot)); +builder.Services.AddSingleton(new DatabaseService(Path.Combine(dataDir, "gott.db"))); var app = builder.Build(); app.UseDefaultFiles(); app.UseStaticFiles(); app.MapChatEndpoint(); +app.MapHistoryEndpoint(); app.Run("http://0.0.0.0:19000"); diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 8e310b8..7ee61e4 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -6,6 +6,7 @@ using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Threading.Tasks; +using Gott.Models; using Microsoft.AspNetCore.Http; namespace Gott.Services; @@ -15,12 +16,14 @@ public class AnthropicService private readonly IHttpClientFactory _httpFactory; private readonly string _token; private readonly ToolService _toolService; + private readonly DatabaseService _db; - public AnthropicService(IHttpClientFactory httpFactory, string token, ToolService toolService) + public AnthropicService(IHttpClientFactory httpFactory, string token, ToolService toolService, DatabaseService db) { _httpFactory = httpFactory; _token = token; _toolService = toolService; + _db = db; } public async Task StreamChat( @@ -28,10 +31,21 @@ public class AnthropicService string modelId, List systemPrompt, List messages, + string chainId, + string sessionId = null, int maxIterations = 25) { + var sw = System.Diagnostics.Stopwatch.StartNew(); var usage = new TokenUsage(); int iterations = 0; + string lastStopReason = ""; + + var chain = new ChainRecord + { + ChainId = chainId, + SessionId = sessionId, + Model = modelId + }; while (iterations < maxIterations) { @@ -59,23 +73,77 @@ public class AnthropicService if (!httpResp.IsSuccessStatusCode) { var errBody = await httpResp.Content.ReadAsStringAsync(); - await WriteSseEvent(response, "error", new { error = $"API {(int)httpResp.StatusCode}: {errBody}" }); + var errMsg = $"API {(int)httpResp.StatusCode}: {errBody}"; + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = iterations, + Role = "system", Type = "error", + Content = errMsg, IsError = true + }); + await WriteSseEvent(response, "error", new { error = errMsg }); + await FinalizeChain(chain, usage, iterations, "error", sw); await WriteSseEvent(response, "done", new { }); return; } var (stopReason, assistantContent, executedTools) = await ProcessStream(response, httpResp, usage); + lastStopReason = stopReason; messages.Add(new { role = "assistant", content = assistantContent }); + // Assistant-Blöcke persistieren + foreach (var block in assistantContent) + { + var blockJson = JsonSerializer.Serialize(block); + var el = JsonSerializer.Deserialize(blockJson); + var blockType = el.GetProperty("type").GetString() ?? ""; + + if (blockType == "text") + { + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = iterations, + Role = "assistant", Type = "text", + Content = el.GetProperty("text").GetString() + }); + } + else if (blockType == "tool_use") + { + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = iterations, + Role = "assistant", Type = "tool_use", + ToolName = el.GetProperty("name").GetString(), + ToolCallId = el.GetProperty("id").GetString(), + Content = el.GetProperty("input").ToString() + }); + } + } + + // Tool Results persistieren + foreach (var tr in executedTools) + { + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = iterations, + Role = "user", Type = "tool_result", + ToolCallId = tr.Id, + Content = tr.Result, + IsError = tr.IsError + }); + } + if (stopReason != "tool_use") { + await FinalizeChain(chain, usage, iterations, stopReason, sw); await WriteSseEvent(response, "done", new { model = modelId, inputTokens = usage.InputTokens, outputTokens = usage.OutputTokens, - iterations + iterations, + chainId, + durationMs = sw.ElapsedMilliseconds }); return; } @@ -95,10 +163,21 @@ public class AnthropicService messages.Add(new { role = "user", content = toolResults }); } + await FinalizeChain(chain, usage, iterations, "max_iterations", sw); await WriteSseEvent(response, "error", new { error = "Max iterations erreicht" }); await WriteSseEvent(response, "done", new { }); } + private async Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw) + { + chain.InputTokens = usage.InputTokens; + chain.OutputTokens = usage.OutputTokens; + chain.Iterations = iterations; + chain.StopReason = stopReason; + chain.DurationMs = sw.ElapsedMilliseconds; + await _db.UpdateChain(chain); + } + private async Task<(string StopReason, List Content, List Tools)> ProcessStream( HttpResponse response, HttpResponseMessage httpResp, diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs new file mode 100644 index 0000000..8c4c526 --- /dev/null +++ b/Services/DatabaseService.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Dapper; +using Gott.Models; +using Microsoft.Data.Sqlite; + +namespace Gott.Services; + +public class DatabaseService +{ + private readonly string _connectionString; + + public DatabaseService(string dbPath) + { + _connectionString = $"Data Source={dbPath}"; + InitializeDatabase(); + } + + private SqliteConnection CreateConnection() + { + var conn = new SqliteConnection(_connectionString); + conn.Open(); + return conn; + } + + private void InitializeDatabase() + { + using var conn = CreateConnection(); + + // Dapper: snake_case → PascalCase Mapping + Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; + conn.Execute(@" + CREATE TABLE IF NOT EXISTS chains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chain_id TEXT NOT NULL UNIQUE, + session_id TEXT, + model TEXT NOT NULL, + system_prompt TEXT, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + iterations INTEGER NOT NULL DEFAULT 0, + stop_reason TEXT, + duration_ms INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chain_id TEXT NOT NULL, + role TEXT NOT NULL, + type TEXT NOT NULL, + content TEXT, + tool_name TEXT, + tool_call_id TEXT, + is_error INTEGER NOT NULL DEFAULT 0, + iteration INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + FOREIGN KEY (chain_id) REFERENCES chains(chain_id) + ); + + CREATE INDEX IF NOT EXISTS idx_chains_session ON chains(session_id); + CREATE INDEX IF NOT EXISTS idx_messages_chain ON messages(chain_id); + "); + } + + // ── Chains ── + + public async Task CreateChain(ChainRecord chain) + { + 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) + ", chain); + } + + public async Task UpdateChain(ChainRecord chain) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync(@" + UPDATE chains SET + input_tokens = @InputTokens, + output_tokens = @OutputTokens, + iterations = @Iterations, + stop_reason = @StopReason, + duration_ms = @DurationMs + WHERE chain_id = @ChainId + ", chain); + } + + public async Task GetChain(string chainId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM chains WHERE chain_id = @chainId", new { chainId } + ); + } + + public async Task> GetRecentChains(int limit = 50) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM chains ORDER BY id DESC LIMIT @limit", new { limit } + ); + } + + public async Task> GetSessionChains(string sessionId, int limit = 100) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM chains WHERE session_id = @sessionId ORDER BY id DESC LIMIT @limit", + new { sessionId, limit } + ); + } + + // ── Messages ── + + public async Task SaveMessage(MessageRecord record) + { + using var conn = CreateConnection(); + record.CreatedAt = DateTime.UtcNow.ToString("o"); + return await conn.ExecuteScalarAsync(@" + INSERT INTO messages (chain_id, role, type, content, tool_name, tool_call_id, is_error, iteration, created_at) + VALUES (@ChainId, @Role, @Type, @Content, @ToolName, @ToolCallId, @IsError, @Iteration, @CreatedAt); + SELECT last_insert_rowid(); + ", record); + } + + public async Task> GetChainMessages(string chainId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM messages WHERE chain_id = @chainId ORDER BY id", new { chainId } + ); + } +} diff --git a/gott.csproj b/gott.csproj index 1781201..d617c3e 100755 --- a/gott.csproj +++ b/gott.csproj @@ -21,6 +21,8 @@ + + diff --git a/wwwroot/index.html b/wwwroot/index.html index f6a95ba..260c7a3 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -269,6 +269,52 @@ document.getElementById('input').addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 150) + 'px'; }); + +async function loadHistory() { + try { + const resp = await fetch('/api/history?sessionId=null'); + const data = await resp.json(); + const chat = document.getElementById('chat'); + + // Chains sind DESC sortiert, also umkehren für chronologische Reihenfolge + data.reverse(); + + for (const entry of data) { + for (const msg of entry.messages) { + if (msg.role === 'user' && msg.type === 'text') { + const div = document.createElement('div'); + div.className = 'msg user'; + div.textContent = msg.content; + chat.appendChild(div); + messages.push({ role: 'user', content: msg.content }); + } else if (msg.role === 'assistant' && msg.type === 'text') { + const div = document.createElement('div'); + div.className = 'msg assistant'; + div.textContent = msg.content; + chat.appendChild(div); + messages.push({ role: 'assistant', content: msg.content }); + } else if (msg.role === 'assistant' && msg.type === 'tool_use') { + const div = document.createElement('div'); + div.className = 'tool-box'; + div.innerHTML = '' + esc(msg.toolName) + '' + + '' + esc(truncate(msg.content, 120)) + ''; + chat.appendChild(div); + } else if (msg.type === 'tool_result') { + const div = document.createElement('div'); + div.className = 'tool-box'; + div.innerHTML = '
' + + esc(truncate(msg.content, 300)) + '
'; + chat.appendChild(div); + } + } + } + scrollDown(); + } catch (e) { + console.error('History laden fehlgeschlagen:', e); + } +} + +loadHistory();