using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using Dapper; using Gott.Models; using Microsoft.Data.Sqlite; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Processing; namespace Gott.Services; public partial 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; } public SqliteConnection GetConnection() => CreateConnection(); 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, "messages", "thought_signature", "TEXT"); 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, "agents", "use_auto_context", "INTEGER NOT NULL DEFAULT 1"); TryAddColumn(conn, "agents", "use_github_search", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "agents", "use_serper_search", "INTEGER NOT NULL DEFAULT 0"); 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, "sessions", "telegram_bot_id", "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"); TryAddColumn(conn, "agents", "allow_user_choice", "INTEGER NOT NULL DEFAULT 1"); TryAddColumn(conn, "agents", "task_instruction", "TEXT"); TryAddColumn(conn, "chains", "plan_mode", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "sessions", "preparer_agent_id", "TEXT"); TryAddColumn(conn, "messages", "tag", "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, is_admin INTEGER NOT NULL DEFAULT 0, persona TEXT, settings TEXT, default_path 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 telegram_bots ( id INTEGER PRIMARY KEY AUTOINCREMENT, bot_id TEXT NOT NULL UNIQUE, user_id TEXT NOT NULL, name TEXT NOT NULL, bot_token TEXT NOT NULL, webhook_secret TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_telegram_bots_user ON telegram_bots(user_id); "); TryAddColumn(conn, "telegram_bots", "is_public", "INTEGER NOT NULL DEFAULT 0"); // Migration: Bestehende Session-Telegram-Daten in telegram_bots überführen MigrateTelegramBotsFromSessions(conn); 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); "); conn.Execute(@" CREATE TABLE IF NOT EXISTS api_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, key_id TEXT NOT NULL UNIQUE, provider TEXT NOT NULL, name TEXT NOT NULL, api_key TEXT NOT NULL, is_active INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_api_keys_provider ON api_keys(provider); "); // Migration: AnthropicToken aus appsettings.Production.json → api_keys Tabelle MigrateAnthropicTokenFromConfig(conn); conn.Execute(@" CREATE TABLE IF NOT EXISTS scanned_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, scanned_key_id TEXT NOT NULL UNIQUE, api_key TEXT NOT NULL, source_url TEXT, status TEXT NOT NULL DEFAULT 'unchecked', plan TEXT NOT NULL DEFAULT 'Unknown', usage_7d REAL, usage_5h REAL, scanned_at TEXT NOT NULL, checked_at TEXT, deleted INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_scanned_keys_api_key ON scanned_keys(api_key); CREATE INDEX IF NOT EXISTS idx_scanned_keys_status ON scanned_keys(status); "); conn.Execute(@" CREATE TABLE IF NOT EXISTS scanner_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT NOT NULL, type TEXT NOT NULL, message TEXT NOT NULL, scanner TEXT NOT NULL DEFAULT 'event' ); CREATE INDEX IF NOT EXISTS idx_scanner_logs_timestamp ON scanner_logs(timestamp); CREATE INDEX IF NOT EXISTS idx_scanner_logs_type ON scanner_logs(type); "); TryAddColumn(conn, "agents", "avatar_small", "TEXT"); TryAddColumn(conn, "agents", "is_public", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "sessions", "image_small", "TEXT"); TryAddColumn(conn, "scanned_keys", "deleted", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "scanned_keys", "adopted_api_key_id", "INTEGER"); TryAddColumn(conn, "scanned_keys", "source", "TEXT"); TryAddColumn(conn, "scanned_keys", "type", "TEXT NOT NULL DEFAULT 'anthropic_oauth'"); // api_keys Metadaten-Spalten TryAddColumn(conn, "api_keys", "status", "TEXT"); TryAddColumn(conn, "api_keys", "plan", "TEXT"); TryAddColumn(conn, "api_keys", "usage_7d", "REAL"); TryAddColumn(conn, "api_keys", "usage_5h", "REAL"); TryAddColumn(conn, "api_keys", "checked_at", "TEXT"); TryAddColumn(conn, "api_keys", "source_url", "TEXT"); TryAddColumn(conn, "api_keys", "is_public", "INTEGER NOT NULL DEFAULT 0"); // users: ausgewählter API-Key (legacy, wird durch agent_api_keys ersetzt) TryAddColumn(conn, "users", "selected_api_key_id", "TEXT"); conn.Execute(@" CREATE TABLE IF NOT EXISTS agent_api_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, agent_id TEXT NOT NULL, key_id TEXT NOT NULL, UNIQUE(user_id, agent_id) ); CREATE INDEX IF NOT EXISTS idx_agent_api_keys_user ON agent_api_keys(user_id); "); // Migration: selected_api_key_id → agent_api_keys (einmalig) MigrateSelectedApiKeysToAgentKeys(conn); // Migration: Bestehende PNG/JPEG-Avatare/Images zu WebP konvertieren MigrateImagesToWebP(conn); // Migration: Small-Thumbnails für bestehende Bilder generieren MigrateImageThumbnails(conn); } private void MigrateSelectedApiKeysToAgentKeys(SqliteConnection conn) { // Nur migrieren wenn agent_api_keys leer ist und users mit selected_api_key_id existieren var count = conn.ExecuteScalar("SELECT COUNT(*) FROM agent_api_keys"); if (count > 0) return; var usersWithKeys = conn.Query<(string UserId, string SelectedApiKeyId)>( "SELECT user_id, selected_api_key_id FROM users WHERE selected_api_key_id IS NOT NULL AND selected_api_key_id != ''") .ToList(); foreach (var (userId, keyId) in usersWithKeys) { // Alle Agents des Users + öffentliche Agents var agentIds = conn.Query( "SELECT agent_id FROM agents WHERE user_id = @userId OR is_public = 1", new { userId }).ToList(); // Alle Sessions des Users mit Agent → deren Agents auch berücksichtigen var sessionAgentIds = conn.Query( "SELECT DISTINCT agent_id FROM sessions WHERE user_id = @userId AND agent_id IS NOT NULL AND agent_id != ''", new { userId }).ToList(); var allAgentIds = agentIds.Union(sessionAgentIds).Distinct().ToList(); foreach (var agentId in allAgentIds) { try { conn.Execute( "INSERT OR IGNORE INTO agent_api_keys (user_id, agent_id, key_id) VALUES (@userId, @agentId, @keyId)", new { userId, agentId, keyId }); } catch { /* UNIQUE constraint – already exists */ } } } } private void MigrateImagesToWebP(SqliteConnection conn) { const int webPQuality = 80; // Agent Avatare var agentImages = conn.Query<(string AgentId, string Avatar)>( "SELECT agent_id, avatar FROM agents WHERE avatar IS NOT NULL AND avatar != '' AND avatar NOT LIKE 'data:image/webp%'") .ToList(); foreach (var (agentId, avatar) in agentImages) { var converted = ConvertDataUrlToWebP(avatar, webPQuality); if (converted != null) conn.Execute("UPDATE agents SET avatar = @converted WHERE agent_id = @agentId", new { converted, agentId }); } // Session Images var sessionImages = conn.Query<(string SessionId, string Image)>( "SELECT session_id, image FROM sessions WHERE image IS NOT NULL AND image != '' AND image NOT LIKE 'data:image/webp%'") .ToList(); foreach (var (sessionId, image) in sessionImages) { var converted = ConvertDataUrlToWebP(image, webPQuality); if (converted != null) conn.Execute("UPDATE sessions SET image = @converted WHERE session_id = @sessionId", new { converted, sessionId }); } if (agentImages.Count > 0 || sessionImages.Count > 0) Console.WriteLine($"[Migration] Converted {agentImages.Count} agent avatars and {sessionImages.Count} session images to WebP"); } private void MigrateImageThumbnails(SqliteConnection conn) { const int smallSize = 256; const int smallQuality = 70; // Agent Avatare ohne Thumbnail var agentsWithoutThumb = conn.Query<(string AgentId, string Avatar)>( "SELECT agent_id, avatar FROM agents WHERE avatar IS NOT NULL AND avatar != '' AND (avatar_small IS NULL OR avatar_small = '')") .ToList(); foreach (var (agentId, avatar) in agentsWithoutThumb) { var small = ConvertDataUrlToWebP(avatar, smallQuality, maxSide: smallSize); if (small != null) conn.Execute("UPDATE agents SET avatar_small = @small WHERE agent_id = @agentId", new { small, agentId }); } // Session Images ohne Thumbnail var sessionsWithoutThumb = conn.Query<(string SessionId, string Image)>( "SELECT session_id, image FROM sessions WHERE image IS NOT NULL AND image != '' AND (image_small IS NULL OR image_small = '')") .ToList(); foreach (var (sessionId, image) in sessionsWithoutThumb) { var small = ConvertDataUrlToWebP(image, smallQuality, maxSide: smallSize); if (small != null) conn.Execute("UPDATE sessions SET image_small = @small WHERE session_id = @sessionId", new { small, sessionId }); } if (agentsWithoutThumb.Count > 0 || sessionsWithoutThumb.Count > 0) Console.WriteLine($"[Migration] Generated {agentsWithoutThumb.Count} agent thumbnails and {sessionsWithoutThumb.Count} session thumbnails"); } /// /// Konvertiert ein DataUrl-Bild zu WebP. Optional Resize wenn maxSide > 0. /// private static string ConvertDataUrlToWebP(string dataUrl, int quality, int maxSide = 0) { try { var commaIndex = dataUrl.IndexOf(','); if (commaIndex < 0) return null; var base64 = dataUrl.Substring(commaIndex + 1); var bytes = Convert.FromBase64String(base64); using var image = Image.Load(bytes); if (maxSide > 0 && (image.Width > maxSide || image.Height > maxSide)) { image.Mutate(x => x.Resize(new ResizeOptions { Size = new Size(maxSide, maxSide), Mode = ResizeMode.Max })); } using var ms = new MemoryStream(); image.Save(ms, new WebpEncoder { Quality = quality }); return "data:image/webp;base64," + Convert.ToBase64String(ms.ToArray()); } catch { return null; } } 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 */ } } }