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
This commit is contained in:
1 parent
cf6caea2dc
commit
9e4ed6dada
7 files changed
+253
-3
No files matched your search
@@ -1,3 +1,6 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -10,11 +13,24 @@ namespace Gott.Endpoints;
|
|||||||
|
|
||||||
public static class SkillEndpoint
|
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)
|
public static void MapSkillEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
// Skills (Dateisystem-basiert)
|
// Skills (Dateisystem-basiert)
|
||||||
app.MapGet("/api/skills", HandleListSkills);
|
app.MapGet("/api/skills", HandleListSkills);
|
||||||
|
|
||||||
|
// Skill-Output-Dateien (Bilder etc.)
|
||||||
|
app.MapGet("/api/skills/{slug}/output/{filename}", HandleSkillOutput);
|
||||||
|
|
||||||
// Agent ↔ Skill Zuordnung
|
// Agent ↔ Skill Zuordnung
|
||||||
app.MapGet("/api/agents/{agentId}/skills", HandleGetAgentSkills);
|
app.MapGet("/api/agents/{agentId}/skills", HandleGetAgentSkills);
|
||||||
app.MapPut("/api/agents/{agentId}/skills", HandleSetAgentSkills);
|
app.MapPut("/api/agents/{agentId}/skills", HandleSetAgentSkills);
|
||||||
@@ -79,4 +95,44 @@ public static class SkillEndpoint
|
|||||||
await db.RemoveAgentSkill(agentId, slug);
|
await db.RemoveAgentSkill(agentId, slug);
|
||||||
ctx.Response.StatusCode = 204;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -43,6 +43,7 @@ public static class WorkspaceEndpoint
|
|||||||
app.MapGet("/api/workspace/agent/tree", HandleAgentTree);
|
app.MapGet("/api/workspace/agent/tree", HandleAgentTree);
|
||||||
app.MapGet("/api/workspace/agent/file", HandleAgentFile);
|
app.MapGet("/api/workspace/agent/file", HandleAgentFile);
|
||||||
app.MapPut("/api/workspace/agent/file", HandleAgentFileSave);
|
app.MapPut("/api/workspace/agent/file", HandleAgentFileSave);
|
||||||
|
app.MapGet("/api/workspace/raw", HandleRawFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task HandleTree(HttpContext ctx)
|
private static async Task HandleTree(HttpContext ctx)
|
||||||
@@ -421,4 +422,56 @@ public static class WorkspaceEndpoint
|
|||||||
lineCount
|
lineCount
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
};
|
||||||
|
|
||||||
|
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<Gott.Services.DatabaseService>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -256,7 +256,7 @@ public class ToolService
|
|||||||
if (proc == null) return "Fehler: Prozess konnte nicht gestartet werden";
|
if (proc == null) return "Fehler: Prozess konnte nicht gestartet werden";
|
||||||
await proc.StandardInput.WriteAsync(command);
|
await proc.StandardInput.WriteAsync(command);
|
||||||
proc.StandardInput.Close();
|
proc.StandardInput.Close();
|
||||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(180));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await proc.WaitForExitAsync(cts.Token);
|
await proc.WaitForExitAsync(cts.Token);
|
||||||
@@ -264,7 +264,7 @@ public class ToolService
|
|||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
proc.Kill(entireProcessTree: true);
|
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 stdout = await proc.StandardOutput.ReadToEndAsync();
|
||||||
var stderr = await proc.StandardError.ReadToEndAsync();
|
var stderr = await proc.StandardError.ReadToEndAsync();
|
||||||
|
|||||||
@@ -602,3 +602,50 @@
|
|||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
overflow: auto;
|
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);
|
||||||
|
}
|
||||||
@@ -105,6 +105,22 @@
|
|||||||
margin: auto;
|
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 ── */
|
/* ── CodeMirror / EasyMDE gemeinsame Wrapper-Basis ── */
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,15 @@ async function feSelectFile(entry, itemEl) {
|
|||||||
// Vorherige Editor-Instanz aufräumen
|
// Vorherige Editor-Instanz aufräumen
|
||||||
feDestroyEditor();
|
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 = `<div class="fe-image-preview"><img src="${rawUrl}" alt="${esc(entry.name)}" /></div>`;
|
||||||
|
footer.hidden = true;
|
||||||
|
_feState.selectedFile = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!entry.isText) {
|
if (!entry.isText) {
|
||||||
main.innerHTML = `<div class="fe-placeholder">${getFileIcon(entry.ext)} ${esc(entry.name)}<br><small>${formatSize(entry.size)} - Vorschau nicht verfügbar</small></div>`;
|
main.innerHTML = `<div class="fe-placeholder">${getFileIcon(entry.ext)} ${esc(entry.name)}<br><small>${formatSize(entry.size)} - Vorschau nicht verfügbar</small></div>`;
|
||||||
footer.hidden = true;
|
footer.hidden = true;
|
||||||
|
|||||||
+70
-1
@@ -10,7 +10,15 @@ marked.setOptions({ breaks: true, gfm: true });
|
|||||||
|
|
||||||
function renderMarkdown(text) {
|
function renderMarkdown(text) {
|
||||||
if (!text) return '';
|
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 '<span class="krea-ref-chip" data-filename="' + safeFn + '" title="Klicken für Vorschau">'
|
||||||
|
+ '<i class="fa fa-picture-o"></i> ' + safeFn + '</span>';
|
||||||
|
});
|
||||||
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollDown() {
|
function scrollDown() {
|
||||||
@@ -690,3 +698,64 @@ function removeArchiveLoadButton() {
|
|||||||
const btns = chatEl().querySelectorAll('.archive-load-btn:not(.older-messages-btn)');
|
const btns = chatEl().querySelectorAll('.archive-load-btn:not(.older-messages-btn)');
|
||||||
btns.forEach(b => b.remove());
|
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 = '<i class="fa fa-times"></i>';
|
||||||
|
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 = '<div class="sp-loading" style="color:var(--dangerous)">Bild konnte nicht geladen werden</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user