From 9e4ed6dada0bfb656fbc067c4b38f773c9724ad3 Mon Sep 17 00:00:00 2001 From: Meleki Date: Sun, 1 Mar 2026 20:00:35 +0100 Subject: [PATCH] Add image preview support: Krea-tag parsing with modal, file-explorer preview, skill output endpoint, increase execute timeout to 180s - SkillEndpoint: GET /api/skills/{slug}/output/{filename} for skill output files - WorkspaceEndpoint: GET /api/workspace/raw for raw file serving (images) - File-Explorer: Image preview for png/jpg/gif/webp/svg/bmp/ico - Chat UI: Parse [Krea: filename] tags into clickable chips with image modal - ToolService: Increase execute timeout from 30s to 180s for long-running skills --- Endpoints/SkillEndpoint.cs | 56 +++++++++++++++++++++++++++ Endpoints/WorkspaceEndpoint.cs | 53 +++++++++++++++++++++++++ Services/ToolService.cs | 4 +- wwwroot/css/chat.css | 47 ++++++++++++++++++++++ wwwroot/css/file-explorer.css | 16 ++++++++ wwwroot/js/file-explorer.js | 9 +++++ wwwroot/js/ui.js | 71 +++++++++++++++++++++++++++++++++- 7 files changed, 253 insertions(+), 3 deletions(-) diff --git a/Endpoints/SkillEndpoint.cs b/Endpoints/SkillEndpoint.cs index 18ce23d..ce3e042 100644 --- a/Endpoints/SkillEndpoint.cs +++ b/Endpoints/SkillEndpoint.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; @@ -10,11 +13,24 @@ namespace Gott.Endpoints; public static class SkillEndpoint { + 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" }, + }; + 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); + // Agent ↔ Skill Zuordnung app.MapGet("/api/agents/{agentId}/skills", HandleGetAgentSkills); app.MapPut("/api/agents/{agentId}/skills", HandleSetAgentSkills); @@ -79,4 +95,44 @@ public static class SkillEndpoint 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); + } } diff --git a/Endpoints/WorkspaceEndpoint.cs b/Endpoints/WorkspaceEndpoint.cs index afa9281..e894a95 100644 --- a/Endpoints/WorkspaceEndpoint.cs +++ b/Endpoints/WorkspaceEndpoint.cs @@ -43,6 +43,7 @@ public static class WorkspaceEndpoint 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); } private static async Task HandleTree(HttpContext ctx) @@ -421,4 +422,56 @@ public static class WorkspaceEndpoint lineCount }); } + + 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(); + var filePath = ctx.Request.Query["path"].FirstOrDefault(); + + if (string.IsNullOrEmpty(filePath)) + { + ctx.Response.StatusCode = 400; + return; + } + + var db = ctx.RequestServices.GetRequiredService(); + var session = await db.GetSession(sessionId ?? ""); + if (session == null || string.IsNullOrEmpty(session.Workspace)) + { + ctx.Response.StatusCode = 400; + return; + } + + var basePath = Path.GetFullPath(session.Workspace); + 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 ext = Path.GetExtension(targetPath); + 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(targetPath); + } } diff --git a/Services/ToolService.cs b/Services/ToolService.cs index aae7328..de887a4 100644 --- a/Services/ToolService.cs +++ b/Services/ToolService.cs @@ -256,7 +256,7 @@ public class ToolService if (proc == null) return "Fehler: Prozess konnte nicht gestartet werden"; await proc.StandardInput.WriteAsync(command); proc.StandardInput.Close(); - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(180)); try { await proc.WaitForExitAsync(cts.Token); @@ -264,7 +264,7 @@ public class ToolService catch (OperationCanceledException) { proc.Kill(entireProcessTree: true); - return "Fehler: Timeout nach 30 Sekunden - Prozess abgebrochen"; + return "Fehler: Timeout nach 180 Sekunden - Prozess abgebrochen"; } var stdout = await proc.StandardOutput.ReadToEndAsync(); var stderr = await proc.StandardError.ReadToEndAsync(); diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index 1a14831..fc21ca1 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -602,3 +602,50 @@ max-height: 60vh; overflow: auto; } + +/* Krea Image Refs in Assistant Bubbles */ + +.krea-ref-chip { + display: inline-flex; + align-items: center; + gap: .3em; + padding: .15em .5em; + background: var(--f2-bg); + border: var(--border-width) solid var(--f2-border); + border-radius: var(--border-radius); + font-size: var(--font-size); + white-space: nowrap; + vertical-align: baseline; + cursor: pointer; + transition: background var(--transition-duration), border-color var(--transition-duration); +} + +.krea-ref-chip:hover { + border-color: var(--f3-border); + background: var(--f3-bg); +} + +.krea-ref-chip .fa { + color: var(--primary); +} + +/* Krea Image Modal */ + +.krea-modal { + max-width: 90vw; + max-height: 90vh; +} + +.krea-modal-body { + display: flex; + align-items: center; + justify-content: center; + padding: var(--box-spacing); +} + +.krea-modal-image { + max-width: 100%; + max-height: 75vh; + object-fit: contain; + border-radius: var(--border-radius); +} diff --git a/wwwroot/css/file-explorer.css b/wwwroot/css/file-explorer.css index 003804f..a0706c0 100644 --- a/wwwroot/css/file-explorer.css +++ b/wwwroot/css/file-explorer.css @@ -105,6 +105,22 @@ margin: auto; } +.fe-image-preview { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + padding: 1em; + overflow: auto; +} + +.fe-image-preview img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 4px; +} + /* ── CodeMirror / EasyMDE gemeinsame Wrapper-Basis ── */ diff --git a/wwwroot/js/file-explorer.js b/wwwroot/js/file-explorer.js index cf10ce9..1012ade 100644 --- a/wwwroot/js/file-explorer.js +++ b/wwwroot/js/file-explorer.js @@ -204,6 +204,15 @@ async function feSelectFile(entry, itemEl) { // Vorherige Editor-Instanz aufräumen feDestroyEditor(); + const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico']; + if (!entry.isText && imageExts.includes((entry.ext || '').toLowerCase())) { + const rawUrl = _feApiUrl('raw', `path=${encodeURIComponent(entry.path)}`); + main.innerHTML = `
${esc(entry.name)}
`; + footer.hidden = true; + _feState.selectedFile = null; + return; + } + if (!entry.isText) { main.innerHTML = `
${getFileIcon(entry.ext)} ${esc(entry.name)}
${formatSize(entry.size)} - Vorschau nicht verfügbar
`; footer.hidden = true; diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index acb7884..ee403df 100644 --- a/wwwroot/js/ui.js +++ b/wwwroot/js/ui.js @@ -10,7 +10,15 @@ marked.setOptions({ breaks: true, gfm: true }); function renderMarkdown(text) { if (!text) return ''; - return marked.parse(text.trim()); + var html = marked.parse(text.trim()); + // [Krea: dateiname.png] → klickbarer Bild-Link + html = html.replace(/\[Krea:\s*([^\]]+)\]/g, function(match, filename) { + var fn = filename.trim(); + var safeFn = esc(fn); + return '' + + ' ' + safeFn + ''; + }); + return html; } function scrollDown() { @@ -690,3 +698,64 @@ function removeArchiveLoadButton() { const btns = chatEl().querySelectorAll('.archive-load-btn:not(.older-messages-btn)'); btns.forEach(b => b.remove()); } + +// ── Krea Image Preview Modal ── + +function showKreaImageModal(filename) { + var url = '/api/skills/krea-api/output/' + encodeURIComponent(filename); + + var overlay = document.createElement('div'); + overlay.className = 'sp-overlay krea-overlay'; + + var modal = document.createElement('div'); + modal.className = 'sp-modal krea-modal'; + + // Header + var header = document.createElement('div'); + header.className = 'sp-header'; + var title = document.createElement('span'); + title.textContent = filename; + var closeBtn = document.createElement('button'); + closeBtn.innerHTML = ''; + closeBtn.className = 'sp-close icon-btn'; + header.appendChild(title); + header.appendChild(closeBtn); + + // Body + var body = document.createElement('div'); + body.className = 'sp-body krea-modal-body'; + + var img = document.createElement('img'); + img.src = url; + img.className = 'krea-modal-image'; + img.alt = filename; + img.addEventListener('error', function() { + body.innerHTML = '
Bild konnte nicht geladen werden
'; + }); + + body.appendChild(img); + modal.appendChild(header); + modal.appendChild(body); + overlay.appendChild(modal); + document.body.appendChild(overlay); + + // Schließen + var close = function() { overlay.remove(); }; + closeBtn.addEventListener('click', close); + overlay.addEventListener('click', function(e) { + if (e.target === overlay) close(); + }); + var onKey = function(e) { + if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } + }; + document.addEventListener('keydown', onKey); +} + +// Event-Delegation für Krea-Chips im Chat +document.addEventListener('click', function(e) { + var chip = e.target.closest('.krea-ref-chip'); + if (chip) { + var filename = chip.dataset.filename; + if (filename) showKreaImageModal(filename); + } +});