diff --git a/apps/web/package.json b/apps/web/package.json index 4a1c561..00f885e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "clean": "rm -rf .svelte-kit build .turbo" }, "dependencies": { + "@mana/browser-llm": "^0.1.0-alpha.2", "@mana/event-kit": "^0.1.0", "@mana/event-sync": "^0.5.0", "@mana/shared-auth-sso": "0.1.0-alpha.3", diff --git a/apps/web/src/lib/services/localLLMService.svelte.ts b/apps/web/src/lib/services/localLLMService.svelte.ts new file mode 100644 index 0000000..45e43a7 --- /dev/null +++ b/apps/web/src/lib/services/localLLMService.svelte.ts @@ -0,0 +1,92 @@ +/** + * Lokale KI für wordeck-web — Wiring von @mana/browser-llm. + * + * Aufgaben: + * - ManaLLM.configure() beim App-Start (Server-Endpoint + Token-Provider) + * - Settings-State (`routingMode`, `allowServerLLM`), localStorage-persistent + * - Capability-Probe gegen alle Backends, pro Session gecacht + * + * Server-Backend bleibt unavailable, bis User `allowServerLLM = true` + * in Settings setzt (Lokal-First-Default per MISSION.md §Mana-Versprechen #6). + */ + +import { browser } from '$app/environment'; +import { env as publicEnv } from '$env/dynamic/public'; +import { ManaLLM, type LLMBackendID } from '@mana/browser-llm'; +import { probeAllBackends, type CapabilityReport } from '@mana/browser-llm/capability-probe'; +import { devUser } from '$lib/auth/dev-stub.svelte.ts'; + +const STORAGE_ROUTING = 'wordeck.llm.routing'; +const STORAGE_ALLOW_SERVER = 'wordeck.llm.allow-server'; + +class LocalLLMSettings { + routingMode = $state<'auto' | 'local' | 'server'>('auto'); + allowServerLLM = $state(false); + + constructor() { + if (browser) { + const r = window.localStorage.getItem(STORAGE_ROUTING); + if (r === 'auto' || r === 'local' || r === 'server') this.routingMode = r; + this.allowServerLLM = window.localStorage.getItem(STORAGE_ALLOW_SERVER) === 'true'; + } + } + + setRoutingMode(value: 'auto' | 'local' | 'server') { + this.routingMode = value; + if (browser) window.localStorage.setItem(STORAGE_ROUTING, value); + } + + setAllowServerLLM(value: boolean) { + this.allowServerLLM = value; + if (browser) window.localStorage.setItem(STORAGE_ALLOW_SERVER, String(value)); + } +} + +export const llmSettings = new LocalLLMSettings(); + +let initialized = false; +let capabilityCache: CapabilityReport[] | null = null; + +export function initLocalLLM(): void { + if (!browser || initialized) return; + const apiUrl = publicEnv.PUBLIC_WORDECK_API_URL || 'http://localhost:3098'; + ManaLLM.configure({ + serverEndpoint: apiUrl, + serverAuthToken: () => devUser.token, + }); + initialized = true; +} + +export async function probeBackends(force = false): Promise { + if (capabilityCache && !force) return capabilityCache; + const result = await probeAllBackends(); + capabilityCache = result; + return result; +} + +export const BACKEND_LABELS: Record = { + chrome: { + name: 'Chrome Built-in (Gemini Nano)', + subtitle: 'Browser-bereitgestellt, kein Download. Nur Desktop-Chrome 138+.', + }, + webllm: { + name: 'WebLLM Gemma-2B', + subtitle: '~1.4 GB Download, WebGPU. Funktioniert in Firefox/Safari/Edge.', + }, + whisper: { + name: 'Whisper im Browser', + subtitle: '~244 MB Download. Audio → Text. Wordeck nutzt das nicht direkt, sichtbar für Konsistenz.', + }, + server: { + name: 'wordeck-API (Cloud)', + subtitle: 'Beste Qualität für AI-Card-Gen. Verbraucht Mana. Tier ≥ Beta nötig.', + }, + transformersjs: { + name: 'Transformers.js Gemma', + subtitle: 'WebGPU + WASM-Fallback. Universal-Fallback.', + }, + noop: { + name: 'Heuristik (kein KI-Modell)', + subtitle: 'Funktioniert immer, ohne Modell. Für Wordeck nur als Sicherheitsnetz.', + }, +}; diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index baf91fc..e358cf5 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -13,11 +13,17 @@ OfflineBanner, pwaState, } from '@mana/shared-pwa/components'; + import { initLocalLLM } from '$lib/services/localLLMService.svelte.ts'; let { children } = $props(); onMount(() => { const cleanup = theme.initialize(); + // Lokale KI initialisieren — ManaLLM.configure() mit Server-Endpoint + // + Token-Provider. Server-Backend bleibt unavailable bis User in + // Account-Settings „Cloud erlauben" aktiviert (Lokal-First per + // MISSION.md §Mana-Versprechen #6). + initLocalLLM(); (async () => { try { // @ts-expect-error virtual module from vite-plugin-pwa diff --git a/apps/web/src/routes/account/+page.svelte b/apps/web/src/routes/account/+page.svelte index bf30520..74af839 100644 --- a/apps/web/src/routes/account/+page.svelte +++ b/apps/web/src/routes/account/+page.svelte @@ -9,6 +9,12 @@ import CardSurface from '$lib/components/CardSurface.svelte'; import { apiErrorMessage } from '$lib/api/error.ts'; import { Package, Warning, Translate } from '@mana/shared-icons'; + import { + llmSettings, + probeBackends, + BACKEND_LABELS, + } from '$lib/services/localLLMService.svelte.ts'; + import type { CapabilityReport } from '@mana/browser-llm/capability-probe'; let exporting = $state(false); let deleting = $state(false); @@ -32,8 +38,34 @@ const profileLayers = stackLayers('account-profile', 3); const exportLayers = stackLayers('account-export', 3); const langLayers = stackLayers('account-lang', 3); + const llmLayers = stackLayers('account-llm', 3); const dangerLayers = stackLayers('account-danger', 3); + let llmBackends = $state([]); + let llmProbing = $state(false); + + async function refreshLLMBackends() { + llmProbing = true; + try { + llmBackends = await probeBackends(true); + } finally { + llmProbing = false; + } + } + + onMount(() => { + probeBackends().then((r) => (llmBackends = r)); + }); + + function statusLabel(status: string): string { + switch (status) { + case 'available': return '✓ Bereit'; + case 'downloadable': return '⤓ Lädt beim Aufruf'; + case 'downloading': return '⤓ Lädt…'; + default: return '— nicht verfügbar'; + } + } + async function onExport() { exporting = true; try { @@ -236,6 +268,82 @@ + +
  • + {#each llmLayers as layer, i (i)} + + {/each} + +
    +
    +

    Lokale KI

    +

    + Wordeck kann Karten lokal aus Text generieren — ohne Server, ohne Mana. + Cloud-Pfad bleibt verfügbar für bessere Qualität. +

    + +
    + {#each llmBackends as report (report.id)} +
    +
    + {BACKEND_LABELS[report.id]?.name ?? report.id} + {#if report.availability.reason} + {report.availability.reason} + {/if} +
    + + {statusLabel(report.availability.status)} + +
    + {/each} +
    + + + +
    Routing
    +
    + {#each [['auto', 'Auto'], ['local', 'Nur lokal'], ['server', 'Direkt Cloud']] as [id, label] (id)} + + {/each} +
    + + + +

    + Lokal heißt gratis (siehe + Mana-Versprechen #6). + Was im Browser rechnet, kostet kein Mana. +

    +
    +
    +
    +
  • +
  • {#each dangerLayers as layer, i (i)} @@ -538,4 +646,61 @@ color: #6366F1; font-weight: 600; } + + .llm-card { gap: 0.75rem; } + .llm-backends { + display: flex; + flex-direction: column; + gap: 0.375rem; + margin: 0.5rem 0; + } + .llm-backend-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.5rem; + padding: 0.375rem 0.5rem; + border: 1px solid hsl(var(--color-border)); + border-radius: 0.375rem; + font-size: 0.75rem; + } + .llm-backend-info { display: flex; flex-direction: column; gap: 0.125rem; flex: 1; } + .llm-backend-name { font-weight: 500; color: hsl(var(--color-foreground)); } + .llm-backend-reason { color: hsl(var(--color-muted-foreground)); font-size: 0.6875rem; } + .llm-backend-status { white-space: nowrap; font-weight: 500; } + .llm-status-available { color: hsl(142 71% 45%); } + .llm-status-downloadable, .llm-status-downloading { color: hsl(var(--color-primary)); } + .llm-status-unavailable { color: hsl(var(--color-muted-foreground)); } + .btn-small { font-size: 0.75rem; padding: 0.25rem 0.625rem; } + .llm-section-title { + margin-top: 0.5rem; + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: hsl(var(--color-muted-foreground)); + font-weight: 600; + } + .llm-routing-toggle { display: flex; gap: 0.25rem; flex-wrap: wrap; } + .llm-cloud-toggle { + display: flex; + gap: 0.5rem; + align-items: flex-start; + font-size: 0.8125rem; + cursor: pointer; + margin-top: 0.5rem; + } + .llm-cloud-toggle input { margin-top: 0.25rem; } + .llm-cloud-label { font-weight: 500; color: hsl(var(--color-foreground)); display: block; } + .llm-cloud-desc { + display: block; + font-size: 0.6875rem; + color: hsl(var(--color-muted-foreground)); + margin-top: 0.125rem; + } + .llm-footnote { + margin-top: 0.5rem; + font-size: 0.6875rem; + color: hsl(var(--color-muted-foreground)); + } + .llm-footnote a { text-decoration: underline; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed335cb..2a03c81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,6 +79,9 @@ importers: apps/web: dependencies: + '@mana/browser-llm': + specifier: ^0.1.0-alpha.2 + version: 0.1.0-alpha.2 '@mana/event-kit': specifier: ^0.1.0 version: 0.1.0 @@ -1409,6 +1412,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mana/browser-llm@0.1.0-alpha.2': + resolution: {integrity: sha512-uWU46Cs4Slo9eCLFY3aKYN6JPMCLNhIOOrsJqxgH4Eg3orwpnwU5z8jj+dtPhZPuntDGMS53DsPul3vve4IE2Q==} + '@mana/event-kit@0.1.0': resolution: {integrity: sha512-iW6IE1MrFstwjbBwFljYD0UYVq8nvuvzi7+cgTbdWZsNReDYwpw7QtgliuNWY8XDr4YHSEK9QWcJTLiI2CJQNw==} @@ -5653,6 +5659,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mana/browser-llm@0.1.0-alpha.2': {} + '@mana/event-kit@0.1.0': dependencies: zod: 3.25.76