feat(studio): central error helpers + inline log viewer + Today CTAs
Some checks failed
Security / Security check (push) Failing after 1s
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:
parent
2731062a13
commit
8f5cd2528b
22 changed files with 1087 additions and 134 deletions
114
lib/data/error_presentation.dart
Normal file
114
lib/data/error_presentation.dart
Normal 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
119
lib/data/fai_log.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,19 @@ import 'package:yaml/yaml.dart';
|
||||||
/// closed and ABI-stable — adding a new variant without bumping
|
/// closed and ABI-stable — adding a new variant without bumping
|
||||||
/// the schema would let an old Studio render an unknown action
|
/// the schema would let an old Studio render an unknown action
|
||||||
/// as a no-op button.
|
/// 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
|
/// Bilingual story rendered by the Today-Hero card. Either
|
||||||
/// loaded from disk or supplied as a const fallback.
|
/// loaded from disk or supplied as a const fallback.
|
||||||
|
|
@ -37,6 +49,11 @@ class TodayStoryData {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final TodayCta cta;
|
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({
|
const TodayStoryData({
|
||||||
required this.badgeEn,
|
required this.badgeEn,
|
||||||
required this.badgeDe,
|
required this.badgeDe,
|
||||||
|
|
@ -48,6 +65,7 @@ class TodayStoryData {
|
||||||
required this.ctaLabelDe,
|
required this.ctaLabelDe,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.cta,
|
required this.cta,
|
||||||
|
this.ctaPayload = '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,6 +148,7 @@ class TodayStoryLoader {
|
||||||
ctaLabelDe: s('cta_label_de'),
|
ctaLabelDe: s('cta_label_de'),
|
||||||
icon: icon,
|
icon: icon,
|
||||||
cta: cta,
|
cta: cta,
|
||||||
|
ctaPayload: s('cta_payload'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,6 +169,12 @@ class TodayStoryLoader {
|
||||||
return TodayCta.none;
|
return TodayCta.none;
|
||||||
case 'openSettings':
|
case 'openSettings':
|
||||||
return TodayCta.openSettings;
|
return TodayCta.openSettings;
|
||||||
|
case 'filterCategory':
|
||||||
|
return TodayCta.filterCategory;
|
||||||
|
case 'runQuery':
|
||||||
|
return TodayCta.runQuery;
|
||||||
|
case 'showSuggestedSources':
|
||||||
|
return TodayCta.showSuggestedSources;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1423,5 +1423,23 @@
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"name": {"type": "String"}
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1426,5 +1426,23 @@
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"name": {"type": "String"}
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3913,6 +3913,48 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'A flow named \"{name}\" already exists.'**
|
/// **'A flow named \"{name}\" already exists.'**
|
||||||
String flowEditorAlreadyExists(String name);
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -2282,4 +2282,35 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String flowEditorAlreadyExists(String name) {
|
String flowEditorAlreadyExists(String name) {
|
||||||
return 'Ein Flow namens \"$name\" existiert bereits.';
|
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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2287,4 +2287,35 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String flowEditorAlreadyExists(String name) {
|
String flowEditorAlreadyExists(String name) {
|
||||||
return 'A flow named \"$name\" already exists.';
|
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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,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.54.0';
|
const String kStudioVersion = '0.62.0';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../data/error_presentation.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/theme.dart';
|
import '../theme/theme.dart';
|
||||||
|
|
@ -53,21 +54,16 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
_refresh();
|
_refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
showFaiErrorSnack(context, 'audit.clear', e);
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text(l.auditClearFailed(_friendly(e)))));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _friendly(Object e) {
|
// _friendly was an inline shadow of friendlyError() in
|
||||||
final s = e.toString();
|
// data/friendly_error.dart — kept only because the original
|
||||||
// The hub returns FailedPrecondition for compliance-locked
|
// call site predated the central mapper. Removed in favour
|
||||||
// channels; tonic stringifies that as "failed_precondition:
|
// of showFaiErrorSnack, which uses friendlyError under the
|
||||||
// …". Strip the noise so the operator sees the real reason.
|
// hood (with proper gRPC-code mapping + selectable
|
||||||
final marker = 'failed_precondition: ';
|
// copy-affordance).
|
||||||
final idx = s.toLowerCase().indexOf(marker);
|
|
||||||
return idx >= 0 ? s.substring(idx + marker.length) : s;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _refresh() async {
|
Future<void> _refresh() async {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../data/error_presentation.dart';
|
||||||
|
import '../data/fai_log.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
import '../data/system_actions.dart';
|
import '../data/system_actions.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
@ -343,6 +345,12 @@ class _DaemonPathsPanel extends StatelessWidget {
|
||||||
// Explorer instead of failing the OS "open" handler.
|
// Explorer instead of failing the OS "open" handler.
|
||||||
final entries = <(String, String, IconData, bool)>[
|
final entries = <(String, String, IconData, bool)>[
|
||||||
(l.doctorPathLog, paths.logPath, Icons.description_outlined, false),
|
(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.doctorPathConfig, paths.configPath, Icons.settings_outlined, false),
|
||||||
(l.doctorPathDb, paths.dbPath, Icons.storage_outlined, false),
|
(l.doctorPathDb, paths.dbPath, Icons.storage_outlined, false),
|
||||||
(l.doctorPathModules, paths.modulesDir, Icons.extension_outlined, true),
|
(l.doctorPathModules, paths.modulesDir, Icons.extension_outlined, true),
|
||||||
|
|
@ -387,6 +395,8 @@ class _PathRow extends StatelessWidget {
|
||||||
@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 isLog = !isDirectory && path.toLowerCase().endsWith('.log');
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
@ -412,6 +422,18 @@ class _PathRow extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: FaiSpace.sm),
|
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(
|
OutlinedButton.icon(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final r = await SystemActions.openOrReveal(
|
final r = await SystemActions.openOrReveal(
|
||||||
|
|
@ -420,9 +442,10 @@ class _PathRow extends StatelessWidget {
|
||||||
);
|
);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
final l = AppLocalizations.of(context)!;
|
showFaiErrorSnack(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
context,
|
||||||
SnackBar(content: Text(l.doctorOpenError(r.stderr))),
|
'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,
|
isDirectory ? Icons.folder_open : Icons.open_in_new,
|
||||||
size: 14,
|
size: 14,
|
||||||
),
|
),
|
||||||
label: Text(AppLocalizations.of(context)!.buttonOpen),
|
label: Text(l.buttonOpen),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
|
||||||
|
import '../data/error_presentation.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
import '../data/system_actions.dart';
|
import '../data/system_actions.dart';
|
||||||
import '../data/today_story_loader.dart';
|
import '../data/today_story_loader.dart';
|
||||||
|
|
@ -310,19 +311,16 @@ class _StorePageState extends State<StorePage> {
|
||||||
_source.isEmpty &&
|
_source.isEmpty &&
|
||||||
!_installedOnly;
|
!_installedOnly;
|
||||||
final hasNoFederation = !raw.any((e) => e.isFederated);
|
final hasNoFederation = !raw.any((e) => e.isFederated);
|
||||||
// Past-onboarding signal: the operator has
|
// Today carousel stays visible until the
|
||||||
// 3+ modules installed (wasm + federated
|
// operator explicitly dismisses it. The earlier
|
||||||
// combined). At that point the editorial
|
// installedCount≥3 auto-hide killed the strip
|
||||||
// hero stops earning its scroll cost — they
|
// for anyone past first-launch — meaning the
|
||||||
// know what F∆I does. They can still bring
|
// rotating editorial stories (themes, audit,
|
||||||
// it back via the "Tour" button (TODO) or
|
// SDK, sandbox…) were never seen by experienced
|
||||||
// by clearing _todayDismissed in prefs.
|
// operators, which is exactly the audience the
|
||||||
final installedCount = raw
|
// wider story set is for. One-click dismiss is
|
||||||
.where((e) => e.installed)
|
// enough opt-out.
|
||||||
.length;
|
final showToday = !_todayDismissed && isBrowsing;
|
||||||
final pastOnboarding = installedCount >= 3;
|
|
||||||
final showToday =
|
|
||||||
!_todayDismissed && isBrowsing && !pastOnboarding;
|
|
||||||
// Recommended-source quick-adds are inlined in
|
// Recommended-source quick-adds are inlined in
|
||||||
// the Today footer when both apply, so the
|
// the Today footer when both apply, so the
|
||||||
// operator never sees two competing editorial
|
// operator never sees two competing editorial
|
||||||
|
|
@ -385,22 +383,24 @@ class _StorePageState extends State<StorePage> {
|
||||||
: null,
|
: null,
|
||||||
onDismiss: () =>
|
onDismiss: () =>
|
||||||
setState(() => _todayDismissed = true),
|
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[_todayIndex %
|
||||||
_todayStories.length]
|
_todayStories.length]
|
||||||
.cta ==
|
.cta ==
|
||||||
TodayCta.openSettings
|
TodayCta.showSuggestedSources
|
||||||
? () => FaiSettingsDialog.show(context)
|
? _kRecommendedSources
|
||||||
: null,
|
: const <_RecommendedSource>[],
|
||||||
// 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>[],
|
|
||||||
recommendedAdding: _addingSources,
|
recommendedAdding: _addingSources,
|
||||||
onAddRecommended: _addRecommendedSource,
|
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() {
|
void _clearQuery() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_queryCtrl.clear();
|
_queryCtrl.clear();
|
||||||
|
|
@ -669,10 +710,11 @@ class _StorePageState extends State<StorePage> {
|
||||||
_runSearch();
|
_runSearch();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showFaiErrorSnack(
|
||||||
SnackBar(
|
context,
|
||||||
content: Text(l.storeProviderAddFailed(s.label, e.toString())),
|
'store.provider.add',
|
||||||
),
|
e,
|
||||||
|
title: l.storeProviderAddFailed(s.label, ''),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _addingSources.remove(s.name));
|
if (mounted) setState(() => _addingSources.remove(s.name));
|
||||||
|
|
@ -2143,9 +2185,10 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
final r = await SystemActions.openInOs(widget.item.repository);
|
final r = await SystemActions.openInOs(widget.item.repository);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
final l = AppLocalizations.of(context)!;
|
showFaiErrorSnack(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
context,
|
||||||
SnackBar(content: Text(l.storeOpenBrowserFailed(r.stderr))),
|
'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.
|
/// on the dedicated Welcome page, not in the store carousel.
|
||||||
const List<TodayStoryData> _kFallbackTodayStories = <TodayStoryData>[
|
const List<TodayStoryData> _kFallbackTodayStories = <TodayStoryData>[
|
||||||
TodayStoryData(
|
TodayStoryData(
|
||||||
badgeEn: 'MODULES',
|
badgeEn: 'MCP',
|
||||||
badgeDe: 'MODULE',
|
badgeDe: 'MCP',
|
||||||
titleEn: 'Modules are the building blocks',
|
titleEn: 'Plug in any MCP server',
|
||||||
titleDe: 'Module sind die Bausteine',
|
titleDe: 'Jeden MCP-Server einbinden',
|
||||||
bodyEn:
|
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:
|
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.',
|
'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: '',
|
ctaLabelEn: 'Browse suggested sources',
|
||||||
ctaLabelDe: '',
|
ctaLabelDe: 'Empfohlene Quellen ansehen',
|
||||||
icon: Icons.extension_outlined,
|
icon: Icons.hub_outlined,
|
||||||
cta: TodayCta.none,
|
cta: TodayCta.showSuggestedSources,
|
||||||
),
|
),
|
||||||
TodayStoryData(
|
TodayStoryData(
|
||||||
badgeEn: 'FLOWS',
|
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.',
|
'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:
|
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.',
|
'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: '',
|
ctaLabelEn: '',
|
||||||
ctaLabelDe: '',
|
ctaLabelDe: '',
|
||||||
icon: Icons.account_tree_outlined,
|
icon: Icons.timeline_outlined,
|
||||||
cta: TodayCta.none,
|
cta: TodayCta.none,
|
||||||
),
|
),
|
||||||
TodayStoryData(
|
TodayStoryData(
|
||||||
badgeEn: 'EXTENSIBILITY',
|
badgeEn: 'TEST FIRST',
|
||||||
badgeDe: 'ERWEITERBAR',
|
badgeDe: 'ZUERST TESTEN',
|
||||||
titleEn: 'Bring your own sources',
|
titleEn: 'fai doctor — diagnose before you ship',
|
||||||
titleDe: 'Eigene Quellen anbinden',
|
titleDe: 'fai doctor — diagnostizieren bevor ausgerollt wird',
|
||||||
bodyEn:
|
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:
|
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.',
|
'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: 'Manage sources',
|
ctaLabelEn: '',
|
||||||
ctaLabelDe: 'Quellen verwalten',
|
ctaLabelDe: '',
|
||||||
icon: Icons.hub_outlined,
|
icon: Icons.health_and_safety_outlined,
|
||||||
cta: TodayCta.openSettings,
|
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,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -720,6 +720,7 @@ class _ChecklistRow extends StatelessWidget {
|
||||||
final bool done;
|
final bool done;
|
||||||
final String title;
|
final String title;
|
||||||
final String hint;
|
final String hint;
|
||||||
|
|
||||||
/// Tap handler — takes the operator to the page where
|
/// Tap handler — takes the operator to the page where
|
||||||
/// they can actually complete the item. null = no-op
|
/// they can actually complete the item. null = no-op
|
||||||
/// (e.g. AI item when System AI was already configured).
|
/// (e.g. AI item when System AI was already configured).
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import 'package:file_picker/file_picker.dart';
|
||||||
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 '../data/error_presentation.dart';
|
||||||
import '../data/flow_output.dart';
|
import '../data/flow_output.dart';
|
||||||
import '../data/format.dart';
|
import '../data/format.dart';
|
||||||
import '../data/system_actions.dart';
|
import '../data/system_actions.dart';
|
||||||
|
|
@ -172,9 +173,7 @@ class _BytesView extends StatelessWidget {
|
||||||
).showSnackBar(SnackBar(content: Text(l.flowsOutputSavedAt(path))));
|
).showSnackBar(SnackBar(content: Text(l.flowsOutputSavedAt(path))));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(
|
showFaiErrorSnack(context, 'flows.output.save', e);
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text(l.flowsOutputSaveFailed('$e'))));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,10 +234,11 @@ class _FileView extends StatelessWidget {
|
||||||
final target = uri.startsWith('file://') ? uri.substring(7) : uri;
|
final target = uri.startsWith('file://') ? uri.substring(7) : uri;
|
||||||
final r = await SystemActions.openInOs(target);
|
final r = await SystemActions.openInOs(target);
|
||||||
if (!context.mounted || r.ok) return;
|
if (!context.mounted || r.ok) return;
|
||||||
final l = AppLocalizations.of(context)!;
|
showFaiErrorSnack(
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text(l.flowsOutputOpenFailed(r.stderr))));
|
'flows.output.open',
|
||||||
|
r.stderr.isEmpty ? 'open failed' : r.stderr,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
442
lib/widgets/fai_log_viewer.dart
Normal file
442
lib/widgets/fai_log_viewer.dart
Normal 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>[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../data/error_presentation.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/theme.dart';
|
import '../theme/theme.dart';
|
||||||
|
|
@ -104,9 +105,7 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _uninstalling = false);
|
setState(() => _uninstalling = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showFaiErrorSnack(context, 'modules.uninstall', e);
|
||||||
SnackBar(content: Text(l.modulesUninstallFailed(e.toString()))),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import 'package:fai_client_sdk/fai_client_sdk.dart';
|
import 'package:fai_client_sdk/fai_client_sdk.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../data/error_presentation.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
import '../data/hub_auth_token.dart';
|
import '../data/hub_auth_token.dart';
|
||||||
import '../data/registry_token.dart';
|
import '../data/registry_token.dart';
|
||||||
|
|
@ -92,10 +93,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenSavedToast)));
|
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenSavedToast)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final l = AppLocalizations.of(context)!;
|
showFaiErrorSnack(context, 'auth.hub-token.save', e);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(l.hubAuthTokenSaveFailedToast(e.toString()))),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,10 +109,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenClearedToast)));
|
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenClearedToast)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final l = AppLocalizations.of(context)!;
|
showFaiErrorSnack(context, 'auth.hub-token.clear', e);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(l.hubAuthTokenSaveFailedToast(e.toString()))),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,10 +134,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
).showSnackBar(SnackBar(content: Text(l.registryTokenSavedToast)));
|
).showSnackBar(SnackBar(content: Text(l.registryTokenSavedToast)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final l = AppLocalizations.of(context)!;
|
showFaiErrorSnack(context, 'auth.registry-token.save', e);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(l.registryTokenSaveFailedToast(e.toString()))),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,10 +149,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
).showSnackBar(SnackBar(content: Text(l.registryTokenClearedToast)));
|
).showSnackBar(SnackBar(content: Text(l.registryTokenClearedToast)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final l = AppLocalizations.of(context)!;
|
showFaiErrorSnack(context, 'auth.registry-token.clear', e);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(l.registryTokenSaveFailedToast(e.toString()))),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,10 +179,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
).showSnackBar(SnackBar(content: Text(l.addedN8nToast(draft.name))));
|
).showSnackBar(SnackBar(content: Text(l.addedN8nToast(draft.name))));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final l = AppLocalizations.of(context)!;
|
showFaiErrorSnack(context, 'settings.n8n.add', e);
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString()))));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,10 +190,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
setState(() => _n8nEndpoints = list);
|
setState(() => _n8nEndpoints = list);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final l = AppLocalizations.of(context)!;
|
showFaiErrorSnack(context, 'settings.n8n.remove', e);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(l.removeFailedToast(e.toString()))),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,10 +201,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
setState(() => _n8nEndpoints = list);
|
setState(() => _n8nEndpoints = list);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final l = AppLocalizations.of(context)!;
|
showFaiErrorSnack(context, 'settings.n8n.refresh', e);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(l.refreshFailedToast(e.toString()))),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,10 +231,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
).showSnackBar(SnackBar(content: Text(l.addedMcpToast(draft.name))));
|
).showSnackBar(SnackBar(content: Text(l.addedMcpToast(draft.name))));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final l = AppLocalizations.of(context)!;
|
showFaiErrorSnack(context, 'settings.mcp.add', e);
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString()))));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -265,10 +242,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
setState(() => _mcpClients = list);
|
setState(() => _mcpClients = list);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final l = AppLocalizations.of(context)!;
|
showFaiErrorSnack(context, 'settings.mcp.remove', e);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(l.removeFailedToast(e.toString()))),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -279,10 +253,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
setState(() => _mcpClients = list);
|
setState(() => _mcpClients = list);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final l = AppLocalizations.of(context)!;
|
showFaiErrorSnack(context, 'settings.mcp.refresh', e);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(l.refreshFailedToast(e.toString()))),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../data/error_presentation.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/theme.dart';
|
import '../theme/theme.dart';
|
||||||
|
|
@ -249,10 +250,7 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final l = AppLocalizations.of(context)!;
|
showFaiErrorSnack(context, 'settings.system-ai.cache-clear', e);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(l.systemAiCacheClearFailed(e.toString()))),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,7 @@ class _Tile extends StatelessWidget {
|
||||||
final List<Color> swatches;
|
final List<Color> swatches;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
final bool loading;
|
final bool loading;
|
||||||
|
|
||||||
/// Adds a small tune icon at the top-right, telegraphing
|
/// Adds a small tune icon at the top-right, telegraphing
|
||||||
/// "this tile opens an editor instead of applying
|
/// "this tile opens an editor instead of applying
|
||||||
/// immediately". Used for the Custom tile so the operator
|
/// immediately". Used for the Custom tile so the operator
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export 'fai_empty_state.dart';
|
||||||
export 'fai_en_badge.dart';
|
export 'fai_en_badge.dart';
|
||||||
export 'fai_error_box.dart';
|
export 'fai_error_box.dart';
|
||||||
export 'fai_flow_output.dart';
|
export 'fai_flow_output.dart';
|
||||||
|
export 'fai_log_viewer.dart';
|
||||||
export 'fai_module_sheet.dart';
|
export 'fai_module_sheet.dart';
|
||||||
export 'fai_pill.dart';
|
export 'fai_pill.dart';
|
||||||
export 'fai_settings_dialog.dart';
|
export 'fai_settings_dialog.dart';
|
||||||
|
|
|
||||||
10
pubspec.lock
10
pubspec.lock
|
|
@ -123,12 +123,10 @@ packages:
|
||||||
fai_studio_flow_editor:
|
fai_studio_flow_editor:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "../fai_studio_flow_editor"
|
||||||
ref: main
|
relative: true
|
||||||
resolved-ref: "59aa8fe78e387bfd80fc1dfc28a912d149a91c8f"
|
source: path
|
||||||
url: "https://git.flemming.ai/fai/studio-flow-editor"
|
version: "0.15.0"
|
||||||
source: git
|
|
||||||
version: "0.13.0"
|
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
11
pubspec.yaml
11
pubspec.yaml
|
|
@ -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.60.0
|
version: 0.62.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
@ -48,6 +48,15 @@ dev_dependencies:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
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:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
generate: true
|
generate: true
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue