Krea inline thumbnails with cached WebP generation and gallery layout
- Add /api/skills/{slug}/output/{filename}/thumb endpoint with ImageSharp
- Generate 300px WebP thumbnails, cached in data/cache/skills/{slug}/
- Replace text badge with inline thumbnail preview in chat
- Click opens full-size image modal with backdrop blur
- wrapKreaGalleries() groups consecutive krea-refs into flex container
- Thumbnails lazy-loaded, cache invalidated when original is newer
This commit is contained in:
1 parent
9e4ed6dada
commit
8d86406037
3 files changed
+174
-33
No files matched your search
@@ -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<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; }
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
+23
-16
@@ -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;
|
||||
|
||||
+68
-17
@@ -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 '<span class="krea-ref-chip" data-filename="' + safeFn + '" title="Klicken für Vorschau">'
|
||||
+ '<i class="fa fa-picture-o"></i> ' + safeFn + '</span>';
|
||||
var thumbUrl = '/api/skills/krea-api/output/' + encodeURIComponent(fn) + '/thumb';
|
||||
var fullUrl = '/api/skills/krea-api/output/' + encodeURIComponent(fn);
|
||||
return '<span class="krea-ref" data-filename="' + safeFn + '" data-fullurl="' + fullUrl + '" title="Klicken für Vollbild">'
|
||||
+ '<img class="krea-thumb" src="' + thumbUrl + '" alt="' + safeFn + '" loading="lazy">'
|
||||
+ '</span>';
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
function wrapKreaGalleries(container) {
|
||||
// Findet <p>-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 <p> nur krea-refs enthält (ggf. mit Whitespace)
|
||||
if (el.tagName === 'P' && el.querySelector('.krea-ref') && el.textContent.trim() === '') {
|
||||
// Sammle aufeinanderfolgende krea-only <p>-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 = '<i class="fa fa-times"></i>';
|
||||
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 = '<div class="sp-loading" style="color:var(--dangerous)">Bild konnte nicht geladen werden</div>';
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user