Persistenz: SQLite + Dapper, chains/messages Tabellen, History-Endpoint, Frontend History-Load
This commit is contained in:
1 parent
5f210323fd
commit
9f2f3fee1f
9 files changed
+370
-6
No files matched your search
@@ -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<object> systemPrompt,
|
||||
List<object> 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<JsonElement>(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<object> Content, List<ToolResult> Tools)> ProcessStream(
|
||||
HttpResponse response,
|
||||
HttpResponseMessage httpResp,
|
||||
|
||||
@@ -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<ChainRecord> GetChain(string chainId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<ChainRecord>(
|
||||
"SELECT * FROM chains WHERE chain_id = @chainId", new { chainId }
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ChainRecord>> GetRecentChains(int limit = 50)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<ChainRecord>(
|
||||
"SELECT * FROM chains ORDER BY id DESC LIMIT @limit", new { limit }
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ChainRecord>> GetSessionChains(string sessionId, int limit = 100)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<ChainRecord>(
|
||||
"SELECT * FROM chains WHERE session_id = @sessionId ORDER BY id DESC LIMIT @limit",
|
||||
new { sessionId, limit }
|
||||
);
|
||||
}
|
||||
|
||||
// ── Messages ──
|
||||
|
||||
public async Task<long> SaveMessage(MessageRecord record)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
record.CreatedAt = DateTime.UtcNow.ToString("o");
|
||||
return await conn.ExecuteScalarAsync<long>(@"
|
||||
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<IEnumerable<MessageRecord>> GetChainMessages(string chainId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<MessageRecord>(
|
||||
"SELECT * FROM messages WHERE chain_id = @chainId ORDER BY id", new { chainId }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user