From 295f0ce91eb4d4af1d9ab46ad411059dcba5b154 Mon Sep 17 00:00:00 2001 From: Till Heidrich Date: Thu, 23 Jul 2026 21:29:43 +0000 Subject: [PATCH] feat: single-image transform, NAS/FTP in delivery, backup-all any target, metadata sidecar, prompt view, preset manager, API token + MCP server Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6 --- mcp/README.md | 41 +++++++++ mcp/klarbild-mcp.mjs | 98 +++++++++++++++++++++ migrations/008_backup_metadata_apitoken.sql | 19 ++++ src/components/AdminApp.tsx | 40 ++++++++- src/components/LibraryApp.tsx | 17 +++- src/components/StudioApp.tsx | 68 +++++++++----- src/lib/backup.ts | 71 +++++++++++++++ src/lib/delivery.ts | 19 +++- src/lib/maintenance.ts | 16 +--- src/lib/metadata.ts | 37 ++++++++ src/lib/process.ts | 14 ++- src/lib/prompts.ts | 28 +++--- src/middleware.ts | 17 +++- src/pages/api/admin/delivery-targets.ts | 15 ++-- src/pages/api/admin/settings.ts | 8 +- src/pages/api/items/index.ts | 2 +- src/pages/api/jobs/index.ts | 2 +- src/pages/api/recipes.ts | 7 +- src/pages/api/recipes/[id].ts | 29 ++++++ 19 files changed, 479 insertions(+), 69 deletions(-) create mode 100644 mcp/README.md create mode 100644 mcp/klarbild-mcp.mjs create mode 100644 migrations/008_backup_metadata_apitoken.sql create mode 100644 src/lib/backup.ts create mode 100644 src/lib/metadata.ts create mode 100644 src/pages/api/recipes/[id].ts diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..27c0f4d --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,41 @@ +# Klarbild MCP-Server + +Erlaubt einem KI-Assistenten (z. B. Claude), Bilder an Klarbild zu übergeben und dort zu +verarbeiten — etwa „nimm die Bilder aus Ordner XY" oder „verarbeite die Fotos, die mir jemand +per iMessage geschickt hat". Der Assistent besorgt die Bilddateien (mit seinen eigenen +Werkzeugen) und ruft dann die Klarbild-Tools auf. + +## Einrichtung + +1. In Klarbild einen Token erzeugen: **Admin → „Automatisierung / MCP-Zugriff" → Token erzeugen**. +2. Abhängigkeit installieren: `npm i @modelcontextprotocol/sdk` +3. Server starten (bzw. im MCP-Client eintragen): + +```json +{ + "mcpServers": { + "klarbild": { + "command": "node", + "args": ["/pfad/zu/mcp/klarbild-mcp.mjs"], + "env": { + "KLARBILD_URL": "https://klarbild.heidrich-digital.de", + "KLARBILD_TOKEN": "klb_…" + } + } + } +} +``` + +## Tools + +- **list_recipes** — verfügbare Presets/Rezepte auflisten. +- **process_images** — Bilder (lokale Pfade oder `data:`-URLs) hochladen und verarbeiten. + Entweder `recipeId` (Preset) oder `mode` (`each`/`compose`/`generate`) + Optionen + (`prompt_text`, `output_format`, `delivery`). Liefert die Auftrags-ID. +- **job_status** — Status eines Auftrags abfragen. + +## Sicherheit + +Der Token gibt vollen programmatischen Zugriff auf Upload/Verarbeitung (nicht auf den +Admin-Bereich). Wie ein Passwort behandeln; bei Verdacht neuen Token erzeugen (der alte wird +dadurch ungültig). diff --git a/mcp/klarbild-mcp.mjs b/mcp/klarbild-mcp.mjs new file mode 100644 index 0000000..57c4169 --- /dev/null +++ b/mcp/klarbild-mcp.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +// Klarbild MCP-Server — erlaubt einem KI-Assistenten (z. B. Claude), lokale Bilder +// oder anderweitig beschaffte Bilder (iMessage, Ordner XY …) an Klarbild zu übergeben +// und dort zu verarbeiten. Der Assistent besorgt die Bilddateien; dieser Server lädt +// sie hoch und legt einen Auftrag an. +// +// Setup: +// npm i @modelcontextprotocol/sdk +// KLARBILD_URL=https://klarbild.heidrich-digital.de \ +// KLARBILD_TOKEN=klb_xxx node mcp/klarbild-mcp.mjs +// Den Token erzeugt man in Klarbild unter Admin → „Automatisierung / MCP-Zugriff". + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { readFile } from 'node:fs/promises'; +import { basename } from 'node:path'; + +const BASE = (process.env.KLARBILD_URL || '').replace(/\/$/, ''); +const TOKEN = process.env.KLARBILD_TOKEN || ''; +if (!BASE || !TOKEN) { console.error('KLARBILD_URL und KLARBILD_TOKEN müssen gesetzt sein.'); process.exit(1); } + +const authHeaders = { Authorization: `Bearer ${TOKEN}` }; +const api = (path) => `${BASE}${path}`; + +async function uploadFile(pathOrBase64, name) { + let buf, filename = name; + if (pathOrBase64.startsWith('data:')) { + buf = Buffer.from(pathOrBase64.split(',')[1], 'base64'); + filename = name || 'bild.png'; + } else { + buf = await readFile(pathOrBase64); + filename = name || basename(pathOrBase64); + } + const fd = new FormData(); + fd.append('files', new Blob([buf]), filename); + const r = await fetch(api('/api/uploads'), { method: 'POST', headers: authHeaders, body: fd }); + const j = await r.json(); + const info = (j.files || [])[0]; + if (!info?.source_path) throw new Error(info?.error || 'Upload fehlgeschlagen'); + return { source_path: info.source_path, filename }; +} + +const server = new Server({ name: 'klarbild', version: '1.0.0' }, { capabilities: { tools: {} } }); + +const TOOLS = [ + { name: 'list_recipes', description: 'Verfügbare Presets/Rezepte in Klarbild auflisten.', + inputSchema: { type: 'object', properties: {} } }, + { name: 'process_images', + description: 'Bilder an Klarbild übergeben und verarbeiten. Dateien als lokale Pfade ODER data:-URLs. Entweder recipeId ODER mode+Optionen angeben.', + inputSchema: { type: 'object', properties: { + files: { type: 'array', items: { type: 'string' }, description: 'Lokale Pfade oder data:-URLs' }, + recipeId: { type: 'string', description: 'Optional: Preset-ID (aus list_recipes)' }, + mode: { type: 'string', enum: ['each', 'compose', 'generate'], description: 'Falls kein recipeId' }, + prompt_text: { type: 'string', description: 'Beschreibung für compose/generate' }, + output_format: { type: 'string', description: 'z. B. 30x40, theframe, keep' }, + delivery: { type: 'string', enum: ['library', 'picdrop', 'both'] }, + }, required: ['files'] } }, + { name: 'job_status', description: 'Status eines Auftrags abfragen.', + inputSchema: { type: 'object', properties: { jobId: { type: 'string' } }, required: ['jobId'] } }, +]; + +server.setRequestHandler({ method: 'tools/list' }, async () => ({ tools: TOOLS })); + +server.setRequestHandler({ method: 'tools/call' }, async (req) => { + const { name, arguments: a = {} } = req.params; + try { + if (name === 'list_recipes') { + const j = await (await fetch(api('/api/recipes'), { headers: authHeaders })).json(); + const list = (j.recipes || []).map((r) => `${r.id} — ${r.name} (${r.mode || 'each'})`).join('\n'); + return { content: [{ type: 'text', text: list || 'Keine Presets.' }] }; + } + if (name === 'process_images') { + const files = a.files || []; + const mode = a.mode || 'each'; + const sources = (mode === 'generate') ? [] : await Promise.all(files.map((f) => uploadFile(f))); + const body = a.recipeId + ? { recipeId: a.recipeId, mode, sources, prompt_text: a.prompt_text } + : { mode, prompt_text: a.prompt_text, sources, + recipe: { tasks: a.output_format && a.output_format !== 'keep' ? ['format'] : [], + output_format: a.output_format || 'keep', delivery: a.delivery || 'library' }, + delivery: a.delivery || 'library' }; + const j = await (await fetch(api('/api/jobs'), { method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json(); + if (!j.jobId) throw new Error(j.error || 'Auftrag fehlgeschlagen'); + return { content: [{ type: 'text', text: `Auftrag angelegt: ${j.jobId}` }] }; + } + if (name === 'job_status') { + const j = await (await fetch(api(`/api/jobs/${a.jobId}`), { headers: authHeaders })).json(); + const job = j.job || j; + return { content: [{ type: 'text', text: `Status: ${job.status} — ${job.done_count}/${job.total} fertig${job.failed_count ? `, ${job.failed_count} fehlgeschlagen` : ''}` }] }; + } + return { content: [{ type: 'text', text: `Unbekanntes Tool: ${name}` }], isError: true }; + } catch (e) { + return { content: [{ type: 'text', text: `Fehler: ${e?.message || e}` }], isError: true }; + } +}); + +await server.connect(new StdioServerTransport()); +console.error('[klarbild-mcp] bereit'); diff --git a/migrations/008_backup_metadata_apitoken.sql b/migrations/008_backup_metadata_apitoken.sql new file mode 100644 index 0000000..b1233eb --- /dev/null +++ b/migrations/008_backup_metadata_apitoken.sql @@ -0,0 +1,19 @@ +-- Backup für beliebige Ziele, Metadaten-Beileger, API-Token für externen Zugriff (MCP). + +-- Jedes Zusatzziel kann als Backup-Ziel markiert werden (erhält ALLE Ergebnisse). +ALTER TABLE delivery_targets ADD COLUMN IF NOT EXISTS is_backup bool NOT NULL DEFAULT false; + +-- Metadaten als begleitende .md-Datei mitschicken (Prompt, Dateiname, Modell …). +ALTER TABLE settings ADD COLUMN IF NOT EXISTS metadata_sidecar bool NOT NULL DEFAULT false; + +-- API-Token für programmatischen Zugriff (Klarbild-MCP / Automationen). +ALTER TABLE settings ADD COLUMN IF NOT EXISTS api_token text; + +-- Backup-Status je Ziel und Position (für „auf mehreren Zielen gesichert"). +CREATE TABLE IF NOT EXISTS item_backups ( + item_id uuid REFERENCES items(id) ON DELETE CASCADE, + target text NOT NULL, -- 'nas' | delivery_target-UUID + status text NOT NULL DEFAULT 'mirrored' CHECK (status IN ('mirrored','failed')), + at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (item_id, target) +); diff --git a/src/components/AdminApp.tsx b/src/components/AdminApp.tsx index ea39a02..d108b54 100644 --- a/src/components/AdminApp.tsx +++ b/src/components/AdminApp.tsx @@ -33,6 +33,21 @@ export default function AdminApp() { setNt({ protocol: 'sftp' }); notify('Ziel angelegt.'); load(); }; const delTarget = async (id: string) => { if (!confirm('Ziel löschen?')) return; await fetch(`/api/admin/delivery-targets?id=${id}`, { method: 'DELETE' }); load(); }; + const toggleBackup = async (t: any) => { + await fetch('/api/admin/delivery-targets', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: t.id, is_backup: !t.is_backup }) }); + load(); + }; + const backupAllTargets = async () => { + if (!confirm('Alle fertigen Bilder auf alle Backup-Ziele (NAS + markierte Ziele) sichern?')) return; + notify('Sichere … (kann dauern)'); + const r = await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'backup_all' }) }).then((x) => x.json()); + notify(r.items != null ? `${r.mirrored} Kopien auf Backup-Ziele${r.failed ? `, ${r.failed} Fehler` : ''}.` : (r.error || 'OK')); + load(); + }; + const genToken = async () => { + await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ generate_api_token: true }) }); + notify('API-Token erzeugt.'); load(); + }; const testTarget = async (id: string) => { notify('Teste Ziel …'); const r = await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'test', id }) }).then((x) => x.json()); @@ -59,6 +74,7 @@ export default function AdminApp() { default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency, cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url, keep_sources: !!s.keep_sources, make_thumbnails: s.make_thumbnails !== false, retention_days: s.retention_days, + metadata_sidecar: !!s.metadata_sidecar, nas_enabled: !!s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port, nas_user: s.nas_user, nas_base_path: s.nas_base_path, }; @@ -201,6 +217,8 @@ export default function AdminApp() { Vorschaubilder erzeugenKleine, sparsame Bilder für die Bibliotheksvorschau. +
field('retention_days', e.target.value)} />
Ältere Bilder werden automatisch (täglich) entfernt. Leer = nichts löschen.
@@ -237,15 +255,18 @@ export default function AdminApp() {
-
Weitere Ausgabeziele (FTP/SFTP)
+
Weitere Ausgabeziele (FTP/SFTP) +
Zusätzliche FTP/SFTP-Ziele — z. B. eine zweite Picdrop-Galerie oder ein anderer Server. - Ein Preset (Rezept) oder Ordner kann fest auf ein Ziel zeigen; sonst gilt das Standard-Picdrop oben.
+ Ein Preset/Ordner kann fest auf ein Ziel zeigen; im Studio unter „Wohin?" wählbar (auch NAS). + „Backup"-Ziele erhalten automatisch alle Ergebnisse (wie das NAS).
{targets.map((t) => (
-
{t.name} {t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}
+
{t.name}{t.is_backup && Backup} {t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}
+
@@ -268,10 +289,23 @@ export default function AdminApp() {
setNt({ ...nt, password: e.target.value })} />
setNt({ ...nt, base_path: e.target.value })} />
+
+
+
Automatisierung / MCP-Zugriff
+
+
API-Token für externen/programmatischen Zugriff (z. B. Klarbild-MCP: „nimm diese Bilder und verarbeite sie"). Als Authorization: Bearer <Token> an /api/uploads und /api/jobs.
+ {s.api_token + ?
e.target.select()} />
+ :
Noch kein Token erzeugt.
} + +
+
+
diff --git a/src/components/LibraryApp.tsx b/src/components/LibraryApp.tsx index e9b79bb..ca378a4 100644 --- a/src/components/LibraryApp.tsx +++ b/src/components/LibraryApp.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react'; interface Item { id: string; filename: string; output_px: string; has_alpha: boolean; folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; nas_status: string; - model_used: string; variant_of: string | null; mode: string; thumb_path: string | null; created_at: string; } + model_used: string; variant_of: string | null; mode: string; thumb_path: string | null; prompt_used: string | null; created_at: string; } export default function LibraryApp() { const [items, setItems] = useState([]); @@ -12,6 +12,7 @@ export default function LibraryApp() { const [pick, setPick] = useState>(new Set()); const [variantSel, setVariantSel] = useState>({}); const [compare, setCompare] = useState(null); + const [promptOf, setPromptOf] = useState(null); const [toast, setToast] = useState(null); const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); }; @@ -176,6 +177,7 @@ export default function LibraryApp() { {v.nas_status === 'failed' && NAS ✕}
+ {v.prompt_used && } {v.mode !== 'generate' && } Laden @@ -203,6 +205,16 @@ export default function LibraryApp() {
{compare.filename} · {compare.output_px}px — Regler: links {compare.mode === 'compose' ? 'Vorlage' : 'Original'}, rechts Ergebnis
)} + {promptOf && ( +
setPromptOf(null)}> +
e.stopPropagation()}> +
{promptOf.filename} +
+
{promptOf.prompt_used}
+
{promptOf.model_used ? `Modell: ${modelName(promptOf.model_used)} · ` : ''}{promptOf.output_px}px
+
+
+ )} {toast &&
{toast}
} @@ -254,6 +266,9 @@ function LibStyles() { .vergleich .oben{position:absolute;inset:0;clip-path:inset(0 0 0 50%);} .vergleich input[type=range]{position:absolute;left:0;right:0;bottom:-34px;width:100%;} .lupe-fuss{color:#EAEAE3;font-family:var(--font-mono);font-size:12px;margin-top:30px;text-align:center;} + .promptbox{background:var(--card);border-radius:var(--radius);max-width:min(92vw,620px);width:100%;padding:16px;display:flex;flex-direction:column;gap:10px;} + .pb-kopf{display:flex;justify-content:space-between;align-items:center;gap:10px;} + .pb-text{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);font-size:12.5px;line-height:1.5;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:12px;max-height:50vh;overflow:auto;margin:0;} .toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;} `}; } diff --git a/src/components/StudioApp.tsx b/src/components/StudioApp.tsx index 4ad667d..8b39e2c 100644 --- a/src/components/StudioApp.tsx +++ b/src/components/StudioApp.tsx @@ -33,8 +33,8 @@ const TASKS = [ type Mode = 'each' | 'compose' | 'generate'; const MODES: { id: Mode; name: string; hint: string }[] = [ - { id: 'each', name: 'Bearbeiten', hint: 'Screenshots bereinigen, freistellen, aufs Format bringen.' }, - { id: 'compose', name: 'Kombinieren', hint: 'Mehrere Bilder + Beschreibung zu einem neuen Bild.' }, + { id: 'each', name: 'Bereinigen', hint: 'Screenshots bereinigen, freistellen, aufs Format bringen.' }, + { id: 'compose', name: 'Umwandeln', hint: 'Ein Bild umwandeln (z. B. Foto → Ölgemälde) oder mehrere kombinieren — mit Text.' }, { id: 'generate', name: 'Neu erzeugen', hint: 'Ein komplett neues Bild allein aus Text.' }, ]; @@ -43,6 +43,7 @@ const px = (cm: number, dpi = 300) => Math.round((cm / 2.54) * dpi); interface Pic { id: string; src: string; name: string; source_path?: string; error?: string; uploading?: boolean } export default function StudioApp({ recipes }: { recipes: any[] }) { + const [presets, setPresets] = useState(recipes || []); const [mode, setMode] = useState('each'); const [pics, setPics] = useState([]); const [tasks, setTasks] = useState(['clean', 'format']); @@ -56,6 +57,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) { const [gallery, setGallery] = useState(''); const [targetId, setTargetId] = useState(''); const [targets, setTargets] = useState([]); + const [presetSel, setPresetSel] = useState(''); const [models, setModels] = useState([]); const [modelKey, setModelKey] = useState(''); const [dragging, setDragging] = useState(false); @@ -117,7 +119,27 @@ export default function StudioApp({ recipes }: { recipes: any[] }) { if (def) setModelKey(def.model_id); }).catch(() => {}); fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {}); + loadPresets(); }, []); + const loadPresets = () => fetch('/api/recipes').then((r) => r.json()).then((j) => setPresets(j.recipes || [])).catch(() => {}); + const saveAsPreset = async () => { + const name = prompt('Name des Presets?'); if (!name?.trim()) return; + const order = ['clean', 'cutout', 'format', 'contour', 'deliver']; + const tasksOut = mode === 'each' ? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b)) : (wantsFormat ? ['format'] : []); + const body = { + name: name.trim(), mode, tasks: tasksOut, output_format: wantsFormat ? format : 'keep', + orientation: portrait ? 'portrait' : 'landscape', crop_mode: crop, dpi: 300, + contour_mm: tasks.includes('contour') ? contourMm : null, + custom_instruction: mode === 'each' ? (custom || null) : null, model_key: modelKey || null, + delivery, picdrop_gallery: gallery.trim() || null, delivery_target_id: targetId || null, + }; + await fetch('/api/recipes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + notify('Preset gespeichert.'); loadPresets(); + }; + const deletePreset = async (id: string) => { + if (!id || !confirm('Preset löschen?')) return; + await fetch(`/api/recipes/${id}`, { method: 'DELETE' }); notify('Preset gelöscht.'); loadPresets(); + }; // Ergebnis aus der Bibliothek übernehmen: ?reuse= lädt es als Vorlage. useEffect(() => { @@ -148,6 +170,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) { function applyRecipe(r: any) { if (!r) return; + if (['each', 'compose', 'generate'].includes(r.mode)) setMode(r.mode); setTasks(r.tasks || ['clean', 'format']); setFormat(r.output_format || 'keep'); setPortrait((r.orientation || 'portrait') !== 'landscape'); @@ -163,7 +186,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) { const ready = pics.filter((p) => p.source_path && !p.error); const canRun = !busy && ( mode === 'each' ? (ready.length > 0 && tasks.length > 0 && !theframeConflict && !(tasks.includes('contour') && !hasCutout)) - : mode === 'compose' ? (ready.length >= 2 && desc.trim().length > 0) + : mode === 'compose' ? (ready.length >= 1 && desc.trim().length > 0) : desc.trim().length > 0 ); @@ -199,7 +222,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) { } const uploadHint = mode === 'compose' - ? 'Mindestens 2 Bilder — z. B. das Motiv und ein Foto von Frieda.' + ? 'Ein Bild umwandeln oder mehrere kombinieren (z. B. Motiv + Foto von Frieda).' : 'Mehrere gleichzeitig, bis zu 100. PNG, JPG, WEBP, HEIC.'; return ( @@ -252,25 +275,28 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
- {mode === 'each' && recipes?.length > 0 && ( -
- - { setPresetSel(e.target.value); applyRecipe(presets.find((r) => r.id === e.target.value)); }}> + + {presets.map((r) => )} + {presetSel && } +
- )} +
{(mode === 'compose' || mode === 'generate') && (