From 1f1abf3c4f0b1d13f316302470d3994e2f1f6e0e Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 10 May 2026 16:00:04 +0200 Subject: [PATCH] feat(decks/from-image): URL-Input als Alternative zu Datei-Upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/src/routes/decks-from-image.ts | 169 ++++++++++++++++----- apps/web/src/lib/api/decks.ts | 14 +- apps/web/src/routes/decks/new/+page.svelte | 51 +++++-- 3 files changed, 180 insertions(+), 54 deletions(-) diff --git a/apps/api/src/routes/decks-from-image.ts b/apps/api/src/routes/decks-from-image.ts index 4e5b19f..2709af9 100644 --- a/apps/api/src/routes/decks-from-image.ts +++ b/apps/api/src/routes/decks-from-image.ts @@ -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 { + // 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(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /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; 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); }); diff --git a/apps/web/src/lib/api/decks.ts b/apps/web/src/lib/api/decks.ts index 45cbeaa..0af9c6a 100644 --- a/apps/web/src/lib/api/decks.ts +++ b/apps/web/src/lib/api/decks.ts @@ -43,12 +43,22 @@ export function fetchDistractors( export function generateDeckFromImage( files: File | File[], - opts: { language?: 'de' | 'en'; count?: number }, + opts: { language?: 'de' | 'en'; count?: number; url?: string }, ) { - const form = new FormData(); const arr = Array.isArray(files) ? files : [files]; + + // URL-only (no files): send as JSON — FormData without file parts fails in Bun + if (arr.length === 0) { + return api<{ deck: Deck; cards_created: number }>('/api/v1/decks/from-image', { + method: 'POST', + body: { language: opts.language, count: opts.count, url: opts.url }, + }); + } + + const form = new FormData(); for (const f of arr) form.append('file', f); if (opts.language) form.append('language', opts.language); if (opts.count != null) form.append('count', String(opts.count)); + if (opts.url) form.append('url', opts.url); return apiForm<{ deck: Deck; cards_created: number }>('/api/v1/decks/from-image', form); } diff --git a/apps/web/src/routes/decks/new/+page.svelte b/apps/web/src/routes/decks/new/+page.svelte index 9dca423..8a5d7c0 100644 --- a/apps/web/src/routes/decks/new/+page.svelte +++ b/apps/web/src/routes/decks/new/+page.svelte @@ -22,6 +22,7 @@ let imageFiles = $state([]); let imagePreviews = $state([]); + let imageUrl = $state(''); let imageGenerating = $state(false); let imageError = $state(null); let fileInput = $state(null); @@ -55,11 +56,15 @@ } async function onFromImage() { - if (imageFiles.length === 0 || imageGenerating) return; + if ((imageFiles.length === 0 && !imageUrl.trim()) || imageGenerating) return; imageError = null; imageGenerating = true; try { - const result = await generateDeckFromImage(imageFiles, { count, language }); + const result = await generateDeckFromImage(imageFiles, { + count, + language, + url: imageUrl.trim() || undefined, + }); toasts.success(`🖼 "${result.deck.name}" mit ${result.cards_created} Karten erstellt`); goto(`/decks/${result.deck.id}`); } catch (err) { @@ -151,7 +156,7 @@ > @@ -285,6 +290,16 @@ /> + + {#if imageError}
- {imageGenerating ? '🖼 Analysiere…' : `🖼 Aus ${imagePreviews.length > 1 ? `${imagePreviews.length} Bildern` : 'Bild'} generieren`} + {#if imageGenerating} + 🖼 Analysiere… + {:else if imageFiles.length > 0 && imageUrl.trim()} + 🖼 Aus {imagePreviews.length > 1 ? `${imagePreviews.length} Bildern` : 'Bild'} + URL generieren + {:else if imageUrl.trim()} + 🖼 Aus URL generieren + {:else} + 🖼 Aus {imagePreviews.length > 1 ? `${imagePreviews.length} Bildern` : 'Bild'} generieren + {/if}