using System; using System.Collections.Generic; using System.Linq; 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.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, 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, 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 TABLE IF NOT EXISTS agents ( id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL UNIQUE, name TEXT NOT NULL, avatar TEXT, description TEXT, model TEXT NOT NULL DEFAULT 'sonnet', persona TEXT, instructions TEXT, is_deleted INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); 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); "); conn.Execute(@" CREATE TABLE IF NOT EXISTS attachments ( id INTEGER PRIMARY KEY AUTOINCREMENT, message_id INTEGER NOT NULL, type TEXT NOT NULL, media_type TEXT NOT NULL, file_name TEXT, data TEXT NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY (message_id) REFERENCES messages(id) ); CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id); "); // Migrations TryAddColumn(conn, "sessions", "agent_id", "TEXT"); TryAddColumn(conn, "sessions", "description", "TEXT"); TryAddColumn(conn, "sessions", "image", "TEXT"); TryAddColumn(conn, "sessions", "workspace", "TEXT"); TryAddColumn(conn, "sessions", "context", "TEXT"); TryAddColumn(conn, "sessions", "is_deleted", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "sessions", "custom_commands", "TEXT"); TryAddColumn(conn, "chains", "agent_id", "TEXT"); TryAddColumn(conn, "chains", "cache_creation_tokens", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "chains", "cache_read_tokens", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "chains", "tool_choice", "TEXT"); TryAddColumn(conn, "chains", "tool_names", "TEXT"); TryAddColumn(conn, "messages", "attachment_count", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "messages", "attachment_names", "TEXT"); TryAddColumn(conn, "agents", "tool_choice", "TEXT DEFAULT 'any'"); TryAddColumn(conn, "agents", "custom_commands", "TEXT"); TryAddColumn(conn, "messages", "is_marked", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "agents", "auto_commit", "INTEGER NOT NULL DEFAULT 0"); // Migration: auto_commit von INTEGER (0/1) zu TEXT ("", "mention", "forbid") try { conn.Execute("UPDATE agents SET auto_commit = '' WHERE auto_commit = '0' OR auto_commit = 0"); conn.Execute("UPDATE agents SET auto_commit = 'enforce' WHERE auto_commit = '1' OR auto_commit = 1"); } catch { /* already migrated */ } TryAddColumn(conn, "chains", "stream_error_info", "TEXT"); TryAddColumn(conn, "agents", "use_soul", "INTEGER NOT NULL DEFAULT 1"); TryAddColumn(conn, "agents", "use_identity", "INTEGER NOT NULL DEFAULT 1"); TryAddColumn(conn, "agents", "use_user_info", "INTEGER NOT NULL DEFAULT 1"); TryAddColumn(conn, "agents", "use_memory", "INTEGER NOT NULL DEFAULT 1"); TryAddColumn(conn, "messages", "deleted_on", "TEXT"); TryAddColumn(conn, "agents", "use_web_search", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "agents", "web_search_version", "TEXT DEFAULT ''"); // Migration: use_web_search (bool) → web_search_version (text) try { conn.Execute("UPDATE agents SET web_search_version = 'web_search_20260209' WHERE use_web_search = 1 AND (web_search_version IS NULL OR web_search_version = '')"); } catch { /* already migrated */ } // User-Zuordnung TryAddColumn(conn, "sessions", "user_id", "TEXT"); TryAddColumn(conn, "sessions", "telegram_bot_token", "TEXT"); TryAddColumn(conn, "sessions", "telegram_webhook_secret", "TEXT"); TryAddColumn(conn, "sessions", "telegram_chat_id", "TEXT"); TryAddColumn(conn, "sessions", "telegram_allowed_chat_ids", "TEXT"); TryAddColumn(conn, "agents", "user_id", "TEXT"); TryAddColumn(conn, "users", "is_admin", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "users", "persona", "TEXT"); TryAddColumn(conn, "users", "settings", "TEXT"); TryAddColumn(conn, "users", "default_path", "TEXT"); TryAddColumn(conn, "sessions", "avatar_scale", "REAL NOT NULL DEFAULT 1.0"); TryAddColumn(conn, "agents", "avatar_scale", "REAL NOT NULL DEFAULT 1.0"); TryAddColumn(conn, "cronjobs", "notify_session_id", "TEXT"); TryAddColumn(conn, "sessions", "review_agent_id", "TEXT"); TryAddColumn(conn, "sessions", "max_review_iterations", "INTEGER NOT NULL DEFAULT 3"); TryAddColumn(conn, "sessions", "avatar_offset_x", "REAL NOT NULL DEFAULT 0"); TryAddColumn(conn, "sessions", "avatar_offset_y", "REAL NOT NULL DEFAULT 0"); TryAddColumn(conn, "agents", "avatar_offset_x", "REAL NOT NULL DEFAULT 0"); TryAddColumn(conn, "agents", "avatar_offset_y", "REAL NOT NULL DEFAULT 0"); TryAddColumn(conn, "sessions", "finalizer_agent_id", "TEXT"); conn.Execute(@" CREATE TABLE IF NOT EXISTS agent_skills ( id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL, skill_slug TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(agent_id, skill_slug) ); CREATE INDEX IF NOT EXISTS idx_agent_skills_agent ON agent_skills(agent_id); "); conn.Execute(@" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, display_name TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); "); conn.Execute(@" CREATE TABLE IF NOT EXISTS telegram_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, telegram_chat_id INTEGER NOT NULL, user_id TEXT NOT NULL, session_id TEXT NOT NULL, agent_id TEXT, link_code TEXT, expires_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(telegram_chat_id) ); CREATE INDEX IF NOT EXISTS idx_telegram_links_code ON telegram_links(link_code); "); conn.Execute(@" CREATE TABLE IF NOT EXISTS cronjobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, cronjob_id TEXT NOT NULL UNIQUE, user_id TEXT NOT NULL, session_id TEXT NOT NULL, name TEXT NOT NULL, cron_expression TEXT NOT NULL, prompt TEXT NOT NULL, notify_session_id TEXT, is_enabled INTEGER NOT NULL DEFAULT 1, last_run_at TEXT, next_run_at TEXT, last_run_status TEXT, last_run_error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_cronjobs_user ON cronjobs(user_id); CREATE INDEX IF NOT EXISTS idx_cronjobs_next_run ON cronjobs(next_run_at); "); conn.Execute(@" CREATE TABLE IF NOT EXISTS shared_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, share_token TEXT NOT NULL UNIQUE, message_id INTEGER NOT NULL, user_id TEXT NOT NULL, include_context INTEGER NOT NULL DEFAULT 0, expires_at TEXT, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_shared_messages_token ON shared_messages(share_token); CREATE INDEX IF NOT EXISTS idx_shared_messages_message ON shared_messages(message_id); "); } private void TryAddColumn(SqliteConnection conn, string table, string column, string type) { try { conn.Execute($"ALTER TABLE {table} ADD COLUMN {column} {type}"); } catch { /* column already exists */ } } // ── Shared Messages ── public async Task CreateSharedMessage(long messageId, string userId, bool includeContext, string expiresAt = null) { using var conn = CreateConnection(); var record = new SharedMessageRecord { ShareToken = Guid.NewGuid().ToString("N"), MessageId = messageId, UserId = userId, IncludeContext = includeContext, ExpiresAt = expiresAt, CreatedAt = DateTime.UtcNow.ToString("o") }; await conn.ExecuteAsync(@" INSERT INTO shared_messages (share_token, message_id, user_id, include_context, expires_at, created_at) VALUES (@ShareToken, @MessageId, @UserId, @IncludeContext, @ExpiresAt, @CreatedAt) ", record); return record; } public async Task GetSharedMessage(string shareToken) { using var conn = CreateConnection(); return await conn.QuerySingleOrDefaultAsync( "SELECT * FROM shared_messages WHERE share_token = @shareToken", new { shareToken }); } public async Task> GetSharedMessagesByUser(string userId) { using var conn = CreateConnection(); return await conn.QueryAsync( "SELECT * FROM shared_messages WHERE user_id = @userId ORDER BY created_at DESC", new { userId }); } public async Task DeleteSharedMessage(string shareToken, string userId) { using var conn = CreateConnection(); await conn.ExecuteAsync( "DELETE FROM shared_messages WHERE share_token = @shareToken AND user_id = @userId", new { shareToken, userId }); } public async Task GetMessage(long messageId) { using var conn = CreateConnection(); return await conn.QuerySingleOrDefaultAsync( "SELECT * FROM messages WHERE id = @messageId", new { messageId }); } public async Task> GetChainMessagesUpTo(string chainId, long messageId) { using var conn = CreateConnection(); var result = await conn.QueryAsync( "SELECT * FROM messages WHERE chain_id = @chainId AND id <= @messageId ORDER BY id ASC", new { chainId, messageId }); return result.AsList(); } // ── Agents ── public async Task CreateAgent(AgentRecord agent) { using var conn = CreateConnection(); var now = DateTime.UtcNow.ToString("o"); agent.AgentId = Guid.NewGuid().ToString("N"); agent.CreatedAt = now; agent.UpdatedAt = now; await conn.ExecuteAsync(@" INSERT INTO agents (agent_id, user_id, name, avatar, description, model, tool_choice, custom_commands, auto_commit, use_soul, use_identity, use_user_info, use_memory, use_web_search, web_search_version, avatar_scale, avatar_offset_x, avatar_offset_y, is_deleted, created_at, updated_at) VALUES (@AgentId, @UserId, @Name, @Avatar, @Description, @Model, @ToolChoice, @CustomCommands, @AutoCommit, @UseSoul, @UseIdentity, @UseUserInfo, @UseMemory, @UseWebSearch, @WebSearchVersion, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, 0, @CreatedAt, @UpdatedAt) ", agent); return agent; } public async Task> GetAgents(string userId) { using var conn = CreateConnection(); return await conn.QueryAsync( "SELECT * FROM agents WHERE user_id = @userId AND is_deleted = 0 ORDER BY name", new { userId }); } public async Task GetAgent(string agentId) { using var conn = CreateConnection(); return await conn.QuerySingleOrDefaultAsync( "SELECT * FROM agents WHERE agent_id = @agentId AND is_deleted = 0", new { agentId }); } public async Task UpdateAgent(AgentRecord agent) { using var conn = CreateConnection(); agent.UpdatedAt = DateTime.UtcNow.ToString("o"); await conn.ExecuteAsync(@" UPDATE agents SET name = @Name, avatar = @Avatar, description = @Description, model = @Model, tool_choice = @ToolChoice, custom_commands = @CustomCommands, auto_commit = @AutoCommit, use_soul = @UseSoul, use_identity = @UseIdentity, use_user_info = @UseUserInfo, use_memory = @UseMemory, use_web_search = @UseWebSearch, web_search_version = @WebSearchVersion, avatar_scale = @AvatarScale, avatar_offset_x = @AvatarOffsetX, avatar_offset_y = @AvatarOffsetY, updated_at = @UpdatedAt WHERE agent_id = @AgentId AND user_id = @UserId ", agent); } public async Task DeleteAgent(string agentId, string userId) { using var conn = CreateConnection(); await conn.ExecuteAsync( "UPDATE agents SET is_deleted = 1, updated_at = @now WHERE agent_id = @agentId AND user_id = @userId", new { agentId, userId, now = DateTime.UtcNow.ToString("o") }); } // ── Sessions ── public async Task CreateSession(string userId, string name = null, string description = null, string image = null, string agentId = null, string workspace = null, string context = null, string customCommands = null, double avatarScale = 1.0, double avatarOffsetX = 0, double avatarOffsetY = 0, string reviewAgentId = null, int maxReviewIterations = 3, string finalizerAgentId = null) { using var conn = CreateConnection(); var now = DateTime.UtcNow.ToString("o"); var session = new SessionRecord { SessionId = Guid.NewGuid().ToString("N"), UserId = userId, Name = name, Description = description, Image = image, AgentId = agentId, Workspace = workspace, Context = context, CustomCommands = customCommands, AvatarScale = avatarScale, AvatarOffsetX = avatarOffsetX, AvatarOffsetY = avatarOffsetY, ReviewAgentId = reviewAgentId, MaxReviewIterations = maxReviewIterations, FinalizerAgentId = finalizerAgentId, CreatedAt = now, UpdatedAt = now }; await conn.ExecuteAsync(@" INSERT INTO sessions (session_id, user_id, name, description, image, agent_id, workspace, context, custom_commands, avatar_scale, avatar_offset_x, avatar_offset_y, review_agent_id, max_review_iterations, finalizer_agent_id, created_at, updated_at) VALUES (@SessionId, @UserId, @Name, @Description, @Image, @AgentId, @Workspace, @Context, @CustomCommands, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, @ReviewAgentId, @MaxReviewIterations, @FinalizerAgentId, @CreatedAt, @UpdatedAt) ", session); return session; } public async Task> GetSessions(string userId) { using var conn = CreateConnection(); return await conn.QueryAsync( "SELECT * FROM sessions WHERE user_id = @userId AND is_deleted = 0 ORDER BY updated_at DESC", new { userId }); } public async Task> GetSessionsWithStats(string userId) { using var conn = CreateConnection(); var sessions = await conn.QueryAsync( "SELECT * FROM sessions WHERE user_id = @userId AND is_deleted = 0 ORDER BY updated_at DESC", new { userId }); var results = new List(); foreach (var s in sessions) { var activeCount = await conn.ExecuteScalarAsync(@" SELECT COUNT(*) FROM messages m JOIN chains c ON m.chain_id = c.chain_id WHERE c.session_id = @sessionId AND c.archive_id IS NULL AND m.type = 'text'", new { sessionId = s.SessionId }); var totalCount = await conn.ExecuteScalarAsync(@" SELECT COUNT(*) FROM messages m JOIN chains c ON m.chain_id = c.chain_id WHERE c.session_id = @sessionId AND m.type = 'text'", new { sessionId = s.SessionId }); results.Add(new { s.SessionId, s.Name, s.Description, s.Image, s.AgentId, s.Workspace, s.Context, s.CustomCommands, s.AvatarScale, s.AvatarOffsetX, s.AvatarOffsetY, s.ReviewAgentId, s.MaxReviewIterations, s.FinalizerAgentId, s.IsDeleted, s.CreatedAt, s.UpdatedAt, MessageCount = activeCount, ArchiveMessageCount = totalCount - activeCount, TotalMessageCount = totalCount, TelegramConfigured = !string.IsNullOrEmpty(s.TelegramBotToken), TelegramLinked = !string.IsNullOrEmpty(s.TelegramChatId) }); } return results; } public async Task GetSession(string sessionId) { using var conn = CreateConnection(); return await conn.QuerySingleOrDefaultAsync( "SELECT * FROM sessions WHERE session_id = @sessionId AND is_deleted = 0", 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 UpdateSession(string sessionId, System.Text.Json.JsonElement body, ImageService imageService = null) { using var conn = CreateConnection(); var sets = new List { "updated_at = @now" }; var p = new Dapper.DynamicParameters(); p.Add("sessionId", sessionId); p.Add("now", DateTime.UtcNow.ToString("o")); if (body.TryGetProperty("name", out var n)) { sets.Add("name = @name"); p.Add("name", n.GetString()); } if (body.TryGetProperty("description", out var d)) { sets.Add("description = @desc"); p.Add("desc", d.GetString()); } if (body.TryGetProperty("image", out var img)) { var imageData = img.GetString(); if (imageService != null) imageData = imageService.ResizeDataUrl(imageData); sets.Add("image = @image"); p.Add("image", imageData); } if (body.TryGetProperty("agentId", out var a)) { sets.Add("agent_id = @agentId2"); p.Add("agentId2", a.GetString()); } if (body.TryGetProperty("workspace", out var w)) { sets.Add("workspace = @workspace"); p.Add("workspace", w.GetString()); } if (body.TryGetProperty("context", out var ctx)) { sets.Add("context = @context"); p.Add("context", ctx.GetString()); } if (body.TryGetProperty("customCommands", out var cc)) { sets.Add("custom_commands = @customCommands"); p.Add("customCommands", cc.ValueKind == System.Text.Json.JsonValueKind.Null ? null : cc.GetRawText()); } if (body.TryGetProperty("avatarScale", out var asc)) { sets.Add("avatar_scale = @avatarScale"); p.Add("avatarScale", asc.GetDouble()); } if (body.TryGetProperty("avatarOffsetX", out var aox)) { sets.Add("avatar_offset_x = @avatarOffsetX"); p.Add("avatarOffsetX", aox.GetDouble()); } if (body.TryGetProperty("avatarOffsetY", out var aoy)) { sets.Add("avatar_offset_y = @avatarOffsetY"); p.Add("avatarOffsetY", aoy.GetDouble()); } if (body.TryGetProperty("reviewAgentId", out var ra)) { sets.Add("review_agent_id = @reviewAgentId"); p.Add("reviewAgentId", ra.ValueKind == System.Text.Json.JsonValueKind.Null ? null : ra.GetString()); } if (body.TryGetProperty("maxReviewIterations", out var mri)) { sets.Add("max_review_iterations = @maxReviewIterations"); p.Add("maxReviewIterations", mri.GetInt32()); } if (body.TryGetProperty("finalizerAgentId", out var fa)) { sets.Add("finalizer_agent_id = @finalizerAgentId"); p.Add("finalizerAgentId", fa.ValueKind == System.Text.Json.JsonValueKind.Null ? null : fa.GetString()); } await conn.ExecuteAsync($"UPDATE sessions SET {string.Join(", ", sets)} WHERE session_id = @sessionId", p); } public async Task DeleteSession(string sessionId, string userId) { using var conn = CreateConnection(); await conn.ExecuteAsync( "UPDATE sessions SET is_deleted = 1, updated_at = @now WHERE session_id = @sessionId AND user_id = @userId", new { sessionId, userId, now = DateTime.UtcNow.ToString("o") }); } // ── Archives ── public async Task CreateArchive(string sessionId, string name = null) { using var conn = CreateConnection(); var chainCount = await conn.ExecuteScalarAsync( "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 type = 'text' AND 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, CreatedAt = DateTime.UtcNow.ToString("o") }; await conn.ExecuteAsync(@" INSERT INTO archives (archive_id, session_id, name, chain_count, message_count, created_at) VALUES (@ArchiveId, @SessionId, @Name, @ChainCount, @MessageCount, @CreatedAt) ", archive); await conn.ExecuteAsync( "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(string sessionId) { using var conn = CreateConnection(); return await conn.QueryAsync( "SELECT * FROM archives WHERE session_id = @sessionId ORDER BY id DESC", new { sessionId }); } 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) { using var conn = CreateConnection(); chain.CreatedAt = DateTime.UtcNow.ToString("o"); await conn.ExecuteAsync(@" INSERT INTO chains (chain_id, session_id, archive_id, agent_id, model, system_prompt, tool_choice, tool_names, input_tokens, output_tokens, iterations, stop_reason, duration_ms, created_at) VALUES (@ChainId, @SessionId, @ArchiveId, @AgentId, @Model, @SystemPrompt, @ToolChoice, @ToolNames, @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, cache_creation_tokens = @CacheCreationTokens, cache_read_tokens = @CacheReadTokens, iterations = @Iterations, stop_reason = @StopReason, stream_error_info = @StreamErrorInfo, 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> GetActiveChains(string sessionId) { using var conn = CreateConnection(); return await conn.QueryAsync( "SELECT * FROM chains WHERE session_id = @sessionId AND archive_id IS NULL ORDER BY id DESC", new { sessionId }); } // ── 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, attachment_count, created_at) VALUES (@ChainId, @Role, @Type, @Content, @ToolName, @ToolCallId, @IsError, @Iteration, @AttachmentCount, @CreatedAt); SELECT last_insert_rowid(); ", record); } /// /// Speichert Message + Attachments in einer Transaktion (1x fsync statt N+1). /// public async Task SaveMessageWithAttachments(MessageRecord record, List attachments) { using var conn = CreateConnection(); using var tx = conn.BeginTransaction(); record.CreatedAt = DateTime.UtcNow.ToString("o"); record.AttachmentCount = attachments?.Count ?? 0; if (attachments != null && attachments.Count > 0) { var names = attachments.Select(a => a.FileName ?? "").ToArray(); record.AttachmentNames = System.Text.Json.JsonSerializer.Serialize(names); } var messageId = await conn.ExecuteScalarAsync(@" INSERT INTO messages (chain_id, role, type, content, tool_name, tool_call_id, is_error, iteration, attachment_count, attachment_names, created_at) VALUES (@ChainId, @Role, @Type, @Content, @ToolName, @ToolCallId, @IsError, @Iteration, @AttachmentCount, @AttachmentNames, @CreatedAt); SELECT last_insert_rowid(); ", record, tx); if (attachments != null) { var now = DateTime.UtcNow.ToString("o"); foreach (var att in attachments) { att.MessageId = messageId; att.CreatedAt = now; await conn.ExecuteAsync(@" INSERT INTO attachments (message_id, type, media_type, file_name, data, created_at) VALUES (@MessageId, @Type, @MediaType, @FileName, @Data, @CreatedAt) ", att, tx); } } tx.Commit(); return messageId; } // ── Attachments ── public async Task> GetAttachments(long messageId) { using var conn = CreateConnection(); return await conn.QueryAsync( "SELECT * FROM attachments WHERE message_id = @messageId ORDER BY id", new { messageId }); } public async Task> GetAttachmentsForMessages(IEnumerable messageIds) { using var conn = CreateConnection(); return await conn.QueryAsync( "SELECT * FROM attachments WHERE message_id IN @messageIds ORDER BY id", new { messageIds }); } public async Task> GetChainMessages(string chainId, bool includeDeleted = false) { using var conn = CreateConnection(); var deletedFilter = includeDeleted ? "" : " AND deleted_on IS NULL"; return await conn.QueryAsync( $"SELECT * FROM messages WHERE chain_id = @chainId{deletedFilter} ORDER BY id", new { chainId } ); } public async Task GetActiveMessageCount(string sessionId, bool includeDeleted = false) { using var conn = CreateConnection(); var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; return await conn.ExecuteScalarAsync($@" SELECT COUNT(*) FROM messages m JOIN chains c ON m.chain_id = c.chain_id WHERE c.archive_id IS NULL AND c.session_id = @sessionId AND m.type = 'text' {deletedFilter}", new { sessionId }); } public async Task> GetLatestActiveMessages(string sessionId, int limit, bool includeDeleted = false) { using var conn = CreateConnection(); var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; var messages = await conn.QueryAsync($@" SELECT m.* FROM messages m JOIN chains c ON m.chain_id = c.chain_id WHERE c.archive_id IS NULL AND c.session_id = @sessionId {deletedFilter} ORDER BY m.id DESC LIMIT @limit", new { sessionId, limit }); return messages.Reverse().ToList(); } public async Task GetActiveMessageCountBeforeId(string sessionId, long beforeId, bool includeDeleted = false) { using var conn = CreateConnection(); var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; return await conn.ExecuteScalarAsync($@" 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 AND m.type = 'text' {deletedFilter}", new { sessionId, beforeId }); } public async Task> GetActiveMessagesBeforeId(string sessionId, long beforeId, int limit, bool includeDeleted = false) { using var conn = CreateConnection(); var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; var messages = await conn.QueryAsync($@" 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 {deletedFilter} ORDER BY m.id DESC LIMIT @limit", new { sessionId, beforeId, limit }); return messages.Reverse().ToList(); } public async Task> GetActiveMessagesAfterId(string sessionId, long afterId, int limit = 200, bool includeDeleted = false) { using var conn = CreateConnection(); var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; 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 AND c.session_id = @sessionId {deletedFilter} ORDER BY m.id LIMIT @limit", new { sessionId, afterId, limit }); } // ── Message Marking ── public async Task ToggleMessageMark(long messageId) { using var conn = CreateConnection(); await conn.ExecuteAsync( "UPDATE messages SET is_marked = CASE WHEN is_marked = 1 THEN 0 ELSE 1 END WHERE id = @messageId", new { messageId }); return await conn.ExecuteScalarAsync( "SELECT is_marked FROM messages WHERE id = @messageId", new { messageId }); } // ── Message Deletion ── public async Task ToggleMessageDelete(long messageId) { using var conn = CreateConnection(); var current = await conn.ExecuteScalarAsync( "SELECT deleted_on FROM messages WHERE id = @messageId", new { messageId }); string newValue; if (current == null) { newValue = DateTime.UtcNow.ToString("o"); await conn.ExecuteAsync( "UPDATE messages SET deleted_on = @newValue WHERE id = @messageId", new { messageId, newValue }); } else { newValue = null; await conn.ExecuteAsync( "UPDATE messages SET deleted_on = NULL WHERE id = @messageId", new { messageId }); } return newValue; } public async Task> GetMarkedMessages(string sessionId) { using var conn = CreateConnection(); return await conn.QueryAsync(@" SELECT m.* FROM messages m JOIN chains c ON m.chain_id = c.chain_id WHERE c.session_id = @sessionId AND m.is_marked = 1 ORDER BY m.id", new { sessionId }); } // ── Agent Skills ── public async Task> GetAgentSkills(string agentId) { using var conn = CreateConnection(); return await conn.QueryAsync( "SELECT skill_slug FROM agent_skills WHERE agent_id = @agentId ORDER BY skill_slug", new { agentId }); } public async Task SetAgentSkills(string agentId, IEnumerable skillSlugs) { using var conn = CreateConnection(); using var tx = conn.BeginTransaction(); await conn.ExecuteAsync("DELETE FROM agent_skills WHERE agent_id = @agentId", new { agentId }, tx); var now = DateTime.UtcNow.ToString("o"); foreach (var slug in skillSlugs) { await conn.ExecuteAsync(@" INSERT INTO agent_skills (agent_id, skill_slug, created_at) VALUES (@agentId, @slug, @now)", new { agentId, slug, now }, tx); } tx.Commit(); } public async Task AddAgentSkill(string agentId, string skillSlug) { using var conn = CreateConnection(); var now = DateTime.UtcNow.ToString("o"); await conn.ExecuteAsync(@" INSERT OR IGNORE INTO agent_skills (agent_id, skill_slug, created_at) VALUES (@agentId, @skillSlug, @now)", new { agentId, skillSlug, now }); } public async Task RemoveAgentSkill(string agentId, string skillSlug) { using var conn = CreateConnection(); await conn.ExecuteAsync( "DELETE FROM agent_skills WHERE agent_id = @agentId AND skill_slug = @skillSlug", new { agentId, skillSlug }); } // ── Users ── public void EnsureDefaultUser(AuthService auth) { using var conn = CreateConnection(); var count = conn.ExecuteScalar("SELECT COUNT(*) FROM users"); string adminUserId; if (count == 0) { adminUserId = Guid.NewGuid().ToString("N"); var now = DateTime.UtcNow.ToString("o"); conn.Execute(@" INSERT INTO users (user_id, username, password_hash, display_name, is_admin, created_at, updated_at) VALUES (@userId, @username, @hash, @displayName, 1, @now, @now)", new { userId = adminUserId, username = "admin", hash = auth.HashPassword("admin"), displayName = "Admin", now }); } else { adminUserId = conn.ExecuteScalar("SELECT user_id FROM users ORDER BY id LIMIT 1"); } // Bestehende Sessions/Agents ohne User dem ersten User zuweisen conn.Execute("UPDATE sessions SET user_id = @userId WHERE user_id IS NULL", new { userId = adminUserId }); conn.Execute("UPDATE agents SET user_id = @userId WHERE user_id IS NULL", new { userId = adminUserId }); // Erster User ist immer Admin conn.Execute("UPDATE users SET is_admin = 1 WHERE user_id = @userId AND is_admin = 0", new { userId = adminUserId }); } public async Task GetUserByUsername(string username) { using var conn = CreateConnection(); return await conn.QuerySingleOrDefaultAsync( "SELECT * FROM users WHERE username = @username COLLATE NOCASE", new { username }); } public async Task GetUser(string userId) { using var conn = CreateConnection(); return await conn.QuerySingleOrDefaultAsync( "SELECT * FROM users WHERE user_id = @userId", new { userId }); } public async Task> GetUsers() { using var conn = CreateConnection(); return await conn.QueryAsync( "SELECT * FROM users ORDER BY username"); } public async Task UpdateUserPassword(string userId, string passwordHash) { using var conn = CreateConnection(); await conn.ExecuteAsync( "UPDATE users SET password_hash = @passwordHash, updated_at = @now WHERE user_id = @userId", new { userId, passwordHash, now = DateTime.UtcNow.ToString("o") }); } public async Task UpdateUserProfile(string userId, string displayName, string persona) { using var conn = CreateConnection(); await conn.ExecuteAsync( "UPDATE users SET display_name = @displayName, persona = @persona, updated_at = @now WHERE user_id = @userId", new { userId, displayName, persona, now = DateTime.UtcNow.ToString("o") }); } public async Task GetUserSettings(string userId) { using var conn = CreateConnection(); return await conn.QuerySingleOrDefaultAsync( "SELECT settings FROM users WHERE user_id = @userId", new { userId }); } public async Task UpdateUserSettings(string userId, string settings) { using var conn = CreateConnection(); await conn.ExecuteAsync( "UPDATE users SET settings = @settings, updated_at = @now WHERE user_id = @userId", new { userId, settings, now = DateTime.UtcNow.ToString("o") }); } public async Task CreateUser(string username, string passwordHash, string displayName, bool isAdmin, string defaultPath = null) { using var conn = CreateConnection(); var now = DateTime.UtcNow.ToString("o"); var userId = Guid.NewGuid().ToString("N"); await conn.ExecuteAsync(@" INSERT INTO users (user_id, username, password_hash, display_name, is_admin, default_path, created_at, updated_at) VALUES (@userId, @username, @passwordHash, @displayName, @isAdmin, @defaultPath, @now, @now)", new { userId, username, passwordHash, displayName, isAdmin, defaultPath, now }); return await GetUser(userId); } public async Task UpdateUserAdmin(string userId, string username, string displayName, bool isAdmin, string defaultPath) { using var conn = CreateConnection(); await conn.ExecuteAsync( "UPDATE users SET username = @username, display_name = @displayName, is_admin = @isAdmin, default_path = @defaultPath, updated_at = @now WHERE user_id = @userId", new { userId, username, displayName, isAdmin, defaultPath, now = DateTime.UtcNow.ToString("o") }); } public async Task DeleteUser(string userId) { using var conn = CreateConnection(); await conn.ExecuteAsync("DELETE FROM tokens WHERE user_id = @userId", new { userId }); await conn.ExecuteAsync("DELETE FROM shared_messages WHERE user_id = @userId", new { userId }); await conn.ExecuteAsync("DELETE FROM users WHERE user_id = @userId", new { userId }); } // ── Telegram (Session-basiert) ── public async Task GetSessionByTelegramChatId(long chatId) { using var conn = CreateConnection(); return await conn.QuerySingleOrDefaultAsync( "SELECT * FROM sessions WHERE telegram_chat_id = @chatId AND is_deleted = 0", new { chatId = chatId.ToString() }); } public async Task UpdateSessionTelegram(string sessionId, string botToken, string webhookSecret, string chatId, string allowedChatIds = null) { using var conn = CreateConnection(); await conn.ExecuteAsync(@" UPDATE sessions SET telegram_bot_token = @botToken, telegram_webhook_secret = @webhookSecret, telegram_chat_id = @chatId, telegram_allowed_chat_ids = @allowedChatIds, updated_at = @now WHERE session_id = @sessionId", new { sessionId, botToken, webhookSecret, chatId, allowedChatIds, now = DateTime.UtcNow.ToString("o") }); } public async Task SetSessionTelegramChatId(string sessionId, string chatId) { using var conn = CreateConnection(); // Erst alte Verknüpfung mit dieser chatId entfernen (ein Telegram-Chat = eine Session) await conn.ExecuteAsync( "UPDATE sessions SET telegram_chat_id = NULL, updated_at = @now WHERE telegram_chat_id = @chatId", new { chatId, now = DateTime.UtcNow.ToString("o") }); // Dann diese Session verknüpfen await conn.ExecuteAsync( "UPDATE sessions SET telegram_chat_id = @chatId, updated_at = @now WHERE session_id = @sessionId", new { sessionId, chatId, now = DateTime.UtcNow.ToString("o") }); } public async Task ClearSessionTelegramChatId(string sessionId) { using var conn = CreateConnection(); await conn.ExecuteAsync( "UPDATE sessions SET telegram_chat_id = NULL, updated_at = @now WHERE session_id = @sessionId", new { sessionId, now = DateTime.UtcNow.ToString("o") }); } public async Task> GetUserSessions(string userId) { using var conn = CreateConnection(); return await conn.QueryAsync( "SELECT * FROM sessions WHERE user_id = @userId AND is_deleted = 0 ORDER BY updated_at DESC", new { userId }); } // ── Cronjobs ── public async Task CreateCronjob(CronjobRecord cronjob) { using var conn = CreateConnection(); var now = DateTime.UtcNow.ToString("o"); cronjob.CronjobId = Guid.NewGuid().ToString("N"); cronjob.CreatedAt = now; cronjob.UpdatedAt = now; await conn.ExecuteAsync(@" INSERT INTO cronjobs (cronjob_id, user_id, session_id, name, cron_expression, prompt, notify_session_id, is_enabled, next_run_at, created_at, updated_at) VALUES (@CronjobId, @UserId, @SessionId, @Name, @CronExpression, @Prompt, @NotifySessionId, @IsEnabled, @NextRunAt, @CreatedAt, @UpdatedAt) ", cronjob); return cronjob; } public async Task> GetCronjobs(string userId) { using var conn = CreateConnection(); return await conn.QueryAsync(@" SELECT c.*, s.name AS session_name, ns.name AS notify_session_name FROM cronjobs c LEFT JOIN sessions s ON c.session_id = s.session_id LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id WHERE c.user_id = @userId ORDER BY c.name", new { userId }); } public async Task GetCronjob(string cronjobId) { using var conn = CreateConnection(); return await conn.QuerySingleOrDefaultAsync(@" SELECT c.*, s.name AS session_name, ns.name AS notify_session_name FROM cronjobs c LEFT JOIN sessions s ON c.session_id = s.session_id LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id WHERE c.cronjob_id = @cronjobId", new { cronjobId }); } public async Task UpdateCronjob(CronjobRecord cronjob) { using var conn = CreateConnection(); cronjob.UpdatedAt = DateTime.UtcNow.ToString("o"); await conn.ExecuteAsync(@" UPDATE cronjobs SET name = @Name, session_id = @SessionId, cron_expression = @CronExpression, prompt = @Prompt, notify_session_id = @NotifySessionId, is_enabled = @IsEnabled, next_run_at = @NextRunAt, updated_at = @UpdatedAt WHERE cronjob_id = @CronjobId AND user_id = @UserId", cronjob); } public async Task DeleteCronjob(string cronjobId, string userId) { using var conn = CreateConnection(); await conn.ExecuteAsync( "DELETE FROM cronjobs WHERE cronjob_id = @cronjobId AND user_id = @userId", new { cronjobId, userId }); } public async Task UpdateCronjobRun(string cronjobId, string status, string error, string nextRunAt) { using var conn = CreateConnection(); await conn.ExecuteAsync(@" UPDATE cronjobs SET last_run_at = @now, last_run_status = @status, last_run_error = @error, next_run_at = @nextRunAt, updated_at = @now WHERE cronjob_id = @cronjobId", new { cronjobId, status, error, nextRunAt, now = DateTime.UtcNow.ToString("o") }); } public async Task> GetDueCronjobs() { using var conn = CreateConnection(); var now = DateTime.UtcNow.ToString("o"); return await conn.QueryAsync(@" SELECT c.*, s.name AS session_name, ns.name AS notify_session_name FROM cronjobs c LEFT JOIN sessions s ON c.session_id = s.session_id LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id WHERE c.is_enabled = 1 AND c.next_run_at IS NOT NULL AND c.next_run_at <= @now", new { now }); } }