feat(studio): first-run UX, recovery affordances, l10n, bundled fonts
- Connection-aware Welcome: when the hub is down, show a hero with a primary "Start hub" CTA + install fallback instead of a dead, all-unchecked onboarding checklist (the first-run cliff). - Actionable binary-not-found (file picker + install link, not a "set FAI_BIN" dead end) and a connect-failure banner after repeated failed health polls. - Localize six hardcoded English error/toast clusters (DE+EN ARB). - Bundle Inter + JetBrains Mono as assets; drop the runtime google_fonts fetch (air-gap / KRITIS safe, no font-swap flash). Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
7511867774
commit
5313266cc4
25 changed files with 1231 additions and 234 deletions
BIN
assets/fonts/Inter-Bold.ttf
Normal file
BIN
assets/fonts/Inter-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Inter-Medium.ttf
Normal file
BIN
assets/fonts/Inter-Medium.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Inter-Regular.ttf
Normal file
BIN
assets/fonts/Inter-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Inter-SemiBold.ttf
Normal file
BIN
assets/fonts/Inter-SemiBold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/JetBrainsMono-Medium.ttf
Normal file
BIN
assets/fonts/JetBrainsMono-Medium.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/JetBrainsMono-Regular.ttf
Normal file
BIN
assets/fonts/JetBrainsMono-Regular.ttf
Normal file
Binary file not shown.
|
|
@ -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 <var>=<key>` 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> 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<bool> 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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
170
lib/main.dart
170
lib/main.dart
|
|
@ -35,6 +35,7 @@ Future<void> 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<StudioShell> {
|
|||
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<void> 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<StudioShell> {
|
|||
Future<void> _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<StudioShell> {
|
|||
),
|
||||
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<Offset>(
|
||||
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<Offset>(
|
||||
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<StudioShell> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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<void> _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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<void> _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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
95
lib/widgets/fai_binary_recovery.dart
Normal file
95
lib/widgets/fai_binary_recovery.dart
Normal file
|
|
@ -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<void> _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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -293,11 +293,12 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
});
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FaiSystemAiEditor> {
|
|||
_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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
|
|
|||
106
pubspec.lock
106
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"
|
||||
|
|
|
|||
22
pubspec.yaml
22
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
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue