From 2b96953ad1c3c9fda71260a8b30641f2a2abb175 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 17 Apr 2026 14:46:39 +0200 Subject: [PATCH] fix(llm): user-friendly error messages when no LLM tier available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track skip reasons per tier in the orchestrator (no-consent, no-backend, not-available, not-ready, runtime-error) and expose them via NoTierAvailableError.getUserMessage() with actionable German text pointing the user to the right settings page. Before: "No tier could run task 'companion.chat' (attempted: cloud)" After: "Cloud (Gemini): Cloud-Einwilligung fehlt. Aktiviere sie unter Einstellungen → KI." Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/modules/companion/engine.ts | 7 ++- packages/shared-llm/src/errors.ts | 57 ++++++++++++++++++- packages/shared-llm/src/orchestrator.ts | 12 +++- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/apps/mana/apps/web/src/lib/modules/companion/engine.ts b/apps/mana/apps/web/src/lib/modules/companion/engine.ts index a153d7d1e..459133cdb 100644 --- a/apps/mana/apps/web/src/lib/modules/companion/engine.ts +++ b/apps/mana/apps/web/src/lib/modules/companion/engine.ts @@ -11,7 +11,7 @@ * routes through text-completion). */ -import { llmOrchestrator } from '@mana/shared-llm'; +import { llmOrchestrator, NoTierAvailableError } from '@mana/shared-llm'; import { isLocalLlmSupported, getLocalLlmStatus, loadLocalLlm } from '@mana/local-llm'; import { companionChatTask } from '$lib/llm-tasks/companion-chat'; import { generateContextDocument } from '$lib/data/projections/context-document'; @@ -54,8 +54,11 @@ async function callLlm(messages: LlmMessage[], onToken?: (token: string) => void }); return result.value.content; } catch (err) { + if (err instanceof NoTierAvailableError) { + return err.getUserMessage(); + } const msg = err instanceof Error ? err.message : String(err); - return `LLM nicht verfuegbar: ${msg}`; + return `LLM nicht verfügbar: ${msg}`; } } diff --git a/packages/shared-llm/src/errors.ts b/packages/shared-llm/src/errors.ts index dd0520cd4..91c52c6a8 100644 --- a/packages/shared-llm/src/errors.ts +++ b/packages/shared-llm/src/errors.ts @@ -13,15 +13,70 @@ export class LlmError extends Error { } } +/** Why a specific tier was skipped. */ +export type TierSkipReason = + | 'no-consent' + | 'no-backend' + | 'not-available' + | 'not-ready' + | 'no-tiers-configured' + | 'runtime-error'; + +export interface SkippedTier { + tier: LlmTier; + reason: TierSkipReason; +} + /** No tier from the user's preference list was able to run the task. */ export class NoTierAvailableError extends LlmError { constructor( public readonly taskName: string, - public readonly attempted: LlmTier[] + public readonly attempted: LlmTier[], + public readonly skipped: SkippedTier[] = [] ) { super(`No tier could run task '${taskName}' (attempted: ${attempted.join(', ') || 'none'})`); this.name = 'NoTierAvailableError'; } + + /** User-friendly German explanation of what went wrong. */ + getUserMessage(): string { + if (this.skipped.length === 0 && this.attempted.length === 0) { + return 'Kein KI-Modell konfiguriert. Aktiviere ein Modell unter Einstellungen → KI.'; + } + + const reasons = this.skipped.map((s) => { + switch (s.reason) { + case 'no-consent': + return `**${tierLabel(s.tier)}**: Cloud-Einwilligung fehlt. Aktiviere sie unter Einstellungen → KI.`; + case 'no-backend': + return `**${tierLabel(s.tier)}**: Backend nicht registriert.`; + case 'not-available': + return `**${tierLabel(s.tier)}**: Nicht verfügbar (Service läuft nicht oder WebGPU nicht unterstützt).`; + case 'not-ready': + return `**${tierLabel(s.tier)}**: Modell noch nicht geladen.`; + case 'runtime-error': + return `**${tierLabel(s.tier)}**: Fehler bei der Ausführung.`; + case 'no-tiers-configured': + return 'Kein KI-Modell konfiguriert.'; + } + }); + return reasons.join('\n'); + } +} + +function tierLabel(tier: LlmTier): string { + switch (tier) { + case 'browser': + return 'Browser (lokal)'; + case 'mana-server': + return 'Mana Server'; + case 'cloud': + return 'Cloud (Gemini)'; + case 'byok': + return 'Eigener API-Key'; + default: + return String(tier); + } } /** The user's chosen tier is below the task's declared minimum tier. */ diff --git a/packages/shared-llm/src/orchestrator.ts b/packages/shared-llm/src/orchestrator.ts index 505c1b139..aae95ee75 100644 --- a/packages/shared-llm/src/orchestrator.ts +++ b/packages/shared-llm/src/orchestrator.ts @@ -41,6 +41,7 @@ import { NoTierAvailableError, ProviderBlockedError, TierTooLowError, + type SkippedTier, } from './errors'; import type { LlmTask } from './task'; import type { LlmTier } from './tiers'; @@ -146,6 +147,7 @@ export class LlmOrchestrator { const orderedTiers = override ? [override].filter((t) => candidates.includes(t)) : candidates; // Rule 4-5: try the first runnable tier + const skipped: SkippedTier[] = []; for (const tier of orderedTiers) { if (tier === 'none') { if (task.runRules) { @@ -164,21 +166,25 @@ export class LlmOrchestrator { // Cloud-consent gate if (tier === 'cloud' && !this.settings.cloudConsentGiven) { attempted.push('cloud'); + skipped.push({ tier: 'cloud', reason: 'no-consent' }); continue; } const backend = this.backendsByTier.get(tier); if (!backend) { attempted.push(tier); + skipped.push({ tier, reason: 'no-backend' }); continue; } if (!backend.isAvailable()) { attempted.push(tier); + skipped.push({ tier, reason: 'not-available' }); continue; } const ready = await backend.isReady(); if (!ready) { attempted.push(tier); + skipped.push({ tier, reason: 'not-ready' }); continue; } @@ -226,11 +232,15 @@ export class LlmOrchestrator { throw err; } // Unknown error — try the next tier in the list + skipped.push({ tier, reason: 'runtime-error' }); continue; } } - throw new NoTierAvailableError(task.name, attempted); + if (attempted.length === 0) { + skipped.push({ tier: 'none' as LlmTier, reason: 'no-tiers-configured' }); + } + throw new NoTierAvailableError(task.name, attempted, skipped); } /** Highest tier in the user's allowedTiers list (by rank). */