fix(llm): user-friendly error messages when no LLM tier available

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-17 14:46:39 +02:00
parent c642e1be78
commit 2b96953ad1
3 changed files with 72 additions and 4 deletions

View file

@ -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}`;
}
}

View file

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

View file

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