chain-studio/lib/widgets/chain_system_ai_editor.dart
flemming-it fb809a4cbd feat(studio): show the model download instead of a spinning button
Pulling a model moves gigabytes and could take fifteen minutes behind
a 14-pixel spinner. The model picker now follows the hub's pull stream
and shows a bar with the backend's own status and the megabytes for
the layer in flight.

The byte counts describe the current layer, not the whole model, so
the bar restarts per layer. That is deliberate and stated in the code:
the backend never says how many layers are still coming, so a single
overall number would have to be invented.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-09-08 17:51:28 +02:00

1240 lines
40 KiB
Dart

// ChainSystemAiEditor — modal for configuring the hub-internal
// System AI from inside Studio. Mirrors the YAML block but with
// provider presets, in-place explainers, and a "Test connection"
// button. On save, calls HubAdmin.UpdateSystemAi which both
// persists to ~/.chain/config.yaml and hot-reloads the live hub.
import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/hub.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import 'chain_error_box.dart';
import 'chain_pill.dart';
import 'chain_progress_bar.dart';
/// Provider preset metadata kept in sync with
/// `chain_hub::operator_config::SystemLlmProvider`. Anything that
/// is operator-visible (label, description, default endpoint,
/// suggested env-var) lives here so the dropdown self-explains
/// without needing to read the source.
class _ProviderPreset {
final String wire;
final String label;
final String defaultEndpoint;
final String defaultApiKeyEnv;
final String description;
final String modelHint;
const _ProviderPreset({
required this.wire,
required this.label,
required this.defaultEndpoint,
required this.defaultApiKeyEnv,
required this.description,
required this.modelHint,
});
static const all = <_ProviderPreset>[
_ProviderPreset(
wire: 'ollama',
label: 'Ollama',
defaultEndpoint: 'http://127.0.0.1:11434/v1',
defaultApiKeyEnv: '',
description:
'Local `ollama serve`. Models stay on your machine. No API key needed.',
modelHint: 'gemma3:4b · llama3.2:3b · qwen2.5-coder:7b',
),
_ProviderPreset(
wire: 'openai',
label: 'OpenAI',
defaultEndpoint: 'https://api.openai.com/v1',
defaultApiKeyEnv: 'OPENAI_API_KEY',
description:
'OpenAI hosted API. Requires an API key. Data leaves your machine.',
modelHint: 'gpt-4o-mini · gpt-4o · o1-mini',
),
_ProviderPreset(
wire: 'lmstudio',
label: 'LM Studio',
defaultEndpoint: 'http://127.0.0.1:1234/v1',
defaultApiKeyEnv: '',
description:
'Local LM Studio server. Models stay on your machine. No API key needed.',
modelHint: 'whichever model you loaded in LM Studio',
),
_ProviderPreset(
wire: 'vllm',
label: 'vLLM (self-hosted)',
defaultEndpoint: '',
defaultApiKeyEnv: '',
description:
'Self-hosted vLLM or any other OpenAI-compatible server. Endpoint required.',
modelHint: 'depends on what your vLLM has loaded',
),
_ProviderPreset(
wire: 'custom',
label: 'Custom (OpenAI-compatible)',
defaultEndpoint: '',
defaultApiKeyEnv: '',
description:
'Anything else that speaks OpenAI-compatible chat-completions wire (LiteLLM proxy, internal mirror, …).',
modelHint: '',
),
];
static _ProviderPreset byWire(String wire) {
return all.firstWhere((p) => p.wire == wire, orElse: () => all.first);
}
/// Localized help body for this provider. Falls back to the
/// const English [description] for any future wire that has no
/// ARB key yet.
String descriptionFor(AppLocalizations l) {
switch (wire) {
case 'ollama':
return l.providerDescOllama;
case 'openai':
return l.providerDescOpenai;
case 'lmstudio':
return l.providerDescLmstudio;
case 'vllm':
return l.providerDescVllm;
case 'custom':
return l.providerDescCustom;
default:
return description;
}
}
}
class ChainSystemAiEditor extends StatefulWidget {
final SystemAiStatus initial;
const ChainSystemAiEditor({super.key, required this.initial});
/// Convenience launcher. Returns the new status when the
/// operator saved, null on cancel.
static Future<SystemAiStatus?> show(
BuildContext context,
SystemAiStatus initial,
) {
return showDialog<SystemAiStatus>(
context: context,
builder: (_) => ChainSystemAiEditor(initial: initial),
);
}
@override
State<ChainSystemAiEditor> createState() => _FaiSystemAiEditorState();
}
class _FaiSystemAiEditorState extends State<ChainSystemAiEditor> {
late _ProviderPreset _preset;
late final TextEditingController _endpoint;
late final TextEditingController _model;
late final TextEditingController _apiKeyEnv;
late String _privacyMode;
bool _saving = false;
bool _testing = false;
bool _loadingModels = false;
bool _pulling = false;
List<String>? _models;
String? _modelsError;
AskAiResult? _testResult;
String? _error;
final _scrollController = ScrollController();
HardwareSnapshot? _hw;
CuratedSnapshot? _curated;
Map<String, CuratedModelInfo> _curatedById = const {};
@override
void initState() {
super.initState();
final init = widget.initial;
_preset = _ProviderPreset.byWire(
init.provider.isEmpty ? 'ollama' : init.provider,
);
_endpoint = TextEditingController(
text: init.endpoint.isEmpty ? _preset.defaultEndpoint : init.endpoint,
);
_model = TextEditingController(text: init.model);
_apiKeyEnv = TextEditingController(
text: init.apiKeyEnv.isEmpty ? _preset.defaultApiKeyEnv : 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();
}
_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();
_model.dispose();
_apiKeyEnv.dispose();
_scrollController.dispose();
super.dispose();
}
/// Reveal the test-result / error panel. It renders at the bottom
/// of a scrollable, height-capped form, so on a tall dialog it
/// lands below the fold — the operator taps "Test connection",
/// the footer button flickers, and nothing *visible* changes.
/// Scroll it into view so the outcome is never missed.
void _revealBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scrollController.hasClients) return;
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
});
}
void _onProviderChanged(_ProviderPreset p) {
setState(() {
_preset = p;
// Only auto-fill the field when the operator hasn't typed
// anything custom yet (i.e. it still matches a different
// preset's default or is empty).
if (_endpoint.text.isEmpty || _isAnyDefaultEndpoint(_endpoint.text)) {
_endpoint.text = p.defaultEndpoint;
}
if (_apiKeyEnv.text.isEmpty || _isAnyDefaultApiKeyEnv(_apiKeyEnv.text)) {
_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) =>
_ProviderPreset.all.any((p) => p.defaultEndpoint == t);
bool _isAnyDefaultApiKeyEnv(String t) =>
_ProviderPreset.all.any((p) => p.defaultApiKeyEnv == t);
Future<void> _save({bool keepOpen = false}) async {
setState(() {
_saving = true;
_error = null;
});
try {
final status = await HubService.instance.updateSystemAi(
provider: _preset.wire,
endpoint: _endpoint.text.trim(),
model: _model.text.trim(),
apiKeyEnv: _apiKeyEnv.text.trim(),
privacyMode: _privacyMode,
);
if (!mounted) return;
if (keepOpen) {
setState(() => _saving = false);
} else {
Navigator.pop(context, status);
}
} catch (e) {
setState(() {
_saving = false;
_error = e.toString();
});
_revealBottom();
}
}
Future<void> _clearCache() async {
try {
final purged = await HubService.instance.clearSystemLlmCache();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.systemAiCacheClearedToast(purged))),
);
setState(() {
// Force a rebuild that hides the row; cheap.
});
} catch (e) {
if (!mounted) return;
showChainErrorSnack(context, 'settings.system-ai.cache-clear', e);
}
}
Future<void> _test() async {
setState(() {
_testing = true;
_testResult = null;
_error = null;
});
try {
// Pass the current form values directly — Test must work
// *before* Save so the operator can validate without
// committing.
final r = await HubService.instance.testSystemAi(
provider: _preset.wire,
endpoint: _endpoint.text.trim(),
model: _model.text.trim(),
apiKeyEnv: _apiKeyEnv.text.trim(),
privacyMode: _privacyMode,
);
if (!mounted) return;
setState(() {
_testing = false;
_testResult = r;
});
_revealBottom();
} catch (e) {
setState(() {
_testing = false;
_error = e.toString();
});
_revealBottom();
}
}
Future<void> _refreshModels() async {
setState(() {
_loadingModels = true;
_models = null;
_modelsError = null;
});
final r = await HubService.instance.listSystemAiModels(
provider: _preset.wire,
endpoint: _endpoint.text.trim(),
apiKeyEnv: _apiKeyEnv.text.trim(),
);
if (!mounted) return;
setState(() {
_loadingModels = false;
if (r.errorKind.isEmpty) {
_models = r.ids;
} else {
_modelsError = r.text;
}
});
}
/// Latest observation while a model downloads.
PullStep? _pullStep;
Future<void> _pullModel() async {
final wanted = _model.text.trim();
final l = AppLocalizations.of(context)!;
if (wanted.isEmpty) {
setState(() => _error = l.systemAiPullEmptyError);
return;
}
setState(() {
_pulling = true;
_pullStep = null;
_error = null;
});
({String errorKind, String text, int elapsedMs})? outcome;
try {
// Streamed: a model is gigabytes. The unary call left this
// button spinning for minutes with nothing to show.
await for (final step in HubService.instance.pullSystemAiModelStreaming(
endpoint: _endpoint.text.trim(),
model: wanted,
apiKeyEnv: _apiKeyEnv.text.trim(),
onFinished: (r) => outcome = r,
)) {
if (mounted) setState(() => _pullStep = step);
}
} catch (e) {
outcome = (errorKind: 'network', text: e.toString(), elapsedMs: 0);
}
if (!mounted) return;
final r = outcome ?? (errorKind: '', text: '', elapsedMs: 0);
setState(() {
_pulling = false;
_pullStep = null;
if (r.errorKind.isNotEmpty) {
_error = l.systemAiPullFailedError(r.text);
} else {
_error = null;
}
});
if (r.errorKind.isEmpty) {
// Refresh the model list so the just-pulled model appears
// in the dropdown immediately.
await _refreshModels();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(l.systemAiTitle),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ChainRadius.md),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: ChainSpace.xl,
vertical: ChainSpace.lg,
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520, maxHeight: 600),
child: SingleChildScrollView(
controller: _scrollController,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.systemAiIntro,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (_hw != null) ...[
const SizedBox(height: ChainSpace.sm),
_HardwareBanner(hw: _hw!, lastReviewed: _curated?.lastReviewed),
],
const SizedBox(height: ChainSpace.lg),
_ProviderDropdown(value: _preset, onChanged: _onProviderChanged),
const SizedBox(height: 4),
Text(
_preset.descriptionFor(l),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: ChainSpace.md),
TextField(
controller: _endpoint,
style: ChainTheme.mono(size: 12),
decoration: InputDecoration(
labelText: l.systemAiEndpointLabel,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: ChainSpace.md),
_ModelPicker(
controller: _model,
preset: _preset,
models: _models,
modelsError: _modelsError,
loading: _loadingModels,
pulling: _pulling,
hw: _hw,
curatedById: _curatedById,
onRefresh: _saving || _testing || _loadingModels || _pulling
? null
: _refreshModels,
onPull: _saving || _testing || _loadingModels || _pulling
? null
: _pullModel,
pullStep: _pullStep,
),
const SizedBox(height: ChainSpace.md),
TextField(
controller: _apiKeyEnv,
style: ChainTheme.mono(size: 12),
decoration: InputDecoration(
labelText: _preset.wire == 'openai'
? l.systemAiApiKeyRequired
: l.systemAiApiKeyOptional,
hintText: 'OPENAI_API_KEY',
prefixText: '\$',
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 4),
Text(
l.systemAiApiKeyDisclaimer('<name>'),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: ChainSpace.lg),
_PrivacyModeChips(
value: _privacyMode,
onChanged: (v) => setState(() => _privacyMode = v),
),
if (widget.initial.cacheCount > 0) ...[
const SizedBox(height: ChainSpace.md),
_CacheStatusRow(
cacheCount: widget.initial.cacheCount,
onClear: _saving || _testing ? null : _clearCache,
),
],
if (_error != null) ...[
const SizedBox(height: ChainSpace.md),
// Copyable — every error the operator sees must be
// selectable + one-tap copyable (ChainErrorBox), never
// a bare Text they can't paste back.
ChainErrorBox(text: _error!, isError: true, maxHeight: 200),
],
if (_testResult != null) ...[
const SizedBox(height: ChainSpace.md),
_TestResultPanel(result: _testResult!),
],
],
),
),
),
actions: [
TextButton(
onPressed: _saving || _testing
? null
: () => Navigator.pop(context, null),
child: Text(l.buttonCancel),
),
OutlinedButton.icon(
onPressed: _saving || _testing ? null : _test,
icon: const Icon(Icons.play_arrow, size: 16),
label: Text(_testing ? l.systemAiTesting : l.systemAiTestConnection),
),
FilledButton(
onPressed: _saving || _testing ? null : () => _save(),
child: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l.systemAiSave),
),
],
);
}
}
class _ProviderDropdown extends StatelessWidget {
final _ProviderPreset value;
final void Function(_ProviderPreset) onChanged;
const _ProviderDropdown({required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return DropdownButtonFormField<String>(
initialValue: value.wire,
isDense: true,
decoration: InputDecoration(
labelText: l.systemAiProviderLabel,
border: const OutlineInputBorder(),
isDense: true,
),
items: _ProviderPreset.all
.map((p) => DropdownMenuItem(value: p.wire, child: Text(p.label)))
.toList(),
onChanged: (v) {
if (v == null) return;
onChanged(_ProviderPreset.byWire(v));
},
);
}
}
class _PrivacyModeChips extends StatelessWidget {
final String value;
final ValueChanged<String> onChanged;
const _PrivacyModeChips({required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final modes = <(String, String, String)>[
('off', l.systemAiPrivacyOffLabel, l.systemAiPrivacyOffDesc),
(
'redacted',
l.systemAiPrivacyRedactedLabel,
l.systemAiPrivacyRedactedDesc,
),
('full', l.systemAiPrivacyFullLabel, l.systemAiPrivacyFullDesc),
];
// Material 3 deprecated per-Radio `groupValue` / `onChanged`
// after Flutter 3.32. The modern pattern wraps the radios in
// a RadioGroup<T> ancestor that owns the selection + change
// callback; the individual Radio widgets only declare their
// value. Same UX, no deprecation warnings.
return RadioGroup<String>(
groupValue: value,
onChanged: (v) {
if (v != null) onChanged(v);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.systemAiPrivacyHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 4),
for (final (wire, label, desc) in modes)
InkWell(
onTap: () => onChanged(wire),
borderRadius: BorderRadius.circular(ChainRadius.sm),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: ChainSpace.sm,
horizontal: ChainSpace.sm,
),
child: Row(
children: [
Radio<String>(value: wire),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.bodyMedium),
Text(
desc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
),
],
),
);
}
}
class _TestResultPanel extends StatelessWidget {
final AskAiResult result;
const _TestResultPanel({required this.result});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final ok = result.isSuccess;
final color = ok ? ChainColors.success : theme.colorScheme.error;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(ChainSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(ChainRadius.sm),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
ok ? Icons.check_circle_outline : Icons.error_outline,
size: 14,
color: color,
),
const SizedBox(width: ChainSpace.xs),
Text(
ok ? l.systemAiConnectionOk : l.systemAiConnectionFailed,
style: theme.textTheme.bodyMedium?.copyWith(color: color),
),
const Spacer(),
if (ok && result.latencyMs > 0)
ChainPill(
label: '${result.latencyMs} ms',
tone: ChainPillTone.neutral,
monospace: true,
),
],
),
const SizedBox(height: 4),
// Success → the reply preview (selectable). Failure → the
// raw error in a ChainErrorBox with an explicit copy button,
// so the operator can paste the exact failure back.
if (ok)
SelectableText(
l.systemAiReplyPrefix(result.text),
style: ChainTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
)
else
ChainErrorBox(text: result.text, isError: true, maxHeight: 200),
if (!ok && result.fixHint(l).isNotEmpty) ...[
const SizedBox(height: ChainSpace.xs),
Text(
result.fixHint(l),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface,
),
),
],
],
),
);
}
}
class _ModelPicker extends StatelessWidget {
final TextEditingController controller;
final _ProviderPreset preset;
final List<String>? models;
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;
/// Latest observation while a pull runs; null when idle.
final PullStep? pullStep;
const _ModelPicker({
required this.controller,
required this.preset,
required this.models,
required this.modelsError,
required this.loading,
required this.pulling,
required this.hw,
required this.curatedById,
required this.onRefresh,
required this.onPull,
this.pullStep,
});
bool get _isOllama => preset.wire == 'ollama';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (_, _, _) => TextField(
controller: controller,
style: ChainTheme.mono(size: 12),
decoration: InputDecoration(
labelText: l.systemAiModelLabel,
hintText: preset.modelHint.isEmpty
? l.systemAiModelHintFallback
: preset.modelHint,
helperText: controller.text.trim().isEmpty
? (_isOllama
? l.systemAiModelHelperOllama
: l.systemAiModelHelperBase)
: null,
helperMaxLines: 2,
border: const OutlineInputBorder(),
isDense: true,
),
),
),
),
const SizedBox(width: ChainSpace.sm),
OutlinedButton.icon(
onPressed: onRefresh,
icon: loading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 16),
label: Text(l.systemAiRefresh),
),
if (_isOllama) ...[
const SizedBox(width: ChainSpace.sm),
OutlinedButton.icon(
onPressed: onPull,
icon: pulling
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download, size: 16),
label: Text(pulling ? l.systemAiPulling : l.systemAiPull),
),
],
],
),
if (pulling) ...[
const SizedBox(height: ChainSpace.sm),
// The byte counts describe the layer currently downloading,
// not the whole model, so the bar restarts per layer. That
// is honest; a single overall number would have to be
// invented, because the backend never says how many layers
// are still coming.
ChainProgressBar(
value: pullStep?.fraction,
stage: pullStep?.status.isNotEmpty == true
? pullStep!.status
: l.systemAiPulling,
detail: (pullStep?.total ?? 0) > 0
? l.installBytes(
(pullStep!.completed / 1048576).toStringAsFixed(1),
(pullStep!.total / 1048576).toStringAsFixed(1),
)
: null,
height: 6,
),
],
if (modelsError != null) ...[
const SizedBox(height: 4),
Text(
l.systemAiCouldNotListModels(modelsError!),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
if (models != null) ...[
const SizedBox(height: ChainSpace.sm),
if (models!.isEmpty)
Text(
_isOllama ? l.systemAiNoModelsOllama : l.systemAiNoModelsGeneric,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
)
else ...[
Wrap(
spacing: ChainSpace.xs,
runSpacing: ChainSpace.xs,
children: [
for (final id in _sortedBySuitability(
models!,
hw: hw,
curatedById: curatedById,
))
_ModelChip(
id: id,
suitability: _suitabilityFor(
id,
hw: hw,
curatedById: curatedById,
),
curated: curatedById[id.trim().toLowerCase()],
onTap: () => controller.text = id,
),
],
),
const SizedBox(height: 6),
_SuitabilityLegend(hw: hw),
],
],
],
);
}
}
/// 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 Ch∆In 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 labelFor(BuildContext context) {
final l = AppLocalizations.of(context)!;
switch (this) {
case _Suitability.recommended:
return l.systemAiSuitabilityRecommended;
case _Suitability.balanced:
return l.systemAiSuitabilityBalanced;
case _Suitability.small:
return l.systemAiSuitabilitySmall;
case _Suitability.large:
return l.systemAiSuitabilityLarge;
case _Suitability.huge:
return l.systemAiSuitabilityHuge;
case _Suitability.unknown:
return l.systemAiSuitabilityUnknown;
}
}
Color color(ColorScheme cs) {
switch (this) {
case _Suitability.recommended:
return ChainColors.success;
case _Suitability.balanced:
return cs.primary;
case _Suitability.small:
return ChainColors.warning;
case _Suitability.large:
return ChainColors.warning;
case _Suitability.huge:
return cs.error;
case _Suitability.unknown:
return cs.outline;
}
}
}
/// 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();
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;
}
// 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.
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 — 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, {
HardwareSnapshot? hw,
Map<String, CuratedModelInfo> curatedById = const {},
}) {
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, hw: hw, curatedById: curatedById));
final rb = rank(_suitabilityFor(b, hw: hw, curatedById: curatedById));
if (ra != rb) return ra.compareTo(rb);
return a.compareTo(b);
});
return out;
}
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 context) {
final lines = <String>[suitability.labelFor(context)];
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: _tooltipFor(context),
child: ActionChip(
avatar: Icon(
suitability == _Suitability.recommended ? Icons.star : Icons.circle,
size: suitability == _Suitability.recommended ? 14 : 9,
color: color,
),
label: Text(id, style: ChainTheme.mono(size: 11)),
side: BorderSide(color: color.withValues(alpha: 0.4)),
onPressed: onTap,
),
);
}
}
/// 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 l = AppLocalizations.of(context)!;
final lr = (lastReviewed != null && lastReviewed!.isNotEmpty)
? l.systemAiHwReviewed(lastReviewed!)
: '';
return Container(
padding: const EdgeInsets.symmetric(
horizontal: ChainSpace.sm,
vertical: 6,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(ChainRadius.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(
'${l.systemAiHwDetected(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);
final l = AppLocalizations.of(context)!;
Widget dot(Color c, String label) => Padding(
padding: const EdgeInsets.only(right: ChainSpace.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,
),
),
],
),
);
final tierTag = (hw == null || hw!.tier == 'unknown')
? ''
: ' (${hw!.tier})';
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(right: ChainSpace.md),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.star, size: 11, color: ChainColors.success),
const SizedBox(width: 4),
Text(
l.systemAiLegendRecommended(tierTag),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
),
dot(theme.colorScheme.primary, l.systemAiLegendBalanced),
dot(ChainColors.warning, l.systemAiLegendSmallLarge),
dot(theme.colorScheme.error, l.systemAiLegendHuge),
dot(theme.colorScheme.outline, l.systemAiLegendUnknown),
],
);
}
}
/// Displays the System-AI cache count plus a "Clear cache"
/// button. Hidden when the cache is empty so the dialog stays
/// quiet for fresh installs. Lives inline so the operator sees
/// the cache exists, what it costs, and how to drop it without
/// reading docs.
class _CacheStatusRow extends StatelessWidget {
final int cacheCount;
final VoidCallback? onClear;
const _CacheStatusRow({required this.cacheCount, required this.onClear});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: ChainSpace.sm,
vertical: 6,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(ChainRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Row(
children: [
Icon(Icons.bolt, size: 14, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 6),
Expanded(
child: Text(
l.systemAiCacheRow(cacheCount),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
TextButton.icon(
onPressed: onClear,
icon: const Icon(Icons.delete_outline, size: 14),
label: Text(l.systemAiCacheClear),
),
],
),
);
}
}