From 15ea403c865e3e3d46440f7abf29fadaa3fcbfb0 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 7 May 2026 13:41:11 +0200 Subject: [PATCH] feat(studio): in-place System AI editor (v0.12.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the read-only System AI block in Settings with a real editor. Operator never has to touch ~/.fai/config.yaml again to change provider, endpoint, model, API-key env-var, or privacy mode. UX choices that align with the zero-learning-curve rule: * Provider dropdown lists Ollama / OpenAI / LM Studio / vLLM / Custom by friendly name. Each selection auto-fills the endpoint and api_key_env defaults — but only when the field is still empty or matches a different preset's default, so manual overrides are never clobbered. * Each provider has a one-line description rendered under the dropdown ("Ollama → Local `ollama serve`. Models stay on your machine. No API key needed.") and a model-hint placeholder ("gemma3:4b · llama3.2:3b · qwen2.5-coder:7b") that goes away when a model is typed. * Privacy mode is a vertical radio group with each option's description in place — no doc-lookup needed. * "Test connection" sends an `ok`-ping and renders the same error-kind → fix-hint mapping the audit drill-down uses. * "Save" persists + hot-reloads the hub. No daemon restart required. UI status badge flips to "enabled · " immediately. `fai_dart_sdk` 0.5.0 carries the underlying `updateSystemAi` / `testSystemAi` RPCs. Signed-off-by: flemming-it --- lib/data/hub.dart | 40 ++ lib/main.dart | 2 +- lib/widgets/fai_settings_dialog.dart | 28 +- lib/widgets/fai_system_ai_editor.dart | 533 ++++++++++++++++++++++++++ lib/widgets/widgets.dart | 1 + pubspec.yaml | 2 +- 6 files changed, 601 insertions(+), 5 deletions(-) create mode 100644 lib/widgets/fai_system_ai_editor.dart diff --git a/lib/data/hub.dart b/lib/data/hub.dart index a1dc88d..0461629 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -135,6 +135,46 @@ class HubService { ); } + /// Persist a new System-AI configuration. The hub writes back + /// to `~/.fai/config.yaml` and hot-reloads the in-memory copy + /// in one round-trip; the caller gets the resulting status + /// for an immediate UI refresh. + Future updateSystemAi({ + required String provider, + required String endpoint, + required String model, + required String apiKeyEnv, + required String privacyMode, + }) async { + final r = await _client.updateSystemAi( + provider: provider, + endpoint: endpoint, + model: model, + apiKeyEnv: apiKeyEnv, + privacyMode: privacyMode, + ); + return SystemAiStatus( + enabled: r.enabled, + provider: r.provider, + endpoint: r.endpoint, + model: r.model, + privacyMode: r.privacyMode, + apiKeyEnv: r.apiKeyEnv, + ); + } + + /// Probe the configured provider with a tiny ping. Same + /// error-kind taxonomy as [askAi]; "Test connection" buttons + /// use this to surface "is it working?". + Future testSystemAi() async { + final r = await _client.testSystemAi(); + return AskAiResult( + errorKind: r.errorKind, + text: r.text, + latencyMs: r.latencyMs, + ); + } + /// Active channel + per-channel daemon status snapshot. Future channelStatus() async { final r = await _client.channelStatus(); diff --git a/lib/main.dart b/lib/main.dart index 7672152..2910d99 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,7 +23,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.11.0'; +const String kStudioVersion = '0.12.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index 1833133..3e2b64b 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -9,6 +9,7 @@ import '../data/hub.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import 'fai_pill.dart'; +import 'fai_system_ai_editor.dart'; class FaiSettingsDialog extends StatefulWidget { const FaiSettingsDialog({super.key}); @@ -236,7 +237,18 @@ class _FaiSettingsDialogState extends State { ], if (_aiStatus != null) ...[ const SizedBox(height: FaiSpace.lg), - _SystemAiPanel(status: _aiStatus!), + _SystemAiPanel( + status: _aiStatus!, + onEdit: () async { + final updated = await FaiSystemAiEditor.show( + context, + _aiStatus!, + ); + if (updated != null && mounted) { + setState(() => _aiStatus = updated); + } + }, + ), ], ], ), @@ -346,8 +358,9 @@ class _ChannelRow extends StatelessWidget { class _SystemAiPanel extends StatelessWidget { final SystemAiStatus status; + final VoidCallback onEdit; - const _SystemAiPanel({required this.status}); + const _SystemAiPanel({required this.status, required this.onEdit}); @override Widget build(BuildContext context) { @@ -379,6 +392,12 @@ class _SystemAiPanel extends StatelessWidget { : FaiPillTone.neutral, ), ], + const Spacer(), + TextButton.icon( + onPressed: onEdit, + icon: const Icon(Icons.edit_outlined, size: 14), + label: Text(status.enabled ? 'Edit…' : 'Configure…'), + ), ], ), const SizedBox(height: 4), @@ -392,7 +411,10 @@ class _SystemAiPanel extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: 2), child: Text( - 'Off — explanations on failed events are disabled. Add a `system_llm:` block to ~/.fai/config.yaml and restart the daemon. See docs/architecture/system-ai.md.', + 'Off — failure explanations on the audit page are disabled. ' + 'Click Configure… to pick a provider (Ollama / OpenAI / LM ' + 'Studio / vLLM / Custom). Operator config is rewritten in place; ' + 'no daemon restart needed.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), diff --git a/lib/widgets/fai_system_ai_editor.dart b/lib/widgets/fai_system_ai_editor.dart new file mode 100644 index 0000000..acec2be --- /dev/null +++ b/lib/widgets/fai_system_ai_editor.dart @@ -0,0 +1,533 @@ +// 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, + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 256636d..378302d 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -12,3 +12,4 @@ export 'fai_module_sheet.dart'; export 'fai_pill.dart'; export 'fai_settings_dialog.dart'; export 'fai_status_dot.dart'; +export 'fai_system_ai_editor.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 4815700..8868302 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.11.0 +version: 0.12.0 environment: sdk: ^3.11.0-200.1.beta