feat(studio): hardware-aware model recommendations + sidebar clock (v0.14.0)

System-AI editor now fetches the detected hardware tier and the
hub's curated model database on open. Each model chip's colour
+ tooltip combines both: green star when curated quality is
good/excellent AND min_hw_tier ≤ host; orange when quality is
fine but the host is below min_hw_tier; warning for "basic"
curated entries. Tooltip exposes the curator's note, parameter
count, context window, and licence. Banner above the form
shows "Detected: <summary> · curation reviewed <date>".

Sidebar footer gains a small monospaced live clock between the
theme toggle and the settings icon. Tooltip exposes full local
ISO + UTC for cross-checking against audit-log timestamps.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-07 15:39:27 +02:00
parent a83a138259
commit 942c29a395
5 changed files with 395 additions and 35 deletions

View file

@ -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<HardwareSnapshot> 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<CuratedSnapshot> 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<ChannelStatusSnapshot> 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<CuratedModelInfo> models;
const CuratedSnapshot({
required this.lastReviewed,
required this.models,
});
}
/// One row from the store-index search.
class StoreItem {
final String name;

View file

@ -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<void> 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 0999 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),
),
),
);
}
}

View file

@ -124,6 +124,9 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
String? _modelsError;
AskAiResult? _testResult;
String? _error;
HardwareSnapshot? _hw;
CuratedSnapshot? _curated;
Map<String, CuratedModelInfo> _curatedById = const {};
@override
void initState() {
@ -151,9 +154,29 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
if (_endpoint.text.trim().isNotEmpty) {
_refreshModels();
}
_loadHardwareAndCurated();
});
}
Future<void> _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<FaiSystemAiEditor> {
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<FaiSystemAiEditor> {
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<String, CuratedModelInfo> 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 FI
/// System-AI prompt. Anything in here gets the green
/// `recommended` badge. Keep small + opinionated.
const _kRecommendedModels = <String>{
'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<String, CuratedModelInfo> 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<String> _sortedBySuitability(List<String> ids) {
List<String> _sortedBySuitability(
List<String> ids, {
HardwareSnapshot? hw,
Map<String, CuratedModelInfo> curatedById = const {},
}) {
int rank(_Suitability s) {
switch (s) {
case _Suitability.recommended:
@ -853,8 +918,8 @@ List<String> _sortedBySuitability(List<String> 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<String> _sortedBySuitability(List<String> 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 = <String>[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'),
],