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:
parent
731481ffe3
commit
1f1abf3c4f
3 changed files with 180 additions and 54 deletions
|
|
@ -8,9 +8,12 @@ import { GeneratedDeckSchema, insertGeneratedDeck } from './decks-generate.ts';
|
||||||
|
|
||||||
export type FromImageDeps = { db?: CardsDb };
|
export type FromImageDeps = { db?: CardsDb };
|
||||||
|
|
||||||
const MAX_FILES = 5;
|
const MAX_FILES = 5;
|
||||||
const MAX_BYTES_PER_IMAGE = 10 * 1024 * 1024; // 10 MiB je Bild
|
const MAX_BYTES_PER_IMAGE = 10 * 1024 * 1024;
|
||||||
const MAX_BYTES_PER_PDF = 30 * 1024 * 1024; // 30 MiB je PDF (Gemini unterstützt bis ~300 Seiten)
|
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 {
|
function isAllowedMime(mime: string): boolean {
|
||||||
return mime.startsWith('image/') || mime === 'application/pdf';
|
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;
|
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(/&/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({
|
const InputSchema = z.object({
|
||||||
language: z.enum(['de', 'en']).optional().default('de'),
|
language: z.enum(['de', 'en']).optional().default('de'),
|
||||||
count: z.coerce.number().int().min(1).max(40).optional().default(15),
|
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:
|
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.
|
- Eine Karte = ein Lernstoff-Bissen (atomic). Nicht mehrere Konzepte in eine Karte stopfen.
|
||||||
- Markdown ist erlaubt (**fett**, *kursiv*, Listen, \`code\`).
|
- Markdown ist erlaubt (**fett**, *kursiv*, Listen, \`code\`).
|
||||||
- KEIN HTML, KEIN Code-Fence außerhalb des JSON, KEINE Erklärung außerhalb des JSON.
|
- 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 }> {
|
export function decksFromImageRouter(deps: FromImageDeps = {}): Hono<{ Variables: AuthVars }> {
|
||||||
const r = new Hono<{ Variables: AuthVars }>();
|
const r = new Hono<{ Variables: AuthVars }>();
|
||||||
|
|
@ -53,17 +105,43 @@ export function decksFromImageRouter(deps: FromImageDeps = {}): Hono<{ Variables
|
||||||
r.post('/', async (c) => {
|
r.post('/', async (c) => {
|
||||||
const userId = c.get('userId');
|
const userId = c.get('userId');
|
||||||
|
|
||||||
const form = await c.req.formData().catch(() => null);
|
// Accept multipart (files + optional url) OR json (url-only, no files)
|
||||||
if (!form) {
|
const contentType = c.req.header('Content-Type') ?? '';
|
||||||
return c.json({ error: 'invalid_input', detail: 'multipart body required' }, 400);
|
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 parsed = InputSchema.safeParse(rawInput);
|
||||||
const files = rawFiles.filter((f): f is File => f instanceof File && isAllowedMime(f.type));
|
if (!parsed.success) {
|
||||||
|
|
||||||
if (files.length === 0) {
|
|
||||||
return c.json(
|
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,
|
400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -79,31 +157,41 @@ export function decksFromImageRouter(deps: FromImageDeps = {}): Hono<{ Variables
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = InputSchema.safeParse({
|
const [images, urlContent] = await Promise.all([
|
||||||
language: form.get('language') ?? undefined,
|
Promise.all(
|
||||||
count: form.get('count') ?? undefined,
|
files.map(async (f) => ({
|
||||||
});
|
base64: Buffer.from(await f.arrayBuffer()).toString('base64'),
|
||||||
if (!parsed.success) {
|
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(
|
return c.json(
|
||||||
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
|
{ error: 'url_extraction_failed', detail: `could not extract content from ${url}` },
|
||||||
422,
|
502,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { language, count } = parsed.data;
|
|
||||||
|
|
||||||
const images = await Promise.all(
|
const sourceParts: string[] = [];
|
||||||
files.map(async (f) => ({
|
if (images.length > 0) {
|
||||||
base64: Buffer.from(await f.arrayBuffer()).toString('base64'),
|
const hasPdf = files.some((f) => f.type === 'application/pdf');
|
||||||
mimeType: f.type,
|
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 langLabel = language === 'de' ? 'Deutsch' : 'English';
|
||||||
const hasPdf = files.some((f) => f.type === 'application/pdf');
|
let userText = `Erstelle ${count} Lernkarten auf ${langLabel} aus ${sourceParts.join(' und ')}.`;
|
||||||
const contentLabel = hasPdf
|
|
||||||
? imageCount === 1 ? 'diesem Dokument' : `diesen ${imageCount} Dateien`
|
if (urlContent) {
|
||||||
: imageCount === 1 ? 'diesem Bild' : `diesen ${imageCount} Bildern`;
|
userText += `\n\nURL-Kontext (${url}):\n${urlContent}`;
|
||||||
const userText = `Erstelle ${count} Lernkarten auf ${language === 'de' ? 'Deutsch' : 'English'} aus ${contentLabel}.`;
|
}
|
||||||
|
|
||||||
let generated: z.infer<typeof GeneratedDeckSchema>;
|
let generated: z.infer<typeof GeneratedDeckSchema>;
|
||||||
try {
|
try {
|
||||||
|
|
@ -130,9 +218,18 @@ export function decksFromImageRouter(deps: FromImageDeps = {}): Hono<{ Variables
|
||||||
return c.json({ error: 'llm_call_failed', detail: msg }, 502);
|
return c.json({ error: 'llm_call_failed', detail: msg }, 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallback = hasPdf
|
const fallbackParts: string[] = [];
|
||||||
? imageCount === 1 ? 'KI-generiert aus Dokument' : `KI-generiert aus ${imageCount} Dateien`
|
if (images.length > 0) {
|
||||||
: imageCount === 1 ? 'KI-generiert aus Bild' : `KI-generiert aus ${imageCount} Bildern`;
|
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);
|
const result = await insertGeneratedDeck(dbOf(), userId, generated, fallback);
|
||||||
return c.json(result, 201);
|
return c.json(result, 201);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -43,12 +43,22 @@ export function fetchDistractors(
|
||||||
|
|
||||||
export function generateDeckFromImage(
|
export function generateDeckFromImage(
|
||||||
files: File | File[],
|
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];
|
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);
|
for (const f of arr) form.append('file', f);
|
||||||
if (opts.language) form.append('language', opts.language);
|
if (opts.language) form.append('language', opts.language);
|
||||||
if (opts.count != null) form.append('count', String(opts.count));
|
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);
|
return apiForm<{ deck: Deck; cards_created: number }>('/api/v1/decks/from-image', form);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
|
|
||||||
let imageFiles = $state<File[]>([]);
|
let imageFiles = $state<File[]>([]);
|
||||||
let imagePreviews = $state<string[]>([]);
|
let imagePreviews = $state<string[]>([]);
|
||||||
|
let imageUrl = $state('');
|
||||||
let imageGenerating = $state(false);
|
let imageGenerating = $state(false);
|
||||||
let imageError = $state<string | null>(null);
|
let imageError = $state<string | null>(null);
|
||||||
let fileInput = $state<HTMLInputElement | null>(null);
|
let fileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
@ -55,11 +56,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onFromImage() {
|
async function onFromImage() {
|
||||||
if (imageFiles.length === 0 || imageGenerating) return;
|
if ((imageFiles.length === 0 && !imageUrl.trim()) || imageGenerating) return;
|
||||||
imageError = null;
|
imageError = null;
|
||||||
imageGenerating = true;
|
imageGenerating = true;
|
||||||
try {
|
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`);
|
toasts.success(`🖼 "${result.deck.name}" mit ${result.cards_created} Karten erstellt`);
|
||||||
goto(`/decks/${result.deck.id}`);
|
goto(`/decks/${result.deck.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -151,7 +156,7 @@
|
||||||
>
|
>
|
||||||
<DeckCategoryIcon
|
<DeckCategoryIcon
|
||||||
category={id}
|
category={id}
|
||||||
size={22}
|
size={16}
|
||||||
color={category === id ? color : null}
|
color={category === id ? color : null}
|
||||||
weight={category === id ? 'fill' : 'regular'}
|
weight={category === id ? 'fill' : 'regular'}
|
||||||
/>
|
/>
|
||||||
|
|
@ -285,6 +290,16 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label class="mt-3 block">
|
||||||
|
<span class="text-xs text-[hsl(var(--color-muted-foreground))]">URL als Kontext (optional)</span>
|
||||||
|
<input
|
||||||
|
bind:value={imageUrl}
|
||||||
|
type="url"
|
||||||
|
placeholder="https://de.wikipedia.org/wiki/…"
|
||||||
|
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
{#if imageError}
|
{#if imageError}
|
||||||
<div
|
<div
|
||||||
class="mt-2 rounded border border-[hsl(var(--color-error))]/40 bg-[hsl(var(--color-error))]/10 p-3 text-sm text-[hsl(var(--color-error))]"
|
class="mt-2 rounded border border-[hsl(var(--color-error))]/40 bg-[hsl(var(--color-error))]/10 p-3 text-sm text-[hsl(var(--color-error))]"
|
||||||
|
|
@ -302,29 +317,36 @@
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={imageFiles.length === 0 || imageGenerating || saving || generating}
|
disabled={(imageFiles.length === 0 && !imageUrl.trim()) || imageGenerating || saving || generating}
|
||||||
onclick={onFromImage}
|
onclick={onFromImage}
|
||||||
class="mt-3 rounded border border-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary))] disabled:opacity-50 hover:bg-[hsl(var(--color-primary)/0.08)]"
|
class="mt-3 rounded border border-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary))] disabled:opacity-50 hover:bg-[hsl(var(--color-primary)/0.08)]"
|
||||||
>
|
>
|
||||||
{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}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.category-grid {
|
.category-grid {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(4.5rem, 1fr));
|
flex-wrap: wrap;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-btn {
|
.category-btn {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.375rem;
|
||||||
padding: 0.5rem 0.25rem;
|
padding: 0.375rem 0.75rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 9999px;
|
||||||
border: 1px solid hsl(var(--color-border));
|
border: 1px solid hsl(var(--color-border));
|
||||||
background: hsl(var(--color-surface));
|
background: hsl(var(--color-surface));
|
||||||
color: hsl(var(--color-muted-foreground));
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
|
@ -342,10 +364,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-label {
|
.category-label {
|
||||||
font-size: 0.625rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.03em;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue