Some checks are pending
CI / validate (push) Waiting to run
Server-side AI-Pfad mit atomischer Deck+Cards-Erzeugung:
POST /api/v1/decks/generate
body { prompt, language?: 'de'|'en', count?: 3..40 }
→ ruft mana-llm /v1/chat/completions mit `mana/structured`-Alias
(JSON-Output, hartes zod-Schema)
→ SystemPrompt fixiert das Output-Format (deck_name + cards mit
front/back), verbietet HTML/Code-Fences, akzeptiert Markdown
→ Validation: zod-strict, halluzinations-resilient
→ Insert: Deck + alle Karten + Reviews in einer DB-Transaction,
contentHash beim Insert geschrieben (Phase-9j-konform)
→ 502 wenn LLM Schema bricht oder Endpoint timeoutet (90s cap)
Frontend:
- Neue Route /decks/new-ai mit Prompt-Form, Anzahl-Karten-Slider
(3-40), Sprach-Wähler (DE/EN, default = aktuelle UI-Sprache).
- 5 klickbare Beispiel-Prompts als Inspiration.
- busy-State zeigt "10-60s typisch" (Disclaimer für die LLM-Latenz).
- "✨ KI-Deck"-Button neben "Neues Deck" auf /decks.
- error-Display mit role=alert.
apps/api/src/services/llm-client.ts kapselt den Aufruf:
- mana/structured als Alias (Routing-Layer wählt Provider)
- response_format json_object
- 90s-Timeout per AbortController
- LlmError mit status + body für saubere 502-Mapping
- Optional CARDS_LLM_API_KEY-Env (für später, wenn mana-llm
GPU_API_KEY enforce'd)
Auth: aktuell User-JWT via authMiddleware. Tier-Gating bewusst
nicht aktiv — Cards-MVP ist tier-frei. Wenn AI-Generation Credits
kosten soll, kommt requireTier('beta') + creditsClient.reserve()
davor (Phase-6-Plumbing ist da, ein-Liner-Aktivierung).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
118 lines
3.4 KiB
Svelte
118 lines
3.4 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { goto } from '$app/navigation';
|
|
import type { Deck } from '@cards/domain';
|
|
import { listDecks, deleteDeck } from '$lib/api/decks.ts';
|
|
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
|
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
|
import InboxBanner from '$lib/components/InboxBanner.svelte';
|
|
import { t } from '$lib/i18n/index.svelte.ts';
|
|
|
|
let decks = $state<Deck[]>([]);
|
|
let loading = $state(true);
|
|
let error = $state<string | null>(null);
|
|
|
|
onMount(async () => {
|
|
if (!devUser.id) {
|
|
goto('/');
|
|
return;
|
|
}
|
|
await refresh();
|
|
});
|
|
|
|
async function refresh() {
|
|
try {
|
|
loading = true;
|
|
const r = await listDecks();
|
|
decks = r.decks;
|
|
error = null;
|
|
} catch (e) {
|
|
error = (e as Error).message;
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function onDelete(id: string, name: string) {
|
|
if (!confirm(t('decks.delete_confirm', { name }))) return;
|
|
try {
|
|
await deleteDeck(id);
|
|
toasts.success(t('decks.deleted', { name }));
|
|
await refresh();
|
|
} catch (e) {
|
|
toasts.error(t('decks.delete_failed', { msg: (e as Error).message }));
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="flex items-center justify-between gap-2">
|
|
<h1 class="text-2xl font-semibold">{t('decks.title')}</h1>
|
|
<div class="flex gap-2">
|
|
<a
|
|
href="/decks/new-ai"
|
|
class="rounded border border-[var(--color-primary)] px-3 py-2 text-sm text-[var(--color-primary)] hover:bg-[var(--color-primary)]/10"
|
|
title="Mit KI generieren"
|
|
>
|
|
✨ KI-Deck
|
|
</a>
|
|
<a
|
|
href="/decks/new"
|
|
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)]"
|
|
>{t('decks.new')}</a
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-4">
|
|
<InboxBanner />
|
|
</div>
|
|
|
|
{#if loading}
|
|
<p class="mt-8 text-[var(--color-muted)]">{t('decks.loading')}</p>
|
|
{:else if error}
|
|
<p class="mt-8 text-[var(--color-danger)]">{t('decks.error', { msg: error })}</p>
|
|
{:else if decks.length === 0}
|
|
<div
|
|
class="mt-8 rounded-lg border border-dashed border-[var(--color-border)] p-12 text-center"
|
|
>
|
|
<p class="text-[var(--color-muted)]">{t('decks.empty')}</p>
|
|
<a href="/decks/new" class="mt-4 inline-block text-[var(--color-primary)] hover:underline"
|
|
>{t('decks.empty_cta')} →</a
|
|
>
|
|
</div>
|
|
{:else}
|
|
<ul class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{#each decks as deck (deck.id)}
|
|
<li
|
|
class="group relative rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4 hover:border-[var(--color-primary)]"
|
|
>
|
|
<a href="/decks/{deck.id}" class="block">
|
|
<div class="flex items-start gap-3">
|
|
{#if deck.color}
|
|
<span
|
|
class="mt-1 h-3 w-3 shrink-0 rounded-full"
|
|
style="background:{deck.color}"
|
|
></span>
|
|
{/if}
|
|
<div class="min-w-0 flex-1">
|
|
<h2 class="truncate font-medium">{deck.name}</h2>
|
|
{#if deck.description}
|
|
<p class="mt-1 line-clamp-2 text-sm text-[var(--color-muted)]">
|
|
{deck.description}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
<button
|
|
class="absolute right-2 top-2 rounded p-1 text-[var(--color-muted)] opacity-0 hover:bg-[var(--color-border)] hover:text-[var(--color-danger)] focus-visible:opacity-100 group-hover:opacity-100"
|
|
onclick={() => onDelete(deck.id, deck.name)}
|
|
aria-label={t('decks.delete_confirm', { name: deck.name })}
|
|
title={t('decks.delete_confirm', { name: deck.name })}
|
|
>
|
|
🗑
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|