diff --git a/Endpoints/AgentEndpoint.cs b/Endpoints/AgentEndpoint.cs index e8aecaa..ff29be6 100644 --- a/Endpoints/AgentEndpoint.cs +++ b/Endpoints/AgentEndpoint.cs @@ -27,57 +27,22 @@ public static class AgentEndpoint { var userId = ctx.GetUserId(); var agents = await db.GetAgents(userId); - var allSkills = skillService.GetAllSkills(); - var skillMap = allSkills.ToDictionary(s => s.Slug); - - var results = new List(); - foreach (var a in agents) - { - var agentSkillSlugs = await db.GetAgentSkills(a.AgentId); - var skills = agentSkillSlugs - .Where(slug => skillMap.ContainsKey(slug)) - .Select(slug => new { slug, name = skillMap[slug].Name, description = skillMap[slug].Description }) - .ToList(); - - results.Add(new - { - a.AgentId, - a.Name, - Avatar = a.AvatarSmall ?? a.Avatar, - a.Description, - a.Model, - a.CustomCommands, - a.AutoCommit, - a.UseSoul, - a.UseIdentity, - a.UseUserInfo, - a.UseMemory, - a.UseWebSearch, - a.WebSearchVersion, - a.AllowUserChoice, - a.TaskInstruction, - a.AvatarScale, - a.AvatarOffsetX, - a.AvatarOffsetY, - a.IsPublic, - a.IsDeleted, - a.CreatedAt, - a.UpdatedAt, - Skills = skills - }); - } - - await ctx.Response.WriteAsJsonAsync(results); + await ctx.Response.WriteAsJsonAsync(await MapAgentList(agents, userId, db, skillService)); } private static async Task HandleListAvailable(HttpContext ctx, DatabaseService db, SkillService skillService) { var userId = ctx.GetUserId(); var agents = await db.GetAvailableAgents(userId); - var allSkills = skillService.GetAllSkills(); - var skillMap = allSkills.ToDictionary(s => s.Slug); + await ctx.Response.WriteAsJsonAsync(await MapAgentList(agents, userId, db, skillService, includeOwner: true)); + } + private static async Task> MapAgentList( + IEnumerable agents, string userId, DatabaseService db, SkillService skillService, bool includeOwner = false) + { + var skillMap = skillService.GetAllSkills().ToDictionary(s => s.Slug); var results = new List(); + foreach (var a in agents) { var agentSkillSlugs = await db.GetAgentSkills(a.AgentId); @@ -110,12 +75,12 @@ public static class AgentEndpoint a.IsDeleted, a.CreatedAt, a.UpdatedAt, - IsOwner = a.UserId == userId, + IsOwner = includeOwner ? (bool?)(a.UserId == userId) : null, Skills = skills }); } - await ctx.Response.WriteAsJsonAsync(results); + return results; } private static async Task HandleGet(HttpContext ctx, DatabaseService db) diff --git a/Endpoints/CronjobEndpoint.cs b/Endpoints/CronjobEndpoint.cs index 99c624a..cc51c20 100644 --- a/Endpoints/CronjobEndpoint.cs +++ b/Endpoints/CronjobEndpoint.cs @@ -2,6 +2,7 @@ using System; using System.Text.Json; using System.Threading.Tasks; using Gott.Models; +using Gott.Extensions; using Gott.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -15,14 +16,14 @@ public static class CronjobEndpoint { app.MapGet("/api/cronjobs", async (HttpContext ctx, DatabaseService db) => { - var userId = ctx.Items["UserId"]?.ToString(); + var userId = ctx.GetUserId(); var cronjobs = await db.GetCronjobs(userId); return Results.Ok(cronjobs); }); app.MapGet("/api/cronjobs/{id}", async (string id, HttpContext ctx, DatabaseService db) => { - var userId = ctx.Items["UserId"]?.ToString(); + var userId = ctx.GetUserId(); var cronjob = await db.GetCronjob(id); if (cronjob == null || cronjob.UserId != userId) return Results.NotFound(); return Results.Ok(cronjob); @@ -30,7 +31,7 @@ public static class CronjobEndpoint app.MapPost("/api/cronjobs", async (HttpContext ctx, DatabaseService db) => { - var userId = ctx.Items["UserId"]?.ToString(); + var userId = ctx.GetUserId(); var body = await JsonSerializer.DeserializeAsync(ctx.Request.Body); var name = body.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; @@ -79,7 +80,7 @@ public static class CronjobEndpoint app.MapPut("/api/cronjobs/{id}", async (string id, HttpContext ctx, DatabaseService db) => { - var userId = ctx.Items["UserId"]?.ToString(); + var userId = ctx.GetUserId(); var existing = await db.GetCronjob(id); if (existing == null || existing.UserId != userId) return Results.NotFound(); @@ -134,7 +135,7 @@ public static class CronjobEndpoint app.MapDelete("/api/cronjobs/{id}", async (string id, HttpContext ctx, DatabaseService db) => { - var userId = ctx.Items["UserId"]?.ToString(); + var userId = ctx.GetUserId(); var existing = await db.GetCronjob(id); if (existing == null || existing.UserId != userId) return Results.NotFound(); @@ -144,7 +145,7 @@ public static class CronjobEndpoint app.MapPost("/api/cronjobs/{id}/toggle", async (string id, HttpContext ctx, DatabaseService db) => { - var userId = ctx.Items["UserId"]?.ToString(); + var userId = ctx.GetUserId(); var existing = await db.GetCronjob(id); if (existing == null || existing.UserId != userId) return Results.NotFound(); @@ -158,7 +159,7 @@ public static class CronjobEndpoint app.MapPost("/api/cronjobs/{id}/run", async (string id, HttpContext ctx, DatabaseService db, CronjobService cronjobService) => { - var userId = ctx.Items["UserId"]?.ToString(); + var userId = ctx.GetUserId(); var existing = await db.GetCronjob(id); if (existing == null || existing.UserId != userId) return Results.NotFound(); diff --git a/Endpoints/KreaEndpoint.cs b/Endpoints/KreaEndpoint.cs index b5e50c3..e83bbf2 100644 --- a/Endpoints/KreaEndpoint.cs +++ b/Endpoints/KreaEndpoint.cs @@ -7,9 +7,6 @@ using Gott.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Processing; -using SixLabors.ImageSharp.Formats.Webp; namespace Gott.Endpoints { @@ -142,50 +139,31 @@ namespace Gott.Endpoints return; } - // Cache-Verzeichnis: data/cache/images/ var imagesDir = Path.GetDirectoryName(originalPath); var dataDir = Path.GetDirectoryName(imagesDir); var cacheDir = Path.Combine(dataDir, "cache", "images"); - Directory.CreateDirectory(cacheDir); - var thumbFilename = Path.GetFileNameWithoutExtension(filename) + ".webp"; - var thumbPath = Path.Combine(cacheDir, thumbFilename); - - if (!File.Exists(thumbPath) || File.GetLastWriteTimeUtc(originalPath) > File.GetLastWriteTimeUtc(thumbPath)) + var thumbPath = await ImageService.GenerateThumbnail(originalPath, cacheDir); + if (thumbPath != null) { - try + res.ContentType = "image/webp"; + res.Headers["Cache-Control"] = "private, max-age=86400"; + await res.SendFileAsync(thumbPath); + } + else + { + // Fallback: Original ausliefern + res.ContentType = ext switch { - using var image = await Image.LoadAsync(originalPath); - var maxSize = 320; - image.Mutate(x => x.Resize(new ResizeOptions - { - Size = new Size(maxSize, maxSize), - Mode = ResizeMode.Max - })); - var encoder = new WebpEncoder { Quality = 75 }; - await image.SaveAsync(thumbPath, encoder); - } - catch - { - // Fallback: Original ausliefern - var contentType = ext switch - { - ".png" => "image/png", - ".jpg" => "image/jpeg", - ".jpeg" => "image/jpeg", - ".gif" => "image/gif", - _ => "image/webp", - }; - res.ContentType = contentType; - res.Headers["Cache-Control"] = "private, max-age=3600"; - await res.SendFileAsync(originalPath); - return; - } + ".png" => "image/png", + ".jpg" => "image/jpeg", + ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + _ => "image/webp", + }; + res.Headers["Cache-Control"] = "private, max-age=3600"; + await res.SendFileAsync(originalPath); } - - res.ContentType = "image/webp"; - res.Headers["Cache-Control"] = "private, max-age=86400"; - await res.SendFileAsync(thumbPath); } } } diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs index 71a6bda..df9d64b 100644 --- a/Endpoints/SessionEndpoint.cs +++ b/Endpoints/SessionEndpoint.cs @@ -198,28 +198,26 @@ public static class SessionEndpoint ctx.Response.StatusCode = 204; } - private static async Task HandleSystemPrompt(HttpContext ctx, DatabaseService db, MemoryService memoryService, SkillService skillService) + /// + /// Löst Session, Agent, Workspace und Prompt-Abhängigkeiten auf. + /// Gibt null zurück und setzt StatusCode bei Fehler. + /// + private static async Task<(SessionRecord session, AgentRecord agent, IEnumerable marked, + string memDir, IEnumerable skills, UserRecord user, string workspace, bool planMode)?> + ResolvePromptContext(HttpContext ctx, DatabaseService db, MemoryService memoryService) { var userId = ctx.GetUserId(); var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString(); - if (string.IsNullOrEmpty(sessionId)) - { - ctx.Response.StatusCode = 400; - return; - } + if (string.IsNullOrEmpty(sessionId)) { ctx.Response.StatusCode = 400; return null; } var session = await db.GetSession(sessionId); - if (session == null || session.UserId != userId) - { - ctx.Response.StatusCode = 404; - return; - } + if (session == null || session.UserId != userId) { ctx.Response.StatusCode = 404; return null; } if (string.IsNullOrEmpty(session.AgentId)) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync(new { error = "Kein Agent ausgewählt" }); - return; + return null; } var agent = await db.GetAgent(session.AgentId); @@ -227,18 +225,29 @@ public static class SessionEndpoint { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync(new { error = "Agent nicht gefunden" }); - return; + return null; } - var markedMessages = await db.GetMarkedMessages(sessionId); - var agentMemoryDir = memoryService.GetAgentMemoryDir(agent.AgentId); - var agentSkills = await db.GetAgentSkills(agent.AgentId); + var marked = await db.GetMarkedMessages(sessionId); + var memDir = memoryService.GetAgentMemoryDir(agent.AgentId); + var skills = await db.GetAgentSkills(agent.AgentId); var user = await db.GetUser(userId); - var resolvedWorkspace = WorkspaceHelper.ResolveWorkspace(session, user); - if (string.IsNullOrEmpty(resolvedWorkspace) && agentSkills.Any()) - resolvedWorkspace = "/tmp"; + var workspace = WorkspaceHelper.ResolveWorkspace(session, user); + if (string.IsNullOrEmpty(workspace) && skills.Any()) + workspace = "/tmp"; var planMode = ctx.Request.Query.ContainsKey("planMode") && ctx.Request.Query["planMode"] == "true"; - var systemPrompt = SystemPromptBuilder.Build(agent, session, markedMessages, memoryService, agentMemoryDir, agentSkills, skillService, userPersona: user?.GetEffectivePersona(), resolvedWorkspace: resolvedWorkspace, planMode: planMode); + + return (session, agent, marked, memDir, skills, user, workspace, planMode); + } + + private static async Task HandleSystemPrompt(HttpContext ctx, DatabaseService db, MemoryService memoryService, SkillService skillService) + { + var result = await ResolvePromptContext(ctx, db, memoryService); + if (result == null) return; + var (session, agent, marked, memDir, skills, user, workspace, planMode) = result.Value; + + var systemPrompt = SystemPromptBuilder.Build(agent, session, marked, memoryService, memDir, + skills, skillService, userPersona: user?.GetEffectivePersona(), resolvedWorkspace: workspace, planMode: planMode); ctx.Response.ContentType = "text/plain; charset=utf-8"; await ctx.Response.WriteAsync(systemPrompt); @@ -246,37 +255,12 @@ public static class SessionEndpoint private static async Task HandleSystemPromptBlocks(HttpContext ctx, DatabaseService db, MemoryService memoryService, SkillService skillService) { - var userId = ctx.GetUserId(); - var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString(); - if (string.IsNullOrEmpty(sessionId)) { ctx.Response.StatusCode = 400; return; } + var result = await ResolvePromptContext(ctx, db, memoryService); + if (result == null) return; + var (session, agent, marked, memDir, skills, user, workspace, planMode) = result.Value; - var session = await db.GetSession(sessionId); - if (session == null || session.UserId != userId) { ctx.Response.StatusCode = 404; return; } - - if (string.IsNullOrEmpty(session.AgentId)) - { - ctx.Response.StatusCode = 400; - await ctx.Response.WriteAsJsonAsync(new { error = "Kein Agent ausgewählt" }); - return; - } - - var agent = await db.GetAgent(session.AgentId); - if (agent == null) - { - ctx.Response.StatusCode = 400; - await ctx.Response.WriteAsJsonAsync(new { error = "Agent nicht gefunden" }); - return; - } - - var markedMessages = await db.GetMarkedMessages(sessionId); - var agentMemoryDir = memoryService.GetAgentMemoryDir(agent.AgentId); - var agentSkills = await db.GetAgentSkills(agent.AgentId); - var user = await db.GetUser(userId); - var resolvedWorkspace = WorkspaceHelper.ResolveWorkspace(session, user); - if (string.IsNullOrEmpty(resolvedWorkspace) && agentSkills.Any()) - resolvedWorkspace = "/tmp"; - var planMode = ctx.Request.Query.ContainsKey("planMode") && ctx.Request.Query["planMode"] == "true"; - var blocks = SystemPromptBuilder.BuildBlocks(agent, session, markedMessages, memoryService, agentMemoryDir, agentSkills, skillService, userPersona: user?.GetEffectivePersona(), resolvedWorkspace: resolvedWorkspace, planMode: planMode); + var blocks = SystemPromptBuilder.BuildBlocks(agent, session, marked, memoryService, memDir, + skills, skillService, userPersona: user?.GetEffectivePersona(), resolvedWorkspace: workspace, planMode: planMode); await ctx.Response.WriteAsJsonAsync(blocks.Select(b => new { b.Icon, b.Label, b.Content })); } diff --git a/Endpoints/SkillEndpoint.cs b/Endpoints/SkillEndpoint.cs index ba7e35c..21714a1 100644 --- a/Endpoints/SkillEndpoint.cs +++ b/Endpoints/SkillEndpoint.cs @@ -9,9 +9,6 @@ using Gott.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Processing; -using SixLabors.ImageSharp.Formats.Webp; namespace Gott.Endpoints; @@ -186,44 +183,22 @@ public static class SkillEndpoint var originalPath = Path.Combine(skill.Path, "output", filename); if (!File.Exists(originalPath)) { ctx.Response.StatusCode = 404; return; } - // Cache-Verzeichnis: data/cache/skills/{slug}/ var dataDir = Path.GetDirectoryName(skillService.SkillsDir); var cacheDir = Path.Combine(dataDir, "cache", "skills", slug); - Directory.CreateDirectory(cacheDir); - var thumbFilename = Path.GetFileNameWithoutExtension(filename) + ".webp"; - var thumbPath = Path.Combine(cacheDir, thumbFilename); - - // Thumbnail generieren wenn nicht gecacht oder Original neuer - if (!File.Exists(thumbPath) || File.GetLastWriteTimeUtc(originalPath) > File.GetLastWriteTimeUtc(thumbPath)) + var thumbPath = await ImageService.GenerateThumbnail(originalPath, cacheDir); + if (thumbPath != null) { - try - { - using var image = await Image.LoadAsync(originalPath); - - // Max 320px auf der längsten Seite - var maxSize = 320; - image.Mutate(x => x.Resize(new ResizeOptions - { - Size = new Size(maxSize, maxSize), - Mode = ResizeMode.Max - })); - - var encoder = new WebpEncoder { Quality = 75 }; - await image.SaveAsync(thumbPath, encoder); - } - catch - { - // Fallback: Original ausliefern wenn Thumbnail-Generierung fehlschlägt - ctx.Response.ContentType = MimeTypes.GetValueOrDefault(ext, "application/octet-stream"); - ctx.Response.Headers["Cache-Control"] = "private, max-age=3600"; - await ctx.Response.SendFileAsync(originalPath); - return; - } + ctx.Response.ContentType = "image/webp"; + ctx.Response.Headers["Cache-Control"] = "private, max-age=86400"; + await ctx.Response.SendFileAsync(thumbPath); + } + else + { + // Fallback: Original ausliefern + ctx.Response.ContentType = MimeTypes.GetValueOrDefault(ext, "application/octet-stream"); + ctx.Response.Headers["Cache-Control"] = "private, max-age=3600"; + await ctx.Response.SendFileAsync(originalPath); } - - ctx.Response.ContentType = "image/webp"; - ctx.Response.Headers["Cache-Control"] = "private, max-age=86400"; - await ctx.Response.SendFileAsync(thumbPath); } } diff --git a/Endpoints/TelegramEndpoint.cs b/Endpoints/TelegramEndpoint.cs index 007910b..36236e1 100644 --- a/Endpoints/TelegramEndpoint.cs +++ b/Endpoints/TelegramEndpoint.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Threading.Tasks; using Gott.Extensions; using Gott.Services; +using Gott.Models; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -72,26 +73,29 @@ public static class TelegramEndpoint // ── Bot CRUD ── + private static async Task MapBotResponse(TelegramBotRecord bot, DatabaseService db) + { + var sessionCount = await db.GetTelegramBotSessionCount(bot.BotId); + return new + { + botId = bot.BotId, + name = bot.Name, + botToken = bot.BotToken, + webhookSecret = bot.WebhookSecret, + isPublic = bot.IsPublic, + sessionCount, + createdAt = bot.CreatedAt, + updatedAt = bot.UpdatedAt + }; + } + private static async Task HandleGetBots(HttpRequest req, HttpResponse res, DatabaseService db) { var userId = req.HttpContext.GetUserId(); var bots = await db.GetTelegramBots(userId); var result = new System.Collections.Generic.List(); foreach (var bot in bots) - { - var sessionCount = await db.GetTelegramBotSessionCount(bot.BotId); - result.Add(new - { - botId = bot.BotId, - name = bot.Name, - botToken = bot.BotToken, - webhookSecret = bot.WebhookSecret, - isPublic = bot.IsPublic, - sessionCount, - createdAt = bot.CreatedAt, - updatedAt = bot.UpdatedAt - }); - } + result.Add(await MapBotResponse(bot, db)); await res.WriteAsJsonAsync(result); } @@ -106,18 +110,7 @@ public static class TelegramEndpoint return; } - var sessionCount = await db.GetTelegramBotSessionCount(bot.BotId); - await res.WriteAsJsonAsync(new - { - botId = bot.BotId, - name = bot.Name, - botToken = bot.BotToken, - webhookSecret = bot.WebhookSecret, - isPublic = bot.IsPublic, - sessionCount, - createdAt = bot.CreatedAt, - updatedAt = bot.UpdatedAt - }); + await res.WriteAsJsonAsync(await MapBotResponse(bot, db)); } private static async Task HandleCreateBot(HttpRequest req, HttpResponse res, DatabaseService db) diff --git a/Endpoints/WorkspaceEndpoint.cs b/Endpoints/WorkspaceEndpoint.cs index fa10a57..f6260bd 100644 --- a/Endpoints/WorkspaceEndpoint.cs +++ b/Endpoints/WorkspaceEndpoint.cs @@ -38,27 +38,40 @@ public static class WorkspaceEndpoint public static void MapWorkspaceEndpoint(this IEndpointRouteBuilder app) { - app.MapGet("/api/workspace/tree", HandleTree); - app.MapGet("/api/workspace/file", HandleFile); - app.MapPut("/api/workspace/file", HandleFileSave); + // Session-Workspace-Endpunkte + app.MapGet("/api/workspace/tree", async ctx => + await HandleTreeCore(ctx, await ResolveSessionBasePath(ctx))); + app.MapGet("/api/workspace/file", async ctx => + await HandleFileCore(ctx, await ResolveSessionBasePath(ctx))); + app.MapPut("/api/workspace/file", async ctx => + await HandleFileSaveCore(ctx, await ResolveSessionBasePath(ctx), allowCreate: false)); + app.MapPost("/api/workspace/mkdir", async ctx => + await HandleMkdirCore(ctx, await ResolveSessionBasePath(ctx))); + + // Agent-Memory-Endpunkte (gleiche Logik, anderer basePath) + app.MapGet("/api/workspace/agent/tree", async ctx => + await HandleTreeCore(ctx, ResolveAgentBasePath(ctx))); + app.MapGet("/api/workspace/agent/file", async ctx => + await HandleFileCore(ctx, ResolveAgentBasePath(ctx))); + app.MapPut("/api/workspace/agent/file", async ctx => + await HandleFileSaveCore(ctx, ResolveAgentBasePath(ctx), allowCreate: true)); + app.MapPost("/api/workspace/agent/mkdir", async ctx => + await HandleMkdirCore(ctx, ResolveAgentBasePath(ctx))); + + // Sonstige app.MapGet("/api/workspace/browse", HandleBrowse); app.MapPost("/api/workspace/browse/mkdir", HandleBrowseMkdir); - app.MapGet("/api/workspace/agent/tree", HandleAgentTree); - app.MapGet("/api/workspace/agent/file", HandleAgentFile); - app.MapPut("/api/workspace/agent/file", HandleAgentFileSave); app.MapGet("/api/workspace/raw", HandleRawFile); - app.MapPost("/api/workspace/mkdir", HandleMkdir); - app.MapDelete("/api/workspace/dir", HandleRmdir); - app.MapPost("/api/workspace/agent/mkdir", HandleAgentMkdir); - app.MapDelete("/api/workspace/agent/dir", HandleAgentRmdir); } + // ── BasePath-Auflösung ────────────────────────────────────────────── + /// - /// Lädt Session + User und löst den Workspace-Pfad auf (relativ → absolut via DefaultPath). - /// Gibt null zurück und setzt StatusCode 400, wenn kein gültiger Workspace vorhanden ist. + /// Löst den Workspace-Pfad einer Session auf. Gibt null zurück + setzt 400 bei Fehler. /// - private static async Task<(SessionRecord session, string basePath)?> ResolveSessionWorkspace(HttpContext ctx, string sessionId) + private static async Task ResolveSessionBasePath(HttpContext ctx) { + var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault(); var db = ctx.RequestServices.GetRequiredService(); var session = await db.GetSession(sessionId ?? ""); if (session == null || session.UserId != ctx.GetUserId() || string.IsNullOrEmpty(session.Workspace)) @@ -78,20 +91,40 @@ public static class WorkspaceEndpoint return null; } - return (session, Path.GetFullPath(resolved)); + return Path.GetFullPath(resolved); } - private static async Task HandleTree(HttpContext ctx) + /// + /// Löst den Memory-Pfad eines Agents auf. Gibt null zurück + setzt 400 bei Fehler. + /// + private static string ResolveAgentBasePath(HttpContext ctx) { - var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault(); - var subPath = ctx.Request.Query["path"].FirstOrDefault() ?? ""; + var agentId = ctx.Request.Query["agentId"].FirstOrDefault(); + if (string.IsNullOrEmpty(agentId)) + { + ctx.Response.StatusCode = 400; + ctx.Response.WriteAsJsonAsync(new { error = "agentId fehlt" }).Wait(); + return null; + } - var result = await ResolveSessionWorkspace(ctx, sessionId); - if (result == null) return; - var (session, basePath) = result.Value; + var memoryService = ctx.RequestServices.GetRequiredService(); + var basePath = memoryService.GetAgentMemoryDir(agentId); + + if (!Directory.Exists(basePath)) + Directory.CreateDirectory(basePath); + + return basePath; + } + + // ── Generische Handler ────────────────────────────────────────────── + + private static async Task HandleTreeCore(HttpContext ctx, string basePath) + { + if (basePath == null) return; + + var subPath = ctx.Request.Query["path"].FirstOrDefault() ?? ""; var targetPath = Path.GetFullPath(Path.Combine(basePath, subPath)); - // Sicherheit: nicht aus dem Workspace ausbrechen if (!targetPath.StartsWith(basePath)) { ctx.Response.StatusCode = 403; @@ -107,17 +140,14 @@ public static class WorkspaceEndpoint var entries = new List(); - // Ordner foreach (var dir in Directory.GetDirectories(targetPath).OrderBy(d => Path.GetFileName(d), StringComparer.OrdinalIgnoreCase)) { var name = Path.GetFileName(dir); if (IgnoredDirs.Contains(name)) continue; - var relPath = Path.GetRelativePath(basePath, dir).Replace('\\', '/'); entries.Add(new { name, path = relPath, type = "dir" }); } - // Dateien foreach (var file in Directory.GetFiles(targetPath).OrderBy(f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase)) { var name = Path.GetFileName(file); @@ -125,30 +155,24 @@ public static class WorkspaceEndpoint var relPath = Path.GetRelativePath(basePath, file).Replace('\\', '/'); var size = new FileInfo(file).Length; var isText = TextExtensions.Contains(ext) || IsLikelyTextFile(name); - entries.Add(new { name, path = relPath, type = "file", ext, size, isText }); } await ctx.Response.WriteAsJsonAsync(entries); } - private static async Task HandleFile(HttpContext ctx) + private static async Task HandleFileCore(HttpContext ctx, string basePath) { - var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault(); - var filePath = ctx.Request.Query["path"].FirstOrDefault(); + if (basePath == null) return; + var filePath = ctx.Request.Query["path"].FirstOrDefault(); if (string.IsNullOrEmpty(filePath)) { ctx.Response.StatusCode = 400; return; } - var result = await ResolveSessionWorkspace(ctx, sessionId); - if (result == null) return; - var (session, basePath) = result.Value; - var targetPath = Path.GetFullPath(Path.Combine(basePath, filePath)); - if (!targetPath.StartsWith(basePath)) { ctx.Response.StatusCode = 403; @@ -162,8 +186,6 @@ public static class WorkspaceEndpoint } var info = new FileInfo(targetPath); - - // Max 2MB für Textdateien if (info.Length > 2 * 1024 * 1024) { ctx.Response.StatusCode = 413; @@ -184,33 +206,39 @@ public static class WorkspaceEndpoint }); } - private static async Task HandleFileSave(HttpContext ctx) + private static async Task HandleFileSaveCore(HttpContext ctx, string basePath, bool allowCreate) { - var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault(); - var filePath = ctx.Request.Query["path"].FirstOrDefault(); + if (basePath == null) return; + var filePath = ctx.Request.Query["path"].FirstOrDefault(); if (string.IsNullOrEmpty(filePath)) { ctx.Response.StatusCode = 400; return; } - var result = await ResolveSessionWorkspace(ctx, sessionId); - if (result == null) return; - var (session, basePath) = result.Value; - var targetPath = Path.GetFullPath(Path.Combine(basePath, filePath)); - if (!targetPath.StartsWith(basePath)) { ctx.Response.StatusCode = 403; return; } - if (!File.Exists(targetPath)) + if (allowCreate) { - ctx.Response.StatusCode = 404; - return; + // Agent-Memory: Verzeichnis erstellen falls nötig + var dir = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + } + else + { + // Session-Workspace: Datei muss existieren + if (!File.Exists(targetPath)) + { + ctx.Response.StatusCode = 404; + return; + } } using var reader = new StreamReader(ctx.Request.Body); @@ -230,6 +258,42 @@ public static class WorkspaceEndpoint }); } + private static async Task HandleMkdirCore(HttpContext ctx, string basePath) + { + if (basePath == null) return; + + var dirPath = ctx.Request.Query["path"].FirstOrDefault(); + if (string.IsNullOrEmpty(dirPath)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "Pfad fehlt" }); + return; + } + + var targetPath = Path.GetFullPath(Path.Combine(basePath, dirPath)); + if (!targetPath.StartsWith(basePath)) + { + ctx.Response.StatusCode = 403; + return; + } + + if (Directory.Exists(targetPath)) + { + ctx.Response.StatusCode = 409; + await ctx.Response.WriteAsJsonAsync(new { error = "Ordner existiert bereits" }); + return; + } + + Directory.CreateDirectory(targetPath); + await ctx.Response.WriteAsJsonAsync(new + { + path = Path.GetRelativePath(basePath, targetPath).Replace('\\', '/'), + created = true + }); + } + + // ── Sonstige Endpunkte ────────────────────────────────────────────── + private static async Task HandleBrowse(HttpContext ctx) { var path = ctx.Request.Query["path"].FirstOrDefault() ?? "/"; @@ -277,7 +341,6 @@ public static class WorkspaceEndpoint return; } - // Parent muss existieren var parent = Path.GetDirectoryName(targetPath); if (string.IsNullOrEmpty(parent) || !Directory.Exists(parent)) { @@ -290,345 +353,6 @@ public static class WorkspaceEndpoint await ctx.Response.WriteAsJsonAsync(new { path = targetPath.Replace('\\', '/'), created = true }); } - private static bool IsLikelyTextFile(string name) - { - var lower = name.ToLowerInvariant(); - return lower is "makefile" or "dockerfile" or "rakefile" or "gemfile" - or "procfile" or "vagrantfile" or "license" or "readme" - or "changelog" or "authors" or "contributors"; - } - - private static string GetAgentBasePath(HttpContext ctx, out string error) - { - error = null; - var agentId = ctx.Request.Query["agentId"].FirstOrDefault(); - if (string.IsNullOrEmpty(agentId)) - { - error = "agentId fehlt"; - return null; - } - - var memoryService = ctx.RequestServices.GetRequiredService(); - var basePath = memoryService.GetAgentMemoryDir(agentId); - - if (!Directory.Exists(basePath)) - Directory.CreateDirectory(basePath); - - return basePath; - } - - private static async Task HandleAgentTree(HttpContext ctx) - { - var basePath = GetAgentBasePath(ctx, out var error); - if (basePath == null) - { - ctx.Response.StatusCode = 400; - await ctx.Response.WriteAsJsonAsync(new { error }); - return; - } - - var subPath = ctx.Request.Query["path"].FirstOrDefault() ?? ""; - var targetPath = Path.GetFullPath(Path.Combine(basePath, subPath)); - - if (!targetPath.StartsWith(basePath)) - { - ctx.Response.StatusCode = 403; - return; - } - - if (!Directory.Exists(targetPath)) - { - ctx.Response.StatusCode = 404; - await ctx.Response.WriteAsJsonAsync(new { error = "Verzeichnis nicht gefunden" }); - return; - } - - var entries = new List(); - - foreach (var dir in Directory.GetDirectories(targetPath).OrderBy(d => Path.GetFileName(d), StringComparer.OrdinalIgnoreCase)) - { - var name = Path.GetFileName(dir); - if (IgnoredDirs.Contains(name)) continue; - var relPath = Path.GetRelativePath(basePath, dir).Replace('\\', '/'); - entries.Add(new { name, path = relPath, type = "dir" }); - } - - foreach (var file in Directory.GetFiles(targetPath).OrderBy(f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase)) - { - var name = Path.GetFileName(file); - var ext = Path.GetExtension(file); - var relPath = Path.GetRelativePath(basePath, file).Replace('\\', '/'); - var size = new FileInfo(file).Length; - var isText = TextExtensions.Contains(ext) || IsLikelyTextFile(name); - entries.Add(new { name, path = relPath, type = "file", ext, size, isText }); - } - - await ctx.Response.WriteAsJsonAsync(entries); - } - - private static async Task HandleAgentFile(HttpContext ctx) - { - var basePath = GetAgentBasePath(ctx, out var error); - if (basePath == null) - { - ctx.Response.StatusCode = 400; - await ctx.Response.WriteAsJsonAsync(new { error }); - return; - } - - var filePath = ctx.Request.Query["path"].FirstOrDefault(); - if (string.IsNullOrEmpty(filePath)) - { - ctx.Response.StatusCode = 400; - return; - } - - var targetPath = Path.GetFullPath(Path.Combine(basePath, filePath)); - if (!targetPath.StartsWith(basePath)) - { - ctx.Response.StatusCode = 403; - return; - } - - if (!File.Exists(targetPath)) - { - ctx.Response.StatusCode = 404; - return; - } - - var info = new FileInfo(targetPath); - if (info.Length > 2 * 1024 * 1024) - { - ctx.Response.StatusCode = 413; - await ctx.Response.WriteAsJsonAsync(new { error = "Datei zu groß (max. 2MB)" }); - return; - } - - var content = await File.ReadAllTextAsync(targetPath); - var lineCount = content.Split('\n').Length; - - await ctx.Response.WriteAsJsonAsync(new - { - path = filePath, - name = info.Name, - size = info.Length, - lineCount, - content - }); - } - - private static async Task HandleAgentFileSave(HttpContext ctx) - { - var basePath = GetAgentBasePath(ctx, out var error); - if (basePath == null) - { - ctx.Response.StatusCode = 400; - await ctx.Response.WriteAsJsonAsync(new { error }); - return; - } - - var filePath = ctx.Request.Query["path"].FirstOrDefault(); - if (string.IsNullOrEmpty(filePath)) - { - ctx.Response.StatusCode = 400; - return; - } - - var targetPath = Path.GetFullPath(Path.Combine(basePath, filePath)); - if (!targetPath.StartsWith(basePath)) - { - ctx.Response.StatusCode = 403; - return; - } - - // Verzeichnis erstellen falls nötig - var dir = Path.GetDirectoryName(targetPath); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - using var reader = new StreamReader(ctx.Request.Body); - var content = await reader.ReadToEndAsync(); - - await File.WriteAllTextAsync(targetPath, content); - - var info = new FileInfo(targetPath); - var lineCount = content.Split('\n').Length; - - await ctx.Response.WriteAsJsonAsync(new - { - path = filePath, - name = info.Name, - size = info.Length, - lineCount - }); - } - - private static async Task HandleMkdir(HttpContext ctx) - { - var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault(); - var dirPath = ctx.Request.Query["path"].FirstOrDefault(); - - if (string.IsNullOrEmpty(dirPath)) - { - ctx.Response.StatusCode = 400; - await ctx.Response.WriteAsJsonAsync(new { error = "Pfad fehlt" }); - return; - } - - var result = await ResolveSessionWorkspace(ctx, sessionId); - if (result == null) return; - var (session, basePath) = result.Value; - - var targetPath = Path.GetFullPath(Path.Combine(basePath, dirPath)); - - if (!targetPath.StartsWith(basePath)) - { - ctx.Response.StatusCode = 403; - return; - } - - if (Directory.Exists(targetPath)) - { - ctx.Response.StatusCode = 409; - await ctx.Response.WriteAsJsonAsync(new { error = "Ordner existiert bereits" }); - return; - } - - Directory.CreateDirectory(targetPath); - await ctx.Response.WriteAsJsonAsync(new { path = dirPath, created = true }); - } - - private static async Task HandleRmdir(HttpContext ctx) - { - var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault(); - var dirPath = ctx.Request.Query["path"].FirstOrDefault(); - - if (string.IsNullOrEmpty(dirPath)) - { - ctx.Response.StatusCode = 400; - await ctx.Response.WriteAsJsonAsync(new { error = "Pfad fehlt" }); - return; - } - - var result = await ResolveSessionWorkspace(ctx, sessionId); - if (result == null) return; - var (session, basePath) = result.Value; - - var targetPath = Path.GetFullPath(Path.Combine(basePath, dirPath)); - - if (!targetPath.StartsWith(basePath) || targetPath == basePath) - { - ctx.Response.StatusCode = 403; - return; - } - - if (!Directory.Exists(targetPath)) - { - ctx.Response.StatusCode = 404; - await ctx.Response.WriteAsJsonAsync(new { error = "Ordner nicht gefunden" }); - return; - } - - if (Directory.GetFileSystemEntries(targetPath).Length > 0) - { - ctx.Response.StatusCode = 409; - await ctx.Response.WriteAsJsonAsync(new { error = "Ordner ist nicht leer" }); - return; - } - - Directory.Delete(targetPath); - await ctx.Response.WriteAsJsonAsync(new { path = dirPath, deleted = true }); - } - - private static async Task HandleAgentMkdir(HttpContext ctx) - { - var basePath = GetAgentBasePath(ctx, out var error); - if (basePath == null) - { - ctx.Response.StatusCode = 400; - await ctx.Response.WriteAsJsonAsync(new { error }); - return; - } - - var dirPath = ctx.Request.Query["path"].FirstOrDefault(); - if (string.IsNullOrEmpty(dirPath)) - { - ctx.Response.StatusCode = 400; - await ctx.Response.WriteAsJsonAsync(new { error = "Pfad fehlt" }); - return; - } - - var targetPath = Path.GetFullPath(Path.Combine(basePath, dirPath)); - if (!targetPath.StartsWith(basePath)) - { - ctx.Response.StatusCode = 403; - return; - } - - if (Directory.Exists(targetPath)) - { - ctx.Response.StatusCode = 409; - await ctx.Response.WriteAsJsonAsync(new { error = "Ordner existiert bereits" }); - return; - } - - Directory.CreateDirectory(targetPath); - await ctx.Response.WriteAsJsonAsync(new { path = dirPath, created = true }); - } - - private static async Task HandleAgentRmdir(HttpContext ctx) - { - var basePath = GetAgentBasePath(ctx, out var error); - if (basePath == null) - { - ctx.Response.StatusCode = 400; - await ctx.Response.WriteAsJsonAsync(new { error }); - return; - } - - var dirPath = ctx.Request.Query["path"].FirstOrDefault(); - if (string.IsNullOrEmpty(dirPath)) - { - ctx.Response.StatusCode = 400; - await ctx.Response.WriteAsJsonAsync(new { error = "Pfad fehlt" }); - return; - } - - var targetPath = Path.GetFullPath(Path.Combine(basePath, dirPath)); - if (!targetPath.StartsWith(basePath) || targetPath == basePath) - { - ctx.Response.StatusCode = 403; - return; - } - - if (!Directory.Exists(targetPath)) - { - ctx.Response.StatusCode = 404; - await ctx.Response.WriteAsJsonAsync(new { error = "Ordner nicht gefunden" }); - return; - } - - if (Directory.GetFileSystemEntries(targetPath).Length > 0) - { - ctx.Response.StatusCode = 409; - await ctx.Response.WriteAsJsonAsync(new { error = "Ordner ist nicht leer" }); - return; - } - - Directory.Delete(targetPath); - await ctx.Response.WriteAsJsonAsync(new { path = dirPath, deleted = true }); - } - - private static readonly Dictionary MimeTypes = new(StringComparer.OrdinalIgnoreCase) - { - { ".png", "image/png" }, { ".jpg", "image/jpeg" }, { ".jpeg", "image/jpeg" }, - { ".gif", "image/gif" }, { ".webp", "image/webp" }, { ".svg", "image/svg+xml" }, - { ".bmp", "image/bmp" }, { ".ico", "image/x-icon" }, - { ".pdf", "application/pdf" }, - { ".mp4", "video/mp4" }, { ".webm", "video/webm" }, - { ".mp3", "audio/mpeg" }, { ".wav", "audio/wav" }, { ".ogg", "audio/ogg" }, - }; - private static async Task HandleRawFile(HttpContext ctx) { var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault(); @@ -640,12 +364,10 @@ public static class WorkspaceEndpoint return; } - var result = await ResolveSessionWorkspace(ctx, sessionId); - if (result == null) return; - var basePath = result.Value.basePath; + var basePath = await ResolveSessionBasePath(ctx); + if (basePath == null) return; var targetPath = Path.GetFullPath(Path.Combine(basePath, filePath)); - if (!targetPath.StartsWith(basePath)) { ctx.Response.StatusCode = 403; @@ -658,11 +380,30 @@ public static class WorkspaceEndpoint return; } - var ext = Path.GetExtension(targetPath); - var contentType = MimeTypes.GetValueOrDefault(ext, "application/octet-stream"); + var ext = Path.GetExtension(targetPath).ToLowerInvariant(); + ctx.Response.ContentType = ext switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".svg" => "image/svg+xml", + ".pdf" => "application/pdf", + ".mp4" => "video/mp4", + ".webm" => "video/webm", + ".mp3" => "audio/mpeg", + ".wav" => "audio/wav", + _ => "application/octet-stream" + }; - ctx.Response.ContentType = contentType; - ctx.Response.Headers["Cache-Control"] = "private, max-age=3600"; await ctx.Response.SendFileAsync(targetPath); } + + private static bool IsLikelyTextFile(string name) + { + var lower = name.ToLowerInvariant(); + return lower is "makefile" or "dockerfile" or "rakefile" or "gemfile" + or "procfile" or "vagrantfile" or "license" or "readme" + or "changelog" or "authors" or "contributors"; + } } diff --git a/Models/ChatContext.cs b/Models/ChatContext.cs index 37f5dd5..6852a40 100644 --- a/Models/ChatContext.cs +++ b/Models/ChatContext.cs @@ -22,16 +22,16 @@ public class ChatContext public bool PlanMode { get; set; } // Preparer-Agent (vor dem Worker) - public PreparerContext Preparer { get; set; } + public SubAgentContext Preparer { get; set; } // Review-Agent - public ReviewContext Review { get; set; } + public SubAgentContext Review { get; set; } // Finalizer-Agent - public FinalizerContext Finalizer { get; set; } + public SubAgentContext Finalizer { get; set; } } -public class PreparerContext +public class SubAgentContext { public string ModelId { get; set; } public List SystemPrompt { get; set; } @@ -40,31 +40,11 @@ public class PreparerContext public bool UseMemory { get; set; } public string WebSearchVersion { get; set; } public string ToolChoice { get; set; } + public AgentRecord Agent { get; set; } + + // Preparer-spezifisch public bool AllowUserChoice { get; set; } - public AgentRecord Agent { get; set; } -} -public class FinalizerContext -{ - public string ModelId { get; set; } - public List SystemPrompt { get; set; } - public string AgentId { get; set; } - public string Workspace { get; set; } - public bool UseMemory { get; set; } - public string WebSearchVersion { get; set; } - public string ToolChoice { get; set; } - public AgentRecord Agent { get; set; } -} - -public class ReviewContext -{ - public string ModelId { get; set; } - public List SystemPrompt { get; set; } - public string AgentId { get; set; } - public string Workspace { get; set; } - public bool UseMemory { get; set; } - public string WebSearchVersion { get; set; } - public string ToolChoice { get; set; } + // Review-spezifisch public int MaxReviewIterations { get; set; } - public AgentRecord Agent { get; set; } } diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 1aa091a..5df2681 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -47,8 +47,8 @@ public class AnthropicService string webSearchVersion = null, int maxIterations = 100, string toolChoice = "any", - ReviewContext review = null, - FinalizerContext finalizer = null, + SubAgentContext review = null, + SubAgentContext finalizer = null, bool allowUserChoice = true, bool planMode = false, CancellationToken cancellationToken = default) @@ -85,30 +85,7 @@ public class AnthropicService if (systemPrompt != null && systemPrompt.Count > 0) payload["system"] = systemPrompt; - var hasWorkspace = !string.IsNullOrEmpty(workspace); - var includeMemory = useMemory && _memoryService != null && !string.IsNullOrEmpty(agentId); - var hasTools = hasWorkspace || includeMemory; - var hasWebSearch = !string.IsNullOrEmpty(webSearchVersion); - - if (hasTools || hasWebSearch) - { - var tools = new List(); - if (hasTools) - { - var allTools = _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: allowUserChoice); - if (planMode) - allTools = _toolService.FilterReadOnlyTools(allTools); - tools.AddRange(allTools); - } - if (hasWebSearch) - tools.Add(new { type = webSearchVersion, name = "web_search", max_uses = 5 }); - payload["tools"] = tools; - // "any" erzwingt Tool-Nutzung, aber nur in der ersten Iteration, - // damit Claude nicht endlos Tools aufruft wenn die Aufgabe erledigt ist. - // Im Plan-Modus nie "any" erzwingen – Claude soll frei antworten können. - var effectiveChoice = (!planMode && toolChoice == "any" && iterations == 1) ? "any" : "auto"; - payload["tool_choice"] = new { type = effectiveChoice }; - } + AddToolsToPayload(payload, workspace, agentId, useMemory, webSearchVersion, toolChoice, iterations, allowUserChoice, planMode); var client = CreateClient(); var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); @@ -171,22 +148,7 @@ public class AnthropicService } else if (blockType == "web_search_tool_result") { - // Sources aus dem Ergebnis-Block extrahieren und speichern - var sources = new List(); - if (el.TryGetProperty("content", out var searchContent) && searchContent.ValueKind == JsonValueKind.Array) - { - foreach (var item in searchContent.EnumerateArray()) - { - var itemType = item.TryGetProperty("type", out var it) ? it.GetString() : ""; - if (itemType == "web_search_result") - { - var url = item.TryGetProperty("url", out var u) ? u.GetString() ?? "" : ""; - var title = item.TryGetProperty("title", out var tt) ? tt.GetString() ?? "" : ""; - if (!string.IsNullOrEmpty(url)) - sources.Add(new { url, title }); - } - } - } + var sources = ExtractWebSearchSources(el); if (sources.Count > 0) { await _db.SaveMessage(new MessageRecord @@ -268,20 +230,11 @@ public class AnthropicService var toolResults = new List(); foreach (var tr in executedTools) { - // Tool-Results in der History auf 20.000 Zeichen kürzen. - // Das vollständige Ergebnis wurde bereits in der DB gespeichert. - // 20k Zeichen ≈ ~5k Tokens – verhindert Token-Explosion bei großen - // read_file / execute-Outputs die sich über alle weiteren Iterationen schleppen. - const int MaxToolResultChars = 20_000; - var content = tr.Result.Length > MaxToolResultChars - ? tr.Result[..MaxToolResultChars] + $"\n\n[... gekürzt, {tr.Result.Length - MaxToolResultChars} weitere Zeichen ...]" - : tr.Result; - toolResults.Add(new { type = "tool_result", tool_use_id = tr.Id, - content, + content = TruncateToolResult(tr.Result), is_error = tr.IsError }); } @@ -331,7 +284,7 @@ public class AnthropicService /// public async Task RunPreparer( HttpResponse response, - PreparerContext preparer, + SubAgentContext preparer, List historyMessages, string chainId, string workspace, @@ -416,7 +369,7 @@ public class AnthropicService /// private async Task RunReview( HttpResponse response, - ReviewContext review, + SubAgentContext review, List workerMessages, string chainId, string workspace, @@ -490,7 +443,7 @@ public class AnthropicService /// private async Task RunFinalizer( HttpResponse response, - FinalizerContext finalizer, + SubAgentContext finalizer, List workerMessages, string chainId, string workspace, @@ -686,22 +639,7 @@ public class AnthropicService if (systemPrompt != null && systemPrompt.Count > 0) payload["system"] = systemPrompt; - var hasWorkspace = !string.IsNullOrEmpty(agentWorkspace); - var includeMemory = useMemory && _memoryService != null && !string.IsNullOrEmpty(agentId); - var hasTools = hasWorkspace || includeMemory; - var hasWebSearch = !string.IsNullOrEmpty(webSearchVersion); - - if (hasTools || hasWebSearch) - { - var tools = new List(); - if (hasTools) - tools.AddRange(_toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: false)); - if (hasWebSearch) - tools.Add(new { type = webSearchVersion, name = "web_search", max_uses = 5 }); - payload["tools"] = tools; - var effectiveChoice = (agentToolChoice == "any" && iterations == 1) ? "any" : "auto"; - payload["tool_choice"] = new { type = effectiveChoice }; - } + AddToolsToPayload(payload, agentWorkspace, agentId, useMemory, webSearchVersion, agentToolChoice, iterations); var client = CreateClient(); var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); @@ -722,12 +660,7 @@ public class AnthropicService var resp = JsonSerializer.Deserialize(respBody); if (resp.TryGetProperty("usage", out var u)) - { - if (u.TryGetProperty("input_tokens", out var it)) usage.InputTokens += it.GetInt64(); - if (u.TryGetProperty("output_tokens", out var ot)) usage.OutputTokens += ot.GetInt64(); - if (u.TryGetProperty("cache_creation_input_tokens", out var cc)) usage.CacheCreationTokens += cc.GetInt64(); - if (u.TryGetProperty("cache_read_input_tokens", out var cr)) usage.CacheReadTokens += cr.GetInt64(); - } + TrackUsage(usage, u); var stopReason = resp.TryGetProperty("stop_reason", out var sr) ? sr.GetString() ?? "" : ""; var content = resp.GetProperty("content"); @@ -795,16 +728,11 @@ public class AnthropicService try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, agentWorkspace, _memoryService, agentId, cancellationToken); } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } - const int MaxToolResultChars = 20_000; - var truncatedResult = toolResult.Length > MaxToolResultChars - ? toolResult[..MaxToolResultChars] + $"\n\n[... gekürzt, {toolResult.Length - MaxToolResultChars} weitere Zeichen ...]" - : toolResult; - toolResults.Add(new { type = "tool_result", tool_use_id = toolId, - content = truncatedResult, + content = TruncateToolResult(toolResult), is_error = isError }); @@ -834,23 +762,7 @@ public class AnthropicService else if (blockType == "web_search_tool_result") { assistantContent.Add(JsonSerializer.Deserialize(block.GetRawText())); - - var sources = new List(); - if (block.TryGetProperty("content", out var searchContent) && searchContent.ValueKind == JsonValueKind.Array) - { - foreach (var item in searchContent.EnumerateArray()) - { - var itemType = item.TryGetProperty("type", out var it) ? it.GetString() : ""; - if (itemType == "web_search_result") - { - var url = item.TryGetProperty("url", out var u2) ? u2.GetString() ?? "" : ""; - var title = item.TryGetProperty("title", out var tt2) ? tt2.GetString() ?? "" : ""; - if (!string.IsNullOrEmpty(url)) - sources.Add(new { url, title }); - } - } - } - + var sources = ExtractWebSearchSources(block); if (sources.Count > 0) { await _db.SaveMessage(new MessageRecord @@ -866,11 +778,7 @@ public class AnthropicService } else if (blockType == "server_tool_use") { - var toolId = block.GetProperty("id").GetString() ?? ""; - var toolName = block.GetProperty("name").GetString() ?? ""; - JsonElement inputEl; - try { inputEl = block.GetProperty("input"); } - catch { inputEl = JsonSerializer.Deserialize("{}"); } + var (toolId, toolName, inputEl) = ParseServerToolUse(block); assistantContent.Add(new { type = "server_tool_use", id = toolId, name = toolName, input = inputEl }); } } @@ -926,22 +834,7 @@ public class AnthropicService if (ctx.SystemPrompt != null && ctx.SystemPrompt.Count > 0) payload["system"] = ctx.SystemPrompt; - var hasWorkspace = !string.IsNullOrEmpty(ctx.Workspace); - var includeMemory = ctx.UseMemory && _memoryService != null && !string.IsNullOrEmpty(ctx.AgentId); - var hasTools = hasWorkspace || includeMemory; - var hasWebSearch = !string.IsNullOrEmpty(ctx.WebSearchVersion); - - if (hasTools || hasWebSearch) - { - var tools = new List(); - if (hasTools) - tools.AddRange(_toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: false)); - if (hasWebSearch) - tools.Add(new { type = ctx.WebSearchVersion, name = "web_search", max_uses = 5 }); - payload["tools"] = tools; - var effectiveChoice = (ctx.ToolChoice == "any" && iterations == 1) ? "any" : "auto"; - payload["tool_choice"] = new { type = effectiveChoice }; - } + AddToolsToPayload(payload, ctx.Workspace, ctx.AgentId, ctx.UseMemory, ctx.WebSearchVersion, ctx.ToolChoice, iterations); var client = CreateClient(); var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); @@ -970,12 +863,7 @@ public class AnthropicService // Usage tracken if (resp.TryGetProperty("usage", out var u)) - { - if (u.TryGetProperty("input_tokens", out var it)) usage.InputTokens += it.GetInt64(); - if (u.TryGetProperty("output_tokens", out var ot)) usage.OutputTokens += ot.GetInt64(); - if (u.TryGetProperty("cache_creation_input_tokens", out var cc)) usage.CacheCreationTokens += cc.GetInt64(); - if (u.TryGetProperty("cache_read_input_tokens", out var cr)) usage.CacheReadTokens += cr.GetInt64(); - } + TrackUsage(usage, u); var stopReason = resp.TryGetProperty("stop_reason", out var sr) ? sr.GetString() ?? "" : ""; var content = resp.GetProperty("content"); @@ -1021,17 +909,11 @@ public class AnthropicService try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, ctx.Workspace, _memoryService, ctx.AgentId); } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } - // Tool-Result in der History kürzen (wie bei StreamChat) - const int MaxToolResultChars = 20_000; - var truncatedResult = toolResult.Length > MaxToolResultChars - ? toolResult[..MaxToolResultChars] + $"\n\n[... gekürzt, {toolResult.Length - MaxToolResultChars} weitere Zeichen ...]" - : toolResult; - toolResults.Add(new { type = "tool_result", tool_use_id = toolId, - content = truncatedResult, + content = TruncateToolResult(toolResult), is_error = isError }); @@ -1047,21 +929,7 @@ public class AnthropicService else if (blockType == "web_search_tool_result") { assistantContent.Add(JsonSerializer.Deserialize(block.GetRawText())); - var sources = new List(); - if (block.TryGetProperty("content", out var searchContent) && searchContent.ValueKind == JsonValueKind.Array) - { - foreach (var item in searchContent.EnumerateArray()) - { - var itemType = item.TryGetProperty("type", out var it) ? it.GetString() : ""; - if (itemType == "web_search_result") - { - var url = item.TryGetProperty("url", out var u2) ? u2.GetString() ?? "" : ""; - var title = item.TryGetProperty("title", out var tt2) ? tt2.GetString() ?? "" : ""; - if (!string.IsNullOrEmpty(url)) - sources.Add(new { url, title }); - } - } - } + var sources = ExtractWebSearchSources(block); if (sources.Count > 0) { await _db.SaveMessage(new MessageRecord @@ -1074,11 +942,7 @@ public class AnthropicService } else if (blockType == "server_tool_use") { - var toolId = block.GetProperty("id").GetString() ?? ""; - var toolName = block.GetProperty("name").GetString() ?? ""; - JsonElement inputEl; - try { inputEl = block.GetProperty("input"); } - catch { inputEl = JsonSerializer.Deserialize("{}"); } + var (toolId, toolName, inputEl) = ParseServerToolUse(block); assistantContent.Add(new { type = "server_tool_use", id = toolId, name = toolName, input = inputEl }); } } @@ -1186,14 +1050,7 @@ public class AnthropicService { if (evt.TryGetProperty("message", out var msg) && msg.TryGetProperty("usage", out var u)) - { - if (u.TryGetProperty("input_tokens", out var it)) - usage.InputTokens += it.GetInt64(); - if (u.TryGetProperty("cache_creation_input_tokens", out var cc)) - usage.CacheCreationTokens += cc.GetInt64(); - if (u.TryGetProperty("cache_read_input_tokens", out var cr)) - usage.CacheReadTokens += cr.GetInt64(); - } + TrackUsage(usage, u); break; } @@ -1235,27 +1092,9 @@ public class AnthropicService } else if (cbType == "web_search_tool_result") { - // Gesamten Block als JSON speichern für die Conversation-History var rawBlock = JsonSerializer.Deserialize(cb.GetRawText()); webSearchResultBlocks[idx] = rawBlock; - - // Sources extrahieren für Frontend - var sources = new List(); - if (cb.TryGetProperty("content", out var searchContent) && searchContent.ValueKind == JsonValueKind.Array) - { - foreach (var item in searchContent.EnumerateArray()) - { - var itemType = item.TryGetProperty("type", out var it) ? it.GetString() : ""; - if (itemType == "web_search_result") - { - var url = item.TryGetProperty("url", out var u) ? u.GetString() ?? "" : ""; - var title = item.TryGetProperty("title", out var tt) ? tt.GetString() ?? "" : ""; - if (!string.IsNullOrEmpty(url)) - sources.Add(new { url, title }); - } - } - } - webSearchSources[idx] = sources; + webSearchSources[idx] = ExtractWebSearchSources(cb); } break; } @@ -1452,6 +1291,91 @@ public class AnthropicService return (stopReason, assistantContent, executedTools, streamErrorInfo); } + // ── Shared Helpers ── + + private const int MaxToolResultChars = 20_000; + + private static string TruncateToolResult(string result) + { + return result.Length > MaxToolResultChars + ? result[..MaxToolResultChars] + $"\n\n[... gekürzt, {result.Length - MaxToolResultChars} weitere Zeichen ...]" + : result; + } + + /// + /// Extrahiert Web-Search-Sources aus einem web_search_tool_result-Block. + /// + private static List ExtractWebSearchSources(JsonElement block) + { + var sources = new List(); + if (block.TryGetProperty("content", out var searchContent) && searchContent.ValueKind == JsonValueKind.Array) + { + foreach (var item in searchContent.EnumerateArray()) + { + var itemType = item.TryGetProperty("type", out var it) ? it.GetString() : ""; + if (itemType == "web_search_result") + { + var url = item.TryGetProperty("url", out var u) ? u.GetString() ?? "" : ""; + var title = item.TryGetProperty("title", out var tt) ? tt.GetString() ?? "" : ""; + if (!string.IsNullOrEmpty(url)) + sources.Add(new { url, title }); + } + } + } + return sources; + } + + /// + /// Parst einen server_tool_use-Block und gibt (id, name, input) zurück. + /// + private static (string id, string name, JsonElement input) ParseServerToolUse(JsonElement block) + { + var toolId = block.GetProperty("id").GetString() ?? ""; + var toolName = block.GetProperty("name").GetString() ?? ""; + JsonElement inputEl; + try { inputEl = block.GetProperty("input"); } + catch { inputEl = JsonSerializer.Deserialize("{}"); } + return (toolId, toolName, inputEl); + } + + /// + /// Aktualisiert den TokenUsage-Tracker aus einem usage-JSON-Element. + /// + private static void TrackUsage(TokenUsage usage, JsonElement u) + { + if (u.TryGetProperty("input_tokens", out var it)) usage.InputTokens += it.GetInt64(); + if (u.TryGetProperty("output_tokens", out var ot)) usage.OutputTokens += ot.GetInt64(); + if (u.TryGetProperty("cache_creation_input_tokens", out var cc)) usage.CacheCreationTokens += cc.GetInt64(); + if (u.TryGetProperty("cache_read_input_tokens", out var cr)) usage.CacheReadTokens += cr.GetInt64(); + } + + /// + /// Fügt Tool-Definitionen und WebSearch zum Payload hinzu. + /// + private void AddToolsToPayload(Dictionary payload, string workspace, string agentId, bool useMemory, string webSearchVersion, string toolChoice, int iteration, bool allowUserChoice = false, bool planMode = false) + { + var hasWorkspace = !string.IsNullOrEmpty(workspace); + var includeMemory = useMemory && _memoryService != null && !string.IsNullOrEmpty(agentId); + var hasTools = hasWorkspace || includeMemory; + var hasWebSearch = !string.IsNullOrEmpty(webSearchVersion); + + if (!hasTools && !hasWebSearch) return; + + var tools = new List(); + if (hasTools) + { + var allTools = _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: allowUserChoice); + if (planMode) + allTools = _toolService.FilterReadOnlyTools(allTools); + tools.AddRange(allTools); + } + if (hasWebSearch) + tools.Add(new { type = webSearchVersion, name = "web_search", max_uses = 5 }); + payload["tools"] = tools; + var effectiveChoice = (!planMode && toolChoice == "any" && iteration == 1) ? "any" : "auto"; + payload["tool_choice"] = new { type = effectiveChoice }; + } + private HttpClient CreateClient() { var client = _httpFactory.CreateClient(); diff --git a/Services/ChatService.cs b/Services/ChatService.cs index afa7b72..037d29f 100644 --- a/Services/ChatService.cs +++ b/Services/ChatService.cs @@ -114,29 +114,18 @@ public class ChatService if (!string.IsNullOrWhiteSpace(systemPromptText)) systemPrompt.Add(new { type = "text", text = systemPromptText }); - // Preparer-Agent vorbereiten (falls konfiguriert) - PreparerContext preparerContext = null; - if (!string.IsNullOrEmpty(session.PreparerAgentId)) + // Sub-Agents vorbereiten (falls konfiguriert) + var preparerContext = await BuildSubAgentContext( + session.PreparerAgentId, sessionId, workspace, session, user); + var reviewContext = await BuildSubAgentContext( + session.ReviewAgentId, sessionId, workspace, session, user); + var finalizerContext = await BuildSubAgentContext( + session.FinalizerAgentId, sessionId, workspace, session, user); + + if (preparerContext != null) { - var preparerAgent = await _db.GetAgent(session.PreparerAgentId); - if (preparerAgent != null) - { - var preparerModelId = ResolveModelId(preparerAgent.Model); - var preparerMarked = await _db.GetMarkedMessages(sessionId); - var preparerMemDir = _memoryService.GetAgentMemoryDir(preparerAgent.AgentId); - var preparerSkills = await _db.GetAgentSkills(preparerAgent.AgentId); - var preparerWorkspace = workspace; - var preparerSystemText = SystemPromptBuilder.Build( - preparerAgent, session, preparerMarked, _memoryService, preparerMemDir, - preparerSkills, _skillService, userPersona: user?.GetEffectivePersona(), - resolvedWorkspace: preparerWorkspace); - - var preparerPrompt = new List(); - if (!string.IsNullOrWhiteSpace(preparerSystemText)) - preparerPrompt.Add(new { type = "text", text = preparerSystemText }); - - // Preparer-Instruktion: erklärt Vorbereiter-Mechanismus - preparerPrompt.Add(new { type = "text", text = @"## KRITISCH: Deine Rolle als Vorbereiter-Agent + preparerContext.AllowUserChoice = preparerContext.Agent?.AllowUserChoice ?? false; + preparerContext.SystemPrompt.Add(new { type = "text", text = @"## KRITISCH: Deine Rolle als Vorbereiter-Agent Du bist ein Vorbereiter-Agent. Deine Aufgabe: Die Anfrage des Users aufbereiten, deine Fähigkeiten nutzen, und das Ergebnis an den Session-Agent weiterleiten. @@ -165,95 +154,11 @@ Rückfrage (NUR mit [QUESTION] – stoppt die Weiterleitung): [QUESTION] Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop? ```" }); - - var preparerHasTools = !string.IsNullOrEmpty(preparerWorkspace) || preparerAgent.UseMemory; - preparerContext = new PreparerContext - { - ModelId = preparerModelId, - SystemPrompt = preparerPrompt, - AgentId = preparerAgent.AgentId, - Workspace = preparerWorkspace, - UseMemory = preparerAgent.UseMemory, - WebSearchVersion = preparerAgent.WebSearchVersion, - ToolChoice = preparerHasTools ? "any" : "auto", - AllowUserChoice = preparerAgent.AllowUserChoice, - Agent = preparerAgent - }; - } } - // Review-Agent vorbereiten (falls konfiguriert) - ReviewContext reviewContext = null; - if (!string.IsNullOrEmpty(session.ReviewAgentId)) - { - var reviewAgent = await _db.GetAgent(session.ReviewAgentId); - if (reviewAgent != null) - { - var reviewModelId = ResolveModelId(reviewAgent.Model); - var reviewMarked = await _db.GetMarkedMessages(sessionId); - var reviewMemDir = _memoryService.GetAgentMemoryDir(reviewAgent.AgentId); - var reviewSkills = await _db.GetAgentSkills(reviewAgent.AgentId); - var reviewWorkspace = workspace; // gleicher Workspace wie Worker - var reviewSystemText = SystemPromptBuilder.Build( - reviewAgent, session, reviewMarked, _memoryService, reviewMemDir, - reviewSkills, _skillService, userPersona: user?.GetEffectivePersona(), - resolvedWorkspace: reviewWorkspace); + if (reviewContext != null) + reviewContext.MaxReviewIterations = session.MaxReviewIterations > 0 ? session.MaxReviewIterations : 3; - var reviewPrompt = new List(); - if (!string.IsNullOrWhiteSpace(reviewSystemText)) - reviewPrompt.Add(new { type = "text", text = reviewSystemText }); - - var reviewHasTools = !string.IsNullOrEmpty(reviewWorkspace) || reviewAgent.UseMemory; - reviewContext = new ReviewContext - { - ModelId = reviewModelId, - SystemPrompt = reviewPrompt, - AgentId = reviewAgent.AgentId, - Workspace = reviewWorkspace, - UseMemory = reviewAgent.UseMemory, - WebSearchVersion = reviewAgent.WebSearchVersion, - ToolChoice = reviewHasTools ? "any" : "auto", - MaxReviewIterations = session.MaxReviewIterations > 0 ? session.MaxReviewIterations : 3, - Agent = reviewAgent - }; - } - } - - // Finalizer-Agent vorbereiten (falls konfiguriert) - FinalizerContext finalizerContext = null; - if (!string.IsNullOrEmpty(session.FinalizerAgentId)) - { - var finalizerAgent = await _db.GetAgent(session.FinalizerAgentId); - if (finalizerAgent != null) - { - var finalizerModelId = ResolveModelId(finalizerAgent.Model); - var finalizerMarked = await _db.GetMarkedMessages(sessionId); - var finalizerMemDir = _memoryService.GetAgentMemoryDir(finalizerAgent.AgentId); - var finalizerSkills = await _db.GetAgentSkills(finalizerAgent.AgentId); - var finalizerWorkspace = workspace; - var finalizerSystemText = SystemPromptBuilder.Build( - finalizerAgent, session, finalizerMarked, _memoryService, finalizerMemDir, - finalizerSkills, _skillService, userPersona: user?.GetEffectivePersona(), - resolvedWorkspace: finalizerWorkspace); - - var finalizerPrompt = new List(); - if (!string.IsNullOrWhiteSpace(finalizerSystemText)) - finalizerPrompt.Add(new { type = "text", text = finalizerSystemText }); - - var finalizerHasTools = !string.IsNullOrEmpty(finalizerWorkspace) || finalizerAgent.UseMemory; - finalizerContext = new FinalizerContext - { - ModelId = finalizerModelId, - SystemPrompt = finalizerPrompt, - AgentId = finalizerAgent.AgentId, - Workspace = finalizerWorkspace, - UseMemory = finalizerAgent.UseMemory, - WebSearchVersion = finalizerAgent.WebSearchVersion, - ToolChoice = finalizerHasTools ? "any" : "auto", - Agent = finalizerAgent - }; - } - } return new ChatContext { @@ -531,4 +436,46 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop? return history; } + + /// + /// Baut einen SubAgentContext für Preparer/Review/Finalizer. + /// Gemeinsame Logik: Agent laden, Model auflösen, SystemPrompt bauen, ToolChoice bestimmen. + /// + private async Task BuildSubAgentContext( + string agentId, string sessionId, string workspace, SessionRecord session, UserRecord user) + { + if (string.IsNullOrEmpty(agentId)) + return null; + + var agent = await _db.GetAgent(agentId); + if (agent == null) + return null; + + var modelId = ResolveModelId(agent.Model); + var markedMessages = await _db.GetMarkedMessages(sessionId); + var memDir = _memoryService.GetAgentMemoryDir(agent.AgentId); + var skills = await _db.GetAgentSkills(agent.AgentId); + var systemText = SystemPromptBuilder.Build( + agent, session, markedMessages, _memoryService, memDir, + skills, _skillService, userPersona: user?.GetEffectivePersona(), + resolvedWorkspace: workspace); + + var prompt = new List(); + if (!string.IsNullOrWhiteSpace(systemText)) + prompt.Add(new { type = "text", text = systemText }); + + var hasTools = !string.IsNullOrEmpty(workspace) || agent.UseMemory; + return new SubAgentContext + { + ModelId = modelId, + SystemPrompt = prompt, + AgentId = agent.AgentId, + Workspace = workspace, + UseMemory = agent.UseMemory, + WebSearchVersion = agent.WebSearchVersion, + ToolChoice = hasTools ? "any" : "auto", + Agent = agent + }; + } + } diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index 03307e9..fdd30e0 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -338,7 +338,7 @@ public class DatabaseService foreach (var (agentId, avatar) in agentsWithoutThumb) { - var small = ResizeDataUrlToWebP(avatar, smallSize, smallQuality); + 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 }); } @@ -350,7 +350,7 @@ public class DatabaseService foreach (var (sessionId, image) in sessionsWithoutThumb) { - var small = ResizeDataUrlToWebP(image, smallSize, smallQuality); + 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 }); } @@ -359,7 +359,10 @@ public class DatabaseService Console.WriteLine($"[Migration] Generated {agentsWithoutThumb.Count} agent thumbnails and {sessionsWithoutThumb.Count} session thumbnails"); } - private static string ResizeDataUrlToWebP(string dataUrl, int maxSide, int quality) + /// + /// Konvertiert ein DataUrl-Bild zu WebP. Optional Resize wenn maxSide > 0. + /// + private static string ConvertDataUrlToWebP(string dataUrl, int quality, int maxSide = 0) { try { @@ -370,7 +373,7 @@ public class DatabaseService var bytes = Convert.FromBase64String(base64); using var image = Image.Load(bytes); - if (image.Width > maxSide || image.Height > maxSide) + if (maxSide > 0 && (image.Width > maxSide || image.Height > maxSide)) { image.Mutate(x => x.Resize(new ResizeOptions { @@ -390,28 +393,6 @@ public class DatabaseService } } - private static string ConvertDataUrlToWebP(string dataUrl, 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); - using var ms = new MemoryStream(); - image.Save(ms, new WebpEncoder { Quality = quality }); - - return "data:image/webp;base64," + Convert.ToBase64String(ms.ToArray()); - } - catch - { - return null; // Beschädigte Bilder überspringen - } - } - private void TryAddColumn(SqliteConnection conn, string table, string column, string type) { try { conn.Execute($"ALTER TABLE {table} ADD COLUMN {column} {type}"); } @@ -573,13 +554,6 @@ public class DatabaseService 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(); @@ -1303,23 +1277,6 @@ public class DatabaseService new { sessionId, chatId, now }); } - public async Task SetSessionTelegramChatId(string sessionId, string chatId) - { - using var conn = CreateConnection(); - var now = DateTime.UtcNow.ToString("o"); - await conn.ExecuteAsync( - "UPDATE sessions SET telegram_chat_id = @chatId, updated_at = @now WHERE session_id = @sessionId", - new { sessionId, chatId, now }); - } - - 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> GetSessionsByTelegramBot(string botId) { using var conn = CreateConnection(); @@ -1345,14 +1302,6 @@ public class DatabaseService new { botId }); } - 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) diff --git a/Services/ImageService.cs b/Services/ImageService.cs index 165dbec..080f7f7 100644 --- a/Services/ImageService.cs +++ b/Services/ImageService.cs @@ -62,4 +62,37 @@ public class ImageService return "data:image/webp;base64," + webpBase64; } + + /// + /// Generiert ein WebP-Thumbnail (max 320px) mit Caching. + /// Gibt den Pfad zum Thumbnail zurück, oder null bei Fehler. + /// + public static async System.Threading.Tasks.Task GenerateThumbnail(string originalPath, string cacheDir, int maxSize = 320, int quality = 75) + { + var thumbFilename = Path.GetFileNameWithoutExtension(originalPath) + ".webp"; + var thumbPath = Path.Combine(cacheDir, thumbFilename); + + if (File.Exists(thumbPath) && File.GetLastWriteTimeUtc(originalPath) <= File.GetLastWriteTimeUtc(thumbPath)) + return thumbPath; + + try + { + Directory.CreateDirectory(cacheDir); + using var image = await Image.LoadAsync(originalPath); + if (image.Width > maxSize || image.Height > maxSize) + { + image.Mutate(x => x.Resize(new ResizeOptions + { + Size = new Size(maxSize, maxSize), + Mode = ResizeMode.Max + })); + } + await image.SaveAsync(thumbPath, new WebpEncoder { Quality = quality }); + return thumbPath; + } + catch + { + return null; + } + } } diff --git a/TODO.md b/TODO.md index 646309d..d93054f 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,8 @@ - [x] Größere Tasks brechen irgendwann ab, könnte an der Limitierung von Antrophic/Claude liegen. Jedoch sollte wird es scheinbar nicht behandelt: Der Status bleibt auf "Streaming..." aber es passiert nichts mehr - [x] Session -> Chat öffnen im Frontend soll nicht möglich sein, wenn kein Agent gesetzt ist - [x] Falsche Nachrichtenanzahl im Dashboard (tool uses werden fälschicherweise mitgezählt) - dasselbe bei "Ältere Nachrichten laden" im Chat +- [ ] Beim Benutzen vom Tool "todo_write": beim streamen ist der text abgeschnitten (limitiert), nach dem poll (history laden) passt es wieder. es soll beim stream auch vollständig angezeigt werden und nicht mit "..." enden +- [ ] Manchmal passiert es wenn ein Agent die Memory-Funktion hat, dass er sich selbst Memories einträgt - das ist auch ok so, aber, manchmal werden "dumme" erinnerungen gesetzt, die nur session-bezogen sind (und nicht agent-bezogen) und diese sind dann fast immer einfach nur copy&paste aus dem session system-prompt. Woran kann das liegen bzw. wie kann man das optimieren? ## Backend - [x] System-Prompt anpassen. Explizit auf Workspace-Pfad hinweisen, weil Model halluziniert oft und will in den nicht vorhandenen Pfad /workspace wechseln @@ -41,7 +43,7 @@ ## Ideas / Backlog - [ ] Memory: Embedding-Modell für semantische Suche über Memory-Dateien (Vektor-basiert + BM25 Hybrid Search) -- [ ] Subagents: Möglichkeit Subagents zu starten und zu orchestrieren +- [ ] Claude (Sub-)Agents: Claude hat so ein Feature Build-in, kann man dieses Feature nutzen? bzw. macht es Sinn dieses Feature zu benutzen und kann man es einbauen? ## Features aus Devy übernehmen - [ ] 💬 Typing-Indicator - Animierter Indikator statt statischem "denkt nach..."