Workflow-status: a new WorkflowProgressTracker singleton taps the
suppressed dynamic-workflow subagent events (those carrying
parent_tool_use_id) into per-session telemetry without altering the
existing guard; the snapshot is serialized into the new chains.workflow_info
column on FinalizeChain (ChainId-matched so a panel member of the same
session cannot clear it) and exposed via GET /api/sessions/{id}/workflow-status
(live -> persisted -> none). The header menu gains a 'Workflow-Status' entry
with a modal that live-polls every 2s while running and self-stops on close.
Panel on-demand: Fusion/MoA is now a transient per-request multiModelMode
flag (like plan/ultracode) instead of a persisted session column. Session
settings keep only the panel members + moa_layers; the mode <select> was
removed and the composer dropdown drives the mode per request.
Docs: ARCHITECTURE.md, ARCHITECTURE_QUICK.md and README.md updated for both
features plus the already-shipped z.ai/GLM provider, GLM 5.2+ effort
passthrough, web-search-prime MCP and the ultracode toggle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
553 lines
24 KiB
C#
553 lines
24 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;
|
||
}
|
||
|
||
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, "chains", "workflow_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, "sessions", "unity_mcp_url", "TEXT");
|
||
TryAddColumn(conn, "sessions", "unity_mcp_token", "TEXT");
|
||
TryAddColumn(conn, "messages", "tag", "TEXT");
|
||
TryAddColumn(conn, "agents", "effort", "TEXT NOT NULL DEFAULT ''");
|
||
TryAddColumn(conn, "chains", "effort", "TEXT NOT NULL DEFAULT ''");
|
||
TryAddColumn(conn, "sessions", "multi_model_mode", "TEXT");
|
||
TryAddColumn(conn, "sessions", "panel_agent_ids", "TEXT");
|
||
TryAddColumn(conn, "sessions", "moa_layers", "INTEGER NOT NULL DEFAULT 2");
|
||
|
||
// Migration: 1M-Suffix entfernen (Mai 2026). Seit März 2026 ist 1M-Context Default
|
||
// bei Opus 4.6/4.7/4.8 + Sonnet 4.6 – kein "[1m]"-Suffix mehr nötig.
|
||
try
|
||
{
|
||
conn.Execute("UPDATE agents SET model = REPLACE(model, '[1m]', '') WHERE model LIKE '%[1m]%'");
|
||
conn.Execute("UPDATE chains SET model = REPLACE(model, '[1m]', '') WHERE model LIKE '%[1m]%'");
|
||
}
|
||
catch { /* idempotent */ }
|
||
|
||
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");
|
||
|
||
// OAuth-spezifische Spalten
|
||
TryAddColumn(conn, "api_keys", "refresh_token", "TEXT");
|
||
TryAddColumn(conn, "api_keys", "access_token_expires_at", "TEXT");
|
||
TryAddColumn(conn, "api_keys", "oauth_provider", "TEXT");
|
||
|
||
// users: ausgewählter API-Key (legacy, wird durch agent_api_keys ersetzt)
|
||
TryAddColumn(conn, "users", "selected_api_key_id", "TEXT");
|
||
|
||
// users: Berechtigungen (Default: true für Abwärtskompatibilität)
|
||
TryAddColumn(conn, "users", "can_create_agents", "INTEGER NOT NULL DEFAULT 1");
|
||
TryAddColumn(conn, "users", "can_use_cli", "INTEGER NOT NULL DEFAULT 1");
|
||
|
||
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<int>("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<string>(
|
||
"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<string>(
|
||
"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");
|
||
}
|
||
|
||
/// <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 */ }
|
||
}
|
||
|
||
}
|