feat(web): Lokale KI via @mana/browser-llm — Account-Settings + Init
Some checks are pending
CI / validate (push) Waiting to run

Phase B-6a (Wordeck) aus mana/docs/MANA_BROWSER_LLM.md. Pattern aus
memoro/pageta übernommen.

- apps/web/package.json: @mana/browser-llm@^0.1.0-alpha.2
- lib/services/localLLMService.svelte.ts: $state-runes-Settings
  (routingMode + allowServerLLM, localStorage-persistent),
  initLocalLLM() konfiguriert ManaLLM mit wordeck-API + devUser.token
- routes/+layout.svelte: initLocalLLM() in onMount
- routes/account/+page.svelte: neue Lokale-KI-Karte im
  CardSurface-Stack mit Capability-Anzeige + Routing-Picker +
  Cloud-Opt-In, eigene Styles im <style>-Block

Server-Backend bleibt unavailable bis User „Cloud erlauben" aktiviert.
AI-Card-Generation kann damit lokal (gratis) oder via Cloud (Mana)
laufen — User entscheidet (Lokal-First per MISSION.md §6).
This commit is contained in:
Till JS 2026-05-22 19:55:12 +02:00
parent 0667e105e5
commit c39c42472b
5 changed files with 272 additions and 0 deletions

View file

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

View file

@ -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<CapabilityReport[]> {
if (capabilityCache && !force) return capabilityCache;
const result = await probeAllBackends();
capabilityCache = result;
return result;
}
export const BACKEND_LABELS: Record<LLMBackendID, { name: string; subtitle: string }> = {
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.',
},
};

View file

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

View file

@ -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<CapabilityReport[]>([]);
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 @@
</CardSurface>
</li>
<!-- Lokale-KI-Karte -->
<li class="stack-wrap">
{#each llmLayers as layer, i (i)}
<div class="layer" style:transform="translate({layer.dx}px,{layer.dy}px) rotate({layer.tilt}deg)" aria-hidden="true"></div>
{/each}
<CardSurface size="md" colorAccent="#9D00FF">
<div class="card-inner llm-card">
<div class="card-body">
<p class="card-title">Lokale KI</p>
<p class="card-desc">
Wordeck kann Karten lokal aus Text generieren — ohne Server, ohne Mana.
Cloud-Pfad bleibt verfügbar für bessere Qualität.
</p>
<div class="llm-backends">
{#each llmBackends as report (report.id)}
<div class="llm-backend-row">
<div class="llm-backend-info">
<span class="llm-backend-name">{BACKEND_LABELS[report.id]?.name ?? report.id}</span>
{#if report.availability.reason}
<span class="llm-backend-reason">{report.availability.reason}</span>
{/if}
</div>
<span class="llm-backend-status llm-status-{report.availability.status}">
{statusLabel(report.availability.status)}
</span>
</div>
{/each}
</div>
<button
type="button"
class="btn-ghost btn-small"
onclick={refreshLLMBackends}
disabled={llmProbing}
>
{llmProbing ? 'Prüfe…' : 'Neu prüfen'}
</button>
<div class="llm-section-title">Routing</div>
<div class="llm-routing-toggle">
{#each [['auto', 'Auto'], ['local', 'Nur lokal'], ['server', 'Direkt Cloud']] as [id, label] (id)}
<button
type="button"
class="lang-btn"
class:active={llmSettings.routingMode === id}
onclick={() => llmSettings.setRoutingMode(id as 'auto' | 'local' | 'server')}
>{label}</button>
{/each}
</div>
<label class="llm-cloud-toggle">
<input
type="checkbox"
checked={llmSettings.allowServerLLM}
onchange={(e) => llmSettings.setAllowServerLLM(e.currentTarget.checked)}
/>
<span>
<span class="llm-cloud-label">Cloud-Pfad erlauben</span>
<span class="llm-cloud-desc">
Bei lokal nicht möglich darf Wordeck Text an die wordeck-API senden.
Verbraucht Mana. Tier ≥ Beta nötig.
</span>
</span>
</label>
<p class="llm-footnote">
Lokal heißt gratis (siehe
<a href="https://mana-ev.ch/werte/mana" target="_blank" rel="noopener">Mana-Versprechen #6</a>).
Was im Browser rechnet, kostet kein Mana.
</p>
</div>
</div>
</CardSurface>
</li>
<!-- Danger-Karte -->
<li class="stack-wrap">
{#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; }
</style>

8
pnpm-lock.yaml generated
View file

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