feat(studio): cached/Regenerate UX in audit Explain (v0.16.0)

Audit drilldown's explanation panel now surfaces whether the
answer came from the cache:
- "cached" pill next to the SYSTEM AI label, tooltip shows
  the original generation date + hit count.
- Latency renders as "orig 47000 ms" so the operator sees
  what a live call would have cost.
- Refresh icon next to the latency triggers Regenerate —
  drops the entry, re-asks the model.

System-AI editor shows a cache-status row when the cache is
non-empty with a one-click Clear button. Hidden on fresh
installs to keep the dialog quiet.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-07 17:29:36 +02:00
parent b32b4b5bda
commit d82738e17e
6 changed files with 161 additions and 9 deletions

View file

@ -401,13 +401,16 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
}
}
Future<void> _explain() async {
Future<void> _explain({bool forceFresh = false}) async {
setState(() {
_explaining = true;
_explanation = null;
});
final prompt = _buildPrompt(event, _aiStatus?.privacyMode ?? 'off');
final result = await HubService.instance.askAi(prompt);
final result = await HubService.instance.askAi(
prompt,
forceFresh: forceFresh,
);
if (!mounted) return;
setState(() {
_explaining = false;
@ -536,6 +539,9 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
explaining: _explaining,
result: _explanation,
privacyMode: _aiStatus?.privacyMode ?? 'off',
onRegenerate: _explaining
? null
: () => _explain(forceFresh: true),
),
],
],
@ -626,11 +632,16 @@ class _ExplanationPanel extends StatelessWidget {
final bool explaining;
final AskAiResult? result;
final String privacyMode;
/// Triggered by the "Regenerate" affordance passes
/// `forceFresh: true` so the next askAi skips the cache and
/// hits the live provider. Null while [explaining] is true.
final VoidCallback? onRegenerate;
const _ExplanationPanel({
required this.explaining,
required this.result,
required this.privacyMode,
required this.onRegenerate,
});
@override
@ -671,15 +682,42 @@ class _ExplanationPanel extends StatelessWidget {
? FaiPillTone.warning
: FaiPillTone.neutral,
),
if (result != null && result!.isSuccess && result!.cached) ...[
const SizedBox(width: FaiSpace.xs),
Tooltip(
message:
'Served from the local cache. Originally generated '
'${result!.cachedAt.isEmpty ? "at unknown time" : "on ${result!.cachedAt}"}'
'${result!.cacheHits > 1 ? " · ${result!.cacheHits} hits" : ""}.',
child: const FaiPill(
label: 'cached',
tone: FaiPillTone.success,
icon: Icons.bolt,
),
),
],
const Spacer(),
if (result != null && result!.isSuccess && result!.latencyMs > 0)
Text(
'${result!.latencyMs} ms',
result!.cached
? 'orig ${result!.latencyMs} ms'
: '${result!.latencyMs} ms',
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
),
if (result != null && result!.isSuccess && onRegenerate != null) ...[
const SizedBox(width: FaiSpace.sm),
Tooltip(
message: 'Regenerate — skip cache, ask the model again',
child: IconButton(
icon: const Icon(Icons.refresh, size: 14),
visualDensity: VisualDensity.compact,
onPressed: onRegenerate,
),
),
],
],
),
const SizedBox(height: FaiSpace.sm),