Files
gott/Services/DatabaseService.cs
T
Meleki dde0fede7d User-spezifische API-Key-Auswahl, globales is_active entfernt
Backend:
- is_active komplett entfernt, durch is_public (Admin-Toggle) ersetzt
- Neues selected_api_key_id auf users-Tabelle
- AnthropicService: globaler _token entfernt, apiToken pro Request
- Token-Durchreichung: StreamChat → ProcessStream → RunAgentChat →
  RunPreparer/RunReview/RunFinalizer, ExecuteTool, send_to_session
- ChatEndpoint: User-Key aus DB laden, an StreamChat übergeben
- TelegramService: Key per bot.UserId auflösen
- CronjobService: Key per job.UserId auflösen
- Neue Endpoints: GET /api/api-keys/available, PUT /api/api-keys/select/{keyId},
  GET /api/api-keys/selected, PUT /api/admin/api-keys/{keyId}/public,
  POST /api/admin/api-keys/check-all
- Program.cs: AnthropicService ohne Token-Parameter

Frontend:
- API-Keys-Page: Public-Toggle (Globe/Lock) statt Activate/Deactivate
- Alle prüfen Button für Batch-Key-Check
- Chat: Key-Auswahl für alle User (nicht nur Admin)
- Dummy-Badge wenn kein Key ausgewählt
- Fallback-Badge bei Key ohne Usage-Daten
- Key-Switch-Modal: apiGetAvailableKeys + apiSelectApiKey
2026-03-13 12:27:03 +01:00

462 lines
20 KiB
C#

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;
}
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);
");
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
TryAddColumn(conn, "users", "selected_api_key_id", "TEXT");
// Migration: Bestehende PNG/JPEG-Avatare/Images zu WebP konvertieren
MigrateImagesToWebP(conn);
// Migration: Small-Thumbnails für bestehende Bilder generieren
MigrateImageThumbnails(conn);
}
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");
}
/// <summary>
/// Konvertiert ein DataUrl-Bild zu WebP. Optional Resize wenn maxSide > 0.
/// </summary>
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 */ }
}
}