From a9956c0009f99bf8fdbc80cfaa09e948f60f942f Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 10 Apr 2026 18:19:27 +0200 Subject: [PATCH] feat(mana/web): AI tier selector dropdown in PillNavigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quick-access dropdown in the bottom navigation bar for toggling LLM tiers without navigating to the full Settings page. Follows the same PillDropdown pattern as the existing theme variant selector. Three files changed: packages/shared-ui/src/navigation/types.ts Add showAiTierSelector, aiTierItems, currentAiTierLabel to PillNavigationProps. Same shape as the existing theme variant and language switcher props. packages/shared-ui/src/navigation/PillNavigation.svelte Destructure the three new props (defaults: false, [], 'KI'). Render a PillDropdown with icon="cpu" between the theme variant selector and the theme toggle button. apps/mana/apps/web/src/routes/(app)/+layout.svelte Import llmSettingsState, updateLlmSettings, tierLabel, type LlmTier from @mana/shared-llm. Import isLocalLlmSupported, getLocalLlmStatus, loadLocalLlm from @mana/local-llm. Build aiTierItems as a $derived array of PillDropdownItem: - Three tier toggles: Browser (Gemma 4), Server (Gemma 4), Cloud (Gemini). Each shows active checkmark when enabled. Clicking toggles the tier in/out of allowedTiers. Browser toggle hidden when WebGPU isn't available. - Browser model status line: "✓ Modell geladen" (disabled, green) or "Lade... X%" (disabled, progress) or "Modell laden (~500 MB)" (clickable, triggers loadLocalLlm). Only shown when browser tier is enabled. - Divider + "KI-Einstellungen" link to /settings for the full configuration (cloud consent, behavior toggles, etc.) Build currentAiTierLabel as privacy-sorted first-active-tier short name: "Browser" or "Server" or "Cloud" or "Aus". Wire all three to PillNavigation via showAiTierSelector={true} + {aiTierItems} + {currentAiTierLabel}. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/routes/(app)/+layout.svelte | 72 +++++++++++++++++++ .../src/navigation/PillNavigation.svelte | 19 +++++ packages/shared-ui/src/navigation/types.ts | 6 ++ 3 files changed, 97 insertions(+) diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index e42fd6837..5b477583d 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -39,6 +39,8 @@ import { linkLocalStore, linkMutations } from '@mana/shared-links'; import { manaStore } from '$lib/data/local-store'; import { startLlmQueue, stopLlmQueue } from '$lib/llm-queue'; + import { llmSettingsState, updateLlmSettings, tierLabel, type LlmTier } from '@mana/shared-llm'; + import { isLocalLlmSupported, getLocalLlmStatus, loadLocalLlm } from '@mana/local-llm'; import { startMemoroLlmWatcher, stopMemoroLlmWatcher, @@ -162,6 +164,73 @@ ); let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale)); + // ── AI Tier Selector (PillNav dropdown) ───────────────── + const webgpuSupported = isLocalLlmSupported(); + const localLlmStatus = getLocalLlmStatus(); + const llmSettings = $derived(llmSettingsState.current); + + function toggleAiTier(tier: LlmTier) { + const current = llmSettings.allowedTiers; + const next = current.includes(tier) + ? current.filter((t: LlmTier) => t !== tier) + : [...current, tier]; + updateLlmSettings({ allowedTiers: next }); + } + + const TIER_TOGGLE_LIST: Array<{ tier: LlmTier; shortLabel: string }> = [ + { tier: 'browser', shortLabel: 'Browser (Gemma 4)' }, + { tier: 'mana-server', shortLabel: 'Server (Gemma 4)' }, + { tier: 'cloud', shortLabel: 'Cloud (Gemini)' }, + ]; + + let aiTierItems = $derived([ + // Tier toggles + ...TIER_TOGGLE_LIST.filter((t) => t.tier !== 'browser' || webgpuSupported).map((t) => ({ + id: `ai-tier-${t.tier}`, + label: t.shortLabel, + active: llmSettings.allowedTiers.includes(t.tier), + onClick: () => toggleAiTier(t.tier), + })), + // Browser model status / load button + ...(llmSettings.allowedTiers.includes('browser') && webgpuSupported + ? [ + { + id: 'ai-browser-status', + label: + localLlmStatus.current.state === 'ready' + ? '✓ Modell geladen' + : localLlmStatus.current.state === 'downloading' + ? `Lade… ${((localLlmStatus.current as { progress: number }).progress * 100).toFixed(0)}%` + : 'Modell laden (~500 MB)', + disabled: localLlmStatus.current.state === 'ready', + onClick: + localLlmStatus.current.state !== 'ready' ? () => void loadLocalLlm() : undefined, + }, + ] + : []), + // Divider + settings link + { id: 'ai-divider', label: '', divider: true }, + { + id: 'ai-settings', + label: 'KI-Einstellungen', + icon: 'settings', + onClick: () => goto('/settings'), + }, + ]); + + let currentAiTierLabel = $derived.by(() => { + const active = llmSettings.allowedTiers; + if (active.length === 0) return 'Aus'; + // Show the first (privacy-sorted) tier's short name + const sorted = [...active].sort( + (a, b) => + TIER_TOGGLE_LIST.findIndex((t) => t.tier === a) - + TIER_TOGGLE_LIST.findIndex((t) => t.tier === b) + ); + const first = TIER_TOGGLE_LIST.find((t) => t.tier === sorted[0]); + return first ? first.shortLabel.split(' (')[0] : 'KI'; + }); + // ── User / Guest awareness ────────────────────────────── let userEmail = $derived( authStore.isAuthenticated ? authStore.user?.email || $_('nav.menu') : '' @@ -639,6 +708,9 @@ loginHref="/login" primaryColor="#6366f1" showAppSwitcher={true} + showAiTierSelector={true} + {aiTierItems} + {currentAiTierLabel} {appItems} {userEmail} settingsHref="/settings" diff --git a/packages/shared-ui/src/navigation/PillNavigation.svelte b/packages/shared-ui/src/navigation/PillNavigation.svelte index d1ed6e4c0..05faac2c6 100644 --- a/packages/shared-ui/src/navigation/PillNavigation.svelte +++ b/packages/shared-ui/src/navigation/PillNavigation.svelte @@ -241,6 +241,12 @@ showLanguageSwitcher?: boolean; /** Show theme toggle (standalone button, hidden if showThemeVariants is true) */ showThemeToggle?: boolean; + /** Show AI tier selector dropdown */ + showAiTierSelector?: boolean; + /** AI tier dropdown items (each representing a toggleable tier) */ + aiTierItems?: PillDropdownItem[]; + /** Current AI tier label, e.g. "Browser" or "Server" */ + currentAiTierLabel?: string; /** Primary color for active state (CSS custom property or hex) */ primaryColor?: string; /** Elements to prepend before nav items (tab groups, dividers, nav items) */ @@ -333,6 +339,9 @@ themeVariantItems = [], currentThemeVariantLabel = 'Theme', showThemeVariants = false, + showAiTierSelector = false, + aiTierItems = [], + currentAiTierLabel = 'KI', themeMode = 'system', onThemeModeChange, appItems = [], @@ -651,6 +660,16 @@ {/if} + + {#if showAiTierSelector && aiTierItems.length > 0} + + {/if} + {#if showThemeToggle && onToggleTheme && !showThemeVariants}