From dc382a795ddc5a66f682ec910697e2c0ece74f5a Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 10 May 2026 16:39:39 +0200 Subject: [PATCH] feat(api): URL-Kontext auch in /decks/generate + fetchUrlContent extrahieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `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 --- apps/api/src/lib/url-fetch.ts | 50 ++++++++++++++++++ apps/api/src/routes/decks-from-image.ts | 52 +------------------ apps/api/src/routes/decks-generate.ts | 10 +++- apps/web/src/lib/api/decks.ts | 2 +- .../web/src/lib/components/NewDeckCard.svelte | 2 +- apps/web/src/routes/decks/new/+page.svelte | 2 +- 6 files changed, 63 insertions(+), 55 deletions(-) create mode 100644 apps/api/src/lib/url-fetch.ts diff --git a/apps/api/src/lib/url-fetch.ts b/apps/api/src/lib/url-fetch.ts new file mode 100644 index 0000000..6d71925 --- /dev/null +++ b/apps/api/src/lib/url-fetch.ts @@ -0,0 +1,50 @@ +const MAX_URL_CHARS = 8_000; +const MANA_SEARCH_URL = process.env.MANA_SEARCH_URL ?? 'http://localhost:3076'; + +export 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; + } +} diff --git a/apps/api/src/routes/decks-from-image.ts b/apps/api/src/routes/decks-from-image.ts index 2709af9..f7da8fb 100644 --- a/apps/api/src/routes/decks-from-image.ts +++ b/apps/api/src/routes/decks-from-image.ts @@ -5,15 +5,13 @@ 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; -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'; @@ -23,54 +21,6 @@ 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), diff --git a/apps/api/src/routes/decks-generate.ts b/apps/api/src/routes/decks-generate.ts index 6588206..9187144 100644 --- a/apps/api/src/routes/decks-generate.ts +++ b/apps/api/src/routes/decks-generate.ts @@ -11,6 +11,7 @@ import { cards, decks, reviews } from '../db/schema/index.ts'; import { authMiddleware, type AuthVars } from '../middleware/auth.ts'; import { ulid } from '../lib/ulid.ts'; import { chatJson } from '../services/llm-client.ts'; +import { fetchUrlContent } from '../lib/url-fetch.ts'; export type GenerateDeps = { db?: CardsDb }; @@ -102,6 +103,7 @@ const GenerateInputSchema = z.object({ prompt: z.string().min(3).max(500), language: z.enum(['de', 'en']).optional().default('de'), count: z.number().int().min(1).max(40).optional().default(15), + url: z.string().url().max(2000).optional(), }); const SYSTEM_PROMPT = `Du bist ein Lerndesigner und erstellst Karteikarten-Decks für Spaced-Repetition-Lernen. @@ -140,11 +142,17 @@ export function decksGenerateRouter(deps: GenerateDeps = {}): Hono<{ Variables: ); } - const userPrompt = `Sprache: ${parsed.data.language} + const urlContent = parsed.data.url ? await fetchUrlContent(parsed.data.url) : null; + + let userPrompt = `Sprache: ${parsed.data.language} Erstelle ein Deck zu folgendem Thema mit etwa ${parsed.data.count} Karten: ${parsed.data.prompt}`; + if (urlContent) { + userPrompt += `\n\nURL-Kontext (${parsed.data.url}):\n${urlContent}`; + } + // LLM aufrufen + JSON parsen + Schema validieren. let generated: GeneratedDeck; try { diff --git a/apps/web/src/lib/api/decks.ts b/apps/web/src/lib/api/decks.ts index 13d69e2..1117ab5 100644 --- a/apps/web/src/lib/api/decks.ts +++ b/apps/web/src/lib/api/decks.ts @@ -22,7 +22,7 @@ export function deleteDeck(id: string) { return api<{ deleted: string }>(`/api/v1/decks/${id}`, { method: 'DELETE' }); } -export function generateDeck(input: { prompt: string; language?: 'de' | 'en'; count?: number }) { +export function generateDeck(input: { prompt: string; language?: 'de' | 'en'; count?: number; url?: string }) { return api<{ deck: Deck; cards_created: number }>('/api/v1/decks/generate', { method: 'POST', body: input, diff --git a/apps/web/src/lib/components/NewDeckCard.svelte b/apps/web/src/lib/components/NewDeckCard.svelte index 3b5f882..a13c9ab 100644 --- a/apps/web/src/lib/components/NewDeckCard.svelte +++ b/apps/web/src/lib/components/NewDeckCard.svelte @@ -130,7 +130,7 @@ aiError = null; generating = true; try { - const result = await generateDeck({ prompt: name.trim(), count, language }); + const result = await generateDeck({ prompt: name.trim(), count, language, url: imageUrl.trim() || undefined }); toasts.success(`✨ "${result.deck.name}" mit ${result.cards_created} Karten erstellt`); goto(`/decks/${result.deck.id}`); } catch (err) { diff --git a/apps/web/src/routes/decks/new/+page.svelte b/apps/web/src/routes/decks/new/+page.svelte index c348c1c..6c0c901 100644 --- a/apps/web/src/routes/decks/new/+page.svelte +++ b/apps/web/src/routes/decks/new/+page.svelte @@ -106,7 +106,7 @@ aiError = null; generating = true; try { - const result = await generateDeck({ prompt: name.trim(), count, language }); + const result = await generateDeck({ prompt: name.trim(), count, language, url: imageUrl.trim() || undefined }); toasts.success(`✨ "${result.deck.name}" mit ${result.cards_created} Karten erstellt`); goto(`/decks/${result.deck.id}`); } catch (err) {