diff --git a/Endpoints/SkillEndpoint.cs b/Endpoints/SkillEndpoint.cs index ce3e042..00c76fb 100644 --- a/Endpoints/SkillEndpoint.cs +++ b/Endpoints/SkillEndpoint.cs @@ -8,6 +8,9 @@ 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; @@ -31,6 +34,9 @@ public static class SkillEndpoint // 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); @@ -135,4 +141,81 @@ public static class SkillEndpoint ctx.Response.Headers["Cache-Control"] = "private, max-age=3600"; await ctx.Response.SendFileAsync(filePath); } + + private static readonly HashSet 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; } + + // 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)) + { + try + { + using var image = await Image.LoadAsync(originalPath); + + // Max 300px auf der längsten Seite + var maxSize = 300; + 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); + } } diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index fc21ca1..3d93227 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -605,32 +605,39 @@ /* Krea Image Refs in Assistant Bubbles */ -.krea-ref-chip { - display: inline-flex; - align-items: center; - gap: .3em; - padding: .15em .5em; - background: var(--f2-bg); +.krea-gallery { + display: flex; + flex-wrap: wrap; + gap: var(--badge-spacing); + margin: var(--box-spacing) 0; +} + +.krea-ref { + display: inline-block; + cursor: pointer; 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); + overflow: hidden; + vertical-align: middle; } -.krea-ref-chip:hover { - border-color: var(--f3-border); - background: var(--f3-bg); +.krea-ref:hover { + border-color: var(--primary); } -.krea-ref-chip .fa { - color: var(--primary); +.krea-thumb { + display: block; + max-width: 300px; + max-height: 200px; + object-fit: contain; } /* Krea Image Modal */ +.krea-overlay { + backdrop-filter: blur(4px); +} + .krea-modal { max-width: 90vw; max-height: 90vh; diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index ee403df..5399b07 100644 --- a/wwwroot/js/ui.js +++ b/wwwroot/js/ui.js @@ -11,16 +11,64 @@ marked.setOptions({ breaks: true, gfm: true }); function renderMarkdown(text) { if (!text) return ''; var html = marked.parse(text.trim()); - // [Krea: dateiname.png] → klickbarer Bild-Link + // [Krea: dateiname.png] → Inline-Thumbnail mit Klick für Vollbild html = html.replace(/\[Krea:\s*([^\]]+)\]/g, function(match, filename) { var fn = filename.trim(); var safeFn = esc(fn); - return '' - + ' ' + safeFn + ''; + var thumbUrl = '/api/skills/krea-api/output/' + encodeURIComponent(fn) + '/thumb'; + var fullUrl = '/api/skills/krea-api/output/' + encodeURIComponent(fn); + return '' + + '' + safeFn + '' + + ''; }); return html; } +function wrapKreaGalleries(container) { + // Findet

-Tags die NUR krea-ref(s) enthalten und gruppiert aufeinanderfolgende zu einer Gallery + var children = Array.from(container.children); + var i = 0; + while (i < children.length) { + var el = children[i]; + // Prüfe ob

nur krea-refs enthält (ggf. mit Whitespace) + if (el.tagName === 'P' && el.querySelector('.krea-ref') && el.textContent.trim() === '') { + // Sammle aufeinanderfolgende krea-only

-Tags + var group = [el]; + var j = i + 1; + while (j < children.length) { + var next = children[j]; + if (next.tagName === 'P' && next.querySelector('.krea-ref') && next.textContent.trim() === '') { + group.push(next); + j++; + } else { + break; + } + } + // Gallery erstellen wenn mindestens 1 Bild + if (group.length >= 1) { + var gallery = document.createElement('div'); + gallery.className = 'krea-gallery'; + el.parentNode.insertBefore(gallery, group[0]); + for (var k = 0; k < group.length; k++) { + var refs = group[k].querySelectorAll('.krea-ref'); + for (var r = 0; r < refs.length; r++) { + gallery.appendChild(refs[r]); + } + group[k].remove(); + } + // children-Array aktualisieren + children = Array.from(container.children); + // i bleibt gleich, da gallery jetzt an der Position steht + i++; + } else { + i = j; + } + } else { + i++; + } + } +} + function scrollDown() { const chat = chatEl(); if (!chat) return; @@ -185,6 +233,7 @@ function addAssistantBubble(text, timestamp = '', messageId = null, isMarked = f div.dataset.timestamp = timestamp || new Date().toISOString(); if (messageId) div.dataset.messageId = messageId; div.innerHTML = renderMarkdown(text); + wrapKreaGalleries(div); _addReplyButton(div); _addMarkButton(div, messageId, isMarked); chatEl().appendChild(div); @@ -247,7 +296,10 @@ function endStreamBubble() { const cursor = _streamDiv.querySelector('.cursor'); if (cursor) cursor.remove(); if (!_streamText.trim()) _streamDiv.remove(); - else _streamDiv.innerHTML = renderMarkdown(_streamText); + else { + _streamDiv.innerHTML = renderMarkdown(_streamText); + wrapKreaGalleries(_streamDiv); + } } _streamDiv = null; _streamText = ''; @@ -701,9 +753,7 @@ function removeArchiveLoadButton() { // ── Krea Image Preview Modal ── -function showKreaImageModal(filename) { - var url = '/api/skills/krea-api/output/' + encodeURIComponent(filename); - +function showKreaImageModal(title, fullUrl) { var overlay = document.createElement('div'); overlay.className = 'sp-overlay krea-overlay'; @@ -713,12 +763,12 @@ function showKreaImageModal(filename) { // Header var header = document.createElement('div'); header.className = 'sp-header'; - var title = document.createElement('span'); - title.textContent = filename; + var titleEl = document.createElement('span'); + titleEl.textContent = title; var closeBtn = document.createElement('button'); closeBtn.innerHTML = ''; closeBtn.className = 'sp-close icon-btn'; - header.appendChild(title); + header.appendChild(titleEl); header.appendChild(closeBtn); // Body @@ -726,9 +776,9 @@ function showKreaImageModal(filename) { body.className = 'sp-body krea-modal-body'; var img = document.createElement('img'); - img.src = url; + img.src = fullUrl; img.className = 'krea-modal-image'; - img.alt = filename; + img.alt = title; img.addEventListener('error', function() { body.innerHTML = '

Bild konnte nicht geladen werden
'; }); @@ -751,11 +801,12 @@ function showKreaImageModal(filename) { document.addEventListener('keydown', onKey); } -// Event-Delegation für Krea-Chips im Chat +// Event-Delegation für Krea-Thumbnails 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); + var ref = e.target.closest('.krea-ref'); + if (ref) { + var fullUrl = ref.dataset.fullurl; + var filename = ref.dataset.filename; + if (fullUrl) showKreaImageModal(filename || 'Bild', fullUrl); } });