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:
parent
060c8003d5
commit
4c4e2bb1eb
6 changed files with 415 additions and 4 deletions
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue