feat: llms.txt (AI-readable docs), job delete, friendly missing-source error

- /llms.txt: public machine-readable capability/API/MCP doc for agents.
- Job delete action + queue button for terminal jobs; cleans objects.
- process.ts: clear 'Quelle nicht mehr vorhanden' message instead of raw
  EISDIR/ENOENT when a source was purged.
- Docs corrected: Picdrop 'missing images' was web-UI sorting, not PNG;
  JPG delivery kept as size/perf improvement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
2026-07-24 08:57:31 +00:00
parent d54d322d6a
commit ae91f4bfd5
7 changed files with 125 additions and 7 deletions
+5 -3
View File
@@ -39,9 +39,11 @@ Nummerierte SQL in `migrations/`, beim Start automatisch (kein ORM-Push). Wichti
`settings.output_ext` / `recipes.output_ext`, `settings.*_metadata_sidecar`, `delivery_targets.*`. `settings.output_ext` / `recipes.output_ext`, `settings.*_metadata_sidecar`, `delivery_targets.*`.
## Stolpersteine & Lösungen (Learnings) ## Stolpersteine & Lösungen (Learnings)
- **Picdrop zeigt große PNGs nicht:** Ergebnisse waren 1232 MB PNG; Picdrop ist ein Foto-Proofing-Tool - **„Bild bei Picdrop ✓, aber nicht in der Galerie":** Fehldiagnose vermeiden — die Dateien lagen die ganze
und erwartet handliche JPGs. Lösung: nicht-transparente Ergebnisse als JPG ausliefern (`src/lib/delivery.ts`, Zeit **korrekt** per SFTP in `/POSTER LEA` (per Admin → Picdrop → „Ordner anzeigen" verifizierbar). Der
`deliverableBuffer`) + wählbares Ausgabeformat. Die Dateien lagen dabei **korrekt** per SFTP in `/POSTER LEA`. eigentliche Grund war die **Sortierung in der Picdrop-Weboberfläche**, nicht das Format. Unabhängig davon
liefern wir nicht-transparente Ergebnisse jetzt als JPG aus (`src/lib/delivery.ts`, `deliverableBuffer`):
~25 MB PNG → ~2 MB JPG, schneller und handlicher — plus wählbares Ausgabeformat (`output_ext`).
- **„Unbekanntes Format: sticker5":** Seed legte ein Rezept mit Formatschlüssel `sticker5` an, den der - **„Unbekanntes Format: sticker5":** Seed legte ein Rezept mit Formatschlüssel `sticker5` an, den der
Resolver nicht kannte → jeder Lauf schlug fehl. Lösung: generisches Parsen von `sticker<N>`/`WxH`. Resolver nicht kannte → jeder Lauf schlug fehl. Lösung: generisches Parsen von `sticker<N>`/`WxH`.
- **Auftrag trotz Fehler grün:** `jobs.status` kannte kein `failed`; alle Positionen fehlgeschlagen wurde - **Auftrag trotz Fehler grün:** `jobs.status` kannte kein `failed`; alle Positionen fehlgeschlagen wurde
+7
View File
@@ -78,6 +78,13 @@ export default function QueueApp({ jobId }: { jobId: string | null }) {
{job?.status === 'paused' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/resume`)}>Fortsetzen</button>} {job?.status === 'paused' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/resume`)}>Fortsetzen</button>}
{failed > 0 && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/retry-failed`)}>Fehlgeschlagene erneut</button>} {failed > 0 && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/retry-failed`)}>Fehlgeschlagene erneut</button>}
{['queued', 'running', 'paused'].includes(job?.status) && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/cancel`)}>Abbrechen</button>} {['queued', 'running', 'paused'].includes(job?.status) && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/cancel`)}>Abbrechen</button>}
{['done', 'failed', 'cancelled'].includes(job?.status) && (
<button className="mini" onClick={async () => {
if (!confirm('Diesen Auftrag löschen?')) return;
await fetch(`/api/jobs/${jobId}/delete`, { method: 'POST' });
location.href = '/warteschlange';
}}>Löschen</button>
)}
</div> </div>
</div> </div>
+14 -1
View File
@@ -82,7 +82,20 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
mode === 'compose' ? (item.source_paths || []).filter(Boolean) mode === 'compose' ? (item.source_paths || []).filter(Boolean)
: mode === 'generate' ? [] : mode === 'generate' ? []
: (item.source_path ? [item.source_path] : []); : (item.source_path ? [item.source_path] : []);
const sources = await Promise.all(sourceKeys.map((k) => getObject(k))); // Braucht dieser Modus eine Quelle, ist aber keine (mehr) da? Klare Meldung statt roher fs-Fehler.
if (mode !== 'generate' && sourceKeys.length === 0) {
const e: any = new Error('Quelle nicht mehr vorhanden — bitte das Bild erneut hochladen.');
e.friendly = 'Quelle nicht mehr vorhanden — bitte das Bild erneut hochladen.';
throw e;
}
const sources = await Promise.all(sourceKeys.map(async (k) => {
try { return await getObject(k); }
catch {
const e: any = new Error('Quelldatei nicht mehr vorhanden — bitte das Bild erneut hochladen.');
e.friendly = 'Quelldatei nicht mehr vorhanden — bitte das Bild erneut hochladen.';
throw e;
}
}));
const target = tasks.includes('format') && r.output_format const target = tasks.includes('format') && r.output_format
? resolveDimensions({ format: r.output_format, orientation: r.orientation, dpi }) ? resolveDimensions({ format: r.output_format, orientation: r.orientation, dpi })
+1 -1
View File
@@ -3,7 +3,7 @@ import { ensureInit } from './lib/init';
import { readSession } from './lib/auth'; import { readSession } from './lib/auth';
import { one } from './lib/db'; import { one } from './lib/db';
const PUBLIC_PATHS = [/^\/login/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//, /^\/api\/telegram\/webhook/]; const PUBLIC_PATHS = [/^\/login/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//, /^\/api\/telegram\/webhook/, /^\/llms\.txt/];
/** API-Token (Authorization: Bearer …) → synthetischer Admin-Nutzer für /api/*. */ /** API-Token (Authorization: Bearer …) → synthetischer Admin-Nutzer für /api/*. */
async function tokenUser(header: string | null): Promise<any | null> { async function tokenUser(header: string | null): Promise<any | null> {
+13
View File
@@ -34,6 +34,19 @@ export const POST: APIRoute = async ({ params, locals }) => {
for (const it of items) await enqueue({ itemId: it.id, jobId: id }); for (const it of items) await enqueue({ itemId: it.id, jobId: id });
break; break;
} }
case 'delete': {
// Auftrag samt Positionen und deren Objekten entfernen (für alte/fehlgeschlagene Aufträge).
const { deleteObject } = await import('../../../../lib/storage');
const items = await query<any>(
`SELECT source_path, source_paths, result_path, thumb_path FROM items WHERE job_id=$1`, [id]);
for (const it of items) {
const keys = [it.source_path, it.result_path, it.thumb_path, ...((it.source_paths as string[]) || [])].filter(Boolean);
for (const k of keys) await deleteObject(k).catch(() => {});
}
await query(`DELETE FROM items WHERE job_id=$1`, [id]);
await query(`DELETE FROM jobs WHERE id=$1`, [id]);
break;
}
default: default:
return json({ error: 'Unbekannte Aktion.' }, 400); return json({ error: 'Unbekannte Aktion.' }, 400);
} }
+4 -2
View File
@@ -10,9 +10,11 @@ import Base from '../layouts/Base.astro';
<p class="lead">Was in Klarbild neu ist. Neueste Änderungen oben.</p> <p class="lead">Was in Klarbild neu ist. Neueste Änderungen oben.</p>
<section> <section>
<div class="ver"><span class="tag">24.07.2026</span><h2>Picdrop-Auslieferung, JPG, eigene Formate & fehlgeschlagene Aufträge</h2></div> <div class="ver"><span class="tag">24.07.2026</span><h2>JPG-Auslieferung, eigene Formate, fehlgeschlagene Aufträge & KI-Doku</h2></div>
<ul> <ul>
<li><b>Picdrop bekommt jetzt JPG statt riesiger PNGs:</b> Ergebnisse ohne Transparenz werden als handliches JPG (q92) ausgeliefert — ein 30×40-Poster schrumpft von ~25 MB auf ~2 MB. Freigestellte Motive (mit Transparenz) bleiben PNG.</li> <li><b>Kleinere Dateien für Picdrop:</b> Ergebnisse ohne Transparenz werden als handliches JPG (q92) ausgeliefert — ein 30×40-Poster schrumpft von ~25 MB auf ~2 MB. Freigestellte Motive (mit Transparenz) bleiben PNG. (Die zwischenzeitlich „verschwundenen" Bilder waren übrigens die ganze Zeit da — es lag an der Sortierung in der Picdrop-Weboberfläche.)</li>
<li><b>Auftrag löschen</b> direkt aus der Warteschlange (für alte/fehlgeschlagene Aufträge). Fehlende Quellen melden jetzt einen klaren Hinweis statt kryptischer Technikfehler.</li>
<li><b>KI-lesbare Doku</b> unter <code>/llms.txt</code> — beschreibt Zweck, Formate, API und MCP-Werkzeuge für Assistenten.</li>
<li><b>Dateiformat wählbar:</b> global in den Admin-Einstellungen (Standard-Dateiformat PNG/JPG) <i>und</i> pro Umwandlung im Studio (Standard / PNG / JPG). JPG nur ohne Transparenz.</li> <li><b>Dateiformat wählbar:</b> global in den Admin-Einstellungen (Standard-Dateiformat PNG/JPG) <i>und</i> pro Umwandlung im Studio (Standard / PNG / JPG). JPG nur ohne Transparenz.</li>
<li><b>Eigene Formate:</b> im Studio „Eigenes Maß (cm)" — einfach z. B. <code>25x35</code> eintippen; für Sticker reicht eine Zahl (<code>5</code> = 5×5 cm). Presets speichern das mit.</li> <li><b>Eigene Formate:</b> im Studio „Eigenes Maß (cm)" — einfach z. B. <code>25x35</code> eintippen; für Sticker reicht eine Zahl (<code>5</code> = 5×5 cm). Presets speichern das mit.</li>
<li><b>Fehlgeschlagene Aufträge sind jetzt rot</b> in der Übersicht (vorher grün) und tragen den Grund. Behoben: das Preset „Sticker 5 cm" lief ins Leere („Unbekanntes Format: sticker5").</li> <li><b>Fehlgeschlagene Aufträge sind jetzt rot</b> in der Übersicht (vorher grün) und tragen den Grund. Behoben: das Preset „Sticker 5 cm" lief ins Leere („Unbekanntes Format: sticker5").</li>
+81
View File
@@ -0,0 +1,81 @@
import type { APIRoute } from 'astro';
export const prerender = false;
/**
* Maschinen-/KI-lesbare Kurzdoku unter /llms.txt (öffentlich, ohne Geheimnisse).
* Beschreibt Zweck, Fähigkeiten und die HTTP-/MCP-Schnittstelle von Klarbild,
* damit ein KI-Agent das Tool ohne Weboberfläche bedienen kann.
*/
const BODY = `# Klarbild
> Selbst gehostetes KI-Bildwerkzeug: verwandelt Screenshots und Fundstücke in
> saubere, druckfertige Bilder (bereinigen, freistellen, exakte Fotoformate,
> The-Frame-Querformat, Sticker mit Kontur), liefert an Picdrop (SFTP/FTPS)
> aus und sichert auf beliebige Backup-Ziele (z. B. NAS). Bedienung über Web,
> Telegram-Bot und HTTP-API/MCP.
Basis-URL: https://klarbild.heidrich-digital.de
Sprache der Oberfläche: Deutsch. Stack: Astro 5 (SSR) · Postgres · sharp · pg-boss.
## Authentifizierung (für Agenten)
- Header: \`Authorization: Bearer <API-Token>\` (Admin → „Automatisierung / MCP-Zugriff").
- Der Token gilt für \`/api/*\` — NICHT für \`/api/admin/*\` (nur per Login-Session).
- Ohne Token/Session antworten API-Routen mit 401.
## Kernbegriffe
- Modus (mode): \`each\` (Screenshot bereinigen/freistellen/formatieren),
\`compose\` (ein Bild umwandeln ODER mehrere kombinieren, mit Textbeschreibung),
\`generate\` (neues Bild allein aus Text).
- Aufgaben (tasks, nur bei each): \`clean\`, \`cutout\`, \`format\`, \`contour\`.
- Rezept (recipe): gespeicherte Voreinstellung. Auftrag (job): ein Stapel; Position (item): ein Bild.
- Auftrags-Status: queued, running, paused, done, failed, cancelled. \`failed\` = alle Positionen fehlgeschlagen.
## Formate (output_format)
- Feste Schlüssel: 9x13,10x15,13x18,15x20,20x30,30x40,30x45,40x50,40x60,50x70,60x90,
A4,A3,A2,20x20,30x30, theframe (3840x2160), hochformat (2160x3840), keep (Original behalten).
- Freie Formate ohne Vorgabe: \`BREITExHOEHE\` in cm (z. B. \`25x35\`) oder \`sticker<N>\`
für N×N cm (z. B. \`sticker5\` = 5×5 cm). Grenze 300 cm. Exakte Pixel: round(cm/2.54*dpi).
- Dateiformat (output_ext): \`png\` (verlustfrei, Transparenz) oder \`jpg\` (klein, Picdrop-freundlich).
JPG nur ohne Transparenz; freigestellte Motive bleiben immer PNG. Global (Admin) oder pro Rezept.
## HTTP-API (Auswahl, JSON)
- GET /api/health — Status (öffentlich).
- GET /api/me — aktueller Nutzer/Rechte.
- GET /api/models — verfügbare Bildmodelle (Qualitätsstufen).
- GET /api/recipes — Rezepte/Presets auflisten.
- POST /api/recipes — Rezept anlegen. Body u. a.: name, mode, tasks[], output_format,
orientation(portrait|landscape), crop_mode(crop|extend), dpi, contour_mm, output_ext(png|jpg|null),
delivery(library|picdrop|both), picdrop_gallery, delivery_target_id.
- DELETE /api/recipes/:id — Rezept löschen.
- GET /api/delivery-targets — Auslieferungs-/Backup-Ziele.
- POST /api/jobs — Auftrag starten. Body: { recipe, mode, delivery, private,
prompt_text?(compose/generate), sources:[{source_path, filename}] }. Antwort: { jobId }.
- GET /api/jobs — letzte Aufträge. GET /api/jobs/:id — Auftrag + Positionen (inkl. error_message, finished_at).
- POST /api/jobs/:id/{pause|resume|cancel|retry-failed|delete}.
- POST /api/items/:id/{retry|reuse|alternative}.
- GET /api/items/:id/file — Ergebnisbild. Query: ?thumb=1 (Vorschau), ?preview=1 (kleines JPG fürs Vollbild),
?download=1 (als Datei), ?src=1 (Quelle). Content-Type wird aus den Magic Bytes bestimmt (PNG/JPG/WEBP).
## MCP-Server (für Assistenten)
Werkzeug \`klarbild\` (mcp/klarbild-mcp.mjs), nutzt denselben API-Token:
- list_recipes — verfügbare Rezepte.
- process_images — Bilder (lokaler Ordner / URLs) mit einem Rezept verarbeiten lassen.
- job_status — Fortschritt/Ergebnis eines Auftrags abfragen.
## Auslieferung
- Picdrop = SFTP/FTPS-Ziel; Galerie = Unterordner unter dem Basispfad. Voller Pfad = Basisordner + Galerie
(wird erst beim Ausliefern zusammengesetzt). Standard-Galerie „POSTER LEA"; „The Frame" → „TheFrame-Backgrounds".
- Nicht-transparente Ergebnisse gehen als JPG an Picdrop (kleiner, zuverlässiger).
- Optionaler .md-Metadaten-Beileger je Ziel schaltbar (Picdrop/NAS/Zusatzziel).
## Datenschutz
- Private Session (job.private=true): kein Bibliothekseintrag, keine Auslieferung/Sicherung,
kein gespeicherter Prompt; Ergebnis wird nach kurzer Zeit gelöscht.
- Sichtbarkeit global steuerbar (nur eigene / alle sehen alles); Admin sieht alles.
Weitere Doku (menschlich): In-App /anleitung und /changelog.
`;
export const GET: APIRoute = async () =>
new Response(BODY, { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=3600' } });