feat(studio): "Explain this" + System AI in Settings (v0.11.0)

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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-07 13:18:22 +02:00
parent 060c8003d5
commit 4c4e2bb1eb
6 changed files with 415 additions and 4 deletions

View file

@ -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> 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<AskAiResult> 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<ChannelStatusSnapshot> 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 <var>=<key>` 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;

View file

@ -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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

View file

@ -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<void> _loadAiStatus() async {
try {
final s = await HubService.instance.systemAiStatus();
if (!mounted) return;
setState(() => _aiStatus = s);
} catch (_) {
// Stay null "Explain" stays hidden.
}
}
Future<void> _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,
),
),
],
],
],
),
);
}
}

View file

@ -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<FaiSettingsDialog> {
bool _saving = false;
String? _error;
ChannelStatusSnapshot? _channels;
SystemAiStatus? _aiStatus;
@override
void initState() {
@ -41,6 +43,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
_port = TextEditingController(text: ep.port.toString());
_secure = ep.secure;
_loadChannels();
_loadAiStatus();
}
Future<void> _loadChannels() async {
@ -55,6 +58,16 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
}
}
Future<void> _loadAiStatus() async {
try {
final s = await HubService.instance.systemAiStatus();
if (!mounted) return;
setState(() => _aiStatus = s);
} catch (_) {
// Same fail-quiet rule as channels.
}
}
Future<void> _connectToChannel(ChannelInfo ch) async {
setState(() {
_host.text = '127.0.0.1';
@ -221,6 +234,10 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
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,
),
),
],
),
);
}
}