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:
parent
5b6641567c
commit
a83a138259
4 changed files with 237 additions and 13 deletions
|
|
@ -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 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 = <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'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue