feat(web): Lokale KI via @mana/browser-llm — Account-Settings + Init
Some checks are pending
CI / validate (push) Waiting to run
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:
parent
0667e105e5
commit
c39c42472b
5 changed files with 272 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
92
apps/web/src/lib/services/localLLMService.svelte.ts
Normal file
92
apps/web/src/lib/services/localLLMService.svelte.ts
Normal 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.',
|
||||
},
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
8
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue