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:
parent
a83a138259
commit
942c29a395
5 changed files with 395 additions and 35 deletions
|
|
@ -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.
|
/// Active channel + per-channel daemon status snapshot.
|
||||||
Future<ChannelStatusSnapshot> channelStatus() async {
|
Future<ChannelStatusSnapshot> channelStatus() async {
|
||||||
final r = await _client.channelStatus();
|
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.
|
/// One row from the store-index search.
|
||||||
class StoreItem {
|
class StoreItem {
|
||||||
final String name;
|
final String name;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,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.13.1';
|
const String kStudioVersion = '0.14.0';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
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(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
|
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_ThemeToggle(),
|
_ThemeToggle(),
|
||||||
const Spacer(),
|
const Expanded(
|
||||||
|
child: Center(child: _SidebarClock()),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings_outlined, size: 16),
|
icon: const Icon(Icons.settings_outlined, size: 16),
|
||||||
tooltip: 'Settings (⌘;)',
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,9 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
||||||
String? _modelsError;
|
String? _modelsError;
|
||||||
AskAiResult? _testResult;
|
AskAiResult? _testResult;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
HardwareSnapshot? _hw;
|
||||||
|
CuratedSnapshot? _curated;
|
||||||
|
Map<String, CuratedModelInfo> _curatedById = const {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -151,9 +154,29 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
||||||
if (_endpoint.text.trim().isNotEmpty) {
|
if (_endpoint.text.trim().isNotEmpty) {
|
||||||
_refreshModels();
|
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_endpoint.dispose();
|
_endpoint.dispose();
|
||||||
|
|
@ -327,6 +350,10 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_hw != null) ...[
|
||||||
|
const SizedBox(height: FaiSpace.sm),
|
||||||
|
_HardwareBanner(hw: _hw!, lastReviewed: _curated?.lastReviewed),
|
||||||
|
],
|
||||||
const SizedBox(height: FaiSpace.lg),
|
const SizedBox(height: FaiSpace.lg),
|
||||||
_ProviderDropdown(
|
_ProviderDropdown(
|
||||||
value: _preset,
|
value: _preset,
|
||||||
|
|
@ -357,6 +384,8 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
||||||
modelsError: _modelsError,
|
modelsError: _modelsError,
|
||||||
loading: _loadingModels,
|
loading: _loadingModels,
|
||||||
pulling: _pulling,
|
pulling: _pulling,
|
||||||
|
hw: _hw,
|
||||||
|
curatedById: _curatedById,
|
||||||
onRefresh: _saving || _testing || _loadingModels || _pulling
|
onRefresh: _saving || _testing || _loadingModels || _pulling
|
||||||
? null
|
? null
|
||||||
: _refreshModels,
|
: _refreshModels,
|
||||||
|
|
@ -620,6 +649,15 @@ class _ModelPicker extends StatelessWidget {
|
||||||
final String? modelsError;
|
final String? modelsError;
|
||||||
final bool loading;
|
final bool loading;
|
||||||
final bool pulling;
|
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? onRefresh;
|
||||||
final VoidCallback? onPull;
|
final VoidCallback? onPull;
|
||||||
|
|
||||||
|
|
@ -630,6 +668,8 @@ class _ModelPicker extends StatelessWidget {
|
||||||
required this.modelsError,
|
required this.modelsError,
|
||||||
required this.loading,
|
required this.loading,
|
||||||
required this.pulling,
|
required this.pulling,
|
||||||
|
required this.hw,
|
||||||
|
required this.curatedById,
|
||||||
required this.onRefresh,
|
required this.onRefresh,
|
||||||
required this.onPull,
|
required this.onPull,
|
||||||
});
|
});
|
||||||
|
|
@ -719,16 +759,25 @@ class _ModelPicker extends StatelessWidget {
|
||||||
spacing: FaiSpace.xs,
|
spacing: FaiSpace.xs,
|
||||||
runSpacing: FaiSpace.xs,
|
runSpacing: FaiSpace.xs,
|
||||||
children: [
|
children: [
|
||||||
for (final id in _sortedBySuitability(models!))
|
for (final id in _sortedBySuitability(
|
||||||
|
models!,
|
||||||
|
hw: hw,
|
||||||
|
curatedById: curatedById,
|
||||||
|
))
|
||||||
_ModelChip(
|
_ModelChip(
|
||||||
id: id,
|
id: id,
|
||||||
suitability: _suitabilityFor(id),
|
suitability: _suitabilityFor(
|
||||||
|
id,
|
||||||
|
hw: hw,
|
||||||
|
curatedById: curatedById,
|
||||||
|
),
|
||||||
|
curated: curatedById[id.trim().toLowerCase()],
|
||||||
onTap: () => controller.text = id,
|
onTap: () => controller.text = id,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
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
|
/// Suitability rating for a model id. Curated DB + detected
|
||||||
/// System-AI prompt. Anything in here gets the green
|
/// hardware win over the size-only heuristic when both are
|
||||||
/// `recommended` badge. Keep small + opinionated.
|
/// available; otherwise we fall back to parsing the param count
|
||||||
const _kRecommendedModels = <String>{
|
/// out of the id.
|
||||||
'gemma3:4b',
|
///
|
||||||
'gemma3:12b',
|
/// Lookup order:
|
||||||
'llama3.2:3b',
|
/// 1. Curated entry → use `quality_tier` AND compare
|
||||||
'qwen2.5:7b',
|
/// `min_hw_tier` against detected hardware. Only `good` or
|
||||||
'qwen2.5-coder:7b',
|
/// `excellent` curated entries that the hardware can actually
|
||||||
'gpt-4o-mini',
|
/// run get the green star.
|
||||||
'gpt-4o',
|
/// 2. Size-only heuristic — same fallback logic as before.
|
||||||
'claude-haiku-4-5',
|
_Suitability _suitabilityFor(
|
||||||
'claude-sonnet-4-6',
|
String rawId, {
|
||||||
};
|
HardwareSnapshot? hw,
|
||||||
|
Map<String, CuratedModelInfo> curatedById = const {},
|
||||||
_Suitability _suitabilityFor(String rawId) {
|
}) {
|
||||||
final id = rawId.trim().toLowerCase();
|
final id = rawId.trim().toLowerCase();
|
||||||
if (_kRecommendedModels.contains(id)) {
|
final curated = curatedById[id];
|
||||||
return _Suitability.recommended;
|
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`,
|
// conventions: `gemma3:4b`, `llama3.2-70b-instruct`, `qwen-1.5b`,
|
||||||
// `:13b`, `-3.2b`, etc. Match a number (with optional decimal)
|
// `:13b`, `-3.2b`, etc. Match a number (with optional decimal)
|
||||||
// followed by `b`, bordered by a non-letter on both sides.
|
// followed by `b`, bordered by a non-letter on both sides.
|
||||||
|
|
@ -819,8 +880,8 @@ _Suitability _suitabilityFor(String rawId) {
|
||||||
return _Suitability.huge;
|
return _Suitability.huge;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// OpenAI / Anthropic without size in the name — use known
|
// OpenAI / Anthropic without size in the name — known-family
|
||||||
// family heuristics.
|
// heuristics.
|
||||||
if (id.contains('mini') || id.contains('haiku') || id.contains('flash')) {
|
if (id.contains('mini') || id.contains('haiku') || id.contains('flash')) {
|
||||||
return _Suitability.balanced;
|
return _Suitability.balanced;
|
||||||
}
|
}
|
||||||
|
|
@ -833,7 +894,11 @@ _Suitability _suitabilityFor(String rawId) {
|
||||||
/// Sort by suitability tier first (recommended → balanced →
|
/// Sort by suitability tier first (recommended → balanced →
|
||||||
/// large → small → huge → unknown), then alphabetically — so
|
/// large → small → huge → unknown), then alphabetically — so
|
||||||
/// the operator's eye lands on the green chips immediately.
|
/// 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) {
|
int rank(_Suitability s) {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case _Suitability.recommended:
|
case _Suitability.recommended:
|
||||||
|
|
@ -853,8 +918,8 @@ List<String> _sortedBySuitability(List<String> ids) {
|
||||||
|
|
||||||
final out = [...ids];
|
final out = [...ids];
|
||||||
out.sort((a, b) {
|
out.sort((a, b) {
|
||||||
final ra = rank(_suitabilityFor(a));
|
final ra = rank(_suitabilityFor(a, hw: hw, curatedById: curatedById));
|
||||||
final rb = rank(_suitabilityFor(b));
|
final rb = rank(_suitabilityFor(b, hw: hw, curatedById: curatedById));
|
||||||
if (ra != rb) return ra.compareTo(rb);
|
if (ra != rb) return ra.compareTo(rb);
|
||||||
return a.compareTo(b);
|
return a.compareTo(b);
|
||||||
});
|
});
|
||||||
|
|
@ -864,20 +929,44 @@ List<String> _sortedBySuitability(List<String> ids) {
|
||||||
class _ModelChip extends StatelessWidget {
|
class _ModelChip extends StatelessWidget {
|
||||||
final String id;
|
final String id;
|
||||||
final _Suitability suitability;
|
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;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _ModelChip({
|
const _ModelChip({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.suitability,
|
required this.suitability,
|
||||||
|
required this.curated,
|
||||||
required this.onTap,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final color = suitability.color(theme.colorScheme);
|
final color = suitability.color(theme.colorScheme);
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: suitability.label,
|
message: _tooltipFor(context),
|
||||||
child: ActionChip(
|
child: ActionChip(
|
||||||
avatar: Icon(
|
avatar: Icon(
|
||||||
suitability == _Suitability.recommended
|
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 {
|
class _SuitabilityLegend extends StatelessWidget {
|
||||||
|
final HardwareSnapshot? hw;
|
||||||
|
const _SuitabilityLegend({required this.hw});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(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(
|
return Wrap(
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -926,7 +1066,7 @@ class _SuitabilityLegend extends StatelessWidget {
|
||||||
Icon(Icons.star, size: 11, color: FaiColors.success),
|
Icon(Icons.star, size: 11, color: FaiColors.success),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'tested with F∆I',
|
'recommended for this hardware$tierTag',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
|
|
@ -936,7 +1076,7 @@ class _SuitabilityLegend extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
dot(theme.colorScheme.primary, 'balanced (4-8B)'),
|
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.error, 'huge >15B'),
|
||||||
dot(theme.colorScheme.outline, 'size unknown'),
|
dot(theme.colorScheme.outline, 'size unknown'),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ packages:
|
||||||
path: "../fai_dart_sdk"
|
path: "../fai_dart_sdk"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.6.0"
|
version: "0.7.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -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.13.1
|
version: 0.14.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue