From 2a2e98ca684ab2567406976e0c76fa5577f82df5 Mon Sep 17 00:00:00 2001 From: Meleki Date: Sat, 7 Mar 2026 20:09:01 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Thumbnail-Varianten=20(256px)=20f=C3=BC?= =?UTF-8?q?r=20Agent-Avatare=20und=20Session-Images?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ImageService: ResizeDataUrlSmall() für 256px WebP-Thumbnails (Quality 70) - Neue DB-Spalten: agents.avatar_small, sessions.image_small - Migration generiert Thumbnails für bestehende Bilder automatisch - Listen-Endpoints (/api/agents, /api/sessions) liefern kleine Variante - Detail-Endpoints liefern weiterhin das volle 512px-Bild - Frontend unverändert (Cards nutzen automatisch die kleineren Bilder) --- Endpoints/AgentEndpoint.cs | 9 +++- Endpoints/SessionEndpoint.cs | 5 +- Models/AgentRecord.cs | 3 +- Models/SessionRecord.cs | 1 + Services/DatabaseService.cs | 91 +++++++++++++++++++++++++++++++++--- Services/ImageService.cs | 22 +++++++-- 6 files changed, 116 insertions(+), 15 deletions(-) diff --git a/Endpoints/AgentEndpoint.cs b/Endpoints/AgentEndpoint.cs index 95e779f..c290c8b 100644 --- a/Endpoints/AgentEndpoint.cs +++ b/Endpoints/AgentEndpoint.cs @@ -42,7 +42,7 @@ public static class AgentEndpoint { a.AgentId, a.Name, - a.Avatar, + Avatar = a.AvatarSmall ?? a.Avatar, a.Description, a.Model, a.ToolChoice, @@ -96,6 +96,7 @@ public static class AgentEndpoint UserId = userId, Name = body.TryGetProperty("name", out var n) ? n.GetString() ?? "Neuer Agent" : "Neuer Agent", Avatar = imageService.ResizeDataUrl(avatarRaw), + AvatarSmall = imageService.ResizeDataUrlSmall(avatarRaw), Description = body.TryGetProperty("description", out var d) ? d.GetString() : null, Model = body.TryGetProperty("model", out var m) ? m.GetString() ?? "sonnet" : "sonnet", ToolChoice = body.TryGetProperty("toolChoice", out var tc) ? tc.GetString() ?? "any" : "any", @@ -131,7 +132,11 @@ public static class AgentEndpoint var body = await JsonSerializer.DeserializeAsync(ctx.Request.Body); if (body.TryGetProperty("name", out var n)) agent.Name = n.GetString(); - if (body.TryGetProperty("avatar", out var av)) agent.Avatar = imageService.ResizeDataUrl(av.GetString()); + if (body.TryGetProperty("avatar", out var av)) + { + agent.Avatar = imageService.ResizeDataUrl(av.GetString()); + agent.AvatarSmall = imageService.ResizeDataUrlSmall(av.GetString()); + } if (body.TryGetProperty("description", out var d)) agent.Description = d.GetString(); if (body.TryGetProperty("model", out var m)) agent.Model = m.GetString(); if (body.TryGetProperty("toolChoice", out var tc)) agent.ToolChoice = tc.GetString(); diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs index 4366087..71a6bda 100644 --- a/Endpoints/SessionEndpoint.cs +++ b/Endpoints/SessionEndpoint.cs @@ -66,7 +66,7 @@ public static class SessionEndpoint s.SessionId, s.Name, s.Description, - s.Image, + Image = s.ImageSmall ?? s.Image, s.AgentId, s.Workspace, s.Context, @@ -120,6 +120,7 @@ public static class SessionEndpoint var description = body.TryGetProperty("description", out var d) ? d.GetString() : null; var imageRaw = body.TryGetProperty("image", out var img) ? img.GetString() : null; var image = imageService.ResizeDataUrl(imageRaw); + var imageSmall = imageService.ResizeDataUrlSmall(imageRaw); var agentId = body.TryGetProperty("agentId", out var a) ? a.GetString() : null; var workspaceRaw = body.TryGetProperty("workspace", out var w) ? w.GetString() : null; // Relativen Workspace validieren (Path-Traversal verhindern) @@ -142,7 +143,7 @@ public static class SessionEndpoint var finalizerAgentId = body.TryGetProperty("finalizerAgentId", out var fa) ? fa.GetString() : null; var preparerAgentId = body.TryGetProperty("preparerAgentId", out var pra) ? pra.GetString() : null; - var session = await db.CreateSession(userId, name, description, image, agentId, workspace, context, customCommands, avatarScale, avatarOffsetX, avatarOffsetY, reviewAgentId, maxReviewIterations, finalizerAgentId, preparerAgentId); + var session = await db.CreateSession(userId, name, description, image, imageSmall, agentId, workspace, context, customCommands, avatarScale, avatarOffsetX, avatarOffsetY, reviewAgentId, maxReviewIterations, finalizerAgentId, preparerAgentId); await ctx.Response.WriteAsJsonAsync(session); } diff --git a/Models/AgentRecord.cs b/Models/AgentRecord.cs index aca24c8..f40dbcb 100644 --- a/Models/AgentRecord.cs +++ b/Models/AgentRecord.cs @@ -5,7 +5,8 @@ public class AgentRecord public long Id { get; set; } public string AgentId { get; set; } public string Name { get; set; } - public string Avatar { get; set; } // base64 data url + public string Avatar { get; set; } // base64 data url (512px) + public string AvatarSmall { get; set; } // base64 data url (256px, für Listen) public string Description { get; set; } public string Model { get; set; } // sonnet, sonnet-4-5, sonnet-4-6, opus, opus-4-5, opus-4-6, haiku, haiku-4-5 public string ToolChoice { get; set; } // "any" oder "auto" diff --git a/Models/SessionRecord.cs b/Models/SessionRecord.cs index d9884e8..ea1dfb8 100644 --- a/Models/SessionRecord.cs +++ b/Models/SessionRecord.cs @@ -7,6 +7,7 @@ public class SessionRecord public string Name { get; set; } public string Description { get; set; } public string Image { get; set; } + public string ImageSmall { get; set; } // 256px Thumbnail für Listen public string AgentId { get; set; } public string Workspace { get; set; } public string Context { get; set; } diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index 197a6a0..49fdfd5 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -8,6 +8,7 @@ using Gott.Models; using Microsoft.Data.Sqlite; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Webp; +using SixLabors.ImageSharp.Processing; namespace Gott.Services; @@ -262,8 +263,14 @@ public class DatabaseService CREATE INDEX IF NOT EXISTS idx_shared_messages_message ON shared_messages(message_id); "); + TryAddColumn(conn, "agents", "avatar_small", "TEXT"); + TryAddColumn(conn, "sessions", "image_small", "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) @@ -298,6 +305,70 @@ public class DatabaseService 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 = ResizeDataUrlToWebP(avatar, smallSize, smallQuality); + 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 = ResizeDataUrlToWebP(image, smallSize, smallQuality); + 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"); + } + + private static string ResizeDataUrlToWebP(string dataUrl, int maxSide, int quality) + { + 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 (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 static string ConvertDataUrlToWebP(string dataUrl, int quality) { try @@ -395,8 +466,8 @@ public class DatabaseService 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, allow_user_choice, task_instruction, 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, @AllowUserChoice, @TaskInstruction, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, 0, @CreatedAt, @UpdatedAt) + INSERT INTO agents (agent_id, user_id, name, avatar, avatar_small, description, model, tool_choice, custom_commands, auto_commit, use_soul, use_identity, use_user_info, use_memory, use_web_search, web_search_version, allow_user_choice, task_instruction, avatar_scale, avatar_offset_x, avatar_offset_y, is_deleted, created_at, updated_at) + VALUES (@AgentId, @UserId, @Name, @Avatar, @AvatarSmall, @Description, @Model, @ToolChoice, @CustomCommands, @AutoCommit, @UseSoul, @UseIdentity, @UseUserInfo, @UseMemory, @UseWebSearch, @WebSearchVersion, @AllowUserChoice, @TaskInstruction, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, 0, @CreatedAt, @UpdatedAt) ", agent); return agent; } @@ -420,7 +491,7 @@ public class DatabaseService using var conn = CreateConnection(); agent.UpdatedAt = DateTime.UtcNow.ToString("o"); await conn.ExecuteAsync(@" - UPDATE agents SET name = @Name, avatar = @Avatar, description = @Description, + UPDATE agents SET name = @Name, avatar = @Avatar, avatar_small = @AvatarSmall, 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, @@ -440,7 +511,7 @@ public class DatabaseService // ── 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, string preparerAgentId = null) + public async Task CreateSession(string userId, string name = null, string description = null, string image = null, string imageSmall = 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, string preparerAgentId = null) { using var conn = CreateConnection(); var now = DateTime.UtcNow.ToString("o"); @@ -451,6 +522,7 @@ public class DatabaseService Name = name, Description = description, Image = image, + ImageSmall = imageSmall, AgentId = agentId, Workspace = workspace, Context = context, @@ -466,8 +538,8 @@ public class DatabaseService 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, preparer_agent_id, created_at, updated_at) - VALUES (@SessionId, @UserId, @Name, @Description, @Image, @AgentId, @Workspace, @Context, @CustomCommands, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, @ReviewAgentId, @MaxReviewIterations, @FinalizerAgentId, @PreparerAgentId, @CreatedAt, @UpdatedAt) + INSERT INTO sessions (session_id, user_id, name, description, image, image_small, agent_id, workspace, context, custom_commands, avatar_scale, avatar_offset_x, avatar_offset_y, review_agent_id, max_review_iterations, finalizer_agent_id, preparer_agent_id, created_at, updated_at) + VALUES (@SessionId, @UserId, @Name, @Description, @Image, @ImageSmall, @AgentId, @Workspace, @Context, @CustomCommands, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, @ReviewAgentId, @MaxReviewIterations, @FinalizerAgentId, @PreparerAgentId, @CreatedAt, @UpdatedAt) ", session); return session; } @@ -541,7 +613,12 @@ public class DatabaseService if (body.TryGetProperty("image", out var img)) { var imageData = img.GetString(); - if (imageService != null) imageData = imageService.ResizeDataUrl(imageData); + if (imageService != null) + { + sets.Add("image_small = @imageSmall"); + p.Add("imageSmall", imageService.ResizeDataUrlSmall(imageData)); + imageData = imageService.ResizeDataUrl(imageData); + } sets.Add("image = @image"); p.Add("image", imageData); } diff --git a/Services/ImageService.cs b/Services/ImageService.cs index 5f247e5..165dbec 100644 --- a/Services/ImageService.cs +++ b/Services/ImageService.cs @@ -9,7 +9,9 @@ namespace Gott.Services; public class ImageService { private const int MaxSideLength = 512; + private const int SmallSideLength = 256; private const int WebPQuality = 80; + private const int WebPQualitySmall = 70; /// /// Nimmt eine Base64-Data-URL (z.B. "data:image/png;base64,..."), skaliert das Bild @@ -17,6 +19,20 @@ public class ImageService /// Gibt null zurück wenn input null/leer ist. /// public string ResizeDataUrl(string dataUrl) + { + return ResizeToWebP(dataUrl, MaxSideLength, WebPQuality); + } + + /// + /// Erzeugt eine kleine Thumbnail-Variante (max 256px, Quality 70) für Dashboard-Listen. + /// Gibt null zurück wenn input null/leer ist. + /// + public string ResizeDataUrlSmall(string dataUrl) + { + return ResizeToWebP(dataUrl, SmallSideLength, WebPQualitySmall); + } + + private static string ResizeToWebP(string dataUrl, int maxSide, int quality) { if (string.IsNullOrEmpty(dataUrl)) return null; @@ -30,18 +46,18 @@ public class ImageService using var image = Image.Load(bytes); // Skalieren wenn nötig - if (image.Width > MaxSideLength || image.Height > MaxSideLength) + if (image.Width > maxSide || image.Height > maxSide) { image.Mutate(x => x.Resize(new ResizeOptions { - Size = new Size(MaxSideLength, MaxSideLength), + Size = new Size(maxSide, maxSide), Mode = ResizeMode.Max })); } // Immer als WebP encodieren using var ms = new MemoryStream(); - image.Save(ms, new WebpEncoder { Quality = WebPQuality }); + image.Save(ms, new WebpEncoder { Quality = quality }); var webpBase64 = Convert.ToBase64String(ms.ToArray()); return "data:image/webp;base64," + webpBase64;