- `lib/url-fetch.ts`: fetchUrlContent aus decks-from-image herausgezogen — gemeinsam genutzte Logik für mana-search + direktes HTTP-Fetch-Fallback - `decks-generate.ts`: optionales `url`-Feld im Input-Schema; URL-Inhalt wird an den Prompt angehängt wenn vorhanden - `decks.ts` (web): `generateDeck()` akzeptiert jetzt `url?: string` - UI: imageUrl wird für Text-KI + Bild-KI als Kontext genutzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
188 lines
6.2 KiB
TypeScript
188 lines
6.2 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { z } from 'zod';
|
|
|
|
import { getDb, type CardsDb } from '../db/connection.ts';
|
|
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
|
import { chatVisionJson } from '../services/llm-client.ts';
|
|
import { GeneratedDeckSchema, insertGeneratedDeck } from './decks-generate.ts';
|
|
import { fetchUrlContent } from '../lib/url-fetch.ts';
|
|
|
|
export type FromImageDeps = { db?: CardsDb };
|
|
|
|
const MAX_FILES = 5;
|
|
const MAX_BYTES_PER_IMAGE = 10 * 1024 * 1024;
|
|
const MAX_BYTES_PER_PDF = 30 * 1024 * 1024;
|
|
|
|
function isAllowedMime(mime: string): boolean {
|
|
return mime.startsWith('image/') || mime === 'application/pdf';
|
|
}
|
|
|
|
function maxBytesFor(mime: string): number {
|
|
return mime === 'application/pdf' ? MAX_BYTES_PER_PDF : MAX_BYTES_PER_IMAGE;
|
|
}
|
|
|
|
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 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:
|
|
{
|
|
"deck_name": "<kurzer Titel, max 80 Zeichen>",
|
|
"deck_description": "<eine Zeile Beschreibung, optional>",
|
|
"cards": [
|
|
{ "front": "<Frage oder Begriff>", "back": "<Antwort oder Erklärung>" },
|
|
...
|
|
]
|
|
}
|
|
|
|
Regeln:
|
|
- Front ist Frage / Begriff / Hinweis. Back ist Antwort / Definition / Erklärung.
|
|
- 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 Quellen zusammenfasst.`;
|
|
|
|
export function decksFromImageRouter(deps: FromImageDeps = {}): Hono<{ Variables: AuthVars }> {
|
|
const r = new Hono<{ Variables: AuthVars }>();
|
|
const dbOf = () => deps.db ?? getDb();
|
|
|
|
r.use('*', authMiddleware);
|
|
|
|
r.post('/', async (c) => {
|
|
const userId = c.get('userId');
|
|
|
|
// 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 parsed = InputSchema.safeParse(rawInput);
|
|
if (!parsed.success) {
|
|
return c.json(
|
|
{ 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,
|
|
);
|
|
}
|
|
if (files.length > MAX_FILES) {
|
|
return c.json({ error: 'invalid_input', detail: `max ${MAX_FILES} files per request` }, 400);
|
|
}
|
|
const oversized = files.find((f) => f.size > maxBytesFor(f.type));
|
|
if (oversized) {
|
|
const limit = oversized.type === 'application/pdf' ? '30 MiB' : '10 MiB';
|
|
return c.json(
|
|
{ error: 'invalid_input', detail: `"${oversized.name}" exceeds ${limit} limit` },
|
|
413,
|
|
);
|
|
}
|
|
|
|
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: 'url_extraction_failed', detail: `could not extract content from ${url}` },
|
|
502,
|
|
);
|
|
}
|
|
|
|
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 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 {
|
|
const raw = await chatVisionJson<unknown>({
|
|
images,
|
|
systemPrompt: SYSTEM_PROMPT,
|
|
userText,
|
|
timeoutMs: 120_000,
|
|
});
|
|
const r2 = GeneratedDeckSchema.safeParse(raw);
|
|
if (!r2.success) {
|
|
return c.json(
|
|
{
|
|
error: 'llm_returned_invalid_shape',
|
|
issues: r2.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`),
|
|
raw,
|
|
},
|
|
502,
|
|
);
|
|
}
|
|
generated = r2.data;
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
return c.json({ error: 'llm_call_failed', detail: msg }, 502);
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
return r;
|
|
}
|