// 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 ~/.fai/config.yaml and hot-reloads the live hub. import 'package:flutter/material.dart'; import '../data/hub.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, ); } } 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; @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(); } }); } @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 _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(); if (wanted.isEmpty) { setState(() => _error = 'Type a model id (e.g. gemma3:4b) first.'); 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 = 'Pull failed: ${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); return AlertDialog( title: const Text('System AI'), 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( 'The hub-internal LLM the platform itself uses for inline failure ' 'explanations and operator help. Off by default; configure here. ' 'See docs/architecture/system-ai.md for what crosses the wire.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.lg), _ProviderDropdown( value: _preset, onChanged: _onProviderChanged, ), const SizedBox(height: 4), Text( _preset.description, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.md), TextField( controller: _endpoint, style: FaiTheme.mono(size: 12), decoration: const InputDecoration( labelText: 'Endpoint URL (up to /v1)', border: OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: FaiSpace.md), _ModelPicker( controller: _model, preset: _preset, models: _models, modelsError: _modelsError, loading: _loadingModels, pulling: _pulling, 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' ? 'API key env var (required)' : 'API key env var (optional)', hintText: 'OPENAI_API_KEY', prefixText: '\$', border: const OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: 4), Text( 'Studio never reads or stores the key value — only its env-var name. ' 'The hub reads `\$` at request time.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.lg), _PrivacyModeChips( value: _privacyMode, onChanged: (v) => setState(() => _privacyMode = v), ), 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: const Text('Cancel'), ), OutlinedButton.icon( onPressed: _saving || _testing ? null : _test, icon: const Icon(Icons.play_arrow, size: 16), label: Text(_testing ? 'Testing…' : 'Test connection'), ), FilledButton( onPressed: _saving || _testing ? null : () => _save(), child: _saving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Save'), ), ], ); } } 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) { return DropdownButtonFormField( initialValue: value.wire, isDense: true, decoration: const InputDecoration( labelText: 'Provider', border: 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); const modes = <(String, String, String)>[ ('off', 'Off', 'Feature disabled. No requests leave the hub.'), ( 'redacted', 'Redacted', 'Only event_type / error / module names. KRITIS-friendly default.', ), ( 'full', 'Full', 'Includes the audit detail JSON (hash-only — no raw payloads).', ), ]; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'PRIVACY MODE', 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, groupValue: value, onChanged: (v) => onChanged(v ?? 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 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 ? 'Connection ok' : 'Connection failed', 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 ? 'Reply: ${result.text}' : result.text, style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurface, ), ), if (!ok && result.fixHint.isNotEmpty) ...[ const SizedBox(height: FaiSpace.xs), Text( result.fixHint, 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; 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.onRefresh, required this.onPull, }); bool get _isOllama => preset.wire == 'ollama'; @override Widget build(BuildContext context) { final theme = Theme.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: 'Model (required)', hintText: preset.modelHint.isEmpty ? 'model identifier the provider expects' : preset.modelHint, helperText: controller.text.trim().isEmpty ? 'Required: pick from the list (Refresh) or type one. ${_isOllama ? "Use Pull to download from Ollama." : ""}' : 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: const Text('Refresh'), ), 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 ? 'Pulling…' : 'Pull'), ), ], ], ), if (modelsError != null) ...[ const SizedBox(height: 4), Text( 'Could not list models: $modelsError', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.error, ), ), ], if (models != null) ...[ const SizedBox(height: FaiSpace.sm), if (models!.isEmpty) Text( _isOllama ? 'No models on the Ollama server yet. Type one above and click Pull.' : 'Provider returned no models.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ) else ...[ Wrap( spacing: FaiSpace.xs, runSpacing: FaiSpace.xs, children: [ 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'), ], ); } }