From 4c4e2bb1eb46154e74fc3994b9f927cf5598f234 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 7 May 2026 13:18:22 +0200 Subject: [PATCH] feat(studio): "Explain this" + System AI in Settings (v0.11.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new operator affordances powered by `HubAdmin.AskAi`: * **Audit drill-down → "Explain" button.** Tappable on every failed event when System AI is configured; greyed out with a "Configure in Settings" tooltip otherwise. Shows an inline panel under the event fields with the LLM's plain- language explanation, a "FIX" suggestion, the privacy-mode badge (`redacted` / `full`), and round-trip latency. Errors map to actionable fix hints (`env_missing` → "set $VAR in your shell, then fai daemon restart"; `network` → "is your provider running?"; etc.) — no raw HTTP errors surfaced. * **Settings dialog → System AI panel.** Renders the live HubAdmin.SystemAiStatus (provider, endpoint, model, api_key_env name, privacy mode). When disabled, shows a one-line explainer + pointer to docs/architecture/system-ai.md. Read-only for now; editing the config still requires ~/.fai/config.yaml (Phase-1+ adds in-place editing). Both surfaces follow the new zero-learning-curve UX rule: the user never has to look up what the configurable means or how to fix an error — every state self-explains in place. Signed-off-by: flemming-it --- lib/data/hub.dart | 90 ++++++++++++ lib/main.dart | 2 +- lib/pages/audit.dart | 204 ++++++++++++++++++++++++++- lib/widgets/fai_settings_dialog.dart | 119 ++++++++++++++++ pubspec.lock | 2 +- pubspec.yaml | 2 +- 6 files changed, 415 insertions(+), 4 deletions(-) diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 63a6ed5..a1dc88d 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -106,6 +106,35 @@ class HubService { ); } + /// System-AI status snapshot — used by the Settings dialog + /// and the audit drill-down to decide whether the "Explain" + /// affordance is active. Always returns a struct (no + /// exceptions on disabled state). + Future systemAiStatus() async { + final r = await _client.systemAiStatus(); + return SystemAiStatus( + enabled: r.enabled, + provider: r.provider, + endpoint: r.endpoint, + model: r.model, + privacyMode: r.privacyMode, + apiKeyEnv: r.apiKeyEnv, + ); + } + + /// Send a one-shot prompt to the System AI. Returns the + /// answer + latency on success or an [AskAiResult] with + /// `errorKind` set on failure (never throws). Studio maps + /// the error kind to its inline-fix copy. + Future askAi(String prompt) async { + final r = await _client.askAi(prompt); + 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(); @@ -498,6 +527,67 @@ class PendingApproval { }); } +class SystemAiStatus { + final bool enabled; + final String provider; + final String endpoint; + final String model; + /// off / redacted / full. + final String privacyMode; + final String apiKeyEnv; + + const SystemAiStatus({ + required this.enabled, + required this.provider, + required this.endpoint, + required this.model, + required this.privacyMode, + required this.apiKeyEnv, + }); +} + +class AskAiResult { + /// "" on success; otherwise one of: + /// disabled / env_missing / network / http / parse / empty_response. + final String errorKind; + /// On success: the assistant's reply. + /// On failure: a human-readable diagnostic. + final String text; + /// Round-trip latency in ms (success only). + final int latencyMs; + + const AskAiResult({ + required this.errorKind, + required this.text, + required this.latencyMs, + }); + + bool get isSuccess => errorKind.isEmpty; + + /// One-line fix hint per error kind, mirroring + /// `docs/architecture/system-ai.md`. Empty on success. + String get fixHint { + switch (errorKind) { + case '': + return ''; + case 'disabled': + return 'Configure System AI in Settings (Cmd+,) → System AI panel.'; + case 'env_missing': + return 'The API-key env var is empty. `export =` in your shell, then `fai daemon restart`.'; + case 'network': + return 'Hub cannot reach the configured endpoint. Is your provider running?'; + case 'http': + return 'Provider returned a non-2xx status. Verify endpoint, model name, and API key.'; + case 'parse': + return 'Provider returned an unexpected response shape. Non-OpenAI providers may need a compatibility proxy.'; + case 'empty_response': + return 'Provider answered with no content. Try a different model or rephrase the prompt.'; + default: + return 'Unknown failure kind ($errorKind). See system-ai.md.'; + } + } +} + class ChannelInfo { final String name; final int port; diff --git a/lib/main.dart b/lib/main.dart index 10ddfa7..7672152 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.10.0'; +const String kStudioVersion = '0.11.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 8ae24d9..9e83456 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -303,11 +303,75 @@ class _LiveStatusBar extends StatelessWidget { } } -class _EventDetailDialog extends StatelessWidget { +class _EventDetailDialog extends StatefulWidget { final AuditEvent event; const _EventDetailDialog({required this.event}); + @override + State<_EventDetailDialog> createState() => _EventDetailDialogState(); +} + +class _EventDetailDialogState extends State<_EventDetailDialog> { + AskAiResult? _explanation; + bool _explaining = false; + SystemAiStatus? _aiStatus; + + AuditEvent get event => widget.event; + + @override + void initState() { + super.initState(); + _loadAiStatus(); + } + + Future _loadAiStatus() async { + try { + final s = await HubService.instance.systemAiStatus(); + if (!mounted) return; + setState(() => _aiStatus = s); + } catch (_) { + // Stay null → "Explain" stays hidden. + } + } + + Future _explain() async { + setState(() { + _explaining = true; + _explanation = null; + }); + final prompt = _buildPrompt(event, _aiStatus?.privacyMode ?? 'off'); + final result = await HubService.instance.askAi(prompt); + if (!mounted) return; + setState(() { + _explaining = false; + _explanation = result; + }); + } + + String _buildPrompt(AuditEvent e, String mode) { + // Privacy: redacted = no detail JSON, full = include it. + final buf = StringBuffer() + ..writeln('A F∆I Platform audit event was logged. Explain what') + ..writeln('happened in plain language and suggest one concrete') + ..writeln('fix the operator can apply now.') + ..writeln() + ..writeln('event_type: ${e.type}') + ..writeln('flow: ${e.flowName ?? "(none)"}') + ..writeln('step: ${e.stepId ?? "(none)"}') + ..writeln('module: ${e.moduleName ?? "(none)"}' + '${e.moduleVersion != null ? "@${e.moduleVersion}" : ""}') + ..writeln('error: ${e.error ?? "(none)"}') + ..writeln('duration: ${e.durationMs ?? 0}ms'); + if (mode == 'full' && e.detail != null) { + buf + ..writeln() + ..writeln('detail:') + ..writeln(e.detail); + } + return buf.toString(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -400,11 +464,40 @@ class _EventDetailDialog extends StatelessWidget { ), ), ], + if (_explanation != null || _explaining) ...[ + const SizedBox(height: FaiSpace.lg), + _ExplanationPanel( + explaining: _explaining, + result: _explanation, + privacyMode: _aiStatus?.privacyMode ?? 'off', + ), + ], ], ), ), ), actions: [ + if (_aiStatus?.enabled == true && (event.error != null)) + OutlinedButton.icon( + onPressed: _explaining ? null : _explain, + icon: const Icon(Icons.auto_awesome, size: 16), + label: Text( + _explaining + ? 'Asking…' + : _explanation == null + ? 'Explain' + : 'Re-ask', + ), + ) + else if (_aiStatus != null && !_aiStatus!.enabled && (event.error != null)) + Tooltip( + message: 'Configure System AI in Settings (Cmd+,)', + child: OutlinedButton.icon( + onPressed: null, + icon: const Icon(Icons.auto_awesome_outlined, size: 16), + label: const Text('Explain'), + ), + ), TextButton( onPressed: () => Navigator.pop(context), child: const Text('Close'), @@ -462,3 +555,112 @@ class _Field extends StatelessWidget { ); } } + +class _ExplanationPanel extends StatelessWidget { + final bool explaining; + final AskAiResult? result; + final String privacyMode; + + const _ExplanationPanel({ + required this.explaining, + required this.result, + required this.privacyMode, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final ok = result?.isSuccess ?? false; + final color = explaining + ? theme.colorScheme.primary + : ok + ? theme.colorScheme.primary + : 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(Icons.auto_awesome, size: 14, color: color), + const SizedBox(width: FaiSpace.xs), + Text( + 'SYSTEM AI', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + ), + ), + const SizedBox(width: FaiSpace.xs), + FaiPill( + label: privacyMode, + tone: privacyMode == 'full' + ? FaiPillTone.warning + : FaiPillTone.neutral, + ), + const Spacer(), + if (result != null && result!.isSuccess && result!.latencyMs > 0) + Text( + '${result!.latencyMs} ms', + style: FaiTheme.mono( + size: 10, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: FaiSpace.sm), + if (explaining) + Row( + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: FaiSpace.sm), + Text( + 'Asking the configured System AI…', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ) + else if (result != null) ...[ + SelectableText( + result!.text, + style: theme.textTheme.bodyMedium?.copyWith( + color: ok ? theme.colorScheme.onSurface : theme.colorScheme.error, + ), + ), + if (result!.fixHint.isNotEmpty) ...[ + const SizedBox(height: FaiSpace.sm), + Text( + 'FIX', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 2), + Text( + result!.fixHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ], + ], + ], + ), + ); + } +} diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index 24baa9f..1833133 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import '../data/hub.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; +import 'fai_pill.dart'; class FaiSettingsDialog extends StatefulWidget { const FaiSettingsDialog({super.key}); @@ -32,6 +33,7 @@ class _FaiSettingsDialogState extends State { bool _saving = false; String? _error; ChannelStatusSnapshot? _channels; + SystemAiStatus? _aiStatus; @override void initState() { @@ -41,6 +43,7 @@ class _FaiSettingsDialogState extends State { _port = TextEditingController(text: ep.port.toString()); _secure = ep.secure; _loadChannels(); + _loadAiStatus(); } Future _loadChannels() async { @@ -55,6 +58,16 @@ class _FaiSettingsDialogState extends State { } } + Future _loadAiStatus() async { + try { + final s = await HubService.instance.systemAiStatus(); + if (!mounted) return; + setState(() => _aiStatus = s); + } catch (_) { + // Same fail-quiet rule as channels. + } + } + Future _connectToChannel(ChannelInfo ch) async { setState(() { _host.text = '127.0.0.1'; @@ -221,6 +234,10 @@ class _FaiSettingsDialogState extends State { onConnect: _saving ? null : () => _connectToChannel(ch), ), ], + if (_aiStatus != null) ...[ + const SizedBox(height: FaiSpace.lg), + _SystemAiPanel(status: _aiStatus!), + ], ], ), ), @@ -326,3 +343,105 @@ class _ChannelRow extends StatelessWidget { ); } } + +class _SystemAiPanel extends StatelessWidget { + final SystemAiStatus status; + + const _SystemAiPanel({required this.status}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'SYSTEM AI', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, + ), + ), + const SizedBox(width: FaiSpace.sm), + FaiPill( + label: status.enabled ? 'enabled' : 'off', + tone: status.enabled ? FaiPillTone.success : FaiPillTone.neutral, + ), + if (status.enabled) ...[ + const SizedBox(width: FaiSpace.xs), + FaiPill( + label: status.privacyMode, + tone: status.privacyMode == 'full' + ? FaiPillTone.warning + : FaiPillTone.neutral, + ), + ], + ], + ), + const SizedBox(height: 4), + if (status.enabled) ...[ + _StatRow(label: 'provider', value: status.provider), + _StatRow(label: 'endpoint', value: status.endpoint, mono: true), + _StatRow(label: 'model', value: status.model, mono: true), + if (status.apiKeyEnv.isNotEmpty) + _StatRow(label: 'api_key_env', value: '\$${status.apiKeyEnv}', mono: true), + ] else ...[ + 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.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ], + ); + } +} + +class _StatRow extends StatelessWidget { + final String label; + final String value; + final bool mono; + + const _StatRow({ + required this.label, + required this.value, + this.mono = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 96, + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + value.isEmpty ? '—' : value, + style: mono + ? FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface) + : theme.textTheme.bodySmall, + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 559bd6b..5005ab4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -79,7 +79,7 @@ packages: path: "../fai_dart_sdk" relative: true source: path - version: "0.3.0" + version: "0.4.0" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 587220d..4815700 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.10.0 +version: 0.11.0 environment: sdk: ^3.11.0-200.1.beta