Files
gott/Endpoints/SkillEndpoint.cs
Meleki dcedbaa074 refactor: Backend deduplizieren – 13 Dateien, -528 Zeilen netto
ChatContext: 3 identische Context-Klassen (PreparerContext, ReviewContext,
FinalizerContext) zu einer SubAgentContext-Klasse zusammengefasst.

ChatService: 3x40 Zeilen Sub-Agent-Setup durch BuildSubAgentContext() ersetzt.

AnthropicService: 5 Hilfsmethoden extrahiert – ExtractWebSearchSources(),
TruncateToolResult(), AddToolsToPayload(), ParseServerToolUse(), TrackUsage().

WorkspaceEndpoint: 5 Session/Agent-Endpoint-Paare durch generische Core-Handler
mit basePath-Parameter ersetzt. Ungenutzte rmdir-Endpoints entfernt.

AgentEndpoint: Doppeltes Agent-DTO-Mapping durch MapAgentList() ersetzt.

SessionEndpoint: Doppelte Session/Agent-Auflösung durch ResolvePromptContext() ersetzt.

DatabaseService: Toten Code entfernt (SetSessionTelegramChatId,
ClearSessionTelegramChatId, GetSessions, GetUserSessions). ConvertDataUrlToWebP
und ResizeDataUrlToWebP zu einer Methode zusammengefasst.

TelegramEndpoint: Bot-DTO-Mapping durch MapBotResponse() dedupliziert.

KreaEndpoint + SkillEndpoint: Thumbnail-Generierung in ImageService.GenerateThumbnail()
extrahiert, SixLabors-Imports aus Endpoints entfernt.

CronjobEndpoint: 7x ctx.Items[UserId] durch ctx.GetUserId() ersetzt.
2026-03-09 09:18:49 +01:00

205 lines
7.7 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Gott.Extensions;
using Gott.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Gott.Endpoints;
public static class SkillEndpoint
{
private static readonly Dictionary<string, string> 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" },
};
public static void MapSkillEndpoints(this IEndpointRouteBuilder app)
{
// Skills (Dateisystem-basiert)
app.MapGet("/api/skills", HandleListSkills);
// Skill-Output-Dateien (Bilder etc.)
app.MapGet("/api/skills/{slug}/output/{filename}", HandleSkillOutput);
// Thumbnail (gecached)
app.MapGet("/api/skills/{slug}/output/{filename}/thumb", HandleSkillOutputThumb);
// Agent ↔ Skill Zuordnung
app.MapGet("/api/agents/{agentId}/skills", HandleGetAgentSkills);
app.MapPut("/api/agents/{agentId}/skills", HandleSetAgentSkills);
app.MapPost("/api/agents/{agentId}/skills/{slug}", HandleAddAgentSkill);
app.MapDelete("/api/agents/{agentId}/skills/{slug}", HandleRemoveAgentSkill);
}
private static async Task HandleListSkills(HttpContext ctx, SkillService skillService)
{
var skills = skillService.GetAllSkills();
await ctx.Response.WriteAsJsonAsync(skills);
}
private static async Task<bool> VerifyAgentOwner(HttpContext ctx, DatabaseService db, string agentId)
{
if (string.IsNullOrEmpty(agentId)) return false;
var agent = await db.GetAgent(agentId);
return agent != null && agent.UserId == ctx.GetUserId();
}
private static async Task HandleGetAgentSkills(HttpContext ctx, DatabaseService db, SkillService skillService)
{
var agentId = ctx.Request.RouteValues["agentId"]?.ToString();
if (string.IsNullOrEmpty(agentId) || !await VerifyAgentOwner(ctx, db, agentId)) { ctx.Response.StatusCode = 400; return; }
var slugs = await db.GetAgentSkills(agentId);
var allSkills = skillService.GetAllSkills();
// Nur Skills zurückgeben die auch wirklich existieren
var result = allSkills
.Where(s => slugs.Contains(s.Slug))
.ToList();
await ctx.Response.WriteAsJsonAsync(result);
}
private static async Task HandleSetAgentSkills(HttpContext ctx, DatabaseService db)
{
var agentId = ctx.Request.RouteValues["agentId"]?.ToString();
if (string.IsNullOrEmpty(agentId) || !await VerifyAgentOwner(ctx, db, agentId)) { ctx.Response.StatusCode = 400; return; }
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
var slugs = body.TryGetProperty("skills", out var arr)
? arr.EnumerateArray().Select(e => e.GetString()).Where(s => !string.IsNullOrEmpty(s)).ToList()
: new System.Collections.Generic.List<string>();
await db.SetAgentSkills(agentId, slugs);
var updated = await db.GetAgentSkills(agentId);
await ctx.Response.WriteAsJsonAsync(updated);
}
private static async Task HandleAddAgentSkill(HttpContext ctx, DatabaseService db)
{
var agentId = ctx.Request.RouteValues["agentId"]?.ToString();
var slug = ctx.Request.RouteValues["slug"]?.ToString();
if (string.IsNullOrEmpty(agentId) || string.IsNullOrEmpty(slug) || !await VerifyAgentOwner(ctx, db, agentId)) { ctx.Response.StatusCode = 400; return; }
await db.AddAgentSkill(agentId, slug);
ctx.Response.StatusCode = 204;
}
private static async Task HandleRemoveAgentSkill(HttpContext ctx, DatabaseService db)
{
var agentId = ctx.Request.RouteValues["agentId"]?.ToString();
var slug = ctx.Request.RouteValues["slug"]?.ToString();
if (string.IsNullOrEmpty(agentId) || string.IsNullOrEmpty(slug) || !await VerifyAgentOwner(ctx, db, agentId)) { ctx.Response.StatusCode = 400; return; }
await db.RemoveAgentSkill(agentId, slug);
ctx.Response.StatusCode = 204;
}
private static async Task HandleSkillOutput(HttpContext ctx, SkillService skillService)
{
var slug = ctx.Request.RouteValues["slug"]?.ToString();
var filename = ctx.Request.RouteValues["filename"]?.ToString();
if (string.IsNullOrEmpty(slug) || string.IsNullOrEmpty(filename))
{
ctx.Response.StatusCode = 400;
return;
}
// Sicherheit: Keine Pfad-Traversals erlauben
if (filename.Contains("..") || filename.Contains('/') || filename.Contains('\\'))
{
ctx.Response.StatusCode = 400;
return;
}
var skill = skillService.GetSkill(slug);
if (skill == null)
{
ctx.Response.StatusCode = 404;
return;
}
var filePath = Path.Combine(skill.Path, "output", filename);
if (!File.Exists(filePath))
{
ctx.Response.StatusCode = 404;
return;
}
var ext = Path.GetExtension(filename);
var contentType = MimeTypes.GetValueOrDefault(ext, "application/octet-stream");
ctx.Response.ContentType = contentType;
ctx.Response.Headers["Cache-Control"] = "private, max-age=3600";
await ctx.Response.SendFileAsync(filePath);
}
private static readonly HashSet<string> ThumbExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"
};
private static async Task HandleSkillOutputThumb(HttpContext ctx, SkillService skillService)
{
var slug = ctx.Request.RouteValues["slug"]?.ToString();
var filename = ctx.Request.RouteValues["filename"]?.ToString();
if (string.IsNullOrEmpty(slug) || string.IsNullOrEmpty(filename))
{
ctx.Response.StatusCode = 400;
return;
}
if (filename.Contains("..") || filename.Contains('/') || filename.Contains('\\'))
{
ctx.Response.StatusCode = 400;
return;
}
var ext = Path.GetExtension(filename);
if (!ThumbExtensions.Contains(ext))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsync("Thumbnails nur für Bilder verfügbar");
return;
}
var skill = skillService.GetSkill(slug);
if (skill == null) { ctx.Response.StatusCode = 404; return; }
var originalPath = Path.Combine(skill.Path, "output", filename);
if (!File.Exists(originalPath)) { ctx.Response.StatusCode = 404; return; }
var dataDir = Path.GetDirectoryName(skillService.SkillsDir);
var cacheDir = Path.Combine(dataDir, "cache", "skills", slug);
var thumbPath = await ImageService.GenerateThumbnail(originalPath, cacheDir);
if (thumbPath != null)
{
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);
}
}
}