Compare commits

...

4 commits

Author SHA1 Message Date
flemming-it
d0cfa5df05 feat: federation panel + Dart SDK federation methods (0.70.0)
Some checks failed
Security / Security check (push) Failing after 2s
A new 'Föderation' destination (primary side) lists connected
satellites — name, region, version, wire version, advertised
capabilities — and adds them in one step: 'Add satellite' issues a
single-use bootstrap token bundled with the primary CA as a
ready-to-paste satellite config (the bundled CA makes the first
connect tamper-proof). Localized EN + DE, in-app help doc. Uses the
new HubService.listSatellites / issueSatelliteToken wrapping the
SDK's federation methods.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-06-13 14:16:07 +02:00
flemming-it
b35bbc1b12 fix(studio): readable logs + in-app config.yaml viewer
- Strip ANSI / control characters when reading log files so daemon
  logs that carry colour escape codes render clean instead of as
  garbled special characters (the source side is fixed in the hub
  too; this is the defensive belt for existing files).
- Generalise the file viewer (read top-down + plain mode) and add an
  in-Studio "View" affordance for config.yaml / .yml / .toml on the
  Doctor paths panel, so config is readable without leaving Studio.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-06-11 23:52:40 +02:00
flemming-it
f0a944dce7 feat(studio): award-tier visual polish
Brightness-aware elevation tokens (FaiElevation low/medium) applied
to cards, sheets and the sidebar active item; gentle hover-lift on
Store + Doc cards; a longer, eased sidebar expand using the
under-used FaiMotion.slow; and a branded empty state carrying the
F∆I delta mark with warmer spacing. Subtle, Linear/Raycast-style —
both light and dark themes tuned.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-06-11 23:52:31 +02:00
flemming-it
5313266cc4 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>
2026-06-11 23:52:18 +02:00
35 changed files with 2420 additions and 401 deletions

View file

@ -3,6 +3,20 @@
All notable changes to `fai_studio` recorded here. Pubspec All notable changes to `fai_studio` recorded here. Pubspec
version + `kStudioVersion` in `lib/main.dart` stay in lockstep. version + `kStudioVersion` in `lib/main.dart` stay in lockstep.
## 0.70.0 — 2026-06-13
### Added
- **Federation panel.** A new "Föderation" destination shows the
satellites connected to this hub (name, region, version, wire
version, advertised capabilities) and adds them in one step: press
**Add satellite**, name it, and the hub issues a single-use
bootstrap token bundled with its CA as a ready-to-paste satellite
config — the bundled CA makes the satellite's first connect
tamper-proof. Localized (EN + DE) with an in-app help doc. Backed
by new `HubClient.listSatellites` / `issueBootstrapToken` in the
Dart SDK.
## 0.68.0 — 2026-06-09 ## 0.68.0 — 2026-06-09
### Added ### Added

43
assets/docs/federation.md Normal file
View file

@ -0,0 +1,43 @@
# Federation
Federation lets one hub (the **primary**) run capabilities on
another hub (a **satellite**) — for example to reach a GPU box or to
keep data inside a specific location. A flow step opts in with an
`on:` selector; everything else stays exactly like a local step.
This panel is the **primary** side: it shows the satellites currently
connected and lets you enrol new ones.
## Add a satellite
1. Press **Add satellite** and give it a name (e.g. `satellite-a`).
2. The hub issues a single-use **bootstrap token** and bundles its
**CA certificate**. Copy the shown config into the satellite's
`config.yaml` (one paste) and export the token as
`FAI_BOOTSTRAP_TOKEN`.
3. Start the satellite. It dials the primary, the CA pin makes the
first connect tamper-proof, and the primary signs it a short-lived
certificate. It then appears in this list.
## How it stays secure
- The satellite **dials outbound** — no inbound firewall change.
- Every connection is **mTLS**. The first connect is verified against
the bundled CA; later connects authenticate with the satellite's
own certificate (no token needed).
- The satellite resolves and runs the capability in **its own
sandbox** — only inputs cross the wire, never code.
- Each cross-hub call is recorded on both sides (`dispatched_to`), so
the audit trail and replay determinism survive federation.
## Using a satellite in a flow
Add an `on:` selector to a step:
```yaml
- id: extract
use: text.extract@^1
on: region=berlin # or pin=satellite-a, auto:healthiest, auto:lowest_rtt
with:
input: $inputs.doc
```

View file

@ -0,0 +1,48 @@
# Föderation
Föderation lässt einen Hub (den **Primary**) Capabilities auf einem
anderen Hub (einem **Satelliten**) ausführen — etwa um eine GPU-Maschine
zu erreichen oder Daten an einem bestimmten Ort zu halten. Ein
Flow-Schritt entscheidet sich per `on:`-Selektor dafür; alles andere
bleibt wie bei einem lokalen Schritt.
Dieses Panel ist die **Primary**-Seite: Es zeigt die aktuell
verbundenen Satelliten und richtet neue ein.
## Satellit hinzufügen
1. **Satellit hinzufügen** drücken und einen Namen vergeben
(z.B. `satellite-a`).
2. Der Hub stellt ein einmaliges **Bootstrap-Token** aus und legt sein
**CA-Zertifikat** bei. Kopiere die gezeigte Konfiguration in die
`config.yaml` des Satelliten (ein Einfügen) und exportiere das
Token als `FAI_BOOTSTRAP_TOKEN`.
3. Starte den Satelliten. Er wählt sich beim Primary ein, der CA-Pin
macht den ersten Connect manipulationssicher, und der Primary
signiert ihm ein kurzlebiges Zertifikat. Danach erscheint er in
dieser Liste.
## Warum es sicher bleibt
- Der Satellit **wählt sich nach außen ein** — keine eingehende
Firewall-Änderung nötig.
- Jede Verbindung ist **mTLS**. Der erste Connect wird gegen die
beigelegte CA verifiziert; spätere Connects authentifizieren sich
mit dem eigenen Zertifikat des Satelliten (kein Token nötig).
- Der Satellit löst auf und führt die Capability in **seiner eigenen
Sandbox** aus — nur Eingaben gehen über die Leitung, niemals Code.
- Jeder hub-übergreifende Aufruf wird auf beiden Seiten protokolliert
(`dispatched_to`), sodass Audit-Trail und Replay-Determinismus die
Föderation überstehen.
## Satellit in einem Flow nutzen
Ergänze einen `on:`-Selektor an einem Schritt:
```yaml
- id: extract
use: text.extract@^1
on: region=berlin # oder pin=satellite-a, auto:healthiest, auto:lowest_rtt
with:
input: $inputs.doc
```

BIN
assets/fonts/Inter-Bold.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -11,6 +11,7 @@ import 'package:fai_client_sdk/fai_client_sdk.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../l10n/app_localizations.dart';
import 'flow_output.dart'; import 'flow_output.dart';
import 'hub_auth_token.dart'; import 'hub_auth_token.dart';
export 'flow_output.dart'; export 'flow_output.dart';
@ -858,6 +859,39 @@ class HubService {
Future<void> reject(String id, String reviewer, String reason) => Future<void> reject(String id, String reviewer, String reason) =>
_client.reject(approvalId: id, reviewer: reviewer, reason: reason); _client.reject(approvalId: id, reviewer: reviewer, reason: reason);
/// Federation satellites currently connected to this hub
/// (primary side). Empty when none are connected.
Future<List<Satellite>> listSatellites() async {
final entries = await _client.listSatellites();
return entries
.map(
(e) => Satellite(
hubId: e.hubId,
displayName: e.displayName,
hubVersion: e.hubVersion,
wireVersion: e.wireVersion,
region: e.region,
capabilities: e.capabilities.toList(),
),
)
.toList();
}
/// Issue a single-use federation bootstrap token enrolling
/// `name`. The result carries the primary CA so the operator can
/// bootstrap the satellite in one step (secure first connect).
Future<SatelliteEnrollment> issueSatelliteToken(
String name, {
String region = '',
}) async {
final r = await _client.issueBootstrapToken(name, region: region);
return SatelliteEnrollment(
token: r.token,
expiresAt: r.expiresAt,
primaryCaPem: r.primaryCaPem,
);
}
/// Composite "doctor" snapshot. One round-trip per piece, run /// Composite "doctor" snapshot. One round-trip per piece, run
/// in parallel so the page populates fast. /// in parallel so the page populates fast.
Future<DoctorSnapshot> doctor() async { Future<DoctorSnapshot> doctor() async {
@ -1207,6 +1241,51 @@ class PendingApproval {
/// Full approval record including decided-side fields. Used by /// Full approval record including decided-side fields. Used by
/// the History tab so operators can review what they (or the /// the History tab so operators can review what they (or the
/// A federation satellite currently connected to this hub.
class Satellite {
final String hubId;
final String displayName;
final String hubVersion;
final int wireVersion;
final String region;
final List<String> capabilities;
const Satellite({
required this.hubId,
required this.displayName,
required this.hubVersion,
required this.wireVersion,
required this.region,
required this.capabilities,
});
}
/// The artifact a satellite operator needs to enrol: the single-use
/// bootstrap token plus the primary's CA cert (for a secure first
/// connect). Both are pasted into the satellite's config.
class SatelliteEnrollment {
final String token;
final String expiresAt;
final String primaryCaPem;
const SatelliteEnrollment({
required this.token,
required this.expiresAt,
required this.primaryCaPem,
});
/// A ready-to-paste `federation:` config block for the satellite.
String toConfigYaml(String name) {
final ca = primaryCaPem.trim().isEmpty
? ''
: '\n primary_ca: |\n${primaryCaPem.trimRight().split('\n').map((l) => ' $l').join('\n')}';
return 'federation:\n'
' upstream: https://<this-primary-host>:<federation-port>\n'
' hub_name: $name\n'
' bootstrap_token_env: FAI_BOOTSTRAP_TOKEN$ca';
}
}
/// system) decided. /// system) decided.
class ApprovalRecord { class ApprovalRecord {
final String id; final String id;
@ -1306,24 +1385,25 @@ class AskAiResult {
/// One-line fix hint per error kind, mirroring /// One-line fix hint per error kind, mirroring
/// `docs/architecture/system-ai.md`. Empty on success. /// `docs/architecture/system-ai.md`. Empty on success.
String get fixHint { /// Localized pass the active [AppLocalizations].
String fixHint(AppLocalizations l) {
switch (errorKind) { switch (errorKind) {
case '': case '':
return ''; return '';
case 'disabled': case 'disabled':
return 'Configure System AI in Settings (Cmd+,) → System AI panel.'; return l.sysAiFixDisabled;
case 'env_missing': 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': case 'network':
return 'Hub cannot reach the configured endpoint. Is your provider running?'; return l.sysAiFixNetwork;
case 'http': case 'http':
return 'Provider returned a non-2xx status. Verify endpoint, model name, and API key.'; return l.sysAiFixHttp;
case 'parse': case 'parse':
return 'Provider returned an unexpected response shape. Non-OpenAI providers may need a compatibility proxy.'; return l.sysAiFixParse;
case 'empty_response': case 'empty_response':
return 'Provider answered with no content. Try a different model or rephrase the prompt.'; return l.sysAiFixEmptyResponse;
default: default:
return 'Unknown failure kind ($errorKind). See system-ai.md.'; return l.sysAiFixUnknown(errorKind);
} }
} }
} }

View file

@ -18,9 +18,61 @@
import 'dart:io'; 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 FI 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 { class SystemActions {
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 /// Ask the OS to open [path] in the default handler. On macOS
/// this opens text files in TextEdit, configs in the registered /// this opens text files in TextEdit, configs in the registered
/// editor, etc. Returns true on a clean spawn (process exited /// editor, etc. Returns true on a clean spawn (process exited
@ -149,13 +201,11 @@ class SystemActions {
) async { ) async {
final exe = _faiExecutable(); final exe = _faiExecutable();
if (exe == null) { if (exe == null) {
return ( // Sentinel, not a user-facing string. Callers detect this
ok: false, // via `faiBinaryExists()` and render a localized,
stdout: '', // actionable recovery message (locate binary / install
stderr: // guide) instead of CLI jargon like "set FAI_BIN".
'Could not locate the `fai` binary. Install via the ' return (ok: false, stdout: '', stderr: kFaiBinaryNotFound);
'platform installer or set FAI_BIN to its full path.',
);
} }
try { try {
final r = await Process.run(exe, args); final r = await Process.run(exe, args);
@ -175,9 +225,17 @@ class SystemActions {
return (executable: 'xdg-open', args: const []); return (executable: 'xdg-open', args: const []);
} }
/// Locate the `fai` binary. Order: $FAI_BIN, PATH, fallback to /// Locate the `fai` binary. Order: operator override (set via
/// the canonical install location under the user's home dir. /// the file picker), $FAI_BIN, PATH, fallback to the canonical
/// install location under the user's home dir.
static String? _faiExecutable() { static String? _faiExecutable() {
final override = _faiBinaryOverride;
if (override != null &&
override.isNotEmpty &&
File(override).existsSync()) {
return override;
}
final fromEnv = Platform.environment['FAI_BIN']; final fromEnv = Platform.environment['FAI_BIN'];
if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) { if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) {
return fromEnv; return fromEnv;

View file

@ -1520,5 +1520,97 @@
"placeholders": { "placeholders": {
"error": {"type": "String"} "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",
"navFederation": "Föderation",
"federationTitle": "Föderation",
"federationReloadTooltip": "Satelliten neu laden",
"federationAddSatellite": "Satellit hinzufügen",
"federationEmptyTitle": "Keine Satelliten verbunden",
"federationEmptyHint": "Verbinde einen weiteren Hub als Satellit, um Capabilities dort auszuführen. Stelle dazu ein Enrollment-Token aus.",
"federationPillConnected": "verbunden",
"federationRegionNone": "keine Region",
"federationCapabilities": "{count} angebotene Capabilities",
"federationAddDialogTitle": "Enrollment-Token ausstellen",
"federationNameLabel": "Satelliten-Name",
"federationIssueButton": "Ausstellen",
"federationIssueFailed": "Token konnte nicht ausgestellt werden: {error}",
"federationEnrollmentTitle": "{name} einrichten",
"federationTokenLabel": "Bootstrap-Token (einmalig)",
"federationConfigLabel": "Satelliten-Konfiguration (in den Satelliten einfügen)",
"federationCopied": "In die Zwischenablage kopiert",
"federationEnrollmentHint": "Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA macht den ersten Connect des Satelliten manipulationssicher."
} }

View file

@ -1523,5 +1523,112 @@
"placeholders": { "placeholders": {
"error": {"type": "String"} "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",
"navFederation": "Federation",
"federationTitle": "Federation",
"federationReloadTooltip": "Reload satellites",
"federationAddSatellite": "Add satellite",
"federationEmptyTitle": "No satellites connected",
"federationEmptyHint": "Connect another hub as a satellite to run capabilities on it. Issue an enrollment token to get started.",
"federationPillConnected": "connected",
"federationRegionNone": "no region",
"federationCapabilities": "{count} advertised capabilities",
"@federationCapabilities": {
"placeholders": {
"count": {"type": "int"}
}
},
"federationAddDialogTitle": "Issue an enrollment token",
"federationNameLabel": "Satellite name",
"federationIssueButton": "Issue",
"federationIssueFailed": "Could not issue token: {error}",
"@federationIssueFailed": {
"placeholders": {
"error": {"type": "String"}
}
},
"federationEnrollmentTitle": "Enroll {name}",
"@federationEnrollmentTitle": {
"placeholders": {
"name": {"type": "String"}
}
},
"federationTokenLabel": "Bootstrap token (single use)",
"federationConfigLabel": "Satellite config (paste into the satellite)",
"federationCopied": "Copied to clipboard",
"federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite's first connect tamper-proof."
} }

View file

@ -4279,6 +4279,305 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Install failed: {error}'** /// **'Install failed: {error}'**
String installFailed(String 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;
/// No description provided for @navFederation.
///
/// In en, this message translates to:
/// **'Federation'**
String get navFederation;
/// No description provided for @federationTitle.
///
/// In en, this message translates to:
/// **'Federation'**
String get federationTitle;
/// No description provided for @federationReloadTooltip.
///
/// In en, this message translates to:
/// **'Reload satellites'**
String get federationReloadTooltip;
/// No description provided for @federationAddSatellite.
///
/// In en, this message translates to:
/// **'Add satellite'**
String get federationAddSatellite;
/// No description provided for @federationEmptyTitle.
///
/// In en, this message translates to:
/// **'No satellites connected'**
String get federationEmptyTitle;
/// No description provided for @federationEmptyHint.
///
/// In en, this message translates to:
/// **'Connect another hub as a satellite to run capabilities on it. Issue an enrollment token to get started.'**
String get federationEmptyHint;
/// No description provided for @federationPillConnected.
///
/// In en, this message translates to:
/// **'connected'**
String get federationPillConnected;
/// No description provided for @federationRegionNone.
///
/// In en, this message translates to:
/// **'no region'**
String get federationRegionNone;
/// No description provided for @federationCapabilities.
///
/// In en, this message translates to:
/// **'{count} advertised capabilities'**
String federationCapabilities(int count);
/// No description provided for @federationAddDialogTitle.
///
/// In en, this message translates to:
/// **'Issue an enrollment token'**
String get federationAddDialogTitle;
/// No description provided for @federationNameLabel.
///
/// In en, this message translates to:
/// **'Satellite name'**
String get federationNameLabel;
/// No description provided for @federationIssueButton.
///
/// In en, this message translates to:
/// **'Issue'**
String get federationIssueButton;
/// No description provided for @federationIssueFailed.
///
/// In en, this message translates to:
/// **'Could not issue token: {error}'**
String federationIssueFailed(String error);
/// No description provided for @federationEnrollmentTitle.
///
/// In en, this message translates to:
/// **'Enroll {name}'**
String federationEnrollmentTitle(String name);
/// No description provided for @federationTokenLabel.
///
/// In en, this message translates to:
/// **'Bootstrap token (single use)'**
String get federationTokenLabel;
/// No description provided for @federationConfigLabel.
///
/// In en, this message translates to:
/// **'Satellite config (paste into the satellite)'**
String get federationConfigLabel;
/// No description provided for @federationCopied.
///
/// In en, this message translates to:
/// **'Copied to clipboard'**
String get federationCopied;
/// No description provided for @federationEnrollmentHint.
///
/// In en, this message translates to:
/// **'Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite\'s first connect tamper-proof.'**
String get federationEnrollmentHint;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -2502,4 +2502,197 @@ class AppLocalizationsDe extends AppLocalizations {
String installFailed(String error) { String installFailed(String error) {
return 'Installation fehlgeschlagen: $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';
@override
String get navFederation => 'Föderation';
@override
String get federationTitle => 'Föderation';
@override
String get federationReloadTooltip => 'Satelliten neu laden';
@override
String get federationAddSatellite => 'Satellit hinzufügen';
@override
String get federationEmptyTitle => 'Keine Satelliten verbunden';
@override
String get federationEmptyHint =>
'Verbinde einen weiteren Hub als Satellit, um Capabilities dort auszuführen. Stelle dazu ein Enrollment-Token aus.';
@override
String get federationPillConnected => 'verbunden';
@override
String get federationRegionNone => 'keine Region';
@override
String federationCapabilities(int count) {
return '$count angebotene Capabilities';
}
@override
String get federationAddDialogTitle => 'Enrollment-Token ausstellen';
@override
String get federationNameLabel => 'Satelliten-Name';
@override
String get federationIssueButton => 'Ausstellen';
@override
String federationIssueFailed(String error) {
return 'Token konnte nicht ausgestellt werden: $error';
}
@override
String federationEnrollmentTitle(String name) {
return '$name einrichten';
}
@override
String get federationTokenLabel => 'Bootstrap-Token (einmalig)';
@override
String get federationConfigLabel =>
'Satelliten-Konfiguration (in den Satelliten einfügen)';
@override
String get federationCopied => 'In die Zwischenablage kopiert';
@override
String get federationEnrollmentHint =>
'Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA macht den ersten Connect des Satelliten manipulationssicher.';
} }

View file

@ -2506,4 +2506,196 @@ class AppLocalizationsEn extends AppLocalizations {
String installFailed(String error) { String installFailed(String error) {
return 'Install failed: $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';
@override
String get navFederation => 'Federation';
@override
String get federationTitle => 'Federation';
@override
String get federationReloadTooltip => 'Reload satellites';
@override
String get federationAddSatellite => 'Add satellite';
@override
String get federationEmptyTitle => 'No satellites connected';
@override
String get federationEmptyHint =>
'Connect another hub as a satellite to run capabilities on it. Issue an enrollment token to get started.';
@override
String get federationPillConnected => 'connected';
@override
String get federationRegionNone => 'no region';
@override
String federationCapabilities(int count) {
return '$count advertised capabilities';
}
@override
String get federationAddDialogTitle => 'Issue an enrollment token';
@override
String get federationNameLabel => 'Satellite name';
@override
String get federationIssueButton => 'Issue';
@override
String federationIssueFailed(String error) {
return 'Could not issue token: $error';
}
@override
String federationEnrollmentTitle(String name) {
return 'Enroll $name';
}
@override
String get federationTokenLabel => 'Bootstrap token (single use)';
@override
String get federationConfigLabel =>
'Satellite config (paste into the satellite)';
@override
String get federationCopied => 'Copied to clipboard';
@override
String get federationEnrollmentHint =>
'Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite\'s first connect tamper-proof.';
} }

View file

@ -16,6 +16,7 @@ import 'l10n/app_localizations.dart';
import 'pages/approvals.dart'; import 'pages/approvals.dart';
import 'pages/audit.dart'; import 'pages/audit.dart';
import 'pages/doctor.dart'; import 'pages/doctor.dart';
import 'pages/federation.dart';
import 'pages/flows.dart'; import 'pages/flows.dart';
import 'pages/store.dart'; import 'pages/store.dart';
import 'pages/welcome.dart'; import 'pages/welcome.dart';
@ -27,7 +28,7 @@ import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the /// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header /// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build. /// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.69.0'; const String kStudioVersion = '0.70.0';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -35,6 +36,7 @@ Future<void> main() async {
// (when installed) the operator's theme-plugin choice // (when installed) the operator's theme-plugin choice
// before the first frame so nothing flickers on startup. // before the first frame so nothing flickers on startup.
await HubService.instance.loadPersistedEndpoint(); await HubService.instance.loadPersistedEndpoint();
await SystemActions.loadFaiBinaryOverride();
final themeMode = await HubService.instance.loadThemeMode(); final themeMode = await HubService.instance.loadThemeMode();
final locale = await HubService.instance.loadLocale(); final locale = await HubService.instance.loadLocale();
final themePlugin = await loadActiveThemePlugin(); final themePlugin = await loadActiveThemePlugin();
@ -245,6 +247,42 @@ class StudioShellState extends State<StudioShell> {
String? _activeChannel; // local / dev / beta / production String? _activeChannel; // local / dev / beta / production
Timer? _healthPoll; 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 /// Switch the sidebar's selected page by its registered id
/// (`store`, `flows`, `audit`, etc.). Used by the welcome /// (`store`, `flows`, `audit`, etc.). Used by the welcome
/// page's onboarding checklist to make each item a real /// page's onboarding checklist to make each item a real
@ -311,6 +349,12 @@ class StudioShellState extends State<StudioShell> {
selectedIcon: Icons.inbox, selectedIcon: Icons.inbox,
page: ApprovalsPage(), page: ApprovalsPage(),
), ),
_NavPage(
id: 'federation',
icon: Icons.hub_outlined,
selectedIcon: Icons.hub,
page: FederationPage(),
),
]; ];
/// Pending-approval count surfaced as a sidebar badge so the /// Pending-approval count surfaced as a sidebar badge so the
@ -331,7 +375,17 @@ class StudioShellState extends State<StudioShell> {
Future<void> _checkHealth() async { Future<void> _checkHealth() async {
final ok = await HubService.instance.healthy(); final ok = await HubService.instance.healthy();
if (!mounted) return; 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) { if (ok) {
try { try {
final snap = await HubService.instance.channelStatus(); final snap = await HubService.instance.channelStatus();
@ -453,6 +507,14 @@ class StudioShellState extends State<StudioShell> {
pendingApprovals: _pendingApprovals, pendingApprovals: _pendingApprovals,
), ),
Container(width: 1, color: theme.colorScheme.outlineVariant), Container(width: 1, color: theme.colorScheme.outlineVariant),
Expanded(
child: Column(
children: [
if (_hubUnreachable)
_HubUnreachableBanner(
endpoint: HubService.instance.endpointLabel,
onOpenSettings: () => FaiSettingsDialog.show(context),
),
Expanded( Expanded(
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: FaiMotion.base, duration: FaiMotion.base,
@ -477,6 +539,61 @@ 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),
),
],
), ),
), ),
); );
@ -553,7 +670,12 @@ class _SidebarState extends State<_Sidebar>
super.initState(); super.initState();
_ctrl = AnimationController( _ctrl = AnimationController(
vsync: this, vsync: this,
duration: FaiMotion.fast, // Expand uses the slow (~320 ms) token: the iconlabel
// 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, value: 0,
); );
} }
@ -565,14 +687,14 @@ class _SidebarState extends State<_Sidebar>
} }
Future<void> _startDaemon(BuildContext context) async { Future<void> _startDaemon(BuildContext context) async {
final l = AppLocalizations.of(context)!;
final r = await SystemActions.faiDaemon(['start']); final r = await SystemActions.faiDaemon(['start']);
if (!context.mounted) return; if (!context.mounted) return;
final detail = r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
r.ok r.ok ? l.daemonStartRequested : l.daemonStartFailed(detail),
? 'Daemon start requested. Reconnecting…'
: 'Could not start daemon: ${r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim()}',
), ),
), ),
); );
@ -1161,7 +1283,16 @@ class _SidebarItemState extends State<_SidebarItem> {
// the left. The bg highlight spans the rail's // the left. The bg highlight spans the rail's
// current width (typical nav-rail behaviour). // current width (typical nav-rail behaviour).
padding: const EdgeInsets.symmetric(vertical: FaiSpace.md), 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, child: content,
), ),
), ),
@ -1273,6 +1404,8 @@ class _NavPage {
return l.navAudit; return l.navAudit;
case 'approvals': case 'approvals':
return l.navApprovals; return l.navApprovals;
case 'federation':
return l.navFederation;
default: default:
return id; return id;
} }

View file

@ -973,7 +973,7 @@ class _ExplanationPanel extends StatelessWidget {
: theme.colorScheme.error, : theme.colorScheme.error,
), ),
), ),
if (result!.fixHint.isNotEmpty) ...[ if (result!.fixHint(l).isNotEmpty) ...[
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
Text( Text(
l.auditFixLabel, l.auditFixLabel,
@ -984,7 +984,7 @@ class _ExplanationPanel extends StatelessWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
result!.fixHint, result!.fixHint(l),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface, color: theme.colorScheme.onSurface,
), ),

View file

@ -402,7 +402,15 @@ class _PathRow extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
final isLog = !isDirectory && path.toLowerCase().endsWith('.log'); final lower = path.toLowerCase();
final isLog = !isDirectory && lower.endsWith('.log');
// Text config files get an in-Studio viewer too (read top-down,
// no log colouring) so the operator can read config.yaml
// without leaving Studio or hunting for an external editor.
final isConfig = !isDirectory &&
(lower.endsWith('.yaml') ||
lower.endsWith('.yml') ||
lower.endsWith('.toml'));
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Row( child: Row(
@ -428,10 +436,11 @@ class _PathRow extends StatelessWidget {
), ),
), ),
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
if (isLog) ...[ if (isLog || isConfig) ...[
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () => onPressed: () => isConfig
showFaiLogViewer(context, path: path, title: label), ? showFaiConfigViewer(context, path: path, title: label)
: showFaiLogViewer(context, path: path, title: label),
icon: const Icon(Icons.visibility_outlined, size: 14), icon: const Icon(Icons.visibility_outlined, size: 14),
label: Text(l.buttonView), label: Text(l.buttonView),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
@ -482,6 +491,11 @@ class _DaemonActionsCard extends StatefulWidget {
class _DaemonActionsCardState extends State<_DaemonActionsCard> { class _DaemonActionsCardState extends State<_DaemonActionsCard> {
bool _busy = false; bool _busy = false;
String? _output; String? _output;
/// True when the last daemon action failed because no `fai`
/// binary could be located. Swaps the raw output line for the
/// actionable [FaiBinaryRecovery] block.
bool _binaryMissing = false;
ChannelStatusSnapshot? _channels; ChannelStatusSnapshot? _channels;
@override @override
@ -511,10 +525,16 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
final r = await action(); final r = await action();
if (!mounted) return; if (!mounted) return;
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
final binaryMissing = !r.ok && r.stderr.trim() == kFaiBinaryNotFound;
setState(() { setState(() {
_busy = false; _busy = false;
_output = _binaryMissing = binaryMissing;
(r.ok // When the binary is missing we render the actionable
// recovery block instead of a raw output line the
// sentinel is not a user-facing string.
_output = binaryMissing
? null
: (r.ok
? '${l.daemonActionResultOk(label)}\n${r.stdout}' ? '${l.daemonActionResultOk(label)}\n${r.stdout}'
: '${l.daemonActionResultFailed(label)}\n${r.stderr.isEmpty ? r.stdout : r.stderr}') : '${l.daemonActionResultFailed(label)}\n${r.stderr.isEmpty ? r.stdout : r.stderr}')
.trim(); .trim();
@ -659,6 +679,15 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
], ],
), ),
], ],
if (_binaryMissing) ...[
const SizedBox(height: FaiSpace.md),
FaiBinaryRecovery(
onLocated: () => _run(
l.daemonActionStatus,
() => SystemActions.faiDaemon(['status']),
),
),
],
if (_output != null) ...[ if (_output != null) ...[
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
Container( Container(

334
lib/pages/federation.dart Normal file
View file

@ -0,0 +1,334 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../data/hub.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
import 'welcome.dart' show showFaiDoc;
/// Federation panel: the satellites connected to this hub (primary
/// side) and a one-step "add satellite" flow that issues a bootstrap
/// token + the primary CA as a ready-to-paste config.
class FederationPage extends StatefulWidget {
const FederationPage({super.key});
@override
State<FederationPage> createState() => _FederationPageState();
}
class _FederationPageState extends State<FederationPage> {
late Future<List<Satellite>> _future;
@override
void initState() {
super.initState();
_refresh();
}
void _refresh() {
setState(() {
_future = HubService.instance.listSatellites();
});
}
Future<void> _addSatellite() async {
final name = await _promptName(context);
if (name == null || name.isEmpty) return;
if (!mounted) return;
final l = AppLocalizations.of(context)!;
try {
final enrollment = await HubService.instance.issueSatelliteToken(name);
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (_) => _EnrollmentDialog(name: name, enrollment: enrollment),
);
_refresh();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.federationIssueFailed(e.toString()))),
);
}
}
Future<String?> _promptName(BuildContext context) async {
final controller = TextEditingController();
final l = AppLocalizations.of(context)!;
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.federationAddDialogTitle),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
labelText: l.federationNameLabel,
hintText: 'satellite-a',
border: const OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, null),
child: Text(l.buttonCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, controller.text.trim()),
child: Text(l.federationIssueButton),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(
title: Text(l.federationTitle),
actions: [
IconButton(
icon: const Icon(Icons.help_outline, size: 18),
tooltip: l.helpTooltip,
onPressed: () => showFaiDoc(context, 'federation'),
),
IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: l.federationReloadTooltip,
onPressed: _refresh,
),
const SizedBox(width: FaiSpace.sm),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _addSatellite,
icon: const Icon(Icons.add_link),
label: Text(l.federationAddSatellite),
),
body: FutureBuilder<List<Satellite>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: l.hubUnreachable,
hint: l.hubUnreachableHint,
action: FilledButton.tonal(
onPressed: _refresh,
child: Text(l.buttonRetry),
),
);
}
final sats = snapshot.data ?? [];
if (sats.isEmpty) {
return FaiEmptyState(
icon: Icons.hub_outlined,
title: l.federationEmptyTitle,
hint: l.federationEmptyHint,
action: FilledButton.icon(
onPressed: _addSatellite,
icon: const Icon(Icons.add_link, size: 16),
label: Text(l.federationAddSatellite),
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: sats.length,
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
itemBuilder: (context, i) => _SatelliteCard(satellite: sats[i]),
);
},
),
);
}
}
class _SatelliteCard extends StatelessWidget {
final Satellite satellite;
const _SatelliteCard({required this.satellite});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return FaiCard(
accentTop: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
FaiPill(
label: l.federationPillConnected,
tone: FaiPillTone.success,
icon: Icons.link,
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Text(
satellite.displayName,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
FaiPill(
label: satellite.region.isEmpty
? l.federationRegionNone
: satellite.region,
tone: FaiPillTone.neutral,
icon: Icons.public,
),
],
),
const SizedBox(height: FaiSpace.md),
Wrap(
spacing: FaiSpace.md,
runSpacing: FaiSpace.xs,
children: [
_meta(theme, 'v${satellite.hubVersion}'),
_meta(theme, 'wire ${satellite.wireVersion}'),
_meta(theme, satellite.hubId),
],
),
if (satellite.capabilities.isNotEmpty) ...[
const SizedBox(height: FaiSpace.md),
Text(
l.federationCapabilities(satellite.capabilities.length),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
const SizedBox(height: 4),
Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
children: satellite.capabilities
.map(
(c) => FaiPill(
label: c,
tone: FaiPillTone.accent,
icon: Icons.extension_outlined,
),
)
.toList(),
),
],
],
),
);
}
Widget _meta(ThemeData theme, String text) => Text(
text,
style: FaiTheme.mono(size: 10, color: theme.colorScheme.onSurfaceVariant),
);
}
/// Shows the freshly issued token + a ready-to-paste satellite config
/// (with the primary CA inlined) the operator copies over.
class _EnrollmentDialog extends StatelessWidget {
final String name;
final SatelliteEnrollment enrollment;
const _EnrollmentDialog({required this.name, required this.enrollment});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final config = enrollment.toConfigYaml(name);
return AlertDialog(
title: Text(l.federationEnrollmentTitle(name)),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 520),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(l.federationTokenLabel, style: theme.textTheme.labelSmall),
const SizedBox(height: 4),
_CopyableBlock(text: enrollment.token, copiedMsg: l.federationCopied),
const SizedBox(height: FaiSpace.md),
Text(l.federationConfigLabel, style: theme.textTheme.labelSmall),
const SizedBox(height: 4),
_CopyableBlock(text: config, copiedMsg: l.federationCopied),
const SizedBox(height: FaiSpace.md),
Text(
l.federationEnrollmentHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l.buttonClose),
),
],
);
}
}
class _CopyableBlock extends StatelessWidget {
final String text;
final String copiedMsg;
const _CopyableBlock({required this.text, required this.copiedMsg});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Stack(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(
FaiSpace.md,
FaiSpace.md,
40,
FaiSpace.md,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: SelectableText(
text,
style: FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface),
),
),
Positioned(
top: 2,
right: 2,
child: IconButton(
icon: const Icon(Icons.copy, size: 16),
tooltip: copiedMsg,
onPressed: () {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(copiedMsg)));
},
),
),
],
);
}
}

View file

@ -1792,7 +1792,7 @@ class _FeaturedTile extends StatelessWidget {
} }
} }
class _StoreCard extends StatelessWidget { class _StoreCard extends StatefulWidget {
final StoreItem item; final StoreItem item;
final String locale; final String locale;
@ -1810,6 +1810,19 @@ class _StoreCard extends StatelessWidget {
required this.onInstall, required this.onInstall,
}); });
@override
State<_StoreCard> createState() => _StoreCardState();
}
class _StoreCardState extends State<_StoreCard> {
bool _hovered = false;
StoreItem get item => widget.item;
String get locale => widget.locale;
String? get installedVersion => widget.installedVersion;
VoidCallback get onTap => widget.onTap;
VoidCallback get onInstall => widget.onInstall;
/// True iff the operator has this module installed AND the /// True iff the operator has this module installed AND the
/// store-index offers a strictly-newer best_version. Uses a /// store-index offers a strictly-newer best_version. Uses a
/// dotted-numeric semver-light compare so 0.10.5 > 0.9.99. /// dotted-numeric semver-light compare so 0.10.5 > 0.9.99.
@ -1836,6 +1849,22 @@ class _StoreCard extends StatelessWidget {
final notInstallable = !installable && !item.installed; final notInstallable = !installable && !item.installed;
return Opacity( return Opacity(
opacity: notInstallable ? 0.6 : 1.0, opacity: notInstallable ? 0.6 : 1.0,
// Hover-lift: a 2px rise plus shadow bump signals the card
// is clickable without a heavy hover fill. Resting state
// carries the low elevation so cards read as physical.
child: 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)
: FaiElevation.low(theme.brightness),
),
child: Material( child: Material(
color: theme.colorScheme.surfaceContainer, color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.md), borderRadius: BorderRadius.circular(FaiRadius.md),
@ -1876,7 +1905,10 @@ class _StoreCard extends StatelessWidget {
Text( Text(
item.category.isEmpty item.category.isEmpty
? '' ? ''
: _categoryDisplayName(context, item.category), : _categoryDisplayName(
context,
item.category,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
@ -1980,6 +2012,8 @@ class _StoreCard extends StatelessWidget {
), ),
), ),
), ),
),
),
); );
} }
} }
@ -2003,6 +2037,7 @@ class _StoreDetailSheet extends StatefulWidget {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surfaceContainer, backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
elevation: 8,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)), borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
), ),

View file

@ -28,6 +28,11 @@ class WelcomePage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.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( return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor, backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(titleSpacing: FaiSpace.xl, title: Text(l.navWelcome)), appBar: AppBar(titleSpacing: FaiSpace.xl, title: Text(l.navWelcome)),
@ -52,22 +57,27 @@ class WelcomePage extends StatelessWidget {
constraints: const BoxConstraints(maxWidth: 960), constraints: const BoxConstraints(maxWidth: 960),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: const [ children: [
_Hero(), const _Hero(),
SizedBox(height: FaiSpace.xxl), const SizedBox(height: FaiSpace.xxl),
// Checklist comes second operator-actionable // When the hub is down the onboarding checklist
// path beats educational content. Auto-hides // would render dead (all-unchecked, nothing to
// once the operator dismisses it. // probe). Lead with a "start the hub" card
_OnboardingChecklist(), // instead; restore the checklist once connected.
_PillarRow(), if (hubDown) ...[
SizedBox(height: FaiSpace.xxl), const _HubDownHero(),
_SectionLabel(textKey: _SectionLabelKey.trust), const SizedBox(height: FaiSpace.xxl),
SizedBox(height: FaiSpace.md), ] else
_TrustPosture(), const _OnboardingChecklist(),
SizedBox(height: FaiSpace.xxl), const _PillarRow(),
_SectionLabel(textKey: _SectionLabelKey.docs), const SizedBox(height: FaiSpace.xxl),
SizedBox(height: FaiSpace.md), const _SectionLabel(textKey: _SectionLabelKey.trust),
_DocsRow(), 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 } enum _SectionLabelKey { trust, docs }
class _SectionLabel extends StatelessWidget { class _SectionLabel extends StatelessWidget {
@ -904,15 +1021,37 @@ class _DocsRow extends StatelessWidget {
} }
} }
class _DocCard extends StatelessWidget { class _DocCard extends StatefulWidget {
final _DocEntry entry; final _DocEntry entry;
const _DocCard({required this.entry}); const _DocCard({required this.entry});
@override
State<_DocCard> createState() => _DocCardState();
}
class _DocCardState extends State<_DocCard> {
bool _hovered = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
return Material( 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, color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.md), borderRadius: BorderRadius.circular(FaiRadius.md),
child: InkWell( child: InkWell(
@ -959,6 +1098,8 @@ class _DocCard extends StatelessWidget {
), ),
), ),
), ),
),
),
); );
} }
} }
@ -983,6 +1124,15 @@ class _DocReaderError {
String get forgejoUrl => String get forgejoUrl =>
'https://git.flemming.ai/fai/platform/src/branch/main/docs/architecture/$slug.md'; '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 @override
String toString() => String toString() =>
'Could not load the bundled doc "$slug" ($locale).\n' 'Could not load the bundled doc "$slug" ($locale).\n'
@ -1006,6 +1156,7 @@ class _DocReaderSheet extends StatefulWidget {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surfaceContainer, backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
elevation: 8,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)), borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
), ),
@ -1135,7 +1286,18 @@ class _DocReaderSheetState extends State<_DocReaderSheet> {
), ),
), ),
const SizedBox(height: FaiSpace.sm), 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) ...[ if (structured != null) ...[
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
TextButton.icon( TextButton.icon(

View file

@ -2,55 +2,75 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:google_fonts/google_fonts.dart';
import 'tokens.dart'; import 'tokens.dart';
class FaiTheme { class FaiTheme {
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, /// Inter for UI, JetBrains Mono for technical strings (IDs,
/// paths, capability refs). Both via google_fonts so they /// paths, capability refs). Both bundled (see [_uiFamily]).
/// bundle on first run; for production we'd ship them as
/// app assets.
static TextTheme _textTheme(Brightness b) { static TextTheme _textTheme(Brightness b) {
final base = b == Brightness.dark final base = b == Brightness.dark
? Typography.whiteCupertino ? Typography.whiteCupertino
: Typography.blackCupertino; : Typography.blackCupertino;
return GoogleFonts.interTextTheme(base).copyWith( return base.apply(fontFamily: _uiFamily).copyWith(
displaySmall: GoogleFonts.inter( displaySmall: const TextStyle(
fontFamily: _uiFamily,
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.4, letterSpacing: -0.4,
), ),
headlineSmall: GoogleFonts.inter( headlineSmall: const TextStyle(
fontFamily: _uiFamily,
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.2, letterSpacing: -0.2,
), ),
titleMedium: GoogleFonts.inter( titleMedium: const TextStyle(
fontFamily: _uiFamily,
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0, letterSpacing: 0,
), ),
titleSmall: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500), titleSmall: const TextStyle(
bodyLarge: GoogleFonts.inter( fontFamily: _uiFamily,
fontSize: 13,
fontWeight: FontWeight.w500,
),
bodyLarge: const TextStyle(
fontFamily: _uiFamily,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
height: 1.45, height: 1.45,
), ),
bodyMedium: GoogleFonts.inter( bodyMedium: const TextStyle(
fontFamily: _uiFamily,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
height: 1.4, height: 1.4,
), ),
bodySmall: GoogleFonts.inter( bodySmall: const TextStyle(
fontFamily: _uiFamily,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
height: 1.4, height: 1.4,
), ),
labelMedium: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w500), labelMedium: const TextStyle(
labelSmall: GoogleFonts.inter( fontFamily: _uiFamily,
fontSize: 12,
fontWeight: FontWeight.w500,
),
labelSmall: const TextStyle(
fontFamily: _uiFamily,
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
letterSpacing: 0.2, letterSpacing: 0.2,
@ -59,12 +79,14 @@ class FaiTheme {
} }
/// JetBrains Mono for monospaced technical content. Used by /// 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({ static TextStyle mono({
double size = 12, double size = 12,
FontWeight weight = FontWeight.w400, FontWeight weight = FontWeight.w400,
Color? color, Color? color,
}) => GoogleFonts.jetBrainsMono( }) => TextStyle(
fontFamily: _monoFamily,
fontSize: size, fontSize: size,
fontWeight: weight, fontWeight: weight,
color: color, color: color,
@ -221,8 +243,8 @@ class FaiTheme {
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
centerTitle: false, centerTitle: false,
// Bake the foreground colour straight into the title // Bake the foreground colour straight into the title
// style. `headlineSmall` is built from GoogleFonts.inter // style. `headlineSmall` is built from the bundled Inter
// with no color slot, and Material's "merge foreground // family with no color slot, and Material's "merge foreground
// colour at draw time" path leaves it null in some // colour at draw time" path leaves it null in some
// light-mode builds the title rendered white-on-white. // light-mode builds the title rendered white-on-white.
titleTextStyle: textTheme.headlineSmall?.copyWith( titleTextStyle: textTheme.headlineSmall?.copyWith(

View file

@ -61,6 +61,50 @@ class FaiRadius {
static const md = 10.0; static const md = 10.0;
} }
/// Elevation tokens. Subtle, Linear/Raycast-style depth never
/// heavy drop-shadows. Two resting levels plus a hover bump.
/// Brightness-aware: dark themes lean on deeper, lower-alpha
/// shadows (they read as ambient occlusion against near-black
/// surfaces); light themes use softer, slightly larger spreads.
class FaiElevation {
FaiElevation._();
/// Resting elevation for cards and sheets. Just enough to lift
/// a surface off the canvas without announcing itself.
static List<BoxShadow> low(Brightness b) => b == Brightness.dark
? const [
BoxShadow(
color: Color(0x33000000),
blurRadius: 6,
offset: Offset(0, 2),
),
]
: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 8,
offset: Offset(0, 2),
),
];
/// Raised elevation for hover-lifted cards and floating sheets.
static List<BoxShadow> medium(Brightness b) => b == Brightness.dark
? const [
BoxShadow(
color: Color(0x4D000000),
blurRadius: 16,
offset: Offset(0, 6),
),
]
: const [
BoxShadow(
color: Color(0x1F000000),
blurRadius: 20,
offset: Offset(0, 8),
),
];
}
/// Animation durations. Keep them under 300ms for power-user feel. /// Animation durations. Keep them under 300ms for power-user feel.
class FaiMotion { class FaiMotion {
FaiMotion._(); FaiMotion._();

View 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),
),
],
),
],
),
);
}
}

View file

@ -1,9 +1,17 @@
// FaiEmptyState gracious empty / loading / error placeholder. // FaiEmptyState gracious empty / loading / error placeholder.
// Replaces "(no data)" strings with one tone, one icon, one tip. // Replaces "(no data)" strings with one tone, one icon, one tip.
//
// The blank surface is where an app most easily feels unfinished,
// so this state is deliberately *designed*: the FI delta ()
// sits as a soft watermark behind the icon badge, the badge
// carries a whisper of elevation, and the copy breathes. The
// result reads "nothing here yet, on purpose" rather than
// "something is broken".
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import 'fai_delta_mark.dart';
class FaiEmptyState extends StatelessWidget { class FaiEmptyState extends StatelessWidget {
final IconData icon; final IconData icon;
@ -30,40 +38,67 @@ class FaiEmptyState extends StatelessWidget {
final color = iconColor ?? theme.colorScheme.onSurfaceVariant; final color = iconColor ?? theme.colorScheme.onSurfaceVariant;
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420), constraints: const BoxConstraints(maxWidth: 440),
child: Padding( child: Padding(
padding: const EdgeInsets.all(FaiSpace.xxl), padding: const EdgeInsets.all(FaiSpace.xxl),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Brand hero: an oversized, low-opacity watermark
// anchors the composition while the contextual icon
// badge sits crisply on top so every empty surface
// still carries the FI signature, never a bare icon.
SizedBox(
width: 112,
height: 112,
child: Stack(
alignment: Alignment.center,
children: [
Opacity(
opacity: 0.10,
child: FaiDeltaMark(
color: theme.colorScheme.primary,
size: 112,
),
),
Container( Container(
width: 56, width: 60,
height: 56, height: 60,
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: 0.1), color: theme.colorScheme.surfaceContainerHigh,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(
color: color.withValues(alpha: 0.18),
), ),
child: Icon(icon, size: 24, color: color), boxShadow: FaiElevation.low(theme.brightness),
), ),
const SizedBox(height: FaiSpace.lg), child: Icon(icon, size: 26, color: color),
),
],
),
),
const SizedBox(height: FaiSpace.xl),
Text( Text(
title, title,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.titleMedium, style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
), ),
if (hint != null) ...[ if (hint != null) ...[
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
Text( Text(
hint!, hint!,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
), ),
), ),
], ],
if (action != null) ...[ if (action != null) ...[
const SizedBox(height: FaiSpace.lg), const SizedBox(height: FaiSpace.xl),
action!, action!,
], ],
], ],

View file

@ -36,6 +36,8 @@ Future<void> showFaiLogViewer(
required String path, required String path,
String? title, String? title,
int tailLines = 500, int tailLines = 500,
bool fromTop = false,
bool plain = false,
}) { }) {
return showDialog<void>( return showDialog<void>(
context: context, context: context,
@ -44,20 +46,51 @@ Future<void> showFaiLogViewer(
path: path, path: path,
title: title ?? path.split(Platform.pathSeparator).last, title: title ?? path.split(Platform.pathSeparator).last,
tailLines: tailLines, tailLines: tailLines,
fromTop: fromTop,
plain: plain,
), ),
); );
} }
/// Open a config / plain-text file (e.g. `config.yaml`) inside the
/// same modal viewer, read top-down with no log-level colouring.
/// A thin wrapper over [showFaiLogViewer] so call sites read by
/// intent.
Future<void> showFaiConfigViewer(
BuildContext context, {
required String path,
String? title,
int maxLines = 2000,
}) {
return showFaiLogViewer(
context,
path: path,
title: title,
tailLines: maxLines,
fromTop: true,
plain: true,
);
}
class FaiLogViewer extends StatefulWidget { class FaiLogViewer extends StatefulWidget {
final String path; final String path;
final String title; final String title;
final int tailLines; final int tailLines;
/// Read from the top of the file (config) vs the tail (logs).
final bool fromTop;
/// Skip log-level colouring and render every line neutral
/// (for config / plain-text files).
final bool plain;
const FaiLogViewer({ const FaiLogViewer({
super.key, super.key,
required this.path, required this.path,
required this.title, required this.title,
this.tailLines = 500, this.tailLines = 500,
this.fromTop = false,
this.plain = false,
}); });
@override @override
@ -77,7 +110,11 @@ class _FaiLogViewerState extends State<FaiLogViewer> {
Future<void> _reload() async { Future<void> _reload() async {
setState(() => _loading = true); setState(() => _loading = true);
final lines = await _readTail(widget.path, widget.tailLines); final lines = await _readLines(
widget.path,
widget.tailLines,
fromTop: widget.fromTop,
);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_lines = lines; _lines = lines;
@ -141,7 +178,7 @@ class _FaiLogViewerState extends State<FaiLogViewer> {
), ),
), ),
) )
: _LogBody(lines: _lines), : _LogBody(lines: _lines, plain: widget.plain),
), ),
], ],
), ),
@ -249,8 +286,9 @@ class _Header extends StatelessWidget {
class _LogBody extends StatelessWidget { class _LogBody extends StatelessWidget {
final List<String> lines; final List<String> lines;
final bool plain;
const _LogBody({required this.lines}); const _LogBody({required this.lines, this.plain = false});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -265,6 +303,7 @@ class _LogBody extends StatelessWidget {
text: lines[i], text: lines[i],
gutterWidth: gutterWidth, gutterWidth: gutterWidth,
theme: theme, theme: theme,
plain: plain,
), ),
), ),
); );
@ -276,12 +315,14 @@ class _LogLine extends StatelessWidget {
final String text; final String text;
final double gutterWidth; final double gutterWidth;
final ThemeData theme; final ThemeData theme;
final bool plain;
const _LogLine({ const _LogLine({
required this.lineNumber, required this.lineNumber,
required this.text, required this.text,
required this.gutterWidth, required this.gutterWidth,
required this.theme, required this.theme,
this.plain = false,
}); });
@override @override
@ -307,7 +348,12 @@ class _LogLine extends StatelessWidget {
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
Expanded( Expanded(
child: SelectableText.rich( child: SelectableText.rich(
_highlightLine(text, theme), plain
? TextSpan(
text: text,
style: TextStyle(color: theme.colorScheme.onSurface),
)
: _highlightLine(text, theme),
style: FaiTheme.mono( style: FaiTheme.mono(
size: 11, size: 11,
color: theme.colorScheme.onSurface, color: theme.colorScheme.onSurface,
@ -420,22 +466,43 @@ TextSpan _highlightTraceLine(String line, ThemeData theme) {
); );
} }
/// Read the last [maxLines] of [path]. Returns an empty list on /// Matches ANSI / VT100 escape sequences (colour codes, cursor
/// any I/O failure the viewer renders an empty-state hint in /// moves) plus stray control characters. Daemon logs written to a
/// that case rather than throwing. /// file by older `fai` builds carried `\x1b[..m` colour codes that
Future<List<String>> _readTail(String path, int maxLines) async { /// rendered as garbled "special characters" in the viewer; we
/// strip them defensively so existing log files render clean even
/// though current builds no longer write ANSI to files.
final RegExp _ansiAndControl = RegExp(
r'\x1B\[[0-9;?]*[ -/]*[@-~]|[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]',
);
String _sanitizeLine(String line) => line.replaceAll(_ansiAndControl, '');
/// Read up to [maxLines] of [path], sanitising each line of ANSI /
/// control characters. When [fromTop] is true the *first*
/// [maxLines] are returned (config files, read top-down);
/// otherwise the *last* [maxLines] are returned (logs, tail-first).
/// Returns an empty list on any I/O failure the viewer renders
/// an empty-state hint rather than throwing.
Future<List<String>> _readLines(
String path,
int maxLines, {
bool fromTop = false,
}) async {
try { try {
final f = File(path); final f = File(path);
if (!await f.exists()) return const <String>[]; if (!await f.exists()) return const <String>[];
final raw = await f.readAsString(); final raw = await f.readAsString();
final lines = raw.split('\n'); final lines = raw.split('\n').map(_sanitizeLine).toList();
// Drop the trailing blank line that `split` leaves behind // Drop the trailing blank line that `split` leaves behind
// when the file ends with `\n`. // when the file ends with `\n`.
if (lines.isNotEmpty && lines.last.isEmpty) { if (lines.isNotEmpty && lines.last.isEmpty) {
lines.removeLast(); lines.removeLast();
} }
if (lines.length <= maxLines) return lines; if (lines.length <= maxLines) return lines;
return lines.sublist(lines.length - maxLines); return fromTop
? lines.sublist(0, maxLines)
: lines.sublist(lines.length - maxLines);
} catch (_) { } catch (_) {
return const <String>[]; return const <String>[];
} }

View file

@ -26,6 +26,7 @@ class FaiModuleSheet extends StatefulWidget {
context: context, context: context,
backgroundColor: Theme.of(context).colorScheme.surfaceContainer, backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
isScrollControlled: true, isScrollControlled: true,
elevation: 8,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)), borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
), ),

View file

@ -293,11 +293,12 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
}); });
final r = await SystemActions.faiChannelSwitch(name); final r = await SystemActions.faiChannelSwitch(name);
if (!mounted) return; if (!mounted) return;
final l = AppLocalizations.of(context)!;
setState(() { setState(() {
_saving = false; _saving = false;
_channelToast = r.ok _channelToast = r.ok
? 'Switched active channel to "$name". Daemon restarted.\n${r.stdout.trim()}' ? '${l.channelSwitchOk(name)}\n${r.stdout.trim()}'
: 'Channel switch failed: ${r.stderr.isEmpty ? r.stdout : r.stderr}'; : l.channelSwitchFailed(r.stderr.isEmpty ? r.stdout : r.stderr);
}); });
if (r.ok) await _loadChannels(); if (r.ok) await _loadChannels();
} }

View file

@ -86,6 +86,26 @@ class _ProviderPreset {
static _ProviderPreset byWire(String wire) { static _ProviderPreset byWire(String wire) {
return all.firstWhere((p) => p.wire == wire, orElse: () => all.first); 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 { class FaiSystemAiEditor extends StatefulWidget {
@ -372,7 +392,7 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
_ProviderDropdown(value: _preset, onChanged: _onProviderChanged), _ProviderDropdown(value: _preset, onChanged: _onProviderChanged),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
_preset.description, _preset.descriptionFor(l),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
@ -634,10 +654,10 @@ class _TestResultPanel extends StatelessWidget {
ok ? l.systemAiReplyPrefix(result.text) : result.text, ok ? l.systemAiReplyPrefix(result.text) : result.text,
style: FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface), 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), const SizedBox(height: FaiSpace.xs),
Text( Text(
result.fixHint, result.fixHint(l),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface, color: theme.colorScheme.onSurface,
), ),

View file

@ -4,6 +4,7 @@
// primitives directly no Material `Card` / generic `Container` // primitives directly no Material `Card` / generic `Container`
// soup in page code. // soup in page code.
export 'fai_binary_recovery.dart';
export 'fai_card.dart'; export 'fai_card.dart';
export 'fai_data_row.dart'; export 'fai_data_row.dart';
export 'fai_delta_mark.dart'; export 'fai_delta_mark.dart';

View file

@ -7,7 +7,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)

View file

@ -57,14 +57,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" 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: collection:
dependency: transitive dependency: transitive
description: description:
@ -235,14 +227,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.5.0" 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: google_identity_services_web:
dependency: transitive dependency: transitive
description: description:
@ -283,14 +267,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.3" version: "2.2.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31
url: "https://pub.dev"
source: hosted
version: "2.0.0"
http: http:
dependency: transitive dependency: transitive
description: description:
@ -323,22 +299,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.20.2" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@ -379,14 +339,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
markdown: markdown:
dependency: transitive dependency: transitive
description: description:
@ -427,22 +379,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.5" 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: path:
dependency: "direct main" dependency: "direct main"
description: description:
@ -451,30 +387,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" 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: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@ -531,22 +443,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" 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: scrollable_positioned_list:
dependency: transitive dependency: transitive
description: description:
@ -802,4 +698,4 @@ packages:
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.11.0 <4.0.0" dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.4" flutter: ">=3.38.0"

View file

@ -1,7 +1,7 @@
name: fai_studio name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub" description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none' publish_to: 'none'
version: 0.69.0 version: 0.70.0
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta
@ -19,7 +19,6 @@ dependencies:
# Sibling package; will move to Forgejo path once published. # Sibling package; will move to Forgejo path once published.
fai_client_sdk: fai_client_sdk:
path: ../fai_client_sdk_dart path: ../fai_client_sdk_dart
google_fonts: ^8.1.0
shared_preferences: ^2.5.5 shared_preferences: ^2.5.5
# Inline README rendering inside the Store detail sheet. # Inline README rendering inside the Store detail sheet.
# `flutter_markdown` is discontinued; `flutter_markdown_plus` # `flutter_markdown` is discontinued; `flutter_markdown_plus`
@ -68,3 +67,24 @@ flutter:
# Air-gap-friendly — no network calls to read these. # Air-gap-friendly — no network calls to read these.
assets: assets:
- assets/docs/ - 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

View file

@ -7,7 +7,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)