mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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:
parent
22cce59c3a
commit
778e5a2ad7
11 changed files with 351 additions and 31 deletions
|
|
@ -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
16
apps/cards/apps/web/src/app.d.ts
vendored
Normal 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 {};
|
||||
|
|
@ -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}`);
|
||||
},
|
||||
|
|
|
|||
118
apps/cards/apps/web/src/lib/ai/generate.ts
Normal file
118
apps/cards/apps/web/src/lib/ai/generate.ts
Normal 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"}, ...]
|
||||
- 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<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;
|
||||
}
|
||||
166
apps/cards/apps/web/src/lib/components/AiCardGen.svelte
Normal file
166
apps/cards/apps/web/src/lib/components/AiCardGen.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue