diff --git a/lib/data/hub.dart b/lib/data/hub.dart index a76c9c3..ab6ff41 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -230,6 +230,46 @@ class HubService { ); } + /// Detected host hardware. Used by the System-AI editor to + /// mark models as "recommended" / "may be slow" against the + /// operator's actual machine. + Future hardwareInfo() async { + final r = await _client.hardwareInfo(); + return HardwareSnapshot( + tier: r.tier, + cpuBrand: r.cpuBrand, + cpuCores: r.cpuCores, + ramMb: r.ramMb.toInt(), + appleSilicon: r.appleSilicon, + dedicatedGpu: r.dedicatedGpu, + summary: r.summary, + ); + } + + /// Bundled curated model database. Refresh requires a hub + /// redeploy; the `lastReviewed` field shows how stale the + /// curation is. + Future listSystemAiCuratedModels() async { + final r = await _client.listSystemAiCuratedModels(); + return CuratedSnapshot( + lastReviewed: r.lastReviewed, + models: r.models + .map( + (m) => CuratedModelInfo( + id: m.id, + family: m.family, + paramsB: m.paramsB, + qualityTier: m.qualityTier, + minHwTier: m.minHwTier, + contextK: m.contextK, + notes: m.notes, + license: m.license, + ), + ) + .toList(), + ); + } + /// Active channel + per-channel daemon status snapshot. Future channelStatus() async { final r = await _client.channelStatus(); @@ -707,6 +747,103 @@ class ChannelStatusSnapshot { }); } +/// Detected host hardware — see +/// docs/architecture/system-ai.md → "Hardware tiers". +class HardwareSnapshot { + /// One of: tiny / small / balanced / large / unknown. + final String tier; + final String cpuBrand; + final int cpuCores; + final int ramMb; + final bool appleSilicon; + final bool dedicatedGpu; + /// Human-readable summary the editor renders verbatim. + final String summary; + + const HardwareSnapshot({ + required this.tier, + required this.cpuBrand, + required this.cpuCores, + required this.ramMb, + required this.appleSilicon, + required this.dedicatedGpu, + required this.summary, + }); + + /// Numeric rank used for "is `min_hw_tier` ≤ detected?" + /// comparisons. Unknown sits at -1 so it never qualifies as + /// "at least" anything; the editor falls back to size-only + /// heuristics in that case. + int get rank { + switch (tier) { + case 'tiny': + return 0; + case 'small': + return 1; + case 'balanced': + return 2; + case 'large': + return 3; + default: + return -1; + } + } +} + +/// One curated model with editorial metadata. Wire-faithful +/// with `crates/fai_hub/system-ai/models.yaml`. +class CuratedModelInfo { + final String id; + final String family; + final double paramsB; + /// basic / good / excellent. + final String qualityTier; + /// tiny / small / balanced / large. + final String minHwTier; + final int contextK; + final String notes; + final String license; + + const CuratedModelInfo({ + required this.id, + required this.family, + required this.paramsB, + required this.qualityTier, + required this.minHwTier, + required this.contextK, + required this.notes, + required this.license, + }); + + /// Same rank as HardwareSnapshot; lets us compare with `<=`. + int get minHwRank { + switch (minHwTier) { + case 'tiny': + return 0; + case 'small': + return 1; + case 'balanced': + return 2; + case 'large': + return 3; + default: + return -1; + } + } +} + +/// Curated database snapshot returned from the hub. +class CuratedSnapshot { + /// ISO date string from `models.yaml`, e.g. "2026-05-07". + final String lastReviewed; + final List models; + + const CuratedSnapshot({ + required this.lastReviewed, + required this.models, + }); +} + /// One row from the store-index search. class StoreItem { final String name; diff --git a/lib/main.dart b/lib/main.dart index 0ea9b9d..4cb8c5f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,7 +23,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.13.1'; +const String kStudioVersion = '0.14.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -334,13 +334,20 @@ class _Sidebar extends StatelessWidget { ], ), ), - // Footer: theme toggle + settings. + // Footer: theme toggle · live wall-clock · settings. + // Clock sits between the two icons so it reads as a + // status line — gives the operator a wall-clock + // reference to cross-check audit-log timestamps + // without taking sidebar real estate from anything + // load-bearing. Padding( padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md), child: Row( children: [ _ThemeToggle(), - const Spacer(), + const Expanded( + child: Center(child: _SidebarClock()), + ), IconButton( icon: const Icon(Icons.settings_outlined, size: 16), tooltip: 'Settings (⌘;)', @@ -546,3 +553,79 @@ class _ThemeToggle extends StatelessWidget { ); } } + +/// Small ticking wall-clock for the sidebar. Aligns the on-screen +/// time with the timestamps the audit log writes — operators +/// glance here when an event timestamp looks off and want a +/// reference point. Updates on the second; tooltip exposes the +/// full ISO + UTC offset for unambiguous comparison. +class _SidebarClock extends StatefulWidget { + const _SidebarClock(); + + @override + State<_SidebarClock> createState() => _SidebarClockState(); +} + +class _SidebarClockState extends State<_SidebarClock> { + late DateTime _now; + Timer? _ticker; + + @override + void initState() { + super.initState(); + _now = DateTime.now(); + _scheduleNextTick(); + } + + void _scheduleNextTick() { + // Align ticks on the second boundary so the seconds digit + // changes when the wall-clock second changes, not 0–999 ms + // late depending on when the widget mounted. + final delay = Duration(milliseconds: 1000 - _now.millisecond); + _ticker = Timer(delay, () { + if (!mounted) return; + setState(() => _now = DateTime.now()); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() => _now = DateTime.now()); + }); + }); + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hh = _now.hour.toString().padLeft(2, '0'); + final mm = _now.minute.toString().padLeft(2, '0'); + final ss = _now.second.toString().padLeft(2, '0'); + final yyyy = _now.year.toString().padLeft(4, '0'); + final mo = _now.month.toString().padLeft(2, '0'); + final dd = _now.day.toString().padLeft(2, '0'); + + final offsetMin = _now.timeZoneOffset.inMinutes; + final sign = offsetMin >= 0 ? '+' : '-'; + final absMin = offsetMin.abs(); + final offH = (absMin ~/ 60).toString().padLeft(2, '0'); + final offM = (absMin % 60).toString().padLeft(2, '0'); + final tooltip = + 'Local: $yyyy-$mo-$dd $hh:$mm:$ss $sign$offH:$offM\n' + 'UTC: ${_now.toUtc().toIso8601String()}'; + + return Tooltip( + message: tooltip, + child: Text( + '$hh:$mm:$ss', + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.85), + ), + ), + ); + } +} diff --git a/lib/widgets/fai_system_ai_editor.dart b/lib/widgets/fai_system_ai_editor.dart index f7041f4..87d438d 100644 --- a/lib/widgets/fai_system_ai_editor.dart +++ b/lib/widgets/fai_system_ai_editor.dart @@ -124,6 +124,9 @@ class _FaiSystemAiEditorState extends State { String? _modelsError; AskAiResult? _testResult; String? _error; + HardwareSnapshot? _hw; + CuratedSnapshot? _curated; + Map _curatedById = const {}; @override void initState() { @@ -151,9 +154,29 @@ class _FaiSystemAiEditorState extends State { if (_endpoint.text.trim().isNotEmpty) { _refreshModels(); } + _loadHardwareAndCurated(); }); } + Future _loadHardwareAndCurated() async { + try { + final results = await Future.wait([ + HubService.instance.hardwareInfo(), + HubService.instance.listSystemAiCuratedModels(), + ]); + if (!mounted) return; + final hw = results[0] as HardwareSnapshot; + final curated = results[1] as CuratedSnapshot; + setState(() { + _hw = hw; + _curated = curated; + _curatedById = {for (final m in curated.models) m.id: m}; + }); + } catch (_) { + // Non-fatal: editor still works using the size-only heuristic. + } + } + @override void dispose() { _endpoint.dispose(); @@ -327,6 +350,10 @@ class _FaiSystemAiEditorState extends State { color: theme.colorScheme.onSurfaceVariant, ), ), + if (_hw != null) ...[ + const SizedBox(height: FaiSpace.sm), + _HardwareBanner(hw: _hw!, lastReviewed: _curated?.lastReviewed), + ], const SizedBox(height: FaiSpace.lg), _ProviderDropdown( value: _preset, @@ -357,6 +384,8 @@ class _FaiSystemAiEditorState extends State { modelsError: _modelsError, loading: _loadingModels, pulling: _pulling, + hw: _hw, + curatedById: _curatedById, onRefresh: _saving || _testing || _loadingModels || _pulling ? null : _refreshModels, @@ -620,6 +649,15 @@ class _ModelPicker extends StatelessWidget { final String? modelsError; final bool loading; final bool pulling; + /// Detected host hardware. Used by [_suitabilityFor] to mark + /// curated models as "recommended" when their `min_hw_tier` is + /// at or below the operator's tier. Null while the hub call is + /// in flight or unreachable — chips fall back to the size-only + /// heuristic. + final HardwareSnapshot? hw; + /// Curated DB indexed by model id. Empty while the hub call is + /// in flight; chips then fall back to the size-only heuristic. + final Map curatedById; final VoidCallback? onRefresh; final VoidCallback? onPull; @@ -630,6 +668,8 @@ class _ModelPicker extends StatelessWidget { required this.modelsError, required this.loading, required this.pulling, + required this.hw, + required this.curatedById, required this.onRefresh, required this.onPull, }); @@ -719,16 +759,25 @@ class _ModelPicker extends StatelessWidget { spacing: FaiSpace.xs, runSpacing: FaiSpace.xs, children: [ - for (final id in _sortedBySuitability(models!)) + for (final id in _sortedBySuitability( + models!, + hw: hw, + curatedById: curatedById, + )) _ModelChip( id: id, - suitability: _suitabilityFor(id), + suitability: _suitabilityFor( + id, + hw: hw, + curatedById: curatedById, + ), + curated: curatedById[id.trim().toLowerCase()], onTap: () => controller.text = id, ), ], ), const SizedBox(height: 6), - _SuitabilityLegend(), + _SuitabilityLegend(hw: hw), ], ], ], @@ -785,27 +834,39 @@ extension _SuitabilityCopy on _Suitability { } } -/// Hand-curated allowlist of model ids tested with the F∆I -/// System-AI prompt. Anything in here gets the green -/// `recommended` badge. Keep small + opinionated. -const _kRecommendedModels = { - 'gemma3:4b', - 'gemma3:12b', - 'llama3.2:3b', - 'qwen2.5:7b', - 'qwen2.5-coder:7b', - 'gpt-4o-mini', - 'gpt-4o', - 'claude-haiku-4-5', - 'claude-sonnet-4-6', -}; - -_Suitability _suitabilityFor(String rawId) { +/// Suitability rating for a model id. Curated DB + detected +/// hardware win over the size-only heuristic when both are +/// available; otherwise we fall back to parsing the param count +/// out of the id. +/// +/// Lookup order: +/// 1. Curated entry → use `quality_tier` AND compare +/// `min_hw_tier` against detected hardware. Only `good` or +/// `excellent` curated entries that the hardware can actually +/// run get the green star. +/// 2. Size-only heuristic — same fallback logic as before. +_Suitability _suitabilityFor( + String rawId, { + HardwareSnapshot? hw, + Map curatedById = const {}, +}) { final id = rawId.trim().toLowerCase(); - if (_kRecommendedModels.contains(id)) { - return _Suitability.recommended; + final curated = curatedById[id]; + if (curated != null) { + final hwOk = hw == null || hw.rank < 0 || curated.minHwRank <= hw.rank; + if ((curated.qualityTier == 'good' || curated.qualityTier == 'excellent') && + hwOk) { + return _Suitability.recommended; + } + if (curated.qualityTier == 'good' || curated.qualityTier == 'excellent') { + // Curated quality is fine, but hardware is below `min_hw_tier`. + // Mark as large (orange) to signal "may be slow on this host". + return _Suitability.large; + } + // Curated as `basic` — quality may be insufficient. + return _Suitability.small; } - // Try to extract a parameter count from common naming + // Fallback: try to extract a parameter count from common naming // conventions: `gemma3:4b`, `llama3.2-70b-instruct`, `qwen-1.5b`, // `:13b`, `-3.2b`, etc. Match a number (with optional decimal) // followed by `b`, bordered by a non-letter on both sides. @@ -819,8 +880,8 @@ _Suitability _suitabilityFor(String rawId) { return _Suitability.huge; } } - // OpenAI / Anthropic without size in the name — use known - // family heuristics. + // OpenAI / Anthropic without size in the name — known-family + // heuristics. if (id.contains('mini') || id.contains('haiku') || id.contains('flash')) { return _Suitability.balanced; } @@ -833,7 +894,11 @@ _Suitability _suitabilityFor(String rawId) { /// Sort by suitability tier first (recommended → balanced → /// large → small → huge → unknown), then alphabetically — so /// the operator's eye lands on the green chips immediately. -List _sortedBySuitability(List ids) { +List _sortedBySuitability( + List ids, { + HardwareSnapshot? hw, + Map curatedById = const {}, +}) { int rank(_Suitability s) { switch (s) { case _Suitability.recommended: @@ -853,8 +918,8 @@ List _sortedBySuitability(List ids) { final out = [...ids]; out.sort((a, b) { - final ra = rank(_suitabilityFor(a)); - final rb = rank(_suitabilityFor(b)); + final ra = rank(_suitabilityFor(a, hw: hw, curatedById: curatedById)); + final rb = rank(_suitabilityFor(b, hw: hw, curatedById: curatedById)); if (ra != rb) return ra.compareTo(rb); return a.compareTo(b); }); @@ -864,20 +929,44 @@ List _sortedBySuitability(List ids) { class _ModelChip extends StatelessWidget { final String id; final _Suitability suitability; + /// Curated entry for this id, when one exists. Drives the + /// tooltip — operators get the editorial note ("Solid 4B + /// all-rounder…") rather than just the suitability label. + final CuratedModelInfo? curated; final VoidCallback onTap; const _ModelChip({ required this.id, required this.suitability, + required this.curated, required this.onTap, }); + String _tooltipFor(BuildContext _) { + final lines = [suitability.label]; + final c = curated; + if (c != null) { + lines.add( + '${c.qualityTier} · ${c.paramsB > 0 ? "${_fmtParams(c.paramsB)} params · " : ""}' + '${c.contextK}K ctx · min hw: ${c.minHwTier}', + ); + if (c.notes.isNotEmpty) lines.add(c.notes); + if (c.license.isNotEmpty) lines.add('license: ${c.license}'); + } + return lines.join('\n'); + } + + static String _fmtParams(double b) { + if (b == b.roundToDouble()) return '${b.toInt()}B'; + return '${b.toStringAsFixed(1)}B'; + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final color = suitability.color(theme.colorScheme); return Tooltip( - message: suitability.label, + message: _tooltipFor(context), child: ActionChip( avatar: Icon( suitability == _Suitability.recommended @@ -894,7 +983,57 @@ class _ModelChip extends StatelessWidget { } } +/// Compact banner that shows the detected hardware tier plus the +/// curated DB's `last_reviewed` date, so the operator knows which +/// "recommended" judgement they are reading. +class _HardwareBanner extends StatelessWidget { + final HardwareSnapshot hw; + final String? lastReviewed; + + const _HardwareBanner({required this.hw, required this.lastReviewed}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final lr = (lastReviewed != null && lastReviewed!.isNotEmpty) + ? ' · curation reviewed $lastReviewed' + : ''; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.sm, + vertical: 6, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Row( + children: [ + Icon( + Icons.memory, + size: 13, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Detected: ${hw.summary}$lr', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + } +} + class _SuitabilityLegend extends StatelessWidget { + final HardwareSnapshot? hw; + const _SuitabilityLegend({required this.hw}); + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -915,6 +1054,7 @@ class _SuitabilityLegend extends StatelessWidget { ], ), ); + final tierTag = (hw == null || hw!.tier == 'unknown') ? '' : ' (${hw!.tier})'; return Wrap( crossAxisAlignment: WrapCrossAlignment.center, children: [ @@ -926,7 +1066,7 @@ class _SuitabilityLegend extends StatelessWidget { Icon(Icons.star, size: 11, color: FaiColors.success), const SizedBox(width: 4), Text( - 'tested with F∆I', + 'recommended for this hardware$tierTag', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontSize: 11, @@ -936,7 +1076,7 @@ class _SuitabilityLegend extends StatelessWidget { ), ), dot(theme.colorScheme.primary, 'balanced (4-8B)'), - dot(FaiColors.warning, 'small <3B / large 9-15B'), + dot(FaiColors.warning, 'small <3B / large 9-15B / above hardware'), dot(theme.colorScheme.error, 'huge >15B'), dot(theme.colorScheme.outline, 'size unknown'), ], diff --git a/pubspec.lock b/pubspec.lock index 2082c26..43bd6b2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -79,7 +79,7 @@ packages: path: "../fai_dart_sdk" relative: true source: path - version: "0.6.0" + version: "0.7.0" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8535a0e..6484194 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.13.1 +version: 0.14.0 environment: sdk: ^3.11.0-200.1.beta