feat(mana/web): AI tier selector dropdown in PillNavigation

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-10 18:19:27 +02:00
parent 0f7ab60397
commit a9956c0009
3 changed files with 97 additions and 0 deletions

View file

@ -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<PillDropdownItem[]>([
// 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"

View file

@ -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 @@
</PillDropdown>
{/if}
<!-- AI Tier Selector -->
{#if showAiTierSelector && aiTierItems.length > 0}
<PillDropdown
items={aiTierItems}
direction={dropdownDirection}
label={currentAiTierLabel}
icon="cpu"
/>
{/if}
<!-- Theme Toggle (only show when not using theme variants dropdown) -->
{#if showThemeToggle && onToggleTheme && !showThemeVariants}
<button

View file

@ -167,6 +167,12 @@ export interface PillNavigationProps {
showLanguageSwitcher?: boolean;
/** Show theme toggle */
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 */
primaryColor?: string;
/** Elements to prepend before nav items (tab groups, dividers, nav items) */