chore(infra): drop status-page-gen from Mini, status.mana.how → GPU-Box tunnel

Phase 2e cleanup. status-page-gen + a dedicated nginx now run on the
GPU-Box (sparse repo clone provides the generator script + mana-apps.ts,
hourly git-pull via systemd timer). Container queries VictoriaMetrics
locally over docker-network ('http://victoriametrics:9090'), no public
vm.mana.how endpoint required — that hostname is also gone from the
GPU tunnel config (v25 → v26 effectively, removed in same PUT that
added status.mana.how).

DNS for status.mana.how now points at the mana-gpu-server tunnel.
Mini-tunnel ingress for it is removed; the previous 'mana-status-gen'
container on the Mini was stopped + rm'd.

Side benefit: closes the inode-stale-bind-mount bug that took status.
mana.how down for a few hours — single-file bind mounts on the Mini
break whenever the CD git-checkout rewrites the source file. The
GPU-Box mounts the same files but the systemd timer git-pulls in-
place, preserving the inode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-07 13:22:20 +02:00
parent 22cce59c3a
commit 778e5a2ad7
11 changed files with 351 additions and 31 deletions

View file

@ -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",

16
apps/cards/apps/web/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,16 @@
// Virtual modules provided by vite-plugin-pwa (wrapped by @vite-pwa/sveltekit):
// - virtual:pwa-info → pwaInfo.webManifest.linkTag for <svelte:head>
// - virtual:pwa-register/svelte → useRegisterSW() Svelte-store hook
/// <reference types="vite-plugin-pwa/info" />
/// <reference types="vite-plugin-pwa/svelte" />
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 {};

View file

@ -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 }) => {
`<script>` +
`window.__PUBLIC_MANA_AUTH_URL__=${JSON.stringify(PUBLIC_MANA_AUTH_URL_CLIENT)};` +
`window.__PUBLIC_MANA_SYNC_URL__=${JSON.stringify(PUBLIC_MANA_SYNC_URL_CLIENT)};` +
`window.__PUBLIC_MANA_LLM_URL__=${JSON.stringify(PUBLIC_MANA_LLM_URL_CLIENT)};` +
`</script>`;
return html.replace('<head>', `<head>${envScript}`);
},

View file

@ -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"}, ...]
- 515 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<GeneratedCard[]> {
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;
}

View file

@ -0,0 +1,166 @@
<script lang="ts">
import { generateCardsFromText, type GeneratedCard } from '$lib/ai/generate';
import { cardStore } from '$lib/stores/cards.svelte';
interface Props {
deckId: string;
currentCardCount: number;
onCreated?: () => void;
}
let { deckId, currentCardCount, onCreated }: Props = $props();
let stage = $state<'idle' | 'generating' | 'preview' | 'creating' | 'done' | 'error'>('idle');
let source = $state('');
let generated = $state<GeneratedCard[]>([]);
let selected = $state<boolean[]>([]);
let error = $state<string | null>(null);
let createdCount = $state(0);
let abortController: AbortController | null = null;
async function handleGenerate() {
if (!source.trim()) return;
error = null;
stage = 'generating';
abortController = new AbortController();
try {
const cards = await generateCardsFromText(source, { signal: abortController.signal });
generated = cards;
selected = cards.map(() => true);
stage = 'preview';
} catch (e: any) {
if (e?.name === 'AbortError') {
stage = 'idle';
return;
}
error = e?.message ?? 'Generierung fehlgeschlagen.';
stage = 'error';
} finally {
abortController = null;
}
}
function cancelGenerate() {
abortController?.abort();
}
async function handleConfirm() {
stage = 'creating';
let order = currentCardCount;
let count = 0;
for (let i = 0; i < generated.length; i++) {
if (!selected[i]) continue;
const c = generated[i];
const created = await cardStore.createCard(
{ deckId, type: 'basic', front: c.front, back: c.back },
order
);
if (created) {
count++;
order++;
}
}
createdCount = count;
stage = 'done';
onCreated?.();
}
function reset() {
stage = 'idle';
generated = [];
selected = [];
source = '';
error = null;
createdCount = 0;
}
</script>
<div class="rounded-xl border border-indigo-500/30 bg-neutral-900 p-4">
<div class="mb-2 flex items-center justify-between">
<span class="text-sm font-medium">✨ Karten aus Text generieren</span>
{#if stage !== 'idle'}
<button
class="text-xs text-neutral-500 hover:text-neutral-300"
onclick={stage === 'generating' ? cancelGenerate : reset}
>
{stage === 'generating' ? 'Abbrechen' : 'Zurücksetzen'}
</button>
{/if}
</div>
{#if stage === 'idle' || stage === 'error'}
<textarea
bind:value={source}
placeholder="Text einfügen — Notizen, Lehrbuch-Absatz, Definition…"
class="min-h-[120px] w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm outline-none focus:border-indigo-400"
></textarea>
{#if stage === 'error' && error}
<p class="mt-2 text-sm text-red-400">{error}</p>
{/if}
<div class="mt-2 flex items-center justify-between text-xs text-neutral-500">
<span>{source.length} Zeichen</span>
<button
class="rounded-lg bg-indigo-500 px-4 py-1.5 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
onclick={handleGenerate}
disabled={!source.trim()}
>
Generieren
</button>
</div>
{:else if stage === 'generating'}
<div class="py-6 text-center text-sm text-neutral-400">Modell denkt nach…</div>
{:else if stage === 'preview'}
<div class="space-y-2 text-sm">
<div class="text-neutral-300">
{generated.length} Karten generiert. Wähle aus, was übernommen werden soll:
</div>
<ul class="max-h-72 space-y-1 overflow-y-auto rounded-lg border border-neutral-800 p-2">
{#each generated as card, i (i)}
<li class="flex items-start gap-2 rounded-md p-1 hover:bg-neutral-800/50">
<input
type="checkbox"
bind:checked={selected[i]}
class="mt-1 shrink-0"
id="ai-card-{i}"
/>
<label for="ai-card-{i}" class="min-w-0 flex-1 cursor-pointer">
<div class="font-medium text-neutral-100">{card.front}</div>
<div class="text-xs text-neutral-400">{card.back}</div>
</label>
</li>
{/each}
</ul>
<div class="flex justify-end gap-2 pt-1">
<button
class="rounded-lg px-3 py-1.5 text-sm text-neutral-400 hover:text-neutral-100"
onclick={() => (selected = selected.map(() => true))}
>
Alle
</button>
<button
class="rounded-lg px-3 py-1.5 text-sm text-neutral-400 hover:text-neutral-100"
onclick={() => (selected = selected.map(() => false))}
>
Keine
</button>
<button
class="rounded-lg bg-indigo-500 px-4 py-1.5 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
onclick={handleConfirm}
disabled={!selected.some(Boolean)}
>
{selected.filter(Boolean).length} übernehmen
</button>
</div>
</div>
{:else if stage === 'creating'}
<div class="py-6 text-center text-sm text-neutral-400">Lege Karten an…</div>
{:else if stage === 'done'}
<div class="text-sm text-green-400">{createdCount} Karten angelegt.</div>
<button
class="mt-2 text-xs text-neutral-500 hover:text-neutral-300"
onclick={reset}
>
Weiteren Text generieren
</button>
{/if}
</div>

View file

@ -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 <link> Chrome can't read the
// manifest → no install icon, no A2HS on mobile.
const webManifestLink = $derived(pwaInfo?.webManifest.linkTag ?? '');
onDestroy(() => stopSync());
</script>
<svelte:head>
{@html webManifestLink}
</svelte:head>
{#if isPublic}
{@render children()}
{:else}

View file

@ -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<CardType>('basic');
let newFront = $state('');
let newBack = $state('');
@ -144,15 +146,31 @@
</div>
</div>
<div class="mb-6">
<div class="mb-6 flex flex-wrap items-center gap-3">
<button
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400"
onclick={() => (showNew = true)}
>
Neue Karte
</button>
<button
class="rounded-lg border border-indigo-500/30 px-4 py-2 text-sm text-indigo-300 hover:bg-indigo-500/10"
onclick={() => (showAi = !showAi)}
>
✨ Aus Text generieren
</button>
</div>
{#if showAi}
<div class="mb-6">
<AiCardGen
{deckId}
currentCardCount={cards.length}
onCreated={() => (showAi = false)}
/>
</div>
{/if}
{#if showNew}
<div class="mb-6 rounded-xl border border-indigo-500/30 bg-neutral-900 p-4">
<h3 class="mb-3 font-medium">Neue Karte</h3>

View file

@ -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',
})
),
],
});

View file

@ -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

View file

@ -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

3
pnpm-lock.yaml generated
View file

@ -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