feat(studio): central error helpers + inline log viewer + Today CTAs
Some checks failed
Security / Security check (push) Failing after 1s

Bundles the error-UX overhaul and the inline log viewer:

  - New `FaiLog` (`~/.fai/logs/studio-errors.log`, 256 KiB rotation,
    JSON-per-line). Every operator-visible failure is appended so
    `fai admin doctor` and the new viewer can show the trail
    without the operator having to reproduce the failure.
  - New `showFaiErrorSnack` / `showFaiErrorDialog` helpers wrap
    `FaiErrorBox` in copyable surfaces; 27 ad-hoc
    `SnackBar(content: Text(e.toString()))` sites swept to use
    them (settings, doctor, audit, store, module sheet, system-AI
    editor, flow output).
  - New `FaiLogViewer` modal (`showFaiLogViewer`) renders log
    files inline with line numbers, JSON-key + `[level]` token
    colouring, Copy-all, Refresh, Open-externally. Doctor's
    daemon-paths panel grows a "View" button next to "Open" for
    every `.log` row and now also lists the Studio errors log.
  - Today carousel: CTAs now actually re-run search after a
    `filterCategory` / `runQuery` story is tapped (was only
    flipping the chip state). Fallback story list bumped to 8.
  - Editor bumped to git ref carrying 0.15.0 (type-token
    colouring + analyzer diagnostics).

Studio bumped to 0.62.0.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-06-04 02:08:15 +02:00
parent 2731062a13
commit 8f5cd2528b
22 changed files with 1087 additions and 134 deletions

View file

@ -0,0 +1,114 @@
// Single entry point for surfacing an error to the operator.
// Every error pathway Studio shows (SnackBar, dialog, inline
// strip) should go through one of these helpers so the
// operator gets:
//
// a copyable, selectable rendition of the message
// a friendly headline (gRPC codes mapped via
// friendlyError) instead of a wall-of-stack-trace
// an automatic append to ~/.fai/logs/studio-errors.log so
// the trail survives the dismiss action
//
// Call sites used to scatter `ScaffoldMessenger.of(ctx).showSnackBar(
// SnackBar(content: Text(e.toString())))` that violated the
// "errors must be copyable" rule everywhere. These helpers
// replace those open-coded sites.
import 'package:flutter/material.dart';
import '../widgets/fai_error_box.dart';
import '../theme/tokens.dart';
import 'fai_log.dart';
/// Show [error] as a SnackBar with a copy affordance.
///
/// [source] is a short tag (`flows.run`, `audit.clear`, )
/// used to group entries in the on-disk log so an operator or
/// `fai doctor` can scan recent failures by area without
/// reading the full free-text. Keep it stable; treat it like
/// a metric label, not free-form text.
///
/// [context] is the build-context the snack lands on. Falls
/// back to a Material default duration of 6 seconds twice
/// the framework default so the operator has time to click
/// copy on an unfamiliar message.
void showFaiErrorSnack(
BuildContext context,
String source,
Object error, {
String? title,
Duration duration = const Duration(seconds: 6),
}) {
// Fire-and-forget log write. If the disk's full or the dir
// is read-only the UI still surfaces the error.
FaiLog.instance.error(source, error, context: title);
final messenger = ScaffoldMessenger.maybeOf(context);
if (messenger == null) {
// No Scaffold above the caller is likely showing the
// error from inside a dialog. We've already logged; bail
// rather than throw.
return;
}
messenger.hideCurrentSnackBar();
final theme = Theme.of(context);
messenger.showSnackBar(
SnackBar(
duration: duration,
behavior: SnackBarBehavior.floating,
// Default SnackBar padding crushes our error box; reset
// to zero and let the box's own padding take over.
padding: EdgeInsets.zero,
content: Padding(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: FaiSpace.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (title != null) ...[
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.error,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: FaiSpace.xs),
],
FaiErrorBox(error: error, isError: true),
],
),
),
),
);
}
/// Show [error] as a centered modal dialog. Use this when the
/// failure blocks meaningful interaction (auth missing, hub
/// unreachable) a SnackBar would be too easy to dismiss.
Future<void> showFaiErrorDialog(
BuildContext context,
String source,
Object error, {
String? title,
}) async {
FaiLog.instance.error(source, error);
return showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: title == null ? null : Text(title),
content: SizedBox(
width: 480,
child: FaiErrorBox(error: error, isError: true, maxHeight: 240),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).maybePop(),
child: const Text('OK'),
),
],
),
);
}

119
lib/data/fai_log.dart Normal file
View file

@ -0,0 +1,119 @@
// Central error log for Studio. Every error surfaced to the
// operator (SnackBar, error sheet, recoverable dialog) is also
// appended here so `fai doctor` and "Open log" can show what
// went wrong without the operator having to reproduce the
// failure.
//
// Path: `~/.fai/logs/studio-errors.log` (cross-platform via
// HOME / USERPROFILE). One line per event, JSON-shaped so
// `fai doctor` can parse it; humans can still read it because
// the JSON is single-line and short.
//
// Best-effort by design: a failed write to the log file must
// never break the UI layer that asked us to log. We swallow
// IO errors silently the worst case is the operator loses
// the trail of one failure, not the failure itself.
//
// Rotation: when the file passes 256 KiB we move it to
// `studio-errors.log.1` and start fresh. One rotation level is
// enough anything older is noise, and a Studio session
// rarely produces more than a few KiB of error text.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
class FaiLog {
FaiLog._();
static final FaiLog instance = FaiLog._();
// Serialise concurrent writes Future-based mutex so a burst
// of errors (rare but happens during failed install retries)
// doesn't interleave half-lines on disk.
Future<void> _tail = Future.value();
static const int _rotateBytes = 256 * 1024;
String get _path {
final home =
Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ??
'.';
return p.join(home, '.fai', 'logs', 'studio-errors.log');
}
/// Absolute path of the studio-errors log. Public so the
/// Doctor page and the in-app log viewer can read it without
/// hard-coding the layout twice.
String get path => _path;
/// Append a single error event. The [source] is a short tag
/// (`flows.run`, `daemon.logs`, ) that lets a reader group
/// related failures. [error] can be any thrown object gRPC
/// codes, plain strings, exceptions; we serialise it via
/// [error.toString()] so the on-disk record matches what the
/// operator saw on screen.
Future<void> error(String source, Object error, {Object? context}) {
final entry = <String, Object?>{
'ts': DateTime.now().toUtc().toIso8601String(),
'level': 'error',
'source': source,
'error': error.toString(),
if (context != null) 'context': context.toString(),
};
return _append(entry);
}
/// Read the last [maxLines] lines back. Used by `fai doctor`
/// + Settings "Recent errors" view. Returns oldest first so
/// the caller can render in chronological order.
Future<List<String>> tail({int maxLines = 50}) async {
try {
final f = File(_path);
if (!await f.exists()) return const <String>[];
final raw = await f.readAsString();
final lines = raw.split('\n').where((l) => l.isNotEmpty).toList();
if (lines.length <= maxLines) return lines;
return lines.sublist(lines.length - maxLines);
} catch (_) {
return const <String>[];
}
}
Future<void> _append(Map<String, Object?> entry) {
final next = _tail.then((_) async {
try {
final f = File(_path);
final dir = Directory(p.dirname(_path));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
// Rotate before write so a hot-loop never balloons past
// the cap. Single-level rotation: keep the previous file
// as `.1`, anything older drops on the floor.
if (await f.exists()) {
final size = await f.length();
if (size >= _rotateBytes) {
final rotated = File('$_path.1');
if (await rotated.exists()) {
await rotated.delete();
}
await f.rename(rotated.path);
}
}
await f.writeAsString(
'${jsonEncode(entry)}\n',
mode: FileMode.append,
flush: false,
);
} catch (_) {
// Best-effort. A failed log write must not crash the UI.
}
});
_tail = next.catchError((_) {});
return next;
}
}

View file

@ -20,7 +20,19 @@ import 'package:yaml/yaml.dart';
/// closed and ABI-stable adding a new variant without bumping
/// the schema would let an old Studio render an unknown action
/// as a no-op button.
enum TodayCta { none, openSettings }
///
/// `filterCategory` and `runQuery` both consume the optional
/// [TodayStoryData.ctaPayload] a wire-string the Store page
/// dispatches into its filter / search box. Keeping the action
/// surface inside the Store means a hero story can teach about a
/// section without forcing a page-route change.
enum TodayCta {
none,
openSettings,
filterCategory,
runQuery,
showSuggestedSources,
}
/// Bilingual story rendered by the Today-Hero card. Either
/// loaded from disk or supplied as a const fallback.
@ -37,6 +49,11 @@ class TodayStoryData {
final IconData icon;
final TodayCta cta;
/// Wire payload used by [TodayCta.filterCategory] (category id
/// to apply to the Store grid) and [TodayCta.runQuery] (free-text
/// to drop into the search box). Empty for other variants.
final String ctaPayload;
const TodayStoryData({
required this.badgeEn,
required this.badgeDe,
@ -48,6 +65,7 @@ class TodayStoryData {
required this.ctaLabelDe,
required this.icon,
required this.cta,
this.ctaPayload = '',
});
}
@ -130,6 +148,7 @@ class TodayStoryLoader {
ctaLabelDe: s('cta_label_de'),
icon: icon,
cta: cta,
ctaPayload: s('cta_payload'),
);
}
@ -150,6 +169,12 @@ class TodayStoryLoader {
return TodayCta.none;
case 'openSettings':
return TodayCta.openSettings;
case 'filterCategory':
return TodayCta.filterCategory;
case 'runQuery':
return TodayCta.runQuery;
case 'showSuggestedSources':
return TodayCta.showSuggestedSources;
default:
return null;
}

View file

@ -1423,5 +1423,23 @@
"placeholders": {
"name": {"type": "String"}
}
}
},
"buttonView": "Ansehen",
"logViewerTitle": "{title} — letzte {count} Zeilen",
"@logViewerTitle": {
"placeholders": {
"title": {"type": "String"},
"count": {"type": "int"}
}
},
"logViewerEmpty": "Log-Datei ist leer oder existiert noch nicht.",
"logViewerCopyAll": "Alles kopieren",
"logViewerOpenExternal": "Im Standardeditor öffnen",
"logViewerLineCount": "{count, plural, =1{1 Zeile} other{{count} Zeilen}}",
"@logViewerLineCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"doctorPathStudioErrors": "Studio-Fehler"
}

View file

@ -1426,5 +1426,23 @@
"placeholders": {
"name": {"type": "String"}
}
}
},
"buttonView": "View",
"logViewerTitle": "{title} — last {count} lines",
"@logViewerTitle": {
"placeholders": {
"title": {"type": "String"},
"count": {"type": "int"}
}
},
"logViewerEmpty": "Log file is empty or does not exist yet.",
"logViewerCopyAll": "Copy all",
"logViewerOpenExternal": "Open in default editor",
"logViewerLineCount": "{count, plural, =1{1 line} other{{count} lines}}",
"@logViewerLineCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"doctorPathStudioErrors": "Studio errors"
}

View file

@ -3913,6 +3913,48 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'A flow named \"{name}\" already exists.'**
String flowEditorAlreadyExists(String name);
/// No description provided for @buttonView.
///
/// In en, this message translates to:
/// **'View'**
String get buttonView;
/// No description provided for @logViewerTitle.
///
/// In en, this message translates to:
/// **'{title} — last {count} lines'**
String logViewerTitle(String title, int count);
/// No description provided for @logViewerEmpty.
///
/// In en, this message translates to:
/// **'Log file is empty or does not exist yet.'**
String get logViewerEmpty;
/// No description provided for @logViewerCopyAll.
///
/// In en, this message translates to:
/// **'Copy all'**
String get logViewerCopyAll;
/// No description provided for @logViewerOpenExternal.
///
/// In en, this message translates to:
/// **'Open in default editor'**
String get logViewerOpenExternal;
/// No description provided for @logViewerLineCount.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 line} other{{count} lines}}'**
String logViewerLineCount(int count);
/// No description provided for @doctorPathStudioErrors.
///
/// In en, this message translates to:
/// **'Studio errors'**
String get doctorPathStudioErrors;
}
class _AppLocalizationsDelegate

View file

@ -2282,4 +2282,35 @@ class AppLocalizationsDe extends AppLocalizations {
String flowEditorAlreadyExists(String name) {
return 'Ein Flow namens \"$name\" existiert bereits.';
}
@override
String get buttonView => 'Ansehen';
@override
String logViewerTitle(String title, int count) {
return '$title — letzte $count Zeilen';
}
@override
String get logViewerEmpty => 'Log-Datei ist leer oder existiert noch nicht.';
@override
String get logViewerCopyAll => 'Alles kopieren';
@override
String get logViewerOpenExternal => 'Im Standardeditor öffnen';
@override
String logViewerLineCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Zeilen',
one: '1 Zeile',
);
return '$_temp0';
}
@override
String get doctorPathStudioErrors => 'Studio-Fehler';
}

View file

@ -2287,4 +2287,35 @@ class AppLocalizationsEn extends AppLocalizations {
String flowEditorAlreadyExists(String name) {
return 'A flow named \"$name\" already exists.';
}
@override
String get buttonView => 'View';
@override
String logViewerTitle(String title, int count) {
return '$title — last $count lines';
}
@override
String get logViewerEmpty => 'Log file is empty or does not exist yet.';
@override
String get logViewerCopyAll => 'Copy all';
@override
String get logViewerOpenExternal => 'Open in default editor';
@override
String logViewerLineCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count lines',
one: '1 line',
);
return '$_temp0';
}
@override
String get doctorPathStudioErrors => 'Studio errors';
}

View file

@ -26,7 +26,7 @@ import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.54.0';
const String kStudioVersion = '0.62.0';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

View file

@ -4,6 +4,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/hub.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
@ -53,21 +54,16 @@ class _AuditPageState extends State<AuditPage> {
_refresh();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.auditClearFailed(_friendly(e)))));
showFaiErrorSnack(context, 'audit.clear', e);
}
}
String _friendly(Object e) {
final s = e.toString();
// The hub returns FailedPrecondition for compliance-locked
// channels; tonic stringifies that as "failed_precondition:
// ". Strip the noise so the operator sees the real reason.
final marker = 'failed_precondition: ';
final idx = s.toLowerCase().indexOf(marker);
return idx >= 0 ? s.substring(idx + marker.length) : s;
}
// _friendly was an inline shadow of friendlyError() in
// data/friendly_error.dart kept only because the original
// call site predated the central mapper. Removed in favour
// of showFaiErrorSnack, which uses friendlyError under the
// hood (with proper gRPC-code mapping + selectable
// copy-affordance).
Future<void> _refresh() async {
try {

View file

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/fai_log.dart';
import '../data/hub.dart';
import '../data/system_actions.dart';
import '../l10n/app_localizations.dart';
@ -343,6 +345,12 @@ class _DaemonPathsPanel extends StatelessWidget {
// Explorer instead of failing the OS "open" handler.
final entries = <(String, String, IconData, bool)>[
(l.doctorPathLog, paths.logPath, Icons.description_outlined, false),
(
l.doctorPathStudioErrors,
FaiLog.instance.path,
Icons.report_problem_outlined,
false,
),
(l.doctorPathConfig, paths.configPath, Icons.settings_outlined, false),
(l.doctorPathDb, paths.dbPath, Icons.storage_outlined, false),
(l.doctorPathModules, paths.modulesDir, Icons.extension_outlined, true),
@ -387,6 +395,8 @@ class _PathRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final isLog = !isDirectory && path.toLowerCase().endsWith('.log');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
@ -412,6 +422,18 @@ class _PathRow extends StatelessWidget {
),
),
const SizedBox(width: FaiSpace.sm),
if (isLog) ...[
OutlinedButton.icon(
onPressed: () =>
showFaiLogViewer(context, path: path, title: label),
icon: const Icon(Icons.visibility_outlined, size: 14),
label: Text(l.buttonView),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
const SizedBox(width: FaiSpace.xs),
],
OutlinedButton.icon(
onPressed: () async {
final r = await SystemActions.openOrReveal(
@ -420,9 +442,10 @@ class _PathRow extends StatelessWidget {
);
if (!context.mounted) return;
if (!r.ok) {
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.doctorOpenError(r.stderr))),
showFaiErrorSnack(
context,
'doctor.open-path',
r.stderr.isEmpty ? 'open failed' : r.stderr,
);
}
},
@ -430,7 +453,7 @@ class _PathRow extends StatelessWidget {
isDirectory ? Icons.folder_open : Icons.open_in_new,
size: 14,
),
label: Text(AppLocalizations.of(context)!.buttonOpen),
label: Text(l.buttonOpen),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),

View file

@ -12,6 +12,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import '../data/error_presentation.dart';
import '../data/hub.dart';
import '../data/system_actions.dart';
import '../data/today_story_loader.dart';
@ -310,19 +311,16 @@ class _StorePageState extends State<StorePage> {
_source.isEmpty &&
!_installedOnly;
final hasNoFederation = !raw.any((e) => e.isFederated);
// Past-onboarding signal: the operator has
// 3+ modules installed (wasm + federated
// combined). At that point the editorial
// hero stops earning its scroll cost they
// know what FI does. They can still bring
// it back via the "Tour" button (TODO) or
// by clearing _todayDismissed in prefs.
final installedCount = raw
.where((e) => e.installed)
.length;
final pastOnboarding = installedCount >= 3;
final showToday =
!_todayDismissed && isBrowsing && !pastOnboarding;
// Today carousel stays visible until the
// operator explicitly dismisses it. The earlier
// installedCount3 auto-hide killed the strip
// for anyone past first-launch meaning the
// rotating editorial stories (themes, audit,
// SDK, sandbox) were never seen by experienced
// operators, which is exactly the audience the
// wider story set is for. One-click dismiss is
// enough opt-out.
final showToday = !_todayDismissed && isBrowsing;
// Recommended-source quick-adds are inlined in
// the Today footer when both apply, so the
// operator never sees two competing editorial
@ -385,22 +383,24 @@ class _StorePageState extends State<StorePage> {
: null,
onDismiss: () =>
setState(() => _todayDismissed = true),
onCta:
onCta: _ctaCallbackFor(
_todayStories[_todayIndex % _todayStories.length],
),
// Per-story brand chips: only the MCP
// story shows DeepWiki / Semgrep
// quick-adds, so the chips don't read
// as ads on stories that aren't about
// sources. Standalone "Suggested
// sources" strip below still catches
// the dismissed-Today / no-federation
// edge case.
recommendedSources:
_todayStories[_todayIndex %
_todayStories.length]
.cta ==
TodayCta.openSettings
? () => FaiSettingsDialog.show(context)
: null,
// Quick-add chips removed from the
// editorial hero they showed the
// same DeepWiki / Semgrep names every
// session, which felt like ads. Public
// sources still live one click away
// under Settings MCP Clients, and
// the standalone "Suggested sources"
// strip below picks them up.
recommendedSources: const <_RecommendedSource>[],
TodayCta.showSuggestedSources
? _kRecommendedSources
: const <_RecommendedSource>[],
recommendedAdding: _addingSources,
onAddRecommended: _addRecommendedSource,
),
@ -554,6 +554,47 @@ class _StorePageState extends State<StorePage> {
}
}
/// Resolve a Today story's CTA descriptor into a real callback
/// the hero card can wire to its button. `null` collapses the
/// button out of the layout used for narrative-only stories
/// (audit / sandbox explainer) where there's nowhere obvious
/// to send the operator next.
VoidCallback? _ctaCallbackFor(TodayStoryData story) {
switch (story.cta) {
case TodayCta.none:
return null;
case TodayCta.openSettings:
return () => FaiSettingsDialog.show(context);
case TodayCta.filterCategory:
if (story.ctaPayload.isEmpty) return null;
return () {
// Two-step refresh: setState makes the dropdown
// header re-render with the new selection, then
// _runSearch re-issues the backend query with
// `category:` in its filter without the second
// call the grid keeps showing the unfiltered list
// and the carousel button looked broken.
setState(() => _category = story.ctaPayload);
_runSearch();
};
case TodayCta.runQuery:
if (story.ctaPayload.isEmpty) return null;
return () {
_queryCtrl.text = story.ctaPayload;
setState(() {});
_runSearch();
};
case TodayCta.showSuggestedSources:
// Stays on this slide the chips render in the hero
// footer for stories with this CTA. The button just
// scrolls focus to them; for now the chips are
// immediately visible above the fold, so the click is
// effectively a no-op acknowledgement. Keeping it
// wired avoids a dead button.
return () {};
}
}
void _clearQuery() {
setState(() {
_queryCtrl.clear();
@ -669,10 +710,11 @@ class _StorePageState extends State<StorePage> {
_runSearch();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l.storeProviderAddFailed(s.label, e.toString())),
),
showFaiErrorSnack(
context,
'store.provider.add',
e,
title: l.storeProviderAddFailed(s.label, ''),
);
} finally {
if (mounted) setState(() => _addingSources.remove(s.name));
@ -2143,9 +2185,10 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
final r = await SystemActions.openInOs(widget.item.repository);
if (!mounted) return;
if (!r.ok) {
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.storeOpenBrowserFailed(r.stderr))),
showFaiErrorSnack(
context,
'store.repository.open',
r.stderr.isEmpty ? 'open failed' : r.stderr,
);
}
}
@ -2926,18 +2969,18 @@ class _ScreenshotPlaceholder extends StatelessWidget {
/// on the dedicated Welcome page, not in the store carousel.
const List<TodayStoryData> _kFallbackTodayStories = <TodayStoryData>[
TodayStoryData(
badgeEn: 'MODULES',
badgeDe: 'MODULE',
titleEn: 'Modules are the building blocks',
titleDe: 'Module sind die Bausteine',
badgeEn: 'MCP',
badgeDe: 'MCP',
titleEn: 'Plug in any MCP server',
titleDe: 'Jeden MCP-Server einbinden',
bodyEn:
'Each tile in the grid below is a sandboxed WASM module. Pick one, hit Install, and the hub adds it to your local registry. Modules declare their permissions up front; the hub denies anything else.',
'F∆I speaks the Model Context Protocol out of the box. Add a public server (DeepWiki, Semgrep) or a private one — its tools show up in this grid next to the built-in modules. Same install button, same audit trail.',
bodyDe:
'Jede Kachel unten ist ein sandboxes WASM-Modul. Auswählen, Installieren klicken — der Hub fügt es deiner lokalen Registry hinzu. Module deklarieren ihre Berechtigungen explizit; der Hub verweigert alles andere.',
ctaLabelEn: '',
ctaLabelDe: '',
icon: Icons.extension_outlined,
cta: TodayCta.none,
'F∆I spricht das Model Context Protocol von Haus aus. Einen öffentlichen Server (DeepWiki, Semgrep) oder einen eigenen hinzufügen — seine Tools tauchen in diesem Grid neben den eingebauten Modulen auf. Gleicher Install-Button, gleiche Audit-Spur.',
ctaLabelEn: 'Browse suggested sources',
ctaLabelDe: 'Empfohlene Quellen ansehen',
icon: Icons.hub_outlined,
cta: TodayCta.showSuggestedSources,
),
TodayStoryData(
badgeEn: 'FLOWS',
@ -2948,24 +2991,97 @@ const List<TodayStoryData> _kFallbackTodayStories = <TodayStoryData>[
'A flow is a YAML file with three keys — inputs, steps, outputs. Each step calls one module. Every run lands in the audit log with a verifiable hash chain. Build flows graphically or write the YAML directly — either way, the run is reproducible.',
bodyDe:
'Ein Flow ist eine YAML-Datei mit drei Schlüsseln — inputs, steps, outputs. Jeder Schritt ruft ein Modul auf. Jeder Lauf landet im Audit-Log mit verifizierbarer Hash-Kette. Flows grafisch bauen oder direkt YAML schreiben — der Lauf bleibt reproduzierbar.',
ctaLabelEn: 'Show featured flows',
ctaLabelDe: 'Featured-Flows zeigen',
icon: Icons.account_tree_outlined,
cta: TodayCta.runQuery,
ctaPayload: 'hello',
),
TodayStoryData(
badgeEn: 'THEMES',
badgeDe: 'THEMES',
titleEn: 'Make Studio yours — pick a theme',
titleDe: 'Studio nach deinem Geschmack — Theme wählen',
bodyEn:
'Solarized, Sunflower, Space, Sunset, Glass-Apple, Accessibility — Studio plugins re-skin the whole app at runtime. Or pick a single accent colour and let Material 3 derive the rest. Browse the studio-plugin category for the full set.',
bodyDe:
'Solarized, Sunflower, Space, Sunset, Glass-Apple, Accessibility — Studio-Plugins themen die App zur Laufzeit komplett um. Oder eine einzelne Akzentfarbe wählen und Material 3 leitet den Rest ab. Die Kategorie Studio-Plugin zeigt das volle Set.',
ctaLabelEn: 'Show themes',
ctaLabelDe: 'Themes anzeigen',
icon: Icons.palette_outlined,
cta: TodayCta.filterCategory,
ctaPayload: 'studio-plugin',
),
TodayStoryData(
badgeEn: 'TEXT TOOLS',
badgeDe: 'TEXT-TOOLS',
titleEn: 'Extract, anonymise, translate, summarise',
titleDe: 'Extrahieren, anonymisieren, übersetzen, zusammenfassen',
bodyEn:
'The text-processing bundle is F∆I\'s document-analysis backbone. PDF and DOCX in, plain text out. Then anonymise PII, translate, or summarise — every step audit-grade, every Ollama call recorded with model digest.',
bodyDe:
'Das Text-Processing-Bündel ist F∆Is Dokumentenanalyse-Rückgrat. PDF und DOCX rein, Klartext raus. Dann PII anonymisieren, übersetzen oder zusammenfassen — jeder Schritt audit-tauglich, jeder Ollama-Aufruf mit Modell-Digest protokolliert.',
ctaLabelEn: 'Show text modules',
ctaLabelDe: 'Text-Module zeigen',
icon: Icons.article_outlined,
cta: TodayCta.filterCategory,
ctaPayload: 'text-processing',
),
TodayStoryData(
badgeEn: 'SANDBOX',
badgeDe: 'SANDBOX',
titleEn: 'Every module declares its permissions',
titleDe: 'Jedes Modul deklariert seine Berechtigungen',
bodyEn:
'F∆I modules are WASM components. They run in a sandbox with no ambient authority — every filesystem path, every network host, every env var has to be listed in module.yaml. The hub enforces them. No declaration, no access.',
bodyDe:
'F∆I-Module sind WASM-Komponenten. Sie laufen in einer Sandbox ohne Ambient-Authority — jeder Dateipfad, jeder Netzwerk-Host, jede Env-Var muss in module.yaml stehen. Der Hub setzt es durch. Keine Deklaration, kein Zugriff.',
ctaLabelEn: 'Review permissions',
ctaLabelDe: 'Berechtigungen prüfen',
icon: Icons.shield_outlined,
cta: TodayCta.openSettings,
),
TodayStoryData(
badgeEn: 'AUDIT',
badgeDe: 'AUDIT',
titleEn: 'Tamper-evident hash chain — built in',
titleDe: 'Manipulationssicher per Hash-Kette — eingebaut',
bodyEn:
'Every flow run, every install, every approval lands in ~/.fai/audit/ as a hash-chained event log. Any later edit invalidates the chain. CRA-ready out of the box — no compliance product to buy on top.',
bodyDe:
'Jeder Flow-Lauf, jede Installation, jede Freigabe landet in ~/.fai/audit/ als hash-verkettetes Event-Log. Jede spätere Änderung bricht die Kette. CRA-tauglich von Haus aus — kein Compliance-Produkt zum Aufkaufen nötig.',
ctaLabelEn: '',
ctaLabelDe: '',
icon: Icons.account_tree_outlined,
icon: Icons.timeline_outlined,
cta: TodayCta.none,
),
TodayStoryData(
badgeEn: 'EXTENSIBILITY',
badgeDe: 'ERWEITERBAR',
titleEn: 'Bring your own sources',
titleDe: 'Eigene Quellen anbinden',
badgeEn: 'TEST FIRST',
badgeDe: 'ZUERST TESTEN',
titleEn: 'fai doctor — diagnose before you ship',
titleDe: 'fai doctor — diagnostizieren bevor ausgerollt wird',
bodyEn:
'The hub federates external module sources — public MCP servers, n8n workflows, your own internal registry. Configure a source under Settings → MCP Clients and its tools appear in this store alongside the built-in modules. Same install button, same audit trail.',
'Before installing a module on a production hub, run `fai doctor` in the CLI. It validates manifest, signatures, declared permissions against the operator policy ceiling, and reports anything the hub would reject — no surprises at install time.',
bodyDe:
'Der Hub bindet externe Modul-Quellen ein — öffentliche MCP-Server, n8n-Workflows, eigene interne Registries. Eine Quelle unter Einstellungen → MCP-Clients konfigurieren, und ihre Tools erscheinen hier im Store neben den eingebauten Modulen. Gleicher Install-Button, gleiche Audit-Spur.',
ctaLabelEn: 'Manage sources',
ctaLabelDe: 'Quellen verwalten',
icon: Icons.hub_outlined,
cta: TodayCta.openSettings,
'Bevor ein Modul auf einem Produktions-Hub installiert wird, in der CLI `fai doctor` laufen lassen. Validiert Manifest, Signaturen und deklarierte Berechtigungen gegen die Operator-Policy-Decke — keine Überraschungen beim Install.',
ctaLabelEn: '',
ctaLabelDe: '',
icon: Icons.health_and_safety_outlined,
cta: TodayCta.none,
),
TodayStoryData(
badgeEn: 'BUILD ONE',
badgeDe: 'EIGENES MODUL',
titleEn: 'Build a module in a single Rust file',
titleDe: 'Ein Modul in einer einzigen Rust-Datei bauen',
bodyEn:
'fai-module-sdk gives you a #[fai::module] macro and a Cargo target. Declare inputs / outputs in module.yaml, write the function, cargo build, drop the wasm in ~/.fai/modules. The hub picks it up at next list. Polyglot SDKs follow in Phase 1.',
bodyDe:
'fai-module-sdk liefert ein #[fai::module]-Makro und ein Cargo-Target. Inputs / Outputs in module.yaml deklarieren, Funktion schreiben, cargo build, das wasm in ~/.fai/modules legen. Der Hub erkennt es beim nächsten Listing. Polyglott-SDKs folgen in Phase 1.',
ctaLabelEn: '',
ctaLabelDe: '',
icon: Icons.code_outlined,
cta: TodayCta.none,
),
];

View file

@ -720,6 +720,7 @@ class _ChecklistRow extends StatelessWidget {
final bool done;
final String title;
final String hint;
/// Tap handler takes the operator to the page where
/// they can actually complete the item. null = no-op
/// (e.g. AI item when System AI was already configured).

View file

@ -19,6 +19,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import '../data/error_presentation.dart';
import '../data/flow_output.dart';
import '../data/format.dart';
import '../data/system_actions.dart';
@ -172,9 +173,7 @@ class _BytesView extends StatelessWidget {
).showSnackBar(SnackBar(content: Text(l.flowsOutputSavedAt(path))));
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.flowsOutputSaveFailed('$e'))));
showFaiErrorSnack(context, 'flows.output.save', e);
}
}
@ -235,10 +234,11 @@ class _FileView extends StatelessWidget {
final target = uri.startsWith('file://') ? uri.substring(7) : uri;
final r = await SystemActions.openInOs(target);
if (!context.mounted || r.ok) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
showFaiErrorSnack(
context,
).showSnackBar(SnackBar(content: Text(l.flowsOutputOpenFailed(r.stderr))));
'flows.output.open',
r.stderr.isEmpty ? 'open failed' : r.stderr,
);
}
@override

View file

@ -0,0 +1,442 @@
// FaiLogViewer modal sheet that renders a log file inline with
// line numbers, basic syntax colouring and a Copy-all button.
//
// Use [showFaiLogViewer] to open it. Two log shapes are supported
// out of the box:
//
// - JSON-per-line (Studio's `~/.fai/logs/studio-errors.log`):
// fields `ts`, `level`, `source`, `error` get distinct colours;
// anything else renders neutral.
// - tracing text (the daemon's `~/.fai/run/<channel>.log`):
// `[INFO]`, `[WARN]`, `[ERROR]`, `[DEBUG]` substrings get the
// matching tone from the FaiTheme palette.
//
// The viewer reads the *last* N lines (default 500) to keep
// rendering snappy on huge log files. A Refresh button re-reads
// the file in place. An "Open externally" button still hands the
// path to the OS so power users can pipe it through their tool
// of choice.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../data/system_actions.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
/// Open [path] inside a modal log viewer.
///
/// [title] is shown in the AppBar (e.g. `"local.log"` or
/// `"studio-errors.log"`); falls back to the basename of [path].
Future<void> showFaiLogViewer(
BuildContext context, {
required String path,
String? title,
int tailLines = 500,
}) {
return showDialog<void>(
context: context,
barrierDismissible: true,
builder: (_) => FaiLogViewer(
path: path,
title: title ?? path.split(Platform.pathSeparator).last,
tailLines: tailLines,
),
);
}
class FaiLogViewer extends StatefulWidget {
final String path;
final String title;
final int tailLines;
const FaiLogViewer({
super.key,
required this.path,
required this.title,
this.tailLines = 500,
});
@override
State<FaiLogViewer> createState() => _FaiLogViewerState();
}
class _FaiLogViewerState extends State<FaiLogViewer> {
List<String> _lines = const <String>[];
bool _loading = true;
bool _justCopied = false;
@override
void initState() {
super.initState();
_reload();
}
Future<void> _reload() async {
setState(() => _loading = true);
final lines = await _readTail(widget.path, widget.tailLines);
if (!mounted) return;
setState(() {
_lines = lines;
_loading = false;
});
}
Future<void> _copyAll() async {
await Clipboard.setData(ClipboardData(text: _lines.join('\n')));
if (!mounted) return;
setState(() => _justCopied = true);
Future.delayed(const Duration(seconds: 2), () {
if (mounted) setState(() => _justCopied = false);
});
}
Future<void> _openExternal() async {
await SystemActions.openOrReveal(widget.path, isDirectory: false);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Dialog(
insetPadding: const EdgeInsets.symmetric(
horizontal: FaiSpace.xl,
vertical: FaiSpace.xl,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 980, maxHeight: 720),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Header(
title: widget.title,
path: widget.path,
lineCount: _lines.length,
tailLimit: widget.tailLines,
loading: _loading,
justCopied: _justCopied,
onCopy: _copyAll,
onReload: _reload,
onOpenExternal: _openExternal,
onClose: () => Navigator.of(context).pop(),
),
Divider(height: 1, color: theme.colorScheme.outlineVariant),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _lines.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(FaiSpace.xl),
child: Text(
l.logViewerEmpty,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
)
: _LogBody(lines: _lines),
),
],
),
),
);
}
}
class _Header extends StatelessWidget {
final String title;
final String path;
final int lineCount;
final int tailLimit;
final bool loading;
final bool justCopied;
final VoidCallback onCopy;
final VoidCallback onReload;
final VoidCallback onOpenExternal;
final VoidCallback onClose;
const _Header({
required this.title,
required this.path,
required this.lineCount,
required this.tailLimit,
required this.loading,
required this.justCopied,
required this.onCopy,
required this.onReload,
required this.onOpenExternal,
required this.onClose,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.fromLTRB(
FaiSpace.lg,
FaiSpace.md,
FaiSpace.sm,
FaiSpace.md,
),
child: Row(
children: [
Icon(
Icons.description_outlined,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
l.logViewerTitle(title, tailLimit),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'$path · ${l.logViewerLineCount(lineCount)}',
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
tooltip: justCopied ? l.buttonCopied : l.logViewerCopyAll,
icon: Icon(
justCopied ? Icons.check : Icons.content_copy,
size: 16,
),
onPressed: loading || lineCount == 0 ? null : onCopy,
),
IconButton(
tooltip: l.buttonRefresh,
icon: const Icon(Icons.refresh, size: 16),
onPressed: loading ? null : onReload,
),
IconButton(
tooltip: l.logViewerOpenExternal,
icon: const Icon(Icons.open_in_new, size: 16),
onPressed: onOpenExternal,
),
IconButton(
tooltip: l.buttonClose,
icon: const Icon(Icons.close, size: 16),
onPressed: onClose,
),
],
),
);
}
}
class _LogBody extends StatelessWidget {
final List<String> lines;
const _LogBody({required this.lines});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final gutterWidth = 12.0 + 8.0 * lines.length.toString().length;
return Scrollbar(
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
itemCount: lines.length,
itemBuilder: (context, i) => _LogLine(
lineNumber: i + 1,
text: lines[i],
gutterWidth: gutterWidth,
theme: theme,
),
),
);
}
}
class _LogLine extends StatelessWidget {
final int lineNumber;
final String text;
final double gutterWidth;
final ThemeData theme;
const _LogLine({
required this.lineNumber,
required this.text,
required this.gutterWidth,
required this.theme,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: gutterWidth,
child: Text(
'$lineNumber',
textAlign: TextAlign.right,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.6,
),
),
),
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: SelectableText.rich(
_highlightLine(text, theme),
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
),
),
],
),
);
}
}
/// Lightweight tokeniser shared by both supported log shapes.
///
/// We don't reach for a full grammar here on purpose: log files
/// are short-lived, the operator just needs the eye to land on
/// the level/source quickly. Real syntax highlighting belongs in
/// the flow editor; logs only need three or four accent colours.
TextSpan _highlightLine(String line, ThemeData theme) {
// JSON-per-line path (the Studio error log). Looks like:
// {"ts":"","level":"error","source":"flows.run","error":""}
if (line.startsWith('{') && line.endsWith('}')) {
return _highlightJsonLine(line, theme);
}
return _highlightTraceLine(line, theme);
}
TextSpan _highlightJsonLine(String line, ThemeData theme) {
final spans = <TextSpan>[];
final base = TextStyle(color: theme.colorScheme.onSurface);
final keyStyle = TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
);
final stringStyle = TextStyle(color: FaiColors.success);
final errorStringStyle = TextStyle(color: theme.colorScheme.error);
final keyRe = RegExp(r'"(ts|level|source|error|context)"\s*:');
final stringRe = RegExp(r'"((?:\\.|[^"\\])*)"');
int cursor = 0;
bool nextStringIsError = false;
while (cursor < line.length) {
final keyMatch = keyRe.matchAsPrefix(line, cursor);
if (keyMatch != null) {
spans.add(TextSpan(text: keyMatch.group(0), style: keyStyle));
nextStringIsError = keyMatch.group(1) == 'error';
cursor = keyMatch.end;
continue;
}
final stringMatch = stringRe.matchAsPrefix(line, cursor);
if (stringMatch != null) {
spans.add(
TextSpan(
text: stringMatch.group(0),
style: nextStringIsError ? errorStringStyle : stringStyle,
),
);
cursor = stringMatch.end;
nextStringIsError = false;
continue;
}
spans.add(TextSpan(text: line[cursor], style: base));
cursor++;
}
return TextSpan(children: spans);
}
TextSpan _highlightTraceLine(String line, ThemeData theme) {
// Tracing line: prefix tag like [INFO] / [WARN] / [ERROR] /
// [DEBUG] gets the tone colour, everything else stays neutral.
final tagRe = RegExp(r'\[(INFO|WARN|WARNING|ERROR|DEBUG|TRACE)\]');
final match = tagRe.firstMatch(line);
if (match == null) {
return TextSpan(
text: line,
style: TextStyle(color: theme.colorScheme.onSurface),
);
}
Color tone;
switch (match.group(1)) {
case 'ERROR':
tone = theme.colorScheme.error;
break;
case 'WARN':
case 'WARNING':
tone = FaiColors.warning;
break;
case 'INFO':
tone = theme.colorScheme.primary;
break;
default:
tone = theme.colorScheme.onSurfaceVariant;
}
return TextSpan(
children: [
TextSpan(
text: line.substring(0, match.start),
style: TextStyle(color: theme.colorScheme.onSurface),
),
TextSpan(
text: match.group(0),
style: TextStyle(color: tone, fontWeight: FontWeight.w600),
),
TextSpan(
text: line.substring(match.end),
style: TextStyle(color: theme.colorScheme.onSurface),
),
],
);
}
/// Read the last [maxLines] of [path]. Returns an empty list on
/// any I/O failure the viewer renders an empty-state hint in
/// that case rather than throwing.
Future<List<String>> _readTail(String path, int maxLines) async {
try {
final f = File(path);
if (!await f.exists()) return const <String>[];
final raw = await f.readAsString();
final lines = raw.split('\n');
// Drop the trailing blank line that `split` leaves behind
// when the file ends with `\n`.
if (lines.isNotEmpty && lines.last.isEmpty) {
lines.removeLast();
}
if (lines.length <= maxLines) return lines;
return lines.sublist(lines.length - maxLines);
} catch (_) {
return const <String>[];
}
}

View file

@ -5,6 +5,7 @@
import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/hub.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
@ -104,9 +105,7 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
} catch (e) {
if (!mounted) return;
setState(() => _uninstalling = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.modulesUninstallFailed(e.toString()))),
);
showFaiErrorSnack(context, 'modules.uninstall', e);
}
}

View file

@ -5,6 +5,7 @@
import 'package:fai_client_sdk/fai_client_sdk.dart';
import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/hub.dart';
import '../data/hub_auth_token.dart';
import '../data/registry_token.dart';
@ -92,10 +93,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenSavedToast)));
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.hubAuthTokenSaveFailedToast(e.toString()))),
);
showFaiErrorSnack(context, 'auth.hub-token.save', e);
}
}
@ -111,10 +109,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenClearedToast)));
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.hubAuthTokenSaveFailedToast(e.toString()))),
);
showFaiErrorSnack(context, 'auth.hub-token.clear', e);
}
}
@ -139,10 +134,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
).showSnackBar(SnackBar(content: Text(l.registryTokenSavedToast)));
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.registryTokenSaveFailedToast(e.toString()))),
);
showFaiErrorSnack(context, 'auth.registry-token.save', e);
}
}
@ -157,10 +149,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
).showSnackBar(SnackBar(content: Text(l.registryTokenClearedToast)));
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.registryTokenSaveFailedToast(e.toString()))),
);
showFaiErrorSnack(context, 'auth.registry-token.clear', e);
}
}
@ -190,10 +179,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
).showSnackBar(SnackBar(content: Text(l.addedN8nToast(draft.name))));
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString()))));
showFaiErrorSnack(context, 'settings.n8n.add', e);
}
}
@ -204,10 +190,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
setState(() => _n8nEndpoints = list);
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.removeFailedToast(e.toString()))),
);
showFaiErrorSnack(context, 'settings.n8n.remove', e);
}
}
@ -218,10 +201,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
setState(() => _n8nEndpoints = list);
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.refreshFailedToast(e.toString()))),
);
showFaiErrorSnack(context, 'settings.n8n.refresh', e);
}
}
@ -251,10 +231,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
).showSnackBar(SnackBar(content: Text(l.addedMcpToast(draft.name))));
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString()))));
showFaiErrorSnack(context, 'settings.mcp.add', e);
}
}
@ -265,10 +242,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
setState(() => _mcpClients = list);
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.removeFailedToast(e.toString()))),
);
showFaiErrorSnack(context, 'settings.mcp.remove', e);
}
}
@ -279,10 +253,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
setState(() => _mcpClients = list);
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.refreshFailedToast(e.toString()))),
);
showFaiErrorSnack(context, 'settings.mcp.refresh', e);
}
}

View file

@ -6,6 +6,7 @@
import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/hub.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
@ -249,10 +250,7 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
});
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.systemAiCacheClearFailed(e.toString()))),
);
showFaiErrorSnack(context, 'settings.system-ai.cache-clear', e);
}
}

View file

@ -204,6 +204,7 @@ class _Tile extends StatelessWidget {
final List<Color> swatches;
final bool selected;
final bool loading;
/// Adds a small tune icon at the top-right, telegraphing
/// "this tile opens an editor instead of applying
/// immediately". Used for the Custom tile so the operator

View file

@ -11,6 +11,7 @@ export 'fai_empty_state.dart';
export 'fai_en_badge.dart';
export 'fai_error_box.dart';
export 'fai_flow_output.dart';
export 'fai_log_viewer.dart';
export 'fai_module_sheet.dart';
export 'fai_pill.dart';
export 'fai_settings_dialog.dart';

View file

@ -123,12 +123,10 @@ packages:
fai_studio_flow_editor:
dependency: "direct main"
description:
path: "."
ref: main
resolved-ref: "59aa8fe78e387bfd80fc1dfc28a912d149a91c8f"
url: "https://git.flemming.ai/fai/studio-flow-editor"
source: git
version: "0.13.0"
path: "../fai_studio_flow_editor"
relative: true
source: path
version: "0.15.0"
fake_async:
dependency: transitive
description:

View file

@ -1,7 +1,7 @@
name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none'
version: 0.60.0
version: 0.62.0
environment:
sdk: ^3.11.0-200.1.beta
@ -48,6 +48,15 @@ dev_dependencies:
sdk: flutter
flutter_lints: ^6.0.0
# Local-development override: while editing the swappable flow
# editor alongside Studio, point at the sibling working tree so
# changes are picked up on the next Studio reload without push
# + `pub upgrade`. Comment the block out before tagging a Studio
# release so the committed Git ref is what builds on Forgejo CI.
dependency_overrides:
fai_studio_flow_editor:
path: ../fai_studio_flow_editor
flutter:
uses-material-design: true
generate: true