diff --git a/assets/fonts/Inter-Bold.ttf b/assets/fonts/Inter-Bold.ttf new file mode 100644 index 0000000..9fb9b75 Binary files /dev/null and b/assets/fonts/Inter-Bold.ttf differ diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf new file mode 100644 index 0000000..458cd06 Binary files /dev/null and b/assets/fonts/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/assets/fonts/Inter-SemiBold.ttf differ diff --git a/assets/fonts/JetBrainsMono-Medium.ttf b/assets/fonts/JetBrainsMono-Medium.ttf new file mode 100644 index 0000000..9767115 Binary files /dev/null and b/assets/fonts/JetBrainsMono-Medium.ttf differ diff --git a/assets/fonts/JetBrainsMono-Regular.ttf b/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..dff66cc Binary files /dev/null and b/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 1431e66..e2b1f22 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -11,6 +11,7 @@ import 'package:fai_client_sdk/fai_client_sdk.dart'; import 'package:flutter/widgets.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../l10n/app_localizations.dart'; import 'flow_output.dart'; import 'hub_auth_token.dart'; export 'flow_output.dart'; @@ -1306,24 +1307,25 @@ class AskAiResult { /// One-line fix hint per error kind, mirroring /// `docs/architecture/system-ai.md`. Empty on success. - String get fixHint { + /// Localized — pass the active [AppLocalizations]. + String fixHint(AppLocalizations l) { switch (errorKind) { case '': return ''; case 'disabled': - return 'Configure System AI in Settings (Cmd+,) → System AI panel.'; + return l.sysAiFixDisabled; case 'env_missing': - return 'The API-key env var is empty. `export =` in your shell, then `fai daemon restart`.'; + return l.sysAiFixEnvMissing; case 'network': - return 'Hub cannot reach the configured endpoint. Is your provider running?'; + return l.sysAiFixNetwork; case 'http': - return 'Provider returned a non-2xx status. Verify endpoint, model name, and API key.'; + return l.sysAiFixHttp; case 'parse': - return 'Provider returned an unexpected response shape. Non-OpenAI providers may need a compatibility proxy.'; + return l.sysAiFixParse; case 'empty_response': - return 'Provider answered with no content. Try a different model or rephrase the prompt.'; + return l.sysAiFixEmptyResponse; default: - return 'Unknown failure kind ($errorKind). See system-ai.md.'; + return l.sysAiFixUnknown(errorKind); } } } diff --git a/lib/data/system_actions.dart b/lib/data/system_actions.dart index fc48ce6..2884edd 100644 --- a/lib/data/system_actions.dart +++ b/lib/data/system_actions.dart @@ -18,9 +18,61 @@ import 'dart:io'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Sentinel returned in `_runFai(...).stderr` when no `fai` +/// binary could be located. UI layers should not display this +/// verbatim — they check [SystemActions.faiBinaryExists] first +/// and render a localized, actionable message instead. +const String kFaiBinaryNotFound = 'fai-binary-not-found'; + +/// Getting-started / install guide for the F∆I platform. Opened +/// in the OS browser as the fallback affordance when no `fai` +/// binary can be located on the machine. +const String kFaiInstallDocsUrl = + 'https://git.flemming.ai/fai/platform#installation'; + class SystemActions { SystemActions._(); + /// SharedPreferences key for an operator-chosen `fai` binary + /// path (set via the file picker when auto-detection fails). + static const String _kFaiBinaryPrefKey = 'system.fai_binary_path'; + + /// In-memory cache of the operator override, loaded once at + /// startup by [loadFaiBinaryOverride]. Null = no override. + static String? _faiBinaryOverride; + + /// Restore the persisted `fai` binary override (if any) so it + /// is available before the first daemon action. Call once at + /// app start, alongside the other persisted-state loaders. + static Future loadFaiBinaryOverride() async { + final prefs = await SharedPreferences.getInstance(); + final p = prefs.getString(_kFaiBinaryPrefKey); + if (p != null && p.isNotEmpty && File(p).existsSync()) { + _faiBinaryOverride = p; + } + } + + /// Persist an operator-chosen `fai` binary path. Used by the + /// "Locate `fai` binary…" affordance when auto-detection on + /// PATH and the canonical install dir both come up empty. + /// Returns false (and persists nothing) when [path] is empty + /// or does not point at an existing file. + static Future setFaiBinaryPath(String path) async { + if (path.isEmpty || !File(path).existsSync()) return false; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kFaiBinaryPrefKey, path); + _faiBinaryOverride = path; + return true; + } + + /// True when a `fai` binary can be located (override, PATH, or + /// the canonical install dir). UI uses this to decide between + /// the normal daemon-control affordances and the "locate the + /// binary / read the install guide" recovery path. + static bool faiBinaryExists() => _faiExecutable() != null; + /// Ask the OS to open [path] in the default handler. On macOS /// this opens text files in TextEdit, configs in the registered /// editor, etc. Returns true on a clean spawn (process exited @@ -149,13 +201,11 @@ class SystemActions { ) async { final exe = _faiExecutable(); if (exe == null) { - return ( - ok: false, - stdout: '', - stderr: - 'Could not locate the `fai` binary. Install via the ' - 'platform installer or set FAI_BIN to its full path.', - ); + // Sentinel, not a user-facing string. Callers detect this + // via `faiBinaryExists()` and render a localized, + // actionable recovery message (locate binary / install + // guide) instead of CLI jargon like "set FAI_BIN". + return (ok: false, stdout: '', stderr: kFaiBinaryNotFound); } try { final r = await Process.run(exe, args); @@ -175,9 +225,17 @@ class SystemActions { return (executable: 'xdg-open', args: const []); } - /// Locate the `fai` binary. Order: $FAI_BIN, PATH, fallback to - /// the canonical install location under the user's home dir. + /// Locate the `fai` binary. Order: operator override (set via + /// the file picker), $FAI_BIN, PATH, fallback to the canonical + /// install location under the user's home dir. static String? _faiExecutable() { + final override = _faiBinaryOverride; + if (override != null && + override.isNotEmpty && + File(override).existsSync()) { + return override; + } + final fromEnv = Platform.environment['FAI_BIN']; if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) { return fromEnv; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 5708e53..68625fb 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1520,5 +1520,79 @@ "placeholders": { "error": {"type": "String"} } - } + }, + "welcomeHubDownTitle": "Der Hub läuft noch nicht", + "welcomeHubDownBody": "Studio spricht mit einem lokalen Hub — der Engine, die Module lädt und Flows ausführt. Sie ist gerade nicht erreichbar. Starte sie, um loszulegen.", + "welcomeHubDownStart": "Hub starten", + "welcomeHubDownStarting": "Wird gestartet …", + "welcomeHubDownDocsLink": "Klappt der Start nicht? Zur Erste-Schritte-Anleitung", + "welcomeHubDownEndpoint": "Endpunkt: {endpoint}", + "@welcomeHubDownEndpoint": { + "placeholders": { + "endpoint": {"type": "String"} + } + }, + "daemonStartRequested": "Start des Daemons angefordert. Verbinde neu …", + "daemonStartFailed": "Daemon konnte nicht gestartet werden: {detail}", + "@daemonStartFailed": { + "placeholders": { + "detail": {"type": "String"} + } + }, + "faiBinaryNotFound": "Das Programm `fai` wurde auf diesem Rechner nicht gefunden.", + "faiBinaryNotFoundHint": "Installiere die F∆I-Plattform oder verweise Studio auf eine vorhandene `fai`-Datei.", + "faiBinaryLocateButton": "`fai`-Programm suchen …", + "faiBinaryInstallDocsButton": "Installationsanleitung öffnen", + "faiBinaryPickerTitle": "`fai`-Programm auswählen", + "faiBinarySetOk": "Verwende `fai` unter {path}", + "@faiBinarySetOk": { + "placeholders": { + "path": {"type": "String"} + } + }, + "sysAiFixParse": "Der Anbieter hat in einem unerwarteten Format geantwortet. Anbieter, die nicht OpenAI-kompatibel sind, brauchen ggf. einen Übersetzungs-Proxy.", + "sysAiFixUnknown": "Unerwarteter Fehler ({kind}). Siehe System-KI-Dokumentation.", + "@sysAiFixUnknown": { + "placeholders": { + "kind": {"type": "String"} + } + }, + "sysAiFixDisabled": "Richte die System-KI in den Einstellungen (Cmd+,) → Bereich System-KI ein.", + "sysAiFixEnvMissing": "Die Umgebungsvariable für den API-Schlüssel ist leer. Setze sie in deiner Shell und starte den Daemon neu.", + "sysAiFixNetwork": "Der Hub erreicht den konfigurierten Endpunkt nicht. Läuft dein Anbieter?", + "sysAiFixHttp": "Der Anbieter hat einen Status außerhalb 2xx geliefert. Prüfe Endpunkt, Modellname und API-Schlüssel.", + "sysAiFixEmptyResponse": "Der Anbieter hat keinen Inhalt geliefert. Probiere ein anderes Modell oder formuliere den Prompt um.", + "channelSwitchOk": "Aktiver Kanal auf \"{name}\" umgeschaltet. Daemon neu gestartet.", + "@channelSwitchOk": { + "placeholders": { + "name": {"type": "String"} + } + }, + "channelSwitchFailed": "Kanalwechsel fehlgeschlagen: {detail}", + "@channelSwitchFailed": { + "placeholders": { + "detail": {"type": "String"} + } + }, + "providerDescOllama": "Lokales `ollama serve`. Die Modelle bleiben auf deinem Rechner. Kein API-Schlüssel nötig.", + "providerDescOpenai": "Gehostete OpenAI-API. Erfordert einen API-Schlüssel. Daten verlassen deinen Rechner.", + "providerDescLmstudio": "Lokaler LM-Studio-Server. Die Modelle bleiben auf deinem Rechner. Kein API-Schlüssel nötig.", + "providerDescVllm": "Selbst gehostetes vLLM oder ein anderer OpenAI-kompatibler Server. Endpunkt erforderlich.", + "providerDescCustom": "Alles andere, das die OpenAI-kompatible Chat-Completions-Schnittstelle spricht (LiteLLM-Proxy, interner Spiegel, …).", + "welcomeDocLoadFailedBody": "Die mitgelieferte Doku \"{slug}\" ({locale}) konnte nicht geladen werden.\nVersucht: {attempted}\nUrsache: {underlying}\nMeist ist der installierte Studio-Build älter als das Doku-Bundle. Baue Studio neu oder öffne die Online-Kopie.", + "@welcomeDocLoadFailedBody": { + "placeholders": { + "slug": {"type": "String"}, + "locale": {"type": "String"}, + "attempted": {"type": "String"}, + "underlying": {"type": "String"} + } + }, + "hubUnreachableBanner": "{endpoint} nicht erreichbar — Einstellungen öffnen", + "@hubUnreachableBanner": { + "placeholders": { + "endpoint": {"type": "String"} + } + }, + "hubUnreachableOpenSettings": "Einstellungen öffnen" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2e8c83e..af7293d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1523,5 +1523,79 @@ "placeholders": { "error": {"type": "String"} } - } + }, + "welcomeHubDownTitle": "The hub isn't running yet", + "welcomeHubDownBody": "Studio talks to a local hub — the engine that loads modules and runs flows. It isn't reachable right now. Start it to begin.", + "welcomeHubDownStart": "Start hub", + "welcomeHubDownStarting": "Starting…", + "welcomeHubDownDocsLink": "Can't start it? Read the getting-started guide", + "welcomeHubDownEndpoint": "Endpoint: {endpoint}", + "@welcomeHubDownEndpoint": { + "placeholders": { + "endpoint": {"type": "String"} + } + }, + "daemonStartRequested": "Daemon start requested. Reconnecting…", + "daemonStartFailed": "Could not start daemon: {detail}", + "@daemonStartFailed": { + "placeholders": { + "detail": {"type": "String"} + } + }, + "faiBinaryNotFound": "Could not find the `fai` program on this machine.", + "faiBinaryNotFoundHint": "Install the F∆I platform, or point Studio at an existing `fai` binary.", + "faiBinaryLocateButton": "Locate `fai` binary…", + "faiBinaryInstallDocsButton": "Open install guide", + "faiBinaryPickerTitle": "Select the `fai` binary", + "faiBinarySetOk": "Using `fai` binary at {path}", + "@faiBinarySetOk": { + "placeholders": { + "path": {"type": "String"} + } + }, + "sysAiFixParse": "The provider sent a response in an unexpected format. Providers that aren't OpenAI-compatible may need a translation proxy.", + "sysAiFixUnknown": "Unexpected failure ({kind}). See the System AI documentation.", + "@sysAiFixUnknown": { + "placeholders": { + "kind": {"type": "String"} + } + }, + "sysAiFixDisabled": "Configure System AI in Settings (Cmd+,) → System AI panel.", + "sysAiFixEnvMissing": "The API-key environment variable is empty. Set it in your shell, then restart the daemon.", + "sysAiFixNetwork": "The hub can't reach the configured endpoint. Is your provider running?", + "sysAiFixHttp": "The provider returned a non-2xx status. Verify the endpoint, model name, and API key.", + "sysAiFixEmptyResponse": "The provider answered with no content. Try a different model or rephrase the prompt.", + "channelSwitchOk": "Switched active channel to \"{name}\". Daemon restarted.", + "@channelSwitchOk": { + "placeholders": { + "name": {"type": "String"} + } + }, + "channelSwitchFailed": "Channel switch failed: {detail}", + "@channelSwitchFailed": { + "placeholders": { + "detail": {"type": "String"} + } + }, + "providerDescOllama": "Local `ollama serve`. Models stay on your machine. No API key needed.", + "providerDescOpenai": "OpenAI hosted API. Requires an API key. Data leaves your machine.", + "providerDescLmstudio": "Local LM Studio server. Models stay on your machine. No API key needed.", + "providerDescVllm": "Self-hosted vLLM or any other OpenAI-compatible server. Endpoint required.", + "providerDescCustom": "Anything else that speaks the OpenAI-compatible chat-completions wire (LiteLLM proxy, internal mirror, …).", + "welcomeDocLoadFailedBody": "Could not load the bundled doc \"{slug}\" ({locale}).\nTried: {attempted}\nUnderlying: {underlying}\nThis usually means the Studio build on disk predates the doc bundle. Rebuild Studio or open the online copy.", + "@welcomeDocLoadFailedBody": { + "placeholders": { + "slug": {"type": "String"}, + "locale": {"type": "String"}, + "attempted": {"type": "String"}, + "underlying": {"type": "String"} + } + }, + "hubUnreachableBanner": "Can't reach {endpoint} — open Settings", + "@hubUnreachableBanner": { + "placeholders": { + "endpoint": {"type": "String"} + } + }, + "hubUnreachableOpenSettings": "Open Settings" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index bd6684b..257ff77 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4279,6 +4279,197 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Install failed: {error}'** String installFailed(String error); + + /// No description provided for @welcomeHubDownTitle. + /// + /// In en, this message translates to: + /// **'The hub isn\'t running yet'** + String get welcomeHubDownTitle; + + /// No description provided for @welcomeHubDownBody. + /// + /// In en, this message translates to: + /// **'Studio talks to a local hub — the engine that loads modules and runs flows. It isn\'t reachable right now. Start it to begin.'** + String get welcomeHubDownBody; + + /// No description provided for @welcomeHubDownStart. + /// + /// In en, this message translates to: + /// **'Start hub'** + String get welcomeHubDownStart; + + /// No description provided for @welcomeHubDownStarting. + /// + /// In en, this message translates to: + /// **'Starting…'** + String get welcomeHubDownStarting; + + /// No description provided for @welcomeHubDownDocsLink. + /// + /// In en, this message translates to: + /// **'Can\'t start it? Read the getting-started guide'** + String get welcomeHubDownDocsLink; + + /// No description provided for @welcomeHubDownEndpoint. + /// + /// In en, this message translates to: + /// **'Endpoint: {endpoint}'** + String welcomeHubDownEndpoint(String endpoint); + + /// No description provided for @daemonStartRequested. + /// + /// In en, this message translates to: + /// **'Daemon start requested. Reconnecting…'** + String get daemonStartRequested; + + /// No description provided for @daemonStartFailed. + /// + /// In en, this message translates to: + /// **'Could not start daemon: {detail}'** + String daemonStartFailed(String detail); + + /// No description provided for @faiBinaryNotFound. + /// + /// In en, this message translates to: + /// **'Could not find the `fai` program on this machine.'** + String get faiBinaryNotFound; + + /// No description provided for @faiBinaryNotFoundHint. + /// + /// In en, this message translates to: + /// **'Install the F∆I platform, or point Studio at an existing `fai` binary.'** + String get faiBinaryNotFoundHint; + + /// No description provided for @faiBinaryLocateButton. + /// + /// In en, this message translates to: + /// **'Locate `fai` binary…'** + String get faiBinaryLocateButton; + + /// No description provided for @faiBinaryInstallDocsButton. + /// + /// In en, this message translates to: + /// **'Open install guide'** + String get faiBinaryInstallDocsButton; + + /// No description provided for @faiBinaryPickerTitle. + /// + /// In en, this message translates to: + /// **'Select the `fai` binary'** + String get faiBinaryPickerTitle; + + /// No description provided for @faiBinarySetOk. + /// + /// In en, this message translates to: + /// **'Using `fai` binary at {path}'** + String faiBinarySetOk(String path); + + /// No description provided for @sysAiFixParse. + /// + /// In en, this message translates to: + /// **'The provider sent a response in an unexpected format. Providers that aren\'t OpenAI-compatible may need a translation proxy.'** + String get sysAiFixParse; + + /// No description provided for @sysAiFixUnknown. + /// + /// In en, this message translates to: + /// **'Unexpected failure ({kind}). See the System AI documentation.'** + String sysAiFixUnknown(String kind); + + /// No description provided for @sysAiFixDisabled. + /// + /// In en, this message translates to: + /// **'Configure System AI in Settings (Cmd+,) → System AI panel.'** + String get sysAiFixDisabled; + + /// No description provided for @sysAiFixEnvMissing. + /// + /// In en, this message translates to: + /// **'The API-key environment variable is empty. Set it in your shell, then restart the daemon.'** + String get sysAiFixEnvMissing; + + /// No description provided for @sysAiFixNetwork. + /// + /// In en, this message translates to: + /// **'The hub can\'t reach the configured endpoint. Is your provider running?'** + String get sysAiFixNetwork; + + /// No description provided for @sysAiFixHttp. + /// + /// In en, this message translates to: + /// **'The provider returned a non-2xx status. Verify the endpoint, model name, and API key.'** + String get sysAiFixHttp; + + /// No description provided for @sysAiFixEmptyResponse. + /// + /// In en, this message translates to: + /// **'The provider answered with no content. Try a different model or rephrase the prompt.'** + String get sysAiFixEmptyResponse; + + /// No description provided for @channelSwitchOk. + /// + /// In en, this message translates to: + /// **'Switched active channel to \"{name}\". Daemon restarted.'** + String channelSwitchOk(String name); + + /// No description provided for @channelSwitchFailed. + /// + /// In en, this message translates to: + /// **'Channel switch failed: {detail}'** + String channelSwitchFailed(String detail); + + /// No description provided for @providerDescOllama. + /// + /// In en, this message translates to: + /// **'Local `ollama serve`. Models stay on your machine. No API key needed.'** + String get providerDescOllama; + + /// No description provided for @providerDescOpenai. + /// + /// In en, this message translates to: + /// **'OpenAI hosted API. Requires an API key. Data leaves your machine.'** + String get providerDescOpenai; + + /// No description provided for @providerDescLmstudio. + /// + /// In en, this message translates to: + /// **'Local LM Studio server. Models stay on your machine. No API key needed.'** + String get providerDescLmstudio; + + /// No description provided for @providerDescVllm. + /// + /// In en, this message translates to: + /// **'Self-hosted vLLM or any other OpenAI-compatible server. Endpoint required.'** + String get providerDescVllm; + + /// No description provided for @providerDescCustom. + /// + /// In en, this message translates to: + /// **'Anything else that speaks the OpenAI-compatible chat-completions wire (LiteLLM proxy, internal mirror, …).'** + String get providerDescCustom; + + /// No description provided for @welcomeDocLoadFailedBody. + /// + /// In en, this message translates to: + /// **'Could not load the bundled doc \"{slug}\" ({locale}).\nTried: {attempted}\nUnderlying: {underlying}\nThis usually means the Studio build on disk predates the doc bundle. Rebuild Studio or open the online copy.'** + String welcomeDocLoadFailedBody( + String slug, + String locale, + String attempted, + String underlying, + ); + + /// No description provided for @hubUnreachableBanner. + /// + /// In en, this message translates to: + /// **'Can\'t reach {endpoint} — open Settings'** + String hubUnreachableBanner(String endpoint); + + /// No description provided for @hubUnreachableOpenSettings. + /// + /// In en, this message translates to: + /// **'Open Settings'** + String get hubUnreachableOpenSettings; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 6d55a0c..cef189b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2502,4 +2502,134 @@ class AppLocalizationsDe extends AppLocalizations { String installFailed(String error) { return 'Installation fehlgeschlagen: $error'; } + + @override + String get welcomeHubDownTitle => 'Der Hub läuft noch nicht'; + + @override + String get welcomeHubDownBody => + 'Studio spricht mit einem lokalen Hub — der Engine, die Module lädt und Flows ausführt. Sie ist gerade nicht erreichbar. Starte sie, um loszulegen.'; + + @override + String get welcomeHubDownStart => 'Hub starten'; + + @override + String get welcomeHubDownStarting => 'Wird gestartet …'; + + @override + String get welcomeHubDownDocsLink => + 'Klappt der Start nicht? Zur Erste-Schritte-Anleitung'; + + @override + String welcomeHubDownEndpoint(String endpoint) { + return 'Endpunkt: $endpoint'; + } + + @override + String get daemonStartRequested => + 'Start des Daemons angefordert. Verbinde neu …'; + + @override + String daemonStartFailed(String detail) { + return 'Daemon konnte nicht gestartet werden: $detail'; + } + + @override + String get faiBinaryNotFound => + 'Das Programm `fai` wurde auf diesem Rechner nicht gefunden.'; + + @override + String get faiBinaryNotFoundHint => + 'Installiere die F∆I-Plattform oder verweise Studio auf eine vorhandene `fai`-Datei.'; + + @override + String get faiBinaryLocateButton => '`fai`-Programm suchen …'; + + @override + String get faiBinaryInstallDocsButton => 'Installationsanleitung öffnen'; + + @override + String get faiBinaryPickerTitle => '`fai`-Programm auswählen'; + + @override + String faiBinarySetOk(String path) { + return 'Verwende `fai` unter $path'; + } + + @override + String get sysAiFixParse => + 'Der Anbieter hat in einem unerwarteten Format geantwortet. Anbieter, die nicht OpenAI-kompatibel sind, brauchen ggf. einen Übersetzungs-Proxy.'; + + @override + String sysAiFixUnknown(String kind) { + return 'Unerwarteter Fehler ($kind). Siehe System-KI-Dokumentation.'; + } + + @override + String get sysAiFixDisabled => + 'Richte die System-KI in den Einstellungen (Cmd+,) → Bereich System-KI ein.'; + + @override + String get sysAiFixEnvMissing => + 'Die Umgebungsvariable für den API-Schlüssel ist leer. Setze sie in deiner Shell und starte den Daemon neu.'; + + @override + String get sysAiFixNetwork => + 'Der Hub erreicht den konfigurierten Endpunkt nicht. Läuft dein Anbieter?'; + + @override + String get sysAiFixHttp => + 'Der Anbieter hat einen Status außerhalb 2xx geliefert. Prüfe Endpunkt, Modellname und API-Schlüssel.'; + + @override + String get sysAiFixEmptyResponse => + 'Der Anbieter hat keinen Inhalt geliefert. Probiere ein anderes Modell oder formuliere den Prompt um.'; + + @override + String channelSwitchOk(String name) { + return 'Aktiver Kanal auf \"$name\" umgeschaltet. Daemon neu gestartet.'; + } + + @override + String channelSwitchFailed(String detail) { + return 'Kanalwechsel fehlgeschlagen: $detail'; + } + + @override + String get providerDescOllama => + 'Lokales `ollama serve`. Die Modelle bleiben auf deinem Rechner. Kein API-Schlüssel nötig.'; + + @override + String get providerDescOpenai => + 'Gehostete OpenAI-API. Erfordert einen API-Schlüssel. Daten verlassen deinen Rechner.'; + + @override + String get providerDescLmstudio => + 'Lokaler LM-Studio-Server. Die Modelle bleiben auf deinem Rechner. Kein API-Schlüssel nötig.'; + + @override + String get providerDescVllm => + 'Selbst gehostetes vLLM oder ein anderer OpenAI-kompatibler Server. Endpunkt erforderlich.'; + + @override + String get providerDescCustom => + 'Alles andere, das die OpenAI-kompatible Chat-Completions-Schnittstelle spricht (LiteLLM-Proxy, interner Spiegel, …).'; + + @override + String welcomeDocLoadFailedBody( + String slug, + String locale, + String attempted, + String underlying, + ) { + return 'Die mitgelieferte Doku \"$slug\" ($locale) konnte nicht geladen werden.\nVersucht: $attempted\nUrsache: $underlying\nMeist ist der installierte Studio-Build älter als das Doku-Bundle. Baue Studio neu oder öffne die Online-Kopie.'; + } + + @override + String hubUnreachableBanner(String endpoint) { + return '$endpoint nicht erreichbar — Einstellungen öffnen'; + } + + @override + String get hubUnreachableOpenSettings => 'Einstellungen öffnen'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9613671..2ff42b5 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2506,4 +2506,133 @@ class AppLocalizationsEn extends AppLocalizations { String installFailed(String error) { return 'Install failed: $error'; } + + @override + String get welcomeHubDownTitle => 'The hub isn\'t running yet'; + + @override + String get welcomeHubDownBody => + 'Studio talks to a local hub — the engine that loads modules and runs flows. It isn\'t reachable right now. Start it to begin.'; + + @override + String get welcomeHubDownStart => 'Start hub'; + + @override + String get welcomeHubDownStarting => 'Starting…'; + + @override + String get welcomeHubDownDocsLink => + 'Can\'t start it? Read the getting-started guide'; + + @override + String welcomeHubDownEndpoint(String endpoint) { + return 'Endpoint: $endpoint'; + } + + @override + String get daemonStartRequested => 'Daemon start requested. Reconnecting…'; + + @override + String daemonStartFailed(String detail) { + return 'Could not start daemon: $detail'; + } + + @override + String get faiBinaryNotFound => + 'Could not find the `fai` program on this machine.'; + + @override + String get faiBinaryNotFoundHint => + 'Install the F∆I platform, or point Studio at an existing `fai` binary.'; + + @override + String get faiBinaryLocateButton => 'Locate `fai` binary…'; + + @override + String get faiBinaryInstallDocsButton => 'Open install guide'; + + @override + String get faiBinaryPickerTitle => 'Select the `fai` binary'; + + @override + String faiBinarySetOk(String path) { + return 'Using `fai` binary at $path'; + } + + @override + String get sysAiFixParse => + 'The provider sent a response in an unexpected format. Providers that aren\'t OpenAI-compatible may need a translation proxy.'; + + @override + String sysAiFixUnknown(String kind) { + return 'Unexpected failure ($kind). See the System AI documentation.'; + } + + @override + String get sysAiFixDisabled => + 'Configure System AI in Settings (Cmd+,) → System AI panel.'; + + @override + String get sysAiFixEnvMissing => + 'The API-key environment variable is empty. Set it in your shell, then restart the daemon.'; + + @override + String get sysAiFixNetwork => + 'The hub can\'t reach the configured endpoint. Is your provider running?'; + + @override + String get sysAiFixHttp => + 'The provider returned a non-2xx status. Verify the endpoint, model name, and API key.'; + + @override + String get sysAiFixEmptyResponse => + 'The provider answered with no content. Try a different model or rephrase the prompt.'; + + @override + String channelSwitchOk(String name) { + return 'Switched active channel to \"$name\". Daemon restarted.'; + } + + @override + String channelSwitchFailed(String detail) { + return 'Channel switch failed: $detail'; + } + + @override + String get providerDescOllama => + 'Local `ollama serve`. Models stay on your machine. No API key needed.'; + + @override + String get providerDescOpenai => + 'OpenAI hosted API. Requires an API key. Data leaves your machine.'; + + @override + String get providerDescLmstudio => + 'Local LM Studio server. Models stay on your machine. No API key needed.'; + + @override + String get providerDescVllm => + 'Self-hosted vLLM or any other OpenAI-compatible server. Endpoint required.'; + + @override + String get providerDescCustom => + 'Anything else that speaks the OpenAI-compatible chat-completions wire (LiteLLM proxy, internal mirror, …).'; + + @override + String welcomeDocLoadFailedBody( + String slug, + String locale, + String attempted, + String underlying, + ) { + return 'Could not load the bundled doc \"$slug\" ($locale).\nTried: $attempted\nUnderlying: $underlying\nThis usually means the Studio build on disk predates the doc bundle. Rebuild Studio or open the online copy.'; + } + + @override + String hubUnreachableBanner(String endpoint) { + return 'Can\'t reach $endpoint — open Settings'; + } + + @override + String get hubUnreachableOpenSettings => 'Open Settings'; } diff --git a/lib/main.dart b/lib/main.dart index 301e5be..0062c06 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -35,6 +35,7 @@ Future main() async { // (when installed) the operator's theme-plugin choice // before the first frame so nothing flickers on startup. await HubService.instance.loadPersistedEndpoint(); + await SystemActions.loadFaiBinaryOverride(); final themeMode = await HubService.instance.loadThemeMode(); final locale = await HubService.instance.loadLocale(); final themePlugin = await loadActiveThemePlugin(); @@ -245,6 +246,42 @@ class StudioShellState extends State { String? _activeChannel; // local / dev / beta / production Timer? _healthPoll; + /// Consecutive failed health polls. After [_unreachableThreshold] + /// in a row we stop showing an indefinite silent "connecting…" + /// and surface a one-line "can't reach the hub" banner with a + /// jump to Settings. + int _failedPolls = 0; + static const int _unreachableThreshold = 3; + + /// Whether to render the unreachable banner. Distinct from + /// `_connected == false` so a single transient miss doesn't + /// flash the banner — only sustained failure does. + bool get _hubUnreachable => _failedPolls >= _unreachableThreshold; + + /// Current connection state, for descendants (e.g. WelcomePage) + /// that adapt their content to hub availability. + bool? get connected => _connected; + + /// Start the local daemon via the platform binary, then surface + /// the outcome as a snackbar. Shared by the sidebar's connection + /// row and the WelcomePage hero CTA. + Future startDaemon() async { + final l = AppLocalizations.of(context)!; + final r = await SystemActions.faiDaemon(['start']); + if (!mounted) return; + final detail = r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + r.ok ? l.daemonStartRequested : l.daemonStartFailed(detail), + ), + ), + ); + // Probe again shortly so the UI flips to "connected" without + // waiting for the next 5 s tick. + Future.delayed(const Duration(seconds: 1), _checkHealth); + } + /// Switch the sidebar's selected page by its registered id /// (`store`, `flows`, `audit`, etc.). Used by the welcome /// page's onboarding checklist to make each item a real @@ -331,7 +368,17 @@ class StudioShellState extends State { Future _checkHealth() async { final ok = await HubService.instance.healthy(); if (!mounted) return; - if (_connected != ok) setState(() => _connected = ok); + final wasUnreachable = _hubUnreachable; + final nextFailed = ok ? 0 : _failedPolls + 1; + final connectionChanged = _connected != ok; + final bannerChanged = + wasUnreachable != (nextFailed >= _unreachableThreshold); + if (connectionChanged || _failedPolls != nextFailed || bannerChanged) { + setState(() { + _connected = ok; + _failedPolls = nextFailed; + }); + } if (ok) { try { final snap = await HubService.instance.channelStatus(); @@ -454,24 +501,35 @@ class StudioShellState extends State { ), Container(width: 1, color: theme.colorScheme.outlineVariant), Expanded( - child: AnimatedSwitcher( - duration: FaiMotion.base, - switchInCurve: FaiMotion.easing, - switchOutCurve: FaiMotion.easing, - transitionBuilder: (child, anim) => FadeTransition( - opacity: anim, - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.02), - end: Offset.zero, - ).animate(anim), - child: child, + child: Column( + children: [ + if (_hubUnreachable) + _HubUnreachableBanner( + endpoint: HubService.instance.endpointLabel, + onOpenSettings: () => FaiSettingsDialog.show(context), + ), + Expanded( + child: AnimatedSwitcher( + duration: FaiMotion.base, + switchInCurve: FaiMotion.easing, + switchOutCurve: FaiMotion.easing, + transitionBuilder: (child, anim) => FadeTransition( + opacity: anim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.02), + end: Offset.zero, + ).animate(anim), + child: child, + ), + ), + child: KeyedSubtree( + key: ValueKey(_selectedIndex), + child: _pages[_selectedIndex].page, + ), + ), ), - ), - child: KeyedSubtree( - key: ValueKey(_selectedIndex), - child: _pages[_selectedIndex].page, - ), + ], ), ), ], @@ -483,6 +541,58 @@ class StudioShellState extends State { } } +/// One-line banner shown when the hub has been unreachable for +/// several consecutive health polls. Replaces the indefinite +/// silent "connecting…" with an explicit message + a jump to +/// Settings, where the operator can fix the endpoint. +class _HubUnreachableBanner extends StatelessWidget { + final String endpoint; + final VoidCallback onOpenSettings; + + const _HubUnreachableBanner({ + required this.endpoint, + required this.onOpenSettings, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return Material( + color: theme.colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.lg, + vertical: FaiSpace.sm, + ), + child: Row( + children: [ + Icon( + Icons.cloud_off_outlined, + size: 18, + color: theme.colorScheme.onErrorContainer, + ), + const SizedBox(width: FaiSpace.sm), + Expanded( + child: Text( + l.hubUnreachableBanner(endpoint), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + ), + const SizedBox(width: FaiSpace.sm), + TextButton( + onPressed: onOpenSettings, + child: Text(l.hubUnreachableOpenSettings), + ), + ], + ), + ), + ); + } +} + /// Intents driven by the Shortcuts/Actions pair on the shell. class _GoToPageIntent extends Intent { final int index; @@ -553,7 +663,12 @@ class _SidebarState extends State<_Sidebar> super.initState(); _ctrl = AnimationController( vsync: this, - duration: FaiMotion.fast, + // Expand uses the slow (~320 ms) token: the icon→label + // reveal is a deliberate, eased gesture, not a flicker. + // The collapse runs faster (reverseDuration) so dismissing + // the rail feels crisp rather than sluggish. + duration: FaiMotion.slow, + reverseDuration: FaiMotion.base, value: 0, ); } @@ -565,14 +680,14 @@ class _SidebarState extends State<_Sidebar> } Future _startDaemon(BuildContext context) async { + final l = AppLocalizations.of(context)!; final r = await SystemActions.faiDaemon(['start']); if (!context.mounted) return; + final detail = r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - r.ok - ? 'Daemon start requested. Reconnecting…' - : 'Could not start daemon: ${r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim()}', + r.ok ? l.daemonStartRequested : l.daemonStartFailed(detail), ), ), ); @@ -1161,7 +1276,16 @@ class _SidebarItemState extends State<_SidebarItem> { // the left. The bg highlight spans the rail's // current width (typical nav-rail behaviour). padding: const EdgeInsets.symmetric(vertical: FaiSpace.md), - decoration: BoxDecoration(color: bg), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(FaiRadius.sm), + // Lift only the selected item — a whisper of depth + // marks "you are here" without competing with the + // accent fill. + boxShadow: widget.selected + ? FaiElevation.low(theme.brightness) + : null, + ), child: content, ), ), diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index ea6a0c5..ebef8bf 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -973,7 +973,7 @@ class _ExplanationPanel extends StatelessWidget { : theme.colorScheme.error, ), ), - if (result!.fixHint.isNotEmpty) ...[ + if (result!.fixHint(l).isNotEmpty) ...[ const SizedBox(height: FaiSpace.sm), Text( l.auditFixLabel, @@ -984,7 +984,7 @@ class _ExplanationPanel extends StatelessWidget { ), const SizedBox(height: 2), Text( - result!.fixHint, + result!.fixHint(l), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface, ), diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index d5c6c29..1782d45 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -28,6 +28,11 @@ class WelcomePage extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; + // The shell polls hub health on a 5 s tick. When the hub is + // down a brand-new operator otherwise sees a dead, all- + // unchecked onboarding checklist with no obvious recovery — + // so we lead with a "start the hub" hero instead. + final hubDown = StudioShellState.of(context)?.connected == false; return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar(titleSpacing: FaiSpace.xl, title: Text(l.navWelcome)), @@ -52,22 +57,27 @@ class WelcomePage extends StatelessWidget { constraints: const BoxConstraints(maxWidth: 960), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - _Hero(), - SizedBox(height: FaiSpace.xxl), - // Checklist comes second — operator-actionable - // path beats educational content. Auto-hides - // once the operator dismisses it. - _OnboardingChecklist(), - _PillarRow(), - SizedBox(height: FaiSpace.xxl), - _SectionLabel(textKey: _SectionLabelKey.trust), - SizedBox(height: FaiSpace.md), - _TrustPosture(), - SizedBox(height: FaiSpace.xxl), - _SectionLabel(textKey: _SectionLabelKey.docs), - SizedBox(height: FaiSpace.md), - _DocsRow(), + children: [ + const _Hero(), + const SizedBox(height: FaiSpace.xxl), + // When the hub is down the onboarding checklist + // would render dead (all-unchecked, nothing to + // probe). Lead with a "start the hub" card + // instead; restore the checklist once connected. + if (hubDown) ...[ + const _HubDownHero(), + const SizedBox(height: FaiSpace.xxl), + ] else + const _OnboardingChecklist(), + const _PillarRow(), + const SizedBox(height: FaiSpace.xxl), + const _SectionLabel(textKey: _SectionLabelKey.trust), + const SizedBox(height: FaiSpace.md), + const _TrustPosture(), + const SizedBox(height: FaiSpace.xxl), + const _SectionLabel(textKey: _SectionLabelKey.docs), + const SizedBox(height: FaiSpace.md), + const _DocsRow(), ], ), ), @@ -149,6 +159,113 @@ class _Hero extends StatelessWidget { } } +/// Prominent recovery hero shown on the Welcome page when the +/// hub is not reachable. Headline + one-line explanation + a big +/// "Start hub" CTA wired to the shell's daemon-start path, with +/// a secondary link to the install guide for the case where no +/// `fai` binary can be located. +class _HubDownHero extends StatefulWidget { + const _HubDownHero(); + + @override + State<_HubDownHero> createState() => _HubDownHeroState(); +} + +class _HubDownHeroState extends State<_HubDownHero> { + bool _starting = false; + + Future _start() async { + final shell = StudioShellState.of(context); + if (shell == null) return; + setState(() => _starting = true); + await shell.startDaemon(); + if (!mounted) return; + setState(() => _starting = false); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + // No `fai` binary → "Start hub" cannot work; lead the + // operator to the install guide instead. + final canStart = SystemActions.faiBinaryExists(); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(FaiSpace.xl), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(FaiRadius.md), + border: Border.all( + color: theme.colorScheme.error.withValues(alpha: 0.4), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.cloud_off_outlined, + size: 24, + color: theme.colorScheme.error, + ), + const SizedBox(width: FaiSpace.md), + Expanded( + child: Text( + l.welcomeHubDownTitle, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: FaiSpace.md), + Text( + l.welcomeHubDownBody, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.45, + ), + ), + const SizedBox(height: FaiSpace.xs), + Text( + l.welcomeHubDownEndpoint(HubService.instance.endpointLabel), + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.lg), + if (canStart) + FilledButton.icon( + onPressed: _starting ? null : _start, + icon: _starting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.play_arrow), + label: Text( + _starting ? l.welcomeHubDownStarting : l.welcomeHubDownStart, + ), + ) + else + const FaiBinaryRecovery(), + const SizedBox(height: FaiSpace.sm), + TextButton.icon( + icon: const Icon(Icons.open_in_new, size: 16), + label: Text(l.welcomeHubDownDocsLink), + onPressed: () => SystemActions.openInOs(kFaiInstallDocsUrl), + ), + ], + ), + ); + } +} + enum _SectionLabelKey { trust, docs } class _SectionLabel extends StatelessWidget { @@ -904,58 +1021,82 @@ class _DocsRow extends StatelessWidget { } } -class _DocCard extends StatelessWidget { +class _DocCard extends StatefulWidget { final _DocEntry entry; const _DocCard({required this.entry}); + @override + State<_DocCard> createState() => _DocCardState(); +} + +class _DocCardState extends State<_DocCard> { + bool _hovered = false; + @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; - return Material( - color: theme.colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(FaiRadius.md), - child: InkWell( - onTap: () => _DocReaderSheet.show(context, entry), - borderRadius: BorderRadius.circular(FaiRadius.md), - child: Container( - padding: const EdgeInsets.all(FaiSpace.lg), - decoration: BoxDecoration( + final entry = widget.entry; + // Hover-lift mirrors the Store cards: a gentle 2px rise plus + // shadow bump. Resting state stays flat-with-border so the + // doc strip reads calm until the cursor invites a click. + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: FaiMotion.base, + curve: FaiMotion.easing, + transform: Matrix4.translationValues(0, _hovered ? -2 : 0, 0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(FaiRadius.md), + boxShadow: _hovered ? FaiElevation.medium(theme.brightness) : null, + ), + child: Material( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(FaiRadius.md), + child: InkWell( + onTap: () => _DocReaderSheet.show(context, entry), borderRadius: BorderRadius.circular(FaiRadius.md), - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(entry.icon, size: 22, color: theme.colorScheme.primary), - const SizedBox(width: FaiSpace.lg), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - entry.title(l), - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - entry.blurb(l), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - height: 1.4, - ), - ), - ], - ), + child: Container( + padding: const EdgeInsets.all(FaiSpace.lg), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(FaiRadius.md), + border: Border.all(color: theme.colorScheme.outlineVariant), ), - Icon( - Icons.chevron_right, - size: 18, - color: theme.colorScheme.onSurfaceVariant, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(entry.icon, size: 22, color: theme.colorScheme.primary), + const SizedBox(width: FaiSpace.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.title(l), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + entry.blurb(l), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + ), + ], ), - ], + ), ), ), ), @@ -983,6 +1124,15 @@ class _DocReaderError { String get forgejoUrl => 'https://git.flemming.ai/fai/platform/src/branch/main/docs/architecture/$slug.md'; + /// Localized, operator-facing failure body. Use this for any + /// on-screen rendering; [toString] stays English for logs. + String localizedBody(AppLocalizations l) => l.welcomeDocLoadFailedBody( + slug, + locale, + attempted.join(', '), + underlying, + ); + @override String toString() => 'Could not load the bundled doc "$slug" ($locale).\n' @@ -1006,6 +1156,7 @@ class _DocReaderSheet extends StatefulWidget { context: context, isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surfaceContainer, + elevation: 8, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)), ), @@ -1135,7 +1286,18 @@ class _DocReaderSheetState extends State<_DocReaderSheet> { ), ), const SizedBox(height: FaiSpace.sm), - FaiErrorBox(error: err, isError: true, maxHeight: 200), + if (structured != null) + FaiErrorBox( + text: structured.localizedBody(l), + isError: true, + maxHeight: 200, + ) + else + FaiErrorBox( + error: err, + isError: true, + maxHeight: 200, + ), if (structured != null) ...[ const SizedBox(height: FaiSpace.md), TextButton.icon( diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 67fb7bd..2036fdc 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -2,55 +2,75 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'tokens.dart'; class FaiTheme { FaiTheme._(); + /// Bundled font families. Declared in pubspec.yaml `fonts:` and + /// shipped as TTF assets under `assets/fonts/` — NOT fetched at + /// runtime via google_fonts. This keeps the app working fully + /// offline / air-gapped (KRITIS) and avoids the font-swap flash + /// google_fonts shows on first paint. + static const String _uiFamily = 'Inter'; + static const String _monoFamily = 'JetBrains Mono'; + /// Inter for UI, JetBrains Mono for technical strings (IDs, - /// paths, capability refs). Both via google_fonts so they - /// bundle on first run; for production we'd ship them as - /// app assets. + /// paths, capability refs). Both bundled (see [_uiFamily]). static TextTheme _textTheme(Brightness b) { final base = b == Brightness.dark ? Typography.whiteCupertino : Typography.blackCupertino; - return GoogleFonts.interTextTheme(base).copyWith( - displaySmall: GoogleFonts.inter( + return base.apply(fontFamily: _uiFamily).copyWith( + displaySmall: const TextStyle( + fontFamily: _uiFamily, fontSize: 28, fontWeight: FontWeight.w600, letterSpacing: -0.4, ), - headlineSmall: GoogleFonts.inter( + headlineSmall: const TextStyle( + fontFamily: _uiFamily, fontSize: 20, fontWeight: FontWeight.w600, letterSpacing: -0.2, ), - titleMedium: GoogleFonts.inter( + titleMedium: const TextStyle( + fontFamily: _uiFamily, fontSize: 15, fontWeight: FontWeight.w600, letterSpacing: 0, ), - titleSmall: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500), - bodyLarge: GoogleFonts.inter( + titleSmall: const TextStyle( + fontFamily: _uiFamily, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + bodyLarge: const TextStyle( + fontFamily: _uiFamily, fontSize: 14, fontWeight: FontWeight.w400, height: 1.45, ), - bodyMedium: GoogleFonts.inter( + bodyMedium: const TextStyle( + fontFamily: _uiFamily, fontSize: 13, fontWeight: FontWeight.w400, height: 1.4, ), - bodySmall: GoogleFonts.inter( + bodySmall: const TextStyle( + fontFamily: _uiFamily, fontSize: 12, fontWeight: FontWeight.w400, height: 1.4, ), - labelMedium: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w500), - labelSmall: GoogleFonts.inter( + labelMedium: const TextStyle( + fontFamily: _uiFamily, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + labelSmall: const TextStyle( + fontFamily: _uiFamily, fontSize: 11, fontWeight: FontWeight.w500, letterSpacing: 0.2, @@ -59,12 +79,14 @@ class FaiTheme { } /// JetBrains Mono for monospaced technical content. Used by - /// FaiMono helper. + /// FaiMono helper. Only Regular (400) and Medium (500) weights + /// are bundled — heavier requests fall back to the nearest. static TextStyle mono({ double size = 12, FontWeight weight = FontWeight.w400, Color? color, - }) => GoogleFonts.jetBrainsMono( + }) => TextStyle( + fontFamily: _monoFamily, fontSize: size, fontWeight: weight, color: color, @@ -221,8 +243,8 @@ class FaiTheme { scrolledUnderElevation: 0, centerTitle: false, // Bake the foreground colour straight into the title - // style. `headlineSmall` is built from GoogleFonts.inter - // with no color slot, and Material's "merge foreground + // style. `headlineSmall` is built from the bundled Inter + // family with no color slot, and Material's "merge foreground // colour at draw time" path leaves it null in some // light-mode builds — the title rendered white-on-white. titleTextStyle: textTheme.headlineSmall?.copyWith( diff --git a/lib/widgets/fai_binary_recovery.dart b/lib/widgets/fai_binary_recovery.dart new file mode 100644 index 0000000..d92aa99 --- /dev/null +++ b/lib/widgets/fai_binary_recovery.dart @@ -0,0 +1,95 @@ +// Recovery affordance shown when Studio cannot locate the `fai` +// binary on the machine. Replaces the old CLI-jargon dead-end +// ("set FAI_BIN to its full path") with two acts a non-CLI +// operator can actually take: +// +// 1. Locate the binary with a native file picker, persisting +// the choice via SystemActions.setFaiBinaryPath. +// 2. Open the install guide in the OS browser. + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + +import '../data/system_actions.dart'; +import '../l10n/app_localizations.dart'; +import '../theme/tokens.dart'; + +class FaiBinaryRecovery extends StatelessWidget { + /// Fired after the operator successfully picks a `fai` binary, + /// so the host can retry whatever action hit the missing + /// binary (e.g. re-run the daemon start / status probe). + final VoidCallback? onLocated; + + const FaiBinaryRecovery({super.key, this.onLocated}); + + Future _locate(BuildContext context) async { + final l = AppLocalizations.of(context)!; + final messenger = ScaffoldMessenger.of(context); + final picked = await FilePicker.pickFiles( + dialogTitle: l.faiBinaryPickerTitle, + ); + final path = picked?.files.single.path; + if (path == null) return; + final ok = await SystemActions.setFaiBinaryPath(path); + messenger.showSnackBar( + SnackBar( + content: Text(ok ? l.faiBinarySetOk(path) : l.faiBinaryNotFound), + ), + ); + if (ok) onLocated?.call(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(FaiSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all( + color: theme.colorScheme.error.withValues(alpha: 0.4), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l.faiBinaryNotFound, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.error, + ), + ), + const SizedBox(height: FaiSpace.xs), + Text( + l.faiBinaryNotFoundHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.md), + Wrap( + spacing: FaiSpace.sm, + runSpacing: FaiSpace.sm, + children: [ + FilledButton.tonalIcon( + onPressed: () => _locate(context), + icon: const Icon(Icons.folder_open, size: 16), + label: Text(l.faiBinaryLocateButton), + ), + OutlinedButton.icon( + onPressed: () => + SystemActions.openInOs(kFaiInstallDocsUrl), + icon: const Icon(Icons.open_in_new, size: 16), + label: Text(l.faiBinaryInstallDocsButton), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index 9ad7460..8510e17 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -293,11 +293,12 @@ class _FaiSettingsDialogState extends State { }); final r = await SystemActions.faiChannelSwitch(name); if (!mounted) return; + final l = AppLocalizations.of(context)!; setState(() { _saving = false; _channelToast = r.ok - ? 'Switched active channel to "$name". Daemon restarted.\n${r.stdout.trim()}' - : 'Channel switch failed: ${r.stderr.isEmpty ? r.stdout : r.stderr}'; + ? '${l.channelSwitchOk(name)}\n${r.stdout.trim()}' + : l.channelSwitchFailed(r.stderr.isEmpty ? r.stdout : r.stderr); }); if (r.ok) await _loadChannels(); } diff --git a/lib/widgets/fai_system_ai_editor.dart b/lib/widgets/fai_system_ai_editor.dart index 57da186..77c3589 100644 --- a/lib/widgets/fai_system_ai_editor.dart +++ b/lib/widgets/fai_system_ai_editor.dart @@ -86,6 +86,26 @@ class _ProviderPreset { static _ProviderPreset byWire(String wire) { return all.firstWhere((p) => p.wire == wire, orElse: () => all.first); } + + /// Localized help body for this provider. Falls back to the + /// const English [description] for any future wire that has no + /// ARB key yet. + String descriptionFor(AppLocalizations l) { + switch (wire) { + case 'ollama': + return l.providerDescOllama; + case 'openai': + return l.providerDescOpenai; + case 'lmstudio': + return l.providerDescLmstudio; + case 'vllm': + return l.providerDescVllm; + case 'custom': + return l.providerDescCustom; + default: + return description; + } + } } class FaiSystemAiEditor extends StatefulWidget { @@ -372,7 +392,7 @@ class _FaiSystemAiEditorState extends State { _ProviderDropdown(value: _preset, onChanged: _onProviderChanged), const SizedBox(height: 4), Text( - _preset.description, + _preset.descriptionFor(l), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -634,10 +654,10 @@ class _TestResultPanel extends StatelessWidget { ok ? l.systemAiReplyPrefix(result.text) : result.text, style: FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface), ), - if (!ok && result.fixHint.isNotEmpty) ...[ + if (!ok && result.fixHint(l).isNotEmpty) ...[ const SizedBox(height: FaiSpace.xs), Text( - result.fixHint, + result.fixHint(l), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface, ), diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index b9554ed..6f294c0 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -4,6 +4,7 @@ // primitives directly — no Material `Card` / generic `Container` // soup in page code. +export 'fai_binary_recovery.dart'; export 'fai_card.dart'; export 'fai_data_row.dart'; export 'fai_delta_mark.dart'; diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index df8d2f7..f16b4c3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -7,7 +7,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/pubspec.lock b/pubspec.lock index 3c891f9..21128e8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,14 +57,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85" - url: "https://pub.dev" - source: hosted - version: "1.2.0" collection: dependency: transitive description: @@ -235,14 +227,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" - google_fonts: - dependency: "direct main" - description: - name: google_fonts - sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d" - url: "https://pub.dev" - source: hosted - version: "8.1.0" google_identity_services_web: dependency: transitive description: @@ -283,14 +267,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.3" - hooks: - dependency: transitive - description: - name: hooks - sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31 - url: "https://pub.dev" - source: hosted - version: "2.0.0" http: dependency: transitive description: @@ -323,22 +299,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" - jni: - dependency: transitive - description: - name: jni - sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f - url: "https://pub.dev" - source: hosted - version: "1.0.0" - jni_flutter: - dependency: transitive - description: - name: jni_flutter - sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" - url: "https://pub.dev" - source: hosted - version: "1.0.1" leak_tracker: dependency: transitive description: @@ -379,14 +339,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" markdown: dependency: transitive description: @@ -427,22 +379,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" - url: "https://pub.dev" - source: hosted - version: "9.4.1" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" path: dependency: "direct main" description: @@ -451,30 +387,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - path_provider: - dependency: transitive - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" - url: "https://pub.dev" - source: hosted - version: "2.3.1" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.dev" - source: hosted - version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -531,22 +443,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - record_use: - dependency: transitive - description: - name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" - url: "https://pub.dev" - source: hosted - version: "0.6.0" scrollable_positioned_list: dependency: transitive description: @@ -802,4 +698,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml index 931dc4e..543ccc6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,6 @@ dependencies: # Sibling package; will move to Forgejo path once published. fai_client_sdk: path: ../fai_client_sdk_dart - google_fonts: ^8.1.0 shared_preferences: ^2.5.5 # Inline README rendering inside the Store detail sheet. # `flutter_markdown` is discontinued; `flutter_markdown_plus` @@ -68,3 +67,24 @@ flutter: # Air-gap-friendly — no network calls to read these. assets: - assets/docs/ + # Bundled fonts — Inter (UI) + JetBrains Mono (technical + # strings). Shipped as assets rather than fetched at runtime + # via google_fonts so the app works fully offline / air-gapped + # (KRITIS) and never shows a font-swap flash on first paint. + fonts: + - family: Inter + fonts: + - asset: assets/fonts/Inter-Regular.ttf + weight: 400 + - asset: assets/fonts/Inter-Medium.ttf + weight: 500 + - asset: assets/fonts/Inter-SemiBold.ttf + weight: 600 + - asset: assets/fonts/Inter-Bold.ttf + weight: 700 + - family: JetBrains Mono + fonts: + - asset: assets/fonts/JetBrainsMono-Regular.ttf + weight: 400 + - asset: assets/fonts/JetBrainsMono-Medium.ttf + weight: 500 diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index a962892..88b22e5 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,7 +7,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni ) set(PLUGIN_BUNDLED_LIBRARIES)