feat(decks/from-image): URL-Input als Alternative zu Datei-Upload

- API: fetchUrlContent() via mana-search /api/v1/extract (Fallback: direktes Fetch)
- URL-Inhalt wird als Kontext an die LLM-Karten-Generierung übergeben
- Client: url-only Flow sendet JSON statt FormData (Bun-Kompatibilität)
- Deck-Neu-Seite: URL-Eingabefeld neben dem Datei-Upload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-10 16:00:04 +02:00
parent 731481ffe3
commit 1f1abf3c4f
3 changed files with 180 additions and 54 deletions

View file

@ -8,9 +8,12 @@ import { GeneratedDeckSchema, insertGeneratedDeck } from './decks-generate.ts';
export type FromImageDeps = { db?: CardsDb };
const MAX_FILES = 5;
const MAX_BYTES_PER_IMAGE = 10 * 1024 * 1024; // 10 MiB je Bild
const MAX_BYTES_PER_PDF = 30 * 1024 * 1024; // 30 MiB je PDF (Gemini unterstützt bis ~300 Seiten)
const MAX_FILES = 5;
const MAX_BYTES_PER_IMAGE = 10 * 1024 * 1024;
const MAX_BYTES_PER_PDF = 30 * 1024 * 1024;
const MAX_URL_CHARS = 8_000;
const MANA_SEARCH_URL = process.env.MANA_SEARCH_URL ?? 'http://localhost:3076';
function isAllowedMime(mime: string): boolean {
return mime.startsWith('image/') || mime === 'application/pdf';
@ -20,12 +23,61 @@ function maxBytesFor(mime: string): number {
return mime === 'application/pdf' ? MAX_BYTES_PER_PDF : MAX_BYTES_PER_IMAGE;
}
async function fetchUrlContent(url: string): Promise<string | null> {
// Prefer mana-search (go-readability quality)
try {
const res = await fetch(`${MANA_SEARCH_URL}/api/v1/extract`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, options: { includeMarkdown: true, maxLength: MAX_URL_CHARS } }),
signal: AbortSignal.timeout(8_000),
});
if (res.ok) {
const data = await res.json() as {
success: boolean;
content?: { title?: string; markdown?: string; text?: string };
};
if (data.success && data.content) {
const text = data.content.markdown || data.content.text || '';
if (text.trim()) {
const title = data.content.title ? `# ${data.content.title}\n\n` : '';
return (title + text).slice(0, MAX_URL_CHARS);
}
}
}
} catch {
// mana-search nicht erreichbar — Fallback auf direktes Fetch
}
// Fallback: direktes HTTP-Fetch + einfaches HTML-Stripping
try {
const res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; mana-cards/1.0; +https://cardecky.mana.how)' },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return null;
const html = await res.text();
const text = html
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/g, ' ')
.replace(/&#?\w+;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return text ? text.slice(0, MAX_URL_CHARS) : null;
} catch {
return null;
}
}
const InputSchema = z.object({
language: z.enum(['de', 'en']).optional().default('de'),
count: z.coerce.number().int().min(1).max(40).optional().default(15),
url: z.string().url().max(2000).optional(),
});
const SYSTEM_PROMPT = `Du bist ein Lerndesigner. Analysiere die Bilder oder Dokumente und erstelle daraus ein einziges zusammenhängendes Karteikarten-Deck für Spaced-Repetition-Lernen.
const SYSTEM_PROMPT = `Du bist ein Lerndesigner. Analysiere alle bereitgestellten Inhalte (Bilder, Dokumente, Texte, URLs) und erstelle daraus ein einziges zusammenhängendes Karteikarten-Deck für Spaced-Repetition-Lernen.
Du gibst NUR ein gültiges JSON-Objekt zurück, exakt mit diesem Schema:
{
@ -42,7 +94,7 @@ Regeln:
- Eine Karte = ein Lernstoff-Bissen (atomic). Nicht mehrere Konzepte in eine Karte stopfen.
- Markdown ist erlaubt (**fett**, *kursiv*, Listen, \`code\`).
- KEIN HTML, KEIN Code-Fence außerhalb des JSON, KEINE Erklärung außerhalb des JSON.
- Erstelle ein kohärentes Deck, das den Lernstoff aller Dateien zusammenfasst.`;
- Erstelle ein kohärentes Deck, das den Lernstoff aller Quellen zusammenfasst.`;
export function decksFromImageRouter(deps: FromImageDeps = {}): Hono<{ Variables: AuthVars }> {
const r = new Hono<{ Variables: AuthVars }>();
@ -53,17 +105,43 @@ export function decksFromImageRouter(deps: FromImageDeps = {}): Hono<{ Variables
r.post('/', async (c) => {
const userId = c.get('userId');
const form = await c.req.formData().catch(() => null);
if (!form) {
return c.json({ error: 'invalid_input', detail: 'multipart body required' }, 400);
// Accept multipart (files + optional url) OR json (url-only, no files)
const contentType = c.req.header('Content-Type') ?? '';
let files: File[] = [];
let rawInput: { language?: unknown; count?: unknown; url?: unknown } = {};
if (contentType.includes('multipart/form-data')) {
const form = await c.req.formData().catch(() => null);
if (!form) {
return c.json({ error: 'invalid_input', detail: 'multipart body required' }, 400);
}
const rawFiles = form.getAll('file');
files = rawFiles.filter((f): f is File => f instanceof File && isAllowedMime(f.type));
rawInput = {
language: form.get('language') ?? undefined,
count: form.get('count') ?? undefined,
url: form.get('url') ?? undefined,
};
} else {
const body = await c.req.json().catch(() => null);
if (!body || typeof body !== 'object') {
return c.json({ error: 'invalid_input', detail: 'json or multipart body required' }, 400);
}
rawInput = body as { language?: unknown; count?: unknown; url?: unknown };
}
const rawFiles = form.getAll('file');
const files = rawFiles.filter((f): f is File => f instanceof File && isAllowedMime(f.type));
if (files.length === 0) {
const parsed = InputSchema.safeParse(rawInput);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', detail: 'at least one image or PDF file required' },
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422,
);
}
const { language, count, url } = parsed.data;
if (files.length === 0 && !url) {
return c.json(
{ error: 'invalid_input', detail: 'provide at least one image/PDF file or a URL' },
400,
);
}
@ -79,31 +157,41 @@ export function decksFromImageRouter(deps: FromImageDeps = {}): Hono<{ Variables
);
}
const parsed = InputSchema.safeParse({
language: form.get('language') ?? undefined,
count: form.get('count') ?? undefined,
});
if (!parsed.success) {
const [images, urlContent] = await Promise.all([
Promise.all(
files.map(async (f) => ({
base64: Buffer.from(await f.arrayBuffer()).toString('base64'),
mimeType: f.type,
})),
),
url ? fetchUrlContent(url) : Promise.resolve(null),
]);
// URL-only request where extraction failed → can't generate anything meaningful
if (url && images.length === 0 && !urlContent) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422,
{ error: 'url_extraction_failed', detail: `could not extract content from ${url}` },
502,
);
}
const { language, count } = parsed.data;
const images = await Promise.all(
files.map(async (f) => ({
base64: Buffer.from(await f.arrayBuffer()).toString('base64'),
mimeType: f.type,
})),
);
const sourceParts: string[] = [];
if (images.length > 0) {
const hasPdf = files.some((f) => f.type === 'application/pdf');
sourceParts.push(
hasPdf
? images.length === 1 ? 'einem Dokument' : `${images.length} Dateien`
: images.length === 1 ? 'einem Bild' : `${images.length} Bildern`,
);
}
if (urlContent) sourceParts.push('URL-Inhalt');
const imageCount = images.length;
const hasPdf = files.some((f) => f.type === 'application/pdf');
const contentLabel = hasPdf
? imageCount === 1 ? 'diesem Dokument' : `diesen ${imageCount} Dateien`
: imageCount === 1 ? 'diesem Bild' : `diesen ${imageCount} Bildern`;
const userText = `Erstelle ${count} Lernkarten auf ${language === 'de' ? 'Deutsch' : 'English'} aus ${contentLabel}.`;
const langLabel = language === 'de' ? 'Deutsch' : 'English';
let userText = `Erstelle ${count} Lernkarten auf ${langLabel} aus ${sourceParts.join(' und ')}.`;
if (urlContent) {
userText += `\n\nURL-Kontext (${url}):\n${urlContent}`;
}
let generated: z.infer<typeof GeneratedDeckSchema>;
try {
@ -130,9 +218,18 @@ export function decksFromImageRouter(deps: FromImageDeps = {}): Hono<{ Variables
return c.json({ error: 'llm_call_failed', detail: msg }, 502);
}
const fallback = hasPdf
? imageCount === 1 ? 'KI-generiert aus Dokument' : `KI-generiert aus ${imageCount} Dateien`
: imageCount === 1 ? 'KI-generiert aus Bild' : `KI-generiert aus ${imageCount} Bildern`;
const fallbackParts: string[] = [];
if (images.length > 0) {
const hasPdf = files.some((f) => f.type === 'application/pdf');
fallbackParts.push(
hasPdf
? images.length === 1 ? 'Dokument' : `${images.length} Dateien`
: images.length === 1 ? 'Bild' : `${images.length} Bildern`,
);
}
if (url) fallbackParts.push('URL');
const fallback = `KI-generiert aus ${fallbackParts.join(' + ')}`;
const result = await insertGeneratedDeck(dbOf(), userId, generated, fallback);
return c.json(result, 201);
});