// 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; 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; } @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; }); } 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 { // Save first so the hub uses the values currently in the // form. Otherwise "Test" would probe the previous config and // confuse the operator about which values are being verified. await _save(keepOpen: true); if (!mounted || _error != null) return; setState(() { _testing = true; _testResult = null; }); try { final r = await HubService.instance.testSystemAi(); if (!mounted) return; setState(() { _testing = false; _testResult = r; }); } catch (e) { setState(() { _testing = false; _error = e.toString(); }); } } @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), TextField( controller: _model, style: FaiTheme.mono(size: 12), decoration: InputDecoration( labelText: 'Model', hintText: _preset.modelHint.isEmpty ? 'model identifier the provider expects' : _preset.modelHint, border: const OutlineInputBorder(), isDense: true, ), ), 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, ), ), ], ], ), ); } }