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>
213 lines
6.3 KiB
Dart
213 lines
6.3 KiB
Dart
// Today-Hero story loader.
|
|
//
|
|
// Reads ~/.fai/today/active.yaml when present, validates the
|
|
// schema, and hands the parsed bilingual story back to the
|
|
// store page. On any failure (file missing, schema mismatch,
|
|
// banned-words hit, parse error) the caller falls back to the
|
|
// compiled-in const story so KRITIS / fresh installs always
|
|
// have something to render.
|
|
//
|
|
// See docs/today-pipeline.md for the full design rationale and
|
|
// tools/today/ for the operator-facing CLI.
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:yaml/yaml.dart';
|
|
|
|
/// Editorial action attached to a Today story. The set is kept
|
|
/// 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.
|
|
///
|
|
/// `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.
|
|
@immutable
|
|
class TodayStoryData {
|
|
final String badgeEn;
|
|
final String badgeDe;
|
|
final String titleEn;
|
|
final String titleDe;
|
|
final String bodyEn;
|
|
final String bodyDe;
|
|
final String ctaLabelEn;
|
|
final String ctaLabelDe;
|
|
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,
|
|
required this.titleEn,
|
|
required this.titleDe,
|
|
required this.bodyEn,
|
|
required this.bodyDe,
|
|
required this.ctaLabelEn,
|
|
required this.ctaLabelDe,
|
|
required this.icon,
|
|
required this.cta,
|
|
this.ctaPayload = '',
|
|
});
|
|
}
|
|
|
|
/// Static loader. Pure I/O + parsing, no widget concerns. The
|
|
/// store page calls `loadOrFallback` once per fetch and feeds
|
|
/// the result into `_StoreTodayHero`.
|
|
class TodayStoryLoader {
|
|
/// Resolves to `~/.fai/today/active.yaml` on every supported
|
|
/// host. The pipeline writes here from [tools/today/accept.sh].
|
|
static String activePath() {
|
|
final home =
|
|
Platform.environment['HOME'] ??
|
|
Platform.environment['USERPROFILE'] ??
|
|
'';
|
|
return p.join(home, '.fai', 'today', 'active.yaml');
|
|
}
|
|
|
|
/// Reads and validates the active story. Returns `fallback`
|
|
/// when the file is missing, malformed, or fails schema /
|
|
/// banned-words checks. Loud failures stay on disk in the
|
|
/// pipeline tooling — Studio quietly falls back so the UI
|
|
/// never crashes on bad operator input.
|
|
static TodayStoryData loadOrFallback(TodayStoryData fallback) {
|
|
try {
|
|
final f = File(activePath());
|
|
if (!f.existsSync()) return fallback;
|
|
final raw = f.readAsStringSync();
|
|
final story = _parse(raw);
|
|
return story ?? fallback;
|
|
} catch (_) {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
static TodayStoryData? _parse(String raw) {
|
|
final dynamic doc = loadYaml(raw);
|
|
if (doc is! YamlMap) return null;
|
|
|
|
if (doc['schema']?.toString() != 'today/v1') return null;
|
|
|
|
String s(String key) => (doc[key] ?? '').toString().trim();
|
|
if (_bannedWords.hasMatch(
|
|
'${s('title_en')} ${s('title_de')} ${s('body_en')} ${s('body_de')}',
|
|
)) {
|
|
return null;
|
|
}
|
|
|
|
final required = [
|
|
'badge_en',
|
|
'badge_de',
|
|
'title_en',
|
|
'title_de',
|
|
'body_en',
|
|
'body_de',
|
|
'icon',
|
|
'cta',
|
|
];
|
|
for (final k in required) {
|
|
if (s(k).isEmpty) return null;
|
|
}
|
|
|
|
final cta = _ctaFor(s('cta'));
|
|
if (cta == null) return null;
|
|
if (cta != TodayCta.none) {
|
|
if (s('cta_label_en').isEmpty || s('cta_label_de').isEmpty) {
|
|
return null;
|
|
}
|
|
}
|
|
final icon = _iconFor(s('icon'));
|
|
if (icon == null) return null;
|
|
|
|
return TodayStoryData(
|
|
badgeEn: s('badge_en'),
|
|
badgeDe: s('badge_de'),
|
|
titleEn: s('title_en'),
|
|
titleDe: s('title_de'),
|
|
bodyEn: s('body_en'),
|
|
bodyDe: s('body_de'),
|
|
ctaLabelEn: s('cta_label_en'),
|
|
ctaLabelDe: s('cta_label_de'),
|
|
icon: icon,
|
|
cta: cta,
|
|
ctaPayload: s('cta_payload'),
|
|
);
|
|
}
|
|
|
|
/// Same banned-word list as `tools/today/accept.sh`. Kept in
|
|
/// sync by hand — a candidate that bypassed the shell gate
|
|
/// (e.g. a hand-edited active.yaml) still won't reach the UI.
|
|
static final RegExp _bannedWords = RegExp(
|
|
r'\b('
|
|
r'viral|killer|powerful|just\s+works|revolutionary|game[- ]changing|'
|
|
r'seamless|next[- ]generation|cutting[- ]edge|world[- ]class|unprecedented'
|
|
r')\b',
|
|
caseSensitive: false,
|
|
);
|
|
|
|
static TodayCta? _ctaFor(String s) {
|
|
switch (s) {
|
|
case 'none':
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// Whitelist of icons the prompt template is allowed to
|
|
/// produce. Keeping the set explicit means an LLM hallucinated
|
|
/// icon name fails the load (and we fall back) rather than
|
|
/// rendering a random glyph.
|
|
static IconData? _iconFor(String name) {
|
|
switch (name) {
|
|
case 'hub_outlined':
|
|
return Icons.hub_outlined;
|
|
case 'account_tree_outlined':
|
|
return Icons.account_tree_outlined;
|
|
case 'shield_outlined':
|
|
return Icons.shield_outlined;
|
|
case 'extension':
|
|
return Icons.extension;
|
|
case 'bolt':
|
|
return Icons.bolt;
|
|
case 'menu_book_outlined':
|
|
return Icons.menu_book_outlined;
|
|
case 'security':
|
|
return Icons.security;
|
|
case 'auto_awesome':
|
|
return Icons.auto_awesome;
|
|
case 'rocket_launch':
|
|
return Icons.rocket_launch;
|
|
case 'timeline_outlined':
|
|
return Icons.timeline_outlined;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
}
|