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

@ -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,
),
),
],
),
);
}
}