// FaiSystemAiEditor — 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 'fai_pill.dart'; /// Provider preset metadata kept in sync with /// `fai_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 FaiSystemAiEditor extends StatefulWidget { final SystemAiStatus initial; const FaiSystemAiEditor({super.key, required this.initial}); /// Convenience launcher. Returns the new status when the /// operator saved, null on cancel. static Future show( BuildContext context, SystemAiStatus initial, ) { return showDialog( context: context, builder: (_) => FaiSystemAiEditor(initial: initial), ); } @override State createState() => _FaiSystemAiEditorState(); } class _FaiSystemAiEditorState extends State { 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? _models; String? _modelsError; AskAiResult? _testResult; String? _error; HardwareSnapshot? _hw; CuratedSnapshot? _curated; Map _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 _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(); super.dispose(); } 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 _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(); }); } } Future _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; showFaiErrorSnack(context, 'settings.system-ai.cache-clear', e); } } Future _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; }); } catch (e) { setState(() { _testing = false; _error = e.toString(); }); } } Future _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; } }); } Future _pullModel() async { final wanted = _model.text.trim(); final l = AppLocalizations.of(context)!; if (wanted.isEmpty) { setState(() => _error = l.systemAiPullEmptyError); return; } setState(() { _pulling = true; _error = null; }); final r = await HubService.instance.pullSystemAiModel( endpoint: _endpoint.text.trim(), model: wanted, apiKeyEnv: _apiKeyEnv.text.trim(), ); if (!mounted) return; setState(() { _pulling = false; 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(FaiRadius.md), ), contentPadding: const EdgeInsets.symmetric( horizontal: FaiSpace.xl, vertical: FaiSpace.lg, ), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520, maxHeight: 600), child: SingleChildScrollView( 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: FaiSpace.sm), _HardwareBanner(hw: _hw!, lastReviewed: _curated?.lastReviewed), ], const SizedBox(height: FaiSpace.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: FaiSpace.md), TextField( controller: _endpoint, style: FaiTheme.mono(size: 12), decoration: InputDecoration( labelText: l.systemAiEndpointLabel, border: const OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: FaiSpace.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, ), const SizedBox(height: FaiSpace.md), TextField( controller: _apiKeyEnv, style: FaiTheme.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(''), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.lg), _PrivacyModeChips( value: _privacyMode, onChanged: (v) => setState(() => _privacyMode = v), ), if (widget.initial.cacheCount > 0) ...[ const SizedBox(height: FaiSpace.md), _CacheStatusRow( cacheCount: widget.initial.cacheCount, onClear: _saving || _testing ? null : _clearCache, ), ], if (_error != null) ...[ const SizedBox(height: FaiSpace.md), Text( _error!, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.error, ), ), ], if (_testResult != null) ...[ const SizedBox(height: FaiSpace.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( 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 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 ancestor that owns the selection + change // callback; the individual Radio widgets only declare their // value. Same UX, no deprecation warnings. return RadioGroup( 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(FaiRadius.sm), child: Padding( padding: const EdgeInsets.symmetric( vertical: FaiSpace.sm, horizontal: FaiSpace.sm, ), child: Row( children: [ Radio(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 ? FaiColors.success : theme.colorScheme.error; return Container( width: double.infinity, padding: const EdgeInsets.all(FaiSpace.md), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.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: FaiSpace.xs), Text( ok ? l.systemAiConnectionOk : l.systemAiConnectionFailed, style: theme.textTheme.bodyMedium?.copyWith(color: color), ), const Spacer(), if (ok && result.latencyMs > 0) FaiPill( label: '${result.latencyMs} ms', tone: FaiPillTone.neutral, monospace: true, ), ], ), const SizedBox(height: 4), SelectableText( ok ? l.systemAiReplyPrefix(result.text) : result.text, style: FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface), ), if (!ok && result.fixHint(l).isNotEmpty) ...[ const SizedBox(height: FaiSpace.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? 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 curatedById; final VoidCallback? onRefresh; final VoidCallback? onPull; 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, }); 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( valueListenable: controller, builder: (_, _, _) => TextField( controller: controller, style: FaiTheme.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: FaiSpace.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: FaiSpace.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 (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: FaiSpace.sm), if (models!.isEmpty) Text( _isOllama ? l.systemAiNoModelsOllama : l.systemAiNoModelsGeneric, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ) else ...[ Wrap( spacing: FaiSpace.xs, runSpacing: FaiSpace.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 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; } } } /// 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 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'(? _sortedBySuitability( List ids, { HardwareSnapshot? hw, Map 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 = [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: FaiTheme.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: 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( '${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: 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, ), ), ], ), ); final tierTag = (hw == null || hw!.tier == 'unknown') ? '' : ' (${hw!.tier})'; 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( l.systemAiLegendRecommended(tierTag), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontSize: 11, ), ), ], ), ), dot(theme.colorScheme.primary, l.systemAiLegendBalanced), dot(FaiColors.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: 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.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), ), ], ), ); } }