diff --git a/apps/cards/apps/web/package.json b/apps/cards/apps/web/package.json index 75b06056a..95bdfe065 100644 --- a/apps/cards/apps/web/package.json +++ b/apps/cards/apps/web/package.json @@ -18,6 +18,7 @@ "@tailwindcss/vite": "^4.1.7", "@types/node": "^22.10.5", "@types/sql.js": "^1.4.11", + "@vite-pwa/sveltekit": "^1.1.0", "svelte": "^5.41.0", "svelte-check": "^4.3.3", "tailwindcss": "^4.1.17", diff --git a/apps/cards/apps/web/src/app.d.ts b/apps/cards/apps/web/src/app.d.ts new file mode 100644 index 000000000..3b4b2bb75 --- /dev/null +++ b/apps/cards/apps/web/src/app.d.ts @@ -0,0 +1,16 @@ +// Virtual modules provided by vite-plugin-pwa (wrapped by @vite-pwa/sveltekit): +// - virtual:pwa-info → pwaInfo.webManifest.linkTag for +// - virtual:pwa-register/svelte → useRegisterSW() Svelte-store hook +/// +/// + +declare global { + namespace App { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface Locals {} + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface PageData {} + } +} + +export {}; diff --git a/apps/cards/apps/web/src/hooks.server.ts b/apps/cards/apps/web/src/hooks.server.ts index c8b0d19df..b4ba7b0da 100644 --- a/apps/cards/apps/web/src/hooks.server.ts +++ b/apps/cards/apps/web/src/hooks.server.ts @@ -17,6 +17,8 @@ const PUBLIC_MANA_AUTH_URL_CLIENT = process.env.PUBLIC_MANA_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_AUTH_URL || ''; const PUBLIC_MANA_SYNC_URL_CLIENT = process.env.PUBLIC_MANA_SYNC_URL_CLIENT || process.env.PUBLIC_MANA_SYNC_URL || ''; +const PUBLIC_MANA_LLM_URL_CLIENT = + process.env.PUBLIC_MANA_LLM_URL_CLIENT || process.env.PUBLIC_MANA_LLM_URL || ''; export const handle: Handle = async ({ event, resolve }) => { return resolve(event, { @@ -25,6 +27,7 @@ export const handle: Handle = async ({ event, resolve }) => { ``; return html.replace('', `${envScript}`); }, diff --git a/apps/cards/apps/web/src/lib/ai/generate.ts b/apps/cards/apps/web/src/lib/ai/generate.ts new file mode 100644 index 000000000..265741222 --- /dev/null +++ b/apps/cards/apps/web/src/lib/ai/generate.ts @@ -0,0 +1,118 @@ +/** + * AI card generation — text → list of basic cards via mana-llm. + * + * Uses mana-llm's OpenAI-compatible /v1/chat/completions endpoint with + * a system prompt that constrains the output to a JSON array. We strip + * Markdown code fences before parsing because most chat models wrap + * JSON output in ```json blocks even when explicitly told not to. + * + * No streaming — we need the full JSON before we can show anything. + * Phase-2 ideas: chunk long inputs, PDF parsing, image OCR. + */ + +const SYSTEM_PROMPT = `Du bist ein Karteikarten-Generator. Aus dem vom Nutzer gegebenen Text erstellst du Lernkarten zum Auswendiglernen. + +Regeln: +- Antworte AUSSCHLIESSLICH mit einem JSON-Array, ohne Erklärung, ohne Markdown-Code-Fences. +- Schema: [{"front": "Frage oder Begriff", "back": "Antwort"}, ...] +- 5–15 Karten je nach Textlänge. +- Front: kurze, präzise Frage oder ein Begriff. Back: prägnante Antwort, max. 2 Sätze. +- Eine Karte pro klar abgegrenzter Faktenerinnerung — nicht ganze Absätze umkopieren. +- Sprache: dieselbe wie der Quelltext.`; + +export interface GeneratedCard { + front: string; + back: string; +} + +function llmUrl(): string { + if (typeof window !== 'undefined') { + const fromWindow = (window as unknown as { __PUBLIC_MANA_LLM_URL__?: string }) + .__PUBLIC_MANA_LLM_URL__; + if (fromWindow) return fromWindow.replace(/\/$/, ''); + } + return 'http://localhost:3025'; +} + +function stripCodeFences(s: string): string { + return s + .replace(/^\s*```(?:json|javascript|js)?\s*/i, '') + .replace(/\s*```\s*$/i, '') + .trim(); +} + +function defaultModel(): string { + if (typeof window !== 'undefined') { + const fromWindow = (window as unknown as { __PUBLIC_CARDS_AI_MODEL__?: string }) + .__PUBLIC_CARDS_AI_MODEL__; + if (fromWindow) return fromWindow; + } + // mana-llm proxies many providers — this id matches what the + // playground module uses as a sensible default. Adjust per env via + // __PUBLIC_CARDS_AI_MODEL__ injection. + return 'gpt-4o-mini'; +} + +export async function generateCardsFromText( + source: string, + opts: { model?: string; signal?: AbortSignal } = {} +): Promise { + const trimmed = source.trim(); + if (!trimmed) return []; + + const res = await fetch(`${llmUrl()}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: opts.signal, + body: JSON.stringify({ + model: opts.model ?? defaultModel(), + temperature: 0.3, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: trimmed }, + ], + }), + }); + + if (!res.ok) { + const detail = await res.text().catch(() => ''); + throw new Error(`mana-llm: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`); + } + + const json = (await res.json()) as { + choices?: { message?: { content?: string } }[]; + }; + const raw = json.choices?.[0]?.message?.content?.trim(); + if (!raw) throw new Error('Leere Antwort vom LLM erhalten.'); + + let parsed: unknown; + try { + parsed = JSON.parse(stripCodeFences(raw)); + } catch (e) { + throw new Error(`LLM-Antwort war kein gültiges JSON:\n${raw.slice(0, 200)}`); + } + + if (!Array.isArray(parsed)) { + throw new Error('LLM-Antwort ist kein Array.'); + } + + const cards: GeneratedCard[] = []; + for (const item of parsed) { + if ( + typeof item === 'object' && + item !== null && + typeof (item as GeneratedCard).front === 'string' && + typeof (item as GeneratedCard).back === 'string' + ) { + const c = item as GeneratedCard; + if (c.front.trim() && c.back.trim()) { + cards.push({ front: c.front.trim(), back: c.back.trim() }); + } + } + } + + if (cards.length === 0) { + throw new Error('Keine gültigen Karten in der LLM-Antwort gefunden.'); + } + return cards; +} diff --git a/apps/cards/apps/web/src/lib/components/AiCardGen.svelte b/apps/cards/apps/web/src/lib/components/AiCardGen.svelte new file mode 100644 index 000000000..4920b8bd0 --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/AiCardGen.svelte @@ -0,0 +1,166 @@ + + +
+
+ ✨ Karten aus Text generieren + {#if stage !== 'idle'} + + {/if} +
+ + {#if stage === 'idle' || stage === 'error'} + + {#if stage === 'error' && error} +

{error}

+ {/if} +
+ {source.length} Zeichen + +
+ {:else if stage === 'generating'} +
Modell denkt nach…
+ {:else if stage === 'preview'} +
+
+ {generated.length} Karten generiert. Wähle aus, was übernommen werden soll: +
+
    + {#each generated as card, i (i)} +
  • + + +
  • + {/each} +
+
+ + + +
+
+ {:else if stage === 'creating'} +
Lege Karten an…
+ {:else if stage === 'done'} +
✓ {createdCount} Karten angelegt.
+ + {/if} +
diff --git a/apps/cards/apps/web/src/routes/+layout.svelte b/apps/cards/apps/web/src/routes/+layout.svelte index 8cc2b4242..8f0b50b0c 100644 --- a/apps/cards/apps/web/src/routes/+layout.svelte +++ b/apps/cards/apps/web/src/routes/+layout.svelte @@ -8,6 +8,7 @@ import { authStore } from '$lib/stores/auth.svelte'; import { startSync, stopSync } from '$lib/data/sync'; import { useStreak } from '$lib/queries'; + import { pwaInfo } from 'virtual:pwa-info'; let { children }: { children: Snippet } = $props(); @@ -26,9 +27,18 @@ const streakQuery = $derived(useStreak()); const streak = $derived(($streakQuery as number | undefined) ?? 0); + // vite-plugin-pwa exposes the hashed manifest filename via this + // virtual module. Without inlining its Chrome can't read the + // manifest → no install icon, no A2HS on mobile. + const webManifestLink = $derived(pwaInfo?.webManifest.linkTag ?? ''); + onDestroy(() => stopSync()); + + {@html webManifestLink} + + {#if isPublic} {@render children()} {:else} diff --git a/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte b/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte index 48ea52074..0899482f1 100644 --- a/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte +++ b/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte @@ -5,6 +5,7 @@ import { deckStore } from '$lib/stores/decks.svelte'; import { cardStore } from '$lib/stores/cards.svelte'; import { renderMarkdown, type Card, type CardType, type Deck } from '@mana/cards-core'; + import AiCardGen from '$lib/components/AiCardGen.svelte'; const deckId = $derived(page.params.id as string); @@ -17,6 +18,7 @@ const dueCount = $derived(($dueQuery as { card: Card }[] | undefined)?.length ?? 0); let showNew = $state(false); + let showAi = $state(false); let newType = $state('basic'); let newFront = $state(''); let newBack = $state(''); @@ -144,15 +146,31 @@ -
+
+
+ {#if showAi} +
+ (showAi = false)} + /> +
+ {/if} + {#if showNew}

Neue Karte

diff --git a/apps/cards/apps/web/vite.config.ts b/apps/cards/apps/web/vite.config.ts index da1f9251e..89272593f 100644 --- a/apps/cards/apps/web/vite.config.ts +++ b/apps/cards/apps/web/vite.config.ts @@ -1,7 +1,20 @@ import { sveltekit } from '@sveltejs/kit/vite'; +import { SvelteKitPWA } from '@vite-pwa/sveltekit'; import tailwindcss from '@tailwindcss/vite'; +import { createPWAConfig } from '@mana/shared-pwa'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [tailwindcss(), sveltekit()], + plugins: [ + tailwindcss(), + sveltekit(), + SvelteKitPWA( + createPWAConfig({ + name: 'Cards', + shortName: 'Cards', + description: 'Karteikarten mit Spaced Repetition', + themeColor: '#0a0a0a', + }) + ), + ], }); diff --git a/cloudflared-config.yml b/cloudflared-config.yml index 6d561a6ee..b76a4e727 100644 --- a/cloudflared-config.yml +++ b/cloudflared-config.yml @@ -193,8 +193,6 @@ ingress: # ============================================ # Self-hosted landing pages (Nginx on port 4400) # ============================================ - - hostname: status.mana.how - service: http://localhost:4400 - hostname: it.mana.how service: http://localhost:4400 - hostname: chats.mana.how diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index 584570ef7..9376cf9a1 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -1032,6 +1032,7 @@ services: PUBLIC_MANA_AUTH_URL_CLIENT: https://auth.mana.how PUBLIC_MANA_SYNC_URL: http://mana-sync:3050 PUBLIC_MANA_SYNC_URL_CLIENT: https://sync.mana.how + PUBLIC_MANA_LLM_URL_CLIENT: https://llm.mana.how ports: - "5180:5180" healthcheck: @@ -1307,33 +1308,6 @@ services: retries: 3 start_period: 20s - status-page-gen: - image: alpine:3.20 - container_name: mana-status-gen - restart: always - mem_limit: 64m - # VM ist seit Phase 2c auf der GPU-Box. status-gen erreicht sie über - # den Public-Hostname vm.mana.how im mana-gpu-server-Tunnel — Colima- - # Container können die Mini-LAN-Bridge nicht direkt zur GPU-IP routen. - environment: - VICTORIAMETRICS_URL: https://vm.mana.how - OUTPUT_FILE: /output/index.html - volumes: - - ./scripts/generate-status-page.sh:/generate.sh:ro - - ./packages/shared-branding/src/mana-apps.ts:/mana-apps.ts:ro - - /Volumes/ManaData/landings/status:/output - command: - - sh - - -c - - | - apk add --no-cache curl jq || { echo "apk add fehlgeschlagen, retry in 10s"; sleep 10; exit 1; } - mkdir -p /output - while true; do - cp /generate.sh /tmp/generate.sh - sh /tmp/generate.sh - sleep 60 - done - watchtower: image: nickfedor/watchtower:latest container_name: mana-auto-watchtower diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49b9ee142..890d243ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -257,6 +257,9 @@ importers: '@types/sql.js': specifier: ^1.4.11 version: 1.4.11 + '@vite-pwa/sveltekit': + specifier: ^1.1.0 + version: 1.1.0(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.1)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) svelte: specifier: ^5.41.0 version: 5.55.1