From a83a1382591a4075265d5a21dd3bae8dbd71f86c Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 7 May 2026 14:14:03 +0200 Subject: [PATCH] feat(system-ai): auto-load models on open + suitability hints (v0.13.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operator-felt papercuts on the editor: * Models only appeared after a manual Refresh click. Now the editor calls `_refreshModels()` from initState (post-frame) so chips populate immediately. Provider switch also re-fires the refresh because endpoint / model set changes too. * Operator had no signal which model is suitable. Tiny models fail at instruction following; huge models stall on a laptop. Adds a heuristic suitability rating (parses parameter count from common names like `gemma3:4b`, `llama3.2:70b`; falls back to family hints for OpenAI / Claude). Each chip now carries: - a coloured leading dot (green=balanced, orange=small or large, red=huge, grey=unknown) - a star icon for the hand-curated "tested with F∆I" allowlist (gemma3:4b, llama3.2:3b, qwen2.5-coder:7b, gpt-4o-mini, claude-haiku-4-5, …) - a tooltip ("balanced", "huge — likely too slow on a laptop", …) Sorting also moves recommended → balanced → others to the front so the operator's eye lands on green first. * Compact legend below the chip wrap explains the colour code in place — no lookup needed. Signed-off-by: flemming-it --- lib/main.dart | 2 +- lib/widgets/fai_system_ai_editor.dart | 244 ++++++++++++++++++++++++-- pubspec.lock | 2 +- pubspec.yaml | 2 +- 4 files changed, 237 insertions(+), 13 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 8d61f34..0ea9b9d 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.0'; +const String kStudioVersion = '0.13.1'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/widgets/fai_system_ai_editor.dart b/lib/widgets/fai_system_ai_editor.dart index dd511f3..f7041f4 100644 --- a/lib/widgets/fai_system_ai_editor.dart +++ b/lib/widgets/fai_system_ai_editor.dart @@ -142,6 +142,16 @@ class _FaiSystemAiEditorState extends State { : init.apiKeyEnv, ); _privacyMode = init.privacyMode.isEmpty ? 'redacted' : init.privacyMode; + // Pull the model list immediately so the operator sees the + // available models when the dialog opens — no Refresh click + // needed. Fail-quiet: if the provider isn't running, the + // dropdown stays empty and the operator can fill the field + // manually or hit Refresh later. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_endpoint.text.trim().isNotEmpty) { + _refreshModels(); + } + }); } @override @@ -165,7 +175,14 @@ class _FaiSystemAiEditorState extends State { _apiKeyEnv.text = p.defaultApiKeyEnv; } _testResult = null; + _models = null; + _modelsError = null; }); + // New provider → new model list. Fire a refresh so the + // chips update before the operator wonders what's available. + if (_endpoint.text.trim().isNotEmpty) { + _refreshModels(); + } } bool _isAnyDefaultEndpoint(String t) => @@ -697,25 +714,232 @@ class _ModelPicker extends StatelessWidget { color: theme.colorScheme.onSurfaceVariant, ), ) - else + else ...[ Wrap( spacing: FaiSpace.xs, runSpacing: FaiSpace.xs, children: [ - for (final id in models!) - ActionChip( - label: Text( - id, - style: FaiTheme.mono(size: 11), - ), - onPressed: () { - controller.text = id; - }, + for (final id in _sortedBySuitability(models!)) + _ModelChip( + id: id, + suitability: _suitabilityFor(id), + onTap: () => controller.text = id, ), ], ), + const SizedBox(height: 6), + _SuitabilityLegend(), + ], ], ], ); } } + +/// Heuristic suitability rating for a model id, used to colour +/// the chips in the model picker. Pure id-based — no /api/show +/// round-trip; works for Ollama tags (`gemma3:4b`), OpenAI names +/// (`gpt-4o-mini`), Anthropic-style (`claude-haiku-4-5`). +enum _Suitability { + recommended, // tested with F∆I System AI + balanced, // 4-8B params or known-good cloud model + small, // <4B params; quality may be insufficient + large, // 9-15B; slower but ok + huge, // >15B; usually too slow on a developer laptop + unknown, // can't tell from the id +} + +extension _SuitabilityCopy on _Suitability { + String get label { + switch (this) { + case _Suitability.recommended: + return 'recommended'; + case _Suitability.balanced: + return 'balanced'; + case _Suitability.small: + return 'small — quality may be limited'; + case _Suitability.large: + return 'large — slower'; + case _Suitability.huge: + return 'huge — likely too slow on a laptop'; + case _Suitability.unknown: + return 'size unknown'; + } + } + + Color color(ColorScheme cs) { + switch (this) { + case _Suitability.recommended: + return FaiColors.success; + case _Suitability.balanced: + return cs.primary; + case _Suitability.small: + return FaiColors.warning; + case _Suitability.large: + return FaiColors.warning; + case _Suitability.huge: + return cs.error; + case _Suitability.unknown: + return cs.outline; + } + } +} + +/// 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) { + final id = rawId.trim().toLowerCase(); + if (_kRecommendedModels.contains(id)) { + return _Suitability.recommended; + } + // 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. + final m = RegExp(r'(? _sortedBySuitability(List ids) { + int rank(_Suitability s) { + switch (s) { + case _Suitability.recommended: + return 0; + case _Suitability.balanced: + return 1; + case _Suitability.large: + return 2; + case _Suitability.small: + return 3; + case _Suitability.huge: + return 4; + case _Suitability.unknown: + return 5; + } + } + + final out = [...ids]; + out.sort((a, b) { + final ra = rank(_suitabilityFor(a)); + final rb = rank(_suitabilityFor(b)); + if (ra != rb) return ra.compareTo(rb); + return a.compareTo(b); + }); + return out; +} + +class _ModelChip extends StatelessWidget { + final String id; + final _Suitability suitability; + final VoidCallback onTap; + + const _ModelChip({ + required this.id, + required this.suitability, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = suitability.color(theme.colorScheme); + return Tooltip( + message: suitability.label, + child: ActionChip( + avatar: Icon( + suitability == _Suitability.recommended + ? Icons.star + : Icons.circle, + size: suitability == _Suitability.recommended ? 14 : 9, + color: color, + ), + label: Text(id, style: FaiTheme.mono(size: 11)), + side: BorderSide(color: color.withValues(alpha: 0.4)), + onPressed: onTap, + ), + ); + } +} + +class _SuitabilityLegend extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + Widget dot(Color c, String label) => Padding( + padding: const EdgeInsets.only(right: FaiSpace.md), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.circle, size: 8, color: c), + const SizedBox(width: 4), + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 11, + ), + ), + ], + ), + ); + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(right: FaiSpace.md), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.star, size: 11, color: FaiColors.success), + const SizedBox(width: 4), + Text( + 'tested with F∆I', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 11, + ), + ), + ], + ), + ), + dot(theme.colorScheme.primary, 'balanced (4-8B)'), + dot(FaiColors.warning, 'small <3B / large 9-15B'), + dot(theme.colorScheme.error, 'huge >15B'), + dot(theme.colorScheme.outline, 'size unknown'), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 899571a..2082c26 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -79,7 +79,7 @@ packages: path: "../fai_dart_sdk" relative: true source: path - version: "0.5.0" + version: "0.6.0" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d2755b2..8535a0e 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.0 +version: 0.13.1 environment: sdk: ^3.11.0-200.1.beta