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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
2026-07-23 21:29:43 +00:00
parent 5cfafb456c
commit 295f0ce91e
19 changed files with 479 additions and 69 deletions
+41
View File
@@ -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).
+98
View File
@@ -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');
@@ -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)
);
+37 -3
View File
@@ -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() {
<span><b>Vorschaubilder erzeugen</b><em>Kleine, sparsame Bilder für die Bibliotheksvorschau.</em></span></label>
<label className="schalt"><input type="checkbox" checked={!s.keep_sources} onChange={(e) => field('keep_sources', !e.target.checked)} />
<span><b>Quellbilder nach Bearbeitung löschen</b><em>Spart Platz Originale werden nach dem Ergebnis entfernt.</em></span></label>
<label className="schalt"><input type="checkbox" checked={!!s.metadata_sidecar} onChange={(e) => field('metadata_sidecar', e.target.checked)} />
<span><b>Metadaten als .md mitschicken</b><em>Zu jedem Bild eine begleitende Textdatei (Prompt, Modell, Format ) bei Picdrop/NAS/FTP.</em></span></label>
<div className="feld"><label>Aufbewahrung (Tage)</label>
<input className="input" type="number" min={0} placeholder="leer = unbegrenzt" value={s.retention_days ?? ''} onChange={(e) => field('retention_days', e.target.value)} />
<div className="fein">Ältere Bilder werden automatisch (täglich) entfernt. Leer = nichts löschen.</div></div>
@@ -237,15 +255,18 @@ export default function AdminApp() {
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Weitere Ausgabeziele (FTP/SFTP)</span></div>
<div className="kopfzeile"><span className="mono-label">Weitere Ausgabeziele (FTP/SFTP)</span>
<button className="mini" onClick={backupAllTargets}>Alle Bilder sichern</button></div>
<div className="steuer">
<div className="fein">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.</div>
Ein Preset/Ordner kann fest auf ein Ziel zeigen; im Studio unter Wohin?" wählbar (auch NAS).
„Backup"-Ziele erhalten automatisch <b>alle</b> Ergebnisse (wie das NAS).</div>
<div className="liste">
{targets.map((t) => (
<div key={t.id} className="zeile">
<div><b>{t.name}</b> <span className="fein">{t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}</span></div>
<div><b>{t.name}</b>{t.is_backup && <span className="pille">Backup</span>} <span className="fein">{t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}</span></div>
<div className="reihe">
<button className="mini" onClick={() => toggleBackup(t)}>{t.is_backup ? 'Backup aus' : 'Als Backup'}</button>
<button className="mini" onClick={() => testTarget(t.id)}>Test</button>
<button className="mini" onClick={() => delTarget(t.id)}></button>
</div>
@@ -268,10 +289,23 @@ export default function AdminApp() {
<div className="feld"><label>Passwort</label><input className="input" type="password" value={nt.password ?? ''} onChange={(e) => setNt({ ...nt, password: e.target.value })} /></div>
</div>
<div className="feld"><label>Basisordner</label><input className="input" placeholder="/" value={nt.base_path ?? ''} onChange={(e) => setNt({ ...nt, base_path: e.target.value })} /></div>
<label className="schalt"><input type="checkbox" checked={!!nt.is_backup} onChange={(e) => setNt({ ...nt, is_backup: e.target.checked })} />
<span><b>Als Backup-Ziel</b><em>Erhält automatisch alle Ergebnisse.</em></span></label>
<button className="mini" onClick={addTarget}>Ziel hinzufügen</button>
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Automatisierung / MCP-Zugriff</span></div>
<div className="steuer">
<div className="fein">API-Token für externen/programmatischen Zugriff (z. B. Klarbild-MCP: nimm diese Bilder und verarbeite sie"). Als <code>Authorization: Bearer &lt;Token&gt;</code> an <code>/api/uploads</code> und <code>/api/jobs</code>.</div>
{s.api_token
? <div className="feld"><label>API-Token</label><input className="input" readOnly value={s.api_token} onFocus={(e) => e.target.select()} /></div>
: <div className="fein">Noch kein Token erzeugt.</div>}
<button className="mini" onClick={genToken}>{s.api_token ? 'Neuen Token erzeugen' : 'Token erzeugen'}</button>
</div>
</section>
<button className="knopf" onClick={saveSettings}>Einstellungen speichern</button>
<section className="karte">
+16 -1
View File
@@ -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<Item[]>([]);
@@ -12,6 +12,7 @@ export default function LibraryApp() {
const [pick, setPick] = useState<Set<string>>(new Set());
const [variantSel, setVariantSel] = useState<Record<string, string>>({});
const [compare, setCompare] = useState<Item | null>(null);
const [promptOf, setPromptOf] = useState<Item | null>(null);
const [toast, setToast] = useState<string | null>(null);
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); };
@@ -176,6 +177,7 @@ export default function LibraryApp() {
{v.nas_status === 'failed' && <span className="dbadge err">NAS </span>}
</div>
<div className="reihe knapp">
{v.prompt_used && <button className="mini" onClick={() => setPromptOf(v)}>Prompt</button>}
{v.mode !== 'generate' && <button className="mini" onClick={() => setCompare(v)}>Vorher/Nachher</button>}
<a className="mini" href={`/api/items/${v.id}/file?download=1`}>Laden</a>
<button className="mini stark2" onClick={() => reuse(v.id)} title="Ergebnis im Studio weiterbearbeiten">Weiterbearbeiten</button>
@@ -203,6 +205,16 @@ export default function LibraryApp() {
<div className="lupe-fuss">{compare.filename} · {compare.output_px}px Regler: links {compare.mode === 'compose' ? 'Vorlage' : 'Original'}, rechts Ergebnis</div>
</div>
)}
{promptOf && (
<div className="lupe" onClick={() => setPromptOf(null)}>
<div className="promptbox" onClick={(e) => e.stopPropagation()}>
<div className="pb-kopf"><b>{promptOf.filename}</b>
<button className="mini" onClick={() => { navigator.clipboard?.writeText(promptOf.prompt_used || ''); notify('Prompt kopiert.'); }}>Kopieren</button></div>
<pre className="pb-text">{promptOf.prompt_used}</pre>
<div className="fein">{promptOf.model_used ? `Modell: ${modelName(promptOf.model_used)} · ` : ''}{promptOf.output_px}px</div>
</div>
</div>
)}
{toast && <div className="toast">{toast}</div>}
<LibStyles />
</div>
@@ -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;}
`}</style>;
}
+48 -20
View File
@@ -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<any[]>(recipes || []);
const [mode, setMode] = useState<Mode>('each');
const [pics, setPics] = useState<Pic[]>([]);
const [tasks, setTasks] = useState<string[]>(['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<any[]>([]);
const [presetSel, setPresetSel] = useState('');
const [models, setModels] = useState<any[]>([]);
const [modelKey, setModelKey] = useState<string>('');
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=<itemId> 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[] }) {
<section className="karte">
<div className="steuer">
{mode === 'each' && recipes?.length > 0 && (
<div className="feld">
<label>Rezept laden</label>
<select className="select" onChange={(e) => applyRecipe(recipes.find((r) => r.id === e.target.value))} defaultValue="">
<option value="" disabled>Voreinstellung wählen </option>
{recipes.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
<div className="feld">
<label>Presets</label>
<div className="reihe-presets">
<select className="select" value={presetSel}
onChange={(e) => { setPresetSel(e.target.value); applyRecipe(presets.find((r) => r.id === e.target.value)); }}>
<option value="">Preset wählen </option>
{presets.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
{presetSel && <button className="pbtn" title="Preset löschen" onClick={() => { deletePreset(presetSel); setPresetSel(''); }}></button>}
<button className="pbtn" title="Aktuelle Einstellungen als Preset speichern" onClick={saveAsPreset}> speichern</button>
</div>
)}
</div>
{(mode === 'compose' || mode === 'generate') && (
<div className="feld">
<label>{mode === 'compose' ? 'Was soll entstehen?' : 'Beschreibung des neuen Bildes'}</label>
<textarea className="area gross" rows={4} value={desc} onChange={(e) => setDesc(e.target.value)}
placeholder={mode === 'compose'
? 'z. B. „Das Poolbild, aber mit unserer Hündin Frieda am Beckenrand.“'
? 'z. B. „Lass das Foto wie ein Ölgemälde aussehen.“ oder „…​mit unserer Hündin Frieda am Beckenrand.“'
: 'z. B. „Ein minimalistisches Poster mit einem Olivenzweig auf sandfarbenem Grund.“'} />
<div className="fein">{mode === 'compose'
? 'Das erste Bild ist die Leitszene, weitere liefern Personen/Motive.'
? 'Ein Bild = umwandeln (z. B. Stil ändern). Mehrere Bilder = erstes ist Leitszene, weitere liefern Personen/Motive.'
: 'Je genauer die Beschreibung, desto besser das Ergebnis.'}</div>
</div>
)}
@@ -358,12 +384,11 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
</div>
{delivery !== 'library' && (
<div className="ziel">
{targets.length > 0 && (
<select className="select" value={targetId} onChange={(e) => setTargetId(e.target.value)}>
<option value="">Standard-Picdrop</option>
{targets.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
)}
<select className="select" value={targetId} onChange={(e) => setTargetId(e.target.value)}>
<option value="">Standard-Picdrop</option>
<option value="nas">NAS</option>
{targets.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<input className="input" value={gallery} onChange={(e) => setGallery(e.target.value)}
placeholder="Galerie/Ordner (leer = Standard)" />
</div>
@@ -373,7 +398,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
<button className="knopf" disabled={!canRun} onClick={run}>
{busy ? <><span className="spin" />Wird angelegt …</>
: mode === 'each' ? `Loslegen${ready.length ? ` · ${ready.length} Bild${ready.length > 1 ? 'er' : ''}` : ''}`
: mode === 'compose' ? 'Bild kombinieren' : 'Bild erzeugen'}
: 'Bild erzeugen'}
</button>
<div className="fein mitte">Läuft serverseitig weiter — du kannst das Fenster schließen.</div>
</div>
@@ -433,6 +458,9 @@ function StudioStyles() {
.schalter button.an{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;}
.schalter.zart button{font-size:12.5px;}
.ziel{display:flex;flex-direction:column;gap:7px;margin-top:8px;}
.reihe-presets{display:flex;gap:6px;align-items:center;}
.reihe-presets .select{flex:1;}
.pbtn{background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);white-space:nowrap;}
.schalter.wrap{flex-wrap:wrap;}
.schalter.wrap button{flex:1 1 auto;min-width:110px;}
.knopf{width:100%;border:none;border-radius:3px;padding:13px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
+71
View File
@@ -0,0 +1,71 @@
// Generische Datensicherung: spiegelt fertige Ergebnisse auf alle Backup-Ziele
// (NAS aus den Einstellungen + jedes delivery_target mit is_backup), optional mit
// begleitender Metadaten-.md-Datei. Struktur je Ziel: <Basisordner>/JJJJ-MM/.
import { one, query } from './db';
import { getObject } from './storage';
import { uploadBuffer, loadTargetConfig, type PicdropCfg } from './picdrop';
import { loadNasConfig } from './nas';
import { buildMetadataMd, sidecarName } from './metadata';
export interface BackupDest { key: string; label: string; cfg: PicdropCfg; }
/** Alle aktiven Backup-Ziele einsammeln. */
export async function backupDestinations(): Promise<BackupDest[]> {
const dests: BackupDest[] = [];
const nas = await loadNasConfig();
if (nas) dests.push({ key: 'nas', label: 'NAS', cfg: nas });
const targets = await query<any>(`SELECT id, name FROM delivery_targets WHERE is_backup=true`);
for (const t of targets) {
const cfg = await loadTargetConfig(t.id);
if (cfg) dests.push({ key: t.id, label: t.name, cfg });
}
return dests;
}
async function sidecarEnabled(): Promise<boolean> {
const s = await one<{ metadata_sidecar: boolean }>('SELECT metadata_sidecar FROM settings WHERE id=1');
return !!s?.metadata_sidecar;
}
/** Ein Item auf alle Backup-Ziele spiegeln. */
export async function backupItem(itemId: string): Promise<{ mirrored: number; failed: number }> {
const dests = await backupDestinations();
if (!dests.length) return { mirrored: 0, failed: 0 };
const it = await one<any>('SELECT id, result_path, filename, created_at FROM items WHERE id=$1', [itemId]);
if (!it?.result_path) return { mirrored: 0, failed: 0 };
const buf = await getObject(it.result_path);
const ym = new Date(it.created_at || Date.now()).toISOString().slice(0, 7);
const withMeta = await sidecarEnabled();
const md = withMeta ? Buffer.from(await buildMetadataMd(itemId), 'utf8') : null;
let mirrored = 0, failed = 0;
for (const d of dests) {
try {
await uploadBuffer(d.cfg, ym, it.filename || `${it.id}.png`, buf);
if (md) await uploadBuffer(d.cfg, ym, sidecarName(it.filename), md);
await query(`INSERT INTO item_backups (item_id, target, status) VALUES ($1,$2,'mirrored')
ON CONFLICT (item_id, target) DO UPDATE SET status='mirrored', at=now()`, [itemId, d.key]);
if (d.key === 'nas') await query(`UPDATE items SET nas_status='mirrored' WHERE id=$1`, [itemId]);
mirrored++;
} catch (e: any) {
console.error('[backup]', d.label, itemId, e?.message || e);
await query(`INSERT INTO item_backups (item_id, target, status) VALUES ($1,$2,'failed')
ON CONFLICT (item_id, target) DO UPDATE SET status='failed', at=now()`, [itemId, d.key]);
if (d.key === 'nas') await query(`UPDATE items SET nas_status='failed' WHERE id=$1`, [itemId]);
failed++;
}
}
return { mirrored, failed };
}
/** Alle fertigen Bilder auf alle Backup-Ziele sichern (Nachsicherung). */
export async function backupAll(): Promise<{ items: number; mirrored: number; failed: number }> {
const dests = await backupDestinations();
if (!dests.length) return { items: 0, mirrored: 0, failed: 0 };
const rows = await query<{ id: string }>(
`SELECT id FROM items WHERE status='done' AND result_path IS NOT NULL ORDER BY created_at`);
let mirrored = 0, failed = 0;
for (const it of rows) { const r = await backupItem(it.id); mirrored += r.mirrored; failed += r.failed; }
return { items: rows.length, mirrored, failed };
}
+17 -2
View File
@@ -17,15 +17,22 @@ async function galleryFor(item: { folder_id: string | null; job_id: string }): P
return s?.picdrop_default_gallery || DEFAULT_GALLERY;
}
async function cfgForKey(key: string | null | undefined): Promise<PicdropCfg | null> {
if (!key) return null;
if (key === 'nas') { const { loadNasConfig } = await import('./nas'); return (await loadNasConfig()) as unknown as PicdropCfg; }
if (key === 'picdrop') return loadConfig();
return loadTargetConfig(key); // UUID eines Zusatzziels
}
/** Ziel-Zugang: 1) Ordner-Ziel 2) Rezept-Ziel 3) Standard-Picdrop (Einstellungen). */
async function targetFor(item: { folder_id: string | null; job_id: string }): Promise<PicdropCfg | null> {
if (item.folder_id) {
const f = await one<{ delivery_target_id: string | null }>('SELECT delivery_target_id FROM folders WHERE id=$1', [item.folder_id]).catch(() => null);
if (f?.delivery_target_id) { const c = await loadTargetConfig(f.delivery_target_id); if (c) return c; }
if (f?.delivery_target_id) { const c = await cfgForKey(f.delivery_target_id); if (c) return c; }
}
const j = await one<{ recipe_snapshot: any }>('SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]);
const tid = j?.recipe_snapshot?.delivery_target_id;
if (tid) { const c = await loadTargetConfig(tid); if (c) return c; }
if (tid) { const c = await cfgForKey(tid); if (c) return c; }
return loadConfig(); // Standard-Picdrop
}
@@ -41,6 +48,14 @@ export async function deliverItem(itemId: string): Promise<{ ok: boolean; messag
const gallery = await galleryFor(it);
const buf = await getObject(it.result_path);
await uploadBuffer(cfg, gallery, it.filename || `${it.id}.png`, buf);
// Optional: Metadaten als begleitende .md-Datei mitschicken.
const md = await one<{ metadata_sidecar: boolean }>('SELECT metadata_sidecar FROM settings WHERE id=1');
if (md?.metadata_sidecar) {
try {
const { buildMetadataMd, sidecarName } = await import('./metadata');
await uploadBuffer(cfg, gallery, sidecarName(it.filename), Buffer.from(await buildMetadataMd(itemId), 'utf8'));
} catch (e) { console.error('[delivery] Metadaten-Beileger fehlgeschlagen', e); }
}
await query(`UPDATE items SET delivery_status='delivered', delivered_at=now() WHERE id=$1`, [itemId]);
return { ok: true, message: `Ausgeliefert nach „${gallery}".` };
} catch (e: any) {
+4 -12
View File
@@ -69,19 +69,11 @@ export async function purgeSources(): Promise<{ purged: number }> {
return { purged };
}
/** Spiegelt alle fertigen Bilder aufs NAS (einmalige Nachsicherung). */
/** Spiegelt alle fertigen Bilder auf alle Backup-Ziele (NAS + is_backup-Ziele). */
export async function mirrorAllToNas(): Promise<{ mirrored: number; failed: number; total: number }> {
const { mirrorItemToNas, loadNasConfig } = await import('./nas');
if (!(await loadNasConfig())) return { mirrored: 0, failed: 0, total: 0 };
const rows = await query<{ id: string }>(
`SELECT id FROM items WHERE status='done' AND result_path IS NOT NULL
AND (nas_status IS NULL OR nas_status <> 'mirrored') ORDER BY created_at`);
let mirrored = 0, failed = 0;
for (const it of rows) {
const r = await mirrorItemToNas(it.id);
r.ok ? mirrored++ : failed++;
}
return { mirrored, failed, total: rows.length };
const { backupAll } = await import('./backup');
const r = await backupAll();
return { mirrored: r.mirrored, failed: r.failed, total: r.items };
}
let timer: NodeJS.Timeout | null = null;
+37
View File
@@ -0,0 +1,37 @@
import { one } from './db';
/** Baut eine begleitende Metadaten-Datei (Markdown) zu einer Position. */
export async function buildMetadataMd(itemId: string): Promise<string> {
const it = await one<any>(
`SELECT i.filename, i.output_px, i.has_alpha, i.model_used, i.prompt_used, i.cost,
i.created_at, i.delivery_status, j.mode, j.recipe_snapshot
FROM items i JOIN jobs j ON j.id=i.job_id WHERE i.id=$1`, [itemId]);
if (!it) return '';
const r = it.recipe_snapshot || {};
const tasks = Array.isArray(r.tasks) ? r.tasks.join(', ') : '';
const lines = [
`# ${it.filename || itemId}`,
'',
`- **Erzeugt:** ${new Date(it.created_at).toISOString()}`,
`- **Modus:** ${it.mode || 'each'}`,
tasks ? `- **Aufgaben:** ${tasks}` : '',
r.output_format ? `- **Format:** ${r.output_format}${r.orientation ? ` (${r.orientation})` : ''}` : '',
it.output_px ? `- **Auflösung:** ${it.output_px} px${r.dpi ? ` @ ${r.dpi} dpi` : ''}` : '',
`- **Transparenz:** ${it.has_alpha ? 'ja' : 'nein'}`,
it.model_used ? `- **Modell:** ${it.model_used}` : '',
it.cost != null ? `- **Kosten:** $${Number(it.cost).toFixed(4)}` : '',
'',
'## Prompt',
'',
'```',
(it.prompt_used || '(kein Prompt gespeichert)'),
'```',
].filter((l) => l !== '');
return lines.join('\n') + '\n';
}
/** Dateiname des Beilegers: <ergebnis>.md */
export function sidecarName(filename: string | null): string {
const base = (filename || 'klarbild').replace(/\.[^.]+$/, '');
return `${base}.md`;
}
+6 -8
View File
@@ -91,7 +91,7 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
// Prompt + ob das Modell überhaupt gebraucht wird, je Modus.
let prompt: string;
let needsModel: boolean;
if (mode === 'compose') { prompt = buildComposePrompt(description, cropMode === 'extend'); needsModel = true; }
if (mode === 'compose') { prompt = buildComposePrompt(description, cropMode === 'extend', sourceKeys.length); needsModel = true; }
else if (mode === 'generate') { prompt = buildGeneratePrompt(description); needsModel = true; }
else {
prompt = buildPrompt({ tasks, cropMode, customInstruction: r.custom_instruction });
@@ -161,13 +161,11 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
for (const k of sourceKeys) await deleteObject(k).catch(() => {});
}
// Zweite Sicherung auf NAS (best effort, blockiert den Erfolg nicht).
if (cfg?.nas_enabled) {
try {
const { mirrorItemToNas } = await import('./nas');
await mirrorItemToNas(itemId);
} catch (e) { console.error('[process] NAS-Spiegelung fehlgeschlagen', e); }
}
// Zweite Sicherung auf alle Backup-Ziele (NAS + is_backup-Ziele), best effort.
try {
const { backupItem } = await import('./backup');
await backupItem(itemId);
} catch (e) { console.error('[process] Backup fehlgeschlagen', e); }
return { ok: true, cost };
} catch (e: any) {
+18 -10
View File
@@ -44,16 +44,24 @@ export function buildPrompt({ tasks, cropMode, customInstruction }: PromptOpts):
return parts.join(' ');
}
/** Kombinieren: mehrere Vorlagen + Beschreibung → EIN neues Bild.
* Die erste Vorlage ist das Leitbild; weitere liefern Elemente/Personen/Motive. */
export function buildComposePrompt(description: string, extend = false): string {
const parts = [
'You are given several reference images. Combine them into ONE new, coherent, ' +
'high-resolution image that follows the instruction below. Treat the first image as ' +
'the main scene/style reference and use the other images as elements to integrate ' +
'(people, pets, objects) — match their identity, colors and lighting faithfully so ' +
'they look naturally part of the same photo.',
];
/** Kombinieren/Umwandeln: eine ODER mehrere Vorlagen + Beschreibung → EIN neues Bild.
* Bei einer Vorlage = Bild gemäß Anweisung umwandeln (z. B. Foto → Ölgemälde).
* Bei mehreren = erste ist Leitbild, weitere liefern Elemente/Personen/Motive. */
export function buildComposePrompt(description: string, extend = false, count = 2): string {
const parts: string[] = [];
if (count <= 1) {
parts.push(
'Transform the given image according to the instruction below into ONE new, ' +
'high-resolution image. Keep the subject, composition and important details recognizable ' +
'unless the instruction explicitly asks to change them.');
} else {
parts.push(
'You are given several reference images. Combine them into ONE new, coherent, ' +
'high-resolution image that follows the instruction below. Treat the first image as ' +
'the main scene/style reference and use the other images as elements to integrate ' +
'(people, pets, objects) — match their identity, colors and lighting faithfully so ' +
'they look naturally part of the same photo.');
}
if (description.trim()) parts.push(`Instruction: ${description.trim()}`);
if (extend) parts.push('Extend the scene naturally to fill the requested aspect ratio (outpainting).');
parts.push('Output only the resulting image.');
+16 -1
View File
@@ -1,14 +1,29 @@
import { defineMiddleware } from 'astro:middleware';
import { ensureInit } from './lib/init';
import { readSession } from './lib/auth';
import { one } from './lib/db';
const PUBLIC_PATHS = [/^\/login/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//, /^\/api\/telegram\/webhook/];
/** API-Token (Authorization: Bearer …) → synthetischer Admin-Nutzer für /api/*. */
async function tokenUser(header: string | null): Promise<any | null> {
const m = /^Bearer\s+(.+)$/i.exec(header || '');
if (!m) return null;
const s = await one<{ api_token: string | null }>('SELECT api_token FROM settings WHERE id=1');
if (!s?.api_token || s.api_token !== m[1].trim()) return null;
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1`);
return { uid: admin?.id || null, role: 'admin', name: 'API', username: 'api' };
}
export const onRequest = defineMiddleware(async (ctx, next) => {
// Health/Webhook dürfen laufen, auch wenn Init noch hakt — sonst blockiert nichts.
try { await ensureInit(); } catch (e) { if (ctx.url.pathname !== '/api/health') throw e; }
const user = readSession(ctx.request.headers.get('cookie'));
let user = readSession(ctx.request.headers.get('cookie'));
// Programmatischer Zugriff per API-Token (nur für /api/*, außer Admin-Bereich).
if (!user && ctx.url.pathname.startsWith('/api/') && !ctx.url.pathname.startsWith('/api/admin')) {
user = await tokenUser(ctx.request.headers.get('authorization'));
}
ctx.locals.user = user;
const path = ctx.url.pathname;
+10 -5
View File
@@ -8,12 +8,12 @@ const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async () => {
const rows = await query<any>(`SELECT id, name, protocol, host, port, username, base_path,
const rows = await query<any>(`SELECT id, name, protocol, host, port, username, base_path, is_backup,
(password_enc IS NOT NULL) AS password_set FROM delivery_targets ORDER BY name`);
return json({ targets: rows });
};
// POST: anlegen | { action:'test', id } testen
// POST: anlegen | { action:'test', id } | { action:'backup_all' }
export const POST: APIRoute = async ({ request }) => {
const b = await request.json();
if (b.action === 'test') {
@@ -21,12 +21,16 @@ export const POST: APIRoute = async ({ request }) => {
if (!cfg) return json({ ok: false, message: 'Ziel unvollständig konfiguriert.' });
return json(await testConnection(cfg));
}
if (b.action === 'backup_all') {
const { backupAll } = await import('../../../lib/backup');
return json(await backupAll());
}
if (!b.name?.trim()) return json({ error: 'Name fehlt.' }, 400);
const row = await one<any>(
`INSERT INTO delivery_targets (name, protocol, host, port, username, password_enc, base_path)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
`INSERT INTO delivery_targets (name, protocol, host, port, username, password_enc, base_path, is_backup)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
[b.name.trim(), b.protocol || 'sftp', b.host || null, b.port || null, b.username || null,
b.password ? encrypt(String(b.password)) : null, b.base_path || null]);
b.password ? encrypt(String(b.password)) : null, b.base_path || null, !!b.is_backup]);
return json({ id: row!.id });
};
@@ -39,6 +43,7 @@ export const PATCH: APIRoute = async ({ request }) => {
for (const c of ['name', 'protocol', 'host', 'port', 'username', 'base_path']) {
if (c in b) set(c, b[c] === '' ? null : b[c]);
}
if ('is_backup' in b) set('is_backup', !!b.is_backup);
if (b.password) set('password_enc', encrypt(String(b.password)));
if (!sets.length) return json({ ok: true });
args.push(b.id);
+6 -2
View File
@@ -1,8 +1,10 @@
import type { APIRoute } from 'astro';
import { randomBytes } from 'node:crypto';
import { one, query } from '../../../lib/db';
import { encrypt, maskSecret, decrypt } from '../../../lib/crypto';
export const prerender = false;
const randomToken = () => 'klb_' + randomBytes(24).toString('hex');
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
@@ -21,6 +23,7 @@ export const GET: APIRoute = async () => {
cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
// Speicherverwaltung
keep_sources: s.keep_sources, make_thumbnails: s.make_thumbnails, retention_days: s.retention_days,
metadata_sidecar: s.metadata_sidecar, api_token: s.api_token,
// NAS-Sicherung
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, nas_password_set: !!s.nas_password_enc,
@@ -35,10 +38,11 @@ export const PATCH: APIRoute = async ({ request }) => {
if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key)));
if (b.picdrop_password) set('picdrop_password_enc', encrypt(String(b.picdrop_password)));
if (b.nas_password) set('nas_password_enc', encrypt(String(b.nas_password)));
const boolCols = ['keep_sources', 'make_thumbnails', 'nas_enabled'];
if (b.generate_api_token) set('api_token', randomToken());
const boolCols = ['keep_sources', 'make_thumbnails', 'nas_enabled', 'metadata_sidecar'];
for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path',
'picdrop_default_gallery', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url',
'keep_sources', 'make_thumbnails', 'retention_days',
'keep_sources', 'make_thumbnails', 'retention_days', 'metadata_sidecar', 'api_token',
'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path']) {
if (col in b) {
const raw = b[col];
+1 -1
View File
@@ -24,7 +24,7 @@ export const GET: APIRoute = async ({ url, locals }) => {
const rows = await query(
`SELECT i.id, i.filename, i.output_px, i.has_alpha, i.result_path, i.thumb_path, i.folder_id,
i.delivery_status, i.nas_status, i.created_at, i.model_used, i.variant_of, j.mode, u.display_name AS by_name,
i.delivery_status, i.nas_status, i.prompt_used, i.created_at, i.model_used, i.variant_of, j.mode, u.display_name AS by_name,
j.recipe_snapshot->'tasks' AS tasks
FROM items i
JOIN jobs j ON j.id = i.job_id
+1 -1
View File
@@ -24,7 +24,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
const promptText: string = (b.prompt_text || '').trim();
if (mode === 'each' && !sources.length) return json({ error: 'Keine Bilder.' }, 400);
if (mode === 'compose' && sources.length < 2) return json({ error: 'Zum Kombinieren mindestens 2 Bilder.' }, 400);
if (mode === 'compose' && sources.length < 1) return json({ error: 'Mindestens 1 Bild nötig.' }, 400);
if (mode === 'compose' && !promptText) return json({ error: 'Bitte beschreiben, was entstehen soll.' }, 400);
if (mode === 'generate' && !promptText) return json({ error: 'Bitte einen Text eingeben.' }, 400);
+4 -3
View File
@@ -16,10 +16,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
const b = await request.json();
const row = await one(
`INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, contour_mm,
model_key, delivery, picdrop_gallery, custom_instruction, is_default, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,false,$12) RETURNING *`,
model_key, delivery, picdrop_gallery, delivery_target_id, custom_instruction, mode, is_default, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,false,$14) RETURNING *`,
[b.name, JSON.stringify(b.tasks || []), b.output_format, b.orientation, b.crop_mode || 'crop',
b.dpi || 300, b.contour_mm ?? null, b.model_key ?? null, b.delivery || 'library',
b.picdrop_gallery ?? null, b.custom_instruction ?? null, locals.user.uid]);
b.picdrop_gallery ?? null, b.delivery_target_id ?? null, b.custom_instruction ?? null,
['each', 'compose', 'generate'].includes(b.mode) ? b.mode : 'each', locals.user.uid]);
return json({ recipe: row });
};
+29
View File
@@ -0,0 +1,29 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
const COLS = ['name', 'tasks', 'output_format', 'orientation', 'crop_mode', 'dpi', 'contour_mm',
'model_key', 'delivery', 'picdrop_gallery', 'delivery_target_id', 'custom_instruction', 'mode'];
export const PATCH: APIRoute = async ({ params, request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json();
const sets: string[] = []; const args: any[] = [];
const set = (c: string, v: any) => { args.push(v); sets.push(`${c}=$${args.length}`); };
for (const c of COLS) {
if (c in b) set(c, c === 'tasks' ? JSON.stringify(b[c] || []) : (b[c] === '' ? null : b[c]));
}
if (!sets.length) return json({ error: 'Nichts zu ändern.' }, 400);
args.push(params.id);
const row = await one(`UPDATE recipes SET ${sets.join(',')} WHERE id=$${args.length} RETURNING *`, args);
return json({ recipe: row });
};
export const DELETE: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
await query('DELETE FROM recipes WHERE id=$1', [params.id]);
return json({ ok: true });
};