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). */