feat(system-ai): auto-load models on open + suitability hints (v0.13.1)

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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-07 14:14:03 +02:00
parent 5b6641567c
commit a83a138259
4 changed files with 237 additions and 13 deletions

View file

@ -142,6 +142,16 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
: 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<FaiSystemAiEditor> {
_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 FI 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 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) {
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'(?<![a-z])(\d+(?:\.\d+)?)\s*b(?![a-z])').firstMatch(id);
if (m != null) {
final n = double.tryParse(m.group(1)!);
if (n != null) {
if (n < 3) return _Suitability.small;
if (n <= 8) return _Suitability.balanced;
if (n <= 15) return _Suitability.large;
return _Suitability.huge;
}
}
// OpenAI / Anthropic without size in the name use known
// family heuristics.
if (id.contains('mini') || id.contains('haiku') || id.contains('flash')) {
return _Suitability.balanced;
}
if (id.contains('opus') || id.contains('o1') || id.contains('large')) {
return _Suitability.large;
}
return _Suitability.unknown;
}
/// 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) {
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'),
],
);
}
}