Files
gott/Endpoints/SessionEndpoint.cs
T
Meleki 2a2e98ca68 feat: Thumbnail-Varianten (256px) für Agent-Avatare und Session-Images
- 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)
2026-03-07 20:09:01 +01:00

623 lines
24 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Gott.Extensions;
using Gott.Models;
using Gott.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Gott.Endpoints;
public static class SessionEndpoint
{
public static void MapSessionEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/sessions", HandleList);
app.MapGet("/api/sessions/{sessionId}", HandleGet);
app.MapPost("/api/sessions", HandleCreate);
app.MapPut("/api/sessions/{sessionId}", HandleUpdate);
app.MapDelete("/api/sessions/{sessionId}", HandleDelete);
app.MapGet("/api/sessions/{sessionId}/system-prompt", HandleSystemPrompt);
app.MapGet("/api/sessions/{sessionId}/system-prompt-blocks", HandleSystemPromptBlocks);
app.MapGet("/api/sessions/{sessionId}/streaming", HandleStreaming);
app.MapPost("/api/sessions/{sessionId}/exec", HandleExec);
app.MapGet("/api/sessions/{sessionId}/git-status", HandleGitStatus);
}
private static async Task HandleList(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var user = await db.GetUser(userId);
var sessionList = await db.GetSessionsWithStats(userId);
// Git-Status parallel für alle Sessions mit Workspace laden
var gitTasks = new Dictionary<int, Task<GitStatusSummary>>();
for (int i = 0; i < sessionList.Count; i++)
{
var resolvedWs = WorkspaceHelper.ResolveWorkspace(sessionList[i].Session, user);
if (!string.IsNullOrEmpty(resolvedWs))
gitTasks[i] = GetGitStatusSummaryAsync(resolvedWs);
}
if (gitTasks.Count > 0)
await Task.WhenAll(gitTasks.Values);
// Ergebnisse zusammenführen
var results = new List<object>();
for (int i = 0; i < sessionList.Count; i++)
{
var sw = sessionList[i];
var s = sw.Session;
object gitStatus = null;
if (gitTasks.TryGetValue(i, out var task) && task.IsCompletedSuccessfully && task.Result != null)
{
var gs = task.Result;
if (gs.Add > 0 || gs.Edit > 0 || gs.Delete > 0 || gs.Outgoing > 0)
gitStatus = new { add = gs.Add, edit = gs.Edit, delete = gs.Delete, outgoing = gs.Outgoing };
}
results.Add(new
{
s.SessionId,
s.Name,
s.Description,
Image = s.ImageSmall ?? s.Image,
s.AgentId,
s.Workspace,
s.Context,
s.CustomCommands,
s.AvatarScale,
s.AvatarOffsetX,
s.AvatarOffsetY,
s.ReviewAgentId,
s.MaxReviewIterations,
s.FinalizerAgentId,
s.PreparerAgentId,
s.IsDeleted,
s.CreatedAt,
s.UpdatedAt,
sw.MessageCount,
sw.ArchiveMessageCount,
sw.TotalMessageCount,
sw.TelegramConfigured,
sw.TelegramLinked,
GitStatus = gitStatus
});
}
await ctx.Response.WriteAsJsonAsync(results);
}
private static async Task HandleGet(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var session = await db.GetSession(sessionId);
if (session == null || session.UserId != userId)
{
ctx.Response.StatusCode = 404;
return;
}
await ctx.Response.WriteAsJsonAsync(session);
}
private static async Task HandleCreate(HttpContext ctx, DatabaseService db, ImageService imageService)
{
var userId = ctx.GetUserId();
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
var name = body.TryGetProperty("name", out var n) ? n.GetString() : "Neue Session";
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)
var workspace = !string.IsNullOrWhiteSpace(workspaceRaw) && !System.IO.Path.IsPathRooted(workspaceRaw)
? WorkspaceHelper.ValidateRelativeWorkspace(workspaceRaw)
: workspaceRaw;
if (!string.IsNullOrWhiteSpace(workspaceRaw) && workspace == null)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Ungültiger Workspace-Pfad (.. nicht erlaubt)" });
return;
}
var context = body.TryGetProperty("context", out var ctxEl) ? ctxEl.GetString() : null;
var customCommands = body.TryGetProperty("customCommands", out var cc) && cc.ValueKind != JsonValueKind.Null ? cc.GetRawText() : null;
var avatarScale = body.TryGetProperty("avatarScale", out var asc) ? asc.GetDouble() : 1.0;
var avatarOffsetX = body.TryGetProperty("avatarOffsetX", out var aox) ? aox.GetDouble() : 0;
var avatarOffsetY = body.TryGetProperty("avatarOffsetY", out var aoy) ? aoy.GetDouble() : 0;
var reviewAgentId = body.TryGetProperty("reviewAgentId", out var ra) ? ra.GetString() : null;
var maxReviewIterations = body.TryGetProperty("maxReviewIterations", out var mri) ? mri.GetInt32() : 3;
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, imageSmall, agentId, workspace, context, customCommands, avatarScale, avatarOffsetX, avatarOffsetY, reviewAgentId, maxReviewIterations, finalizerAgentId, preparerAgentId);
await ctx.Response.WriteAsJsonAsync(session);
}
private static async Task HandleUpdate(HttpContext ctx, DatabaseService db, ImageService imageService)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var existing = await db.GetSession(sessionId);
if (existing == null || existing.UserId != userId)
{
ctx.Response.StatusCode = 404;
return;
}
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
// Workspace-Pfad validieren (Path-Traversal verhindern)
if (body.TryGetProperty("workspace", out var wsEl))
{
var wsRaw = wsEl.GetString();
if (!string.IsNullOrWhiteSpace(wsRaw) && !System.IO.Path.IsPathRooted(wsRaw))
{
var validated = WorkspaceHelper.ValidateRelativeWorkspace(wsRaw);
if (validated == null)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Ungültiger Workspace-Pfad (.. nicht erlaubt)" });
return;
}
}
}
await db.UpdateSession(sessionId, body, imageService);
var session = await db.GetSession(sessionId);
await ctx.Response.WriteAsJsonAsync(session);
}
private static async Task HandleDelete(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId)) { ctx.Response.StatusCode = 400; return; }
await db.DeleteSession(sessionId, userId);
ctx.Response.StatusCode = 204;
}
private static async Task HandleSystemPrompt(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 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 systemPrompt = SystemPromptBuilder.Build(agent, session, markedMessages, memoryService, agentMemoryDir, agentSkills, skillService, userPersona: user?.GetEffectivePersona(), resolvedWorkspace: resolvedWorkspace, planMode: planMode);
ctx.Response.ContentType = "text/plain; charset=utf-8";
await ctx.Response.WriteAsync(systemPrompt);
}
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 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);
await ctx.Response.WriteAsJsonAsync(blocks.Select(b => new { b.Icon, b.Label, b.Content }));
}
private static async Task HandleStreaming(HttpContext ctx, DatabaseService db, StreamingTracker streamingTracker)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var session = await db.GetSession(sessionId);
if (session == null || session.UserId != userId)
{
ctx.Response.StatusCode = 404;
return;
}
var isStreaming = streamingTracker.IsStreaming(sessionId);
var chainId = streamingTracker.GetChainId(sessionId);
await ctx.Response.WriteAsJsonAsync(new { streaming = isStreaming, chainId });
}
private static async Task HandleExec(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var session = await db.GetSession(sessionId);
if (session == null || session.UserId != userId)
{
ctx.Response.StatusCode = 404;
return;
}
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
var command = body.TryGetProperty("command", out var c) ? c.GetString() : null;
var mode = body.TryGetProperty("mode", out var m) ? m.GetString() ?? "background" : "background";
if (string.IsNullOrWhiteSpace(command))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Kein Befehl angegeben" });
return;
}
var user = await db.GetUser(userId);
var resolvedWs = WorkspaceHelper.ResolveWorkspace(session, user);
var workingDir = !string.IsNullOrWhiteSpace(resolvedWs) ? resolvedWs : "/tmp";
if (mode == "foreground")
{
// Synchron ausführen, auf Ergebnis warten
try
{
var psi = new ProcessStartInfo
{
FileName = "/bin/bash",
WorkingDirectory = workingDir,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var proc = System.Diagnostics.Process.Start(psi);
if (proc == null)
{
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsJsonAsync(new { error = "Prozess konnte nicht gestartet werden" });
return;
}
await proc.StandardInput.WriteAsync(command);
proc.StandardInput.Close();
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
await proc.WaitForExitAsync(cts.Token);
}
catch (OperationCanceledException)
{
proc.Kill(entireProcessTree: true);
await ctx.Response.WriteAsJsonAsync(new { status = "timeout", output = "Timeout nach 30 Sekunden", exitCode = -1 });
return;
}
var stdout = await proc.StandardOutput.ReadToEndAsync();
var stderr = await proc.StandardError.ReadToEndAsync();
var output = new System.Text.StringBuilder();
if (!string.IsNullOrEmpty(stdout)) output.Append(stdout);
if (!string.IsNullOrEmpty(stderr))
{
if (output.Length > 0) output.Append("\n--- stderr ---\n");
output.Append(stderr);
}
await ctx.Response.WriteAsJsonAsync(new { status = "completed", output = output.ToString(), exitCode = proc.ExitCode });
}
catch (Exception ex)
{
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsJsonAsync(new { error = ex.Message });
}
}
else
{
// Fire-and-forget via systemd-run
try
{
var scriptPath = $"/tmp/gott-exec-{Guid.NewGuid():N}.sh";
await System.IO.File.WriteAllTextAsync(scriptPath, $"#!/bin/bash\ncd {workingDir}\n{command}\nrm -f {scriptPath}\n");
var psi = new ProcessStartInfo
{
FileName = "systemd-run",
Arguments = $"--scope --quiet bash {scriptPath}",
WorkingDirectory = workingDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
var process = Process.Start(psi);
if (process != null)
{
process.WaitForExit(500);
}
ctx.Response.StatusCode = 202;
await ctx.Response.WriteAsJsonAsync(new { status = "started", command = command });
}
catch (Exception ex)
{
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsJsonAsync(new { error = ex.Message });
}
}
}
private static async Task HandleGitStatus(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var session = await db.GetSession(sessionId);
if (session == null || session.UserId != userId)
{
ctx.Response.StatusCode = 404;
return;
}
var user = await db.GetUser(userId);
var workingDir = WorkspaceHelper.ResolveWorkspace(session, user);
if (string.IsNullOrEmpty(workingDir))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Kein Workspace konfiguriert" });
return;
}
var result = await GetGitStatusDetailedAsync(workingDir);
if (result == null)
{
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsJsonAsync(new { error = "Git-Status konnte nicht ermittelt werden" });
return;
}
await ctx.Response.WriteAsJsonAsync(result);
}
/// <summary>
/// Ermittelt eine Git-Status-Zusammenfassung (nur Counts) für die Dashboard-Ansicht.
/// Gibt null zurück bei Fehlern (kein Git-Repo, kein git installiert etc.).
/// </summary>
private static async Task<GitStatusSummary> GetGitStatusSummaryAsync(string workingDir)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "git",
Arguments = "status --porcelain",
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var proc = Process.Start(psi);
if (proc == null) return null;
var stdout = await proc.StandardOutput.ReadToEndAsync();
await proc.WaitForExitAsync();
if (proc.ExitCode != 0) return null;
int addCount = 0, editCount = 0, deleteCount = 0;
var lines = stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
if (line.Length < 4) continue;
var x = line[0];
var y = line[1];
if (x == '?' && y == '?') addCount++;
else if (x == 'D' || y == 'D') deleteCount++;
else if (x == 'A') addCount++;
else editCount++;
}
// Outgoing commits
int outgoing = 0;
try
{
var outPsi = new ProcessStartInfo
{
FileName = "git",
Arguments = "rev-list --count @{u}..HEAD",
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var outProc = Process.Start(outPsi);
if (outProc != null)
{
var outResult = (await outProc.StandardOutput.ReadToEndAsync()).Trim();
await outProc.WaitForExitAsync();
if (outProc.ExitCode == 0 && int.TryParse(outResult, out var c))
outgoing = c;
}
}
catch { }
return new GitStatusSummary { Add = addCount, Edit = editCount, Delete = deleteCount, Outgoing = outgoing };
}
catch
{
return null;
}
}
/// <summary>
/// Ermittelt den detaillierten Git-Status (mit Dateinamen) für den Einzel-Endpoint.
/// </summary>
private static async Task<object> GetGitStatusDetailedAsync(string workingDir)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "git",
Arguments = "status --porcelain",
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var proc = Process.Start(psi);
if (proc == null) return null;
var stdout = await proc.StandardOutput.ReadToEndAsync();
await proc.WaitForExitAsync();
if (proc.ExitCode != 0) return null;
var add = new List<string>();
var edit = new List<string>();
var delete = new List<string>();
var lines = stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
if (line.Length < 4) continue;
var x = line[0];
var y = line[1];
var file = line.Substring(3);
var arrowIdx = file.IndexOf(" -> ", StringComparison.Ordinal);
if (arrowIdx >= 0)
file = file.Substring(arrowIdx + 4);
if (x == '?' && y == '?') add.Add(file);
else if (x == 'D' || y == 'D') delete.Add(file);
else if (x == 'A') add.Add(file);
else edit.Add(file);
}
int outgoing = 0;
try
{
var outPsi = new ProcessStartInfo
{
FileName = "git",
Arguments = "rev-list --count @{u}..HEAD",
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var outProc = Process.Start(outPsi);
if (outProc != null)
{
var outResult = (await outProc.StandardOutput.ReadToEndAsync()).Trim();
await outProc.WaitForExitAsync();
if (outProc.ExitCode == 0 && int.TryParse(outResult, out var count))
outgoing = count;
}
}
catch { }
return new { add, edit, delete, outgoing };
}
catch
{
return null;
}
}
}