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

@ -119,6 +119,7 @@ class HubService {
model: r.model, model: r.model,
privacyMode: r.privacyMode, privacyMode: r.privacyMode,
apiKeyEnv: r.apiKeyEnv, apiKeyEnv: r.apiKeyEnv,
cacheCount: r.cacheCount.toInt(),
); );
} }
@ -126,15 +127,29 @@ class HubService {
/// answer + latency on success or an [AskAiResult] with /// answer + latency on success or an [AskAiResult] with
/// `errorKind` set on failure (never throws). Studio maps /// `errorKind` set on failure (never throws). Studio maps
/// the error kind to its inline-fix copy. /// the error kind to its inline-fix copy.
Future<AskAiResult> askAi(String prompt) async { Future<AskAiResult> askAi(String prompt, {bool forceFresh = false}) async {
final r = await _client.askAi(prompt); final r = await _client.askAi(prompt, forceFresh: forceFresh);
return AskAiResult( return AskAiResult(
errorKind: r.errorKind, errorKind: r.errorKind,
text: r.text, text: r.text,
latencyMs: r.latencyMs, latencyMs: r.latencyMs,
cached: r.cached,
cachedAt: r.cachedAt,
cacheHits: r.cacheHits,
); );
} }
/// Drop every cached System-AI explanation. Returns the count
/// of entries that were purged.
Future<int> clearSystemLlmCache() {
return _client.clearSystemLlmCache();
}
/// Forget a single cached entry by prompt (for "Regenerate").
Future<void> forgetCachedExplanation(String prompt) {
return _client.forgetCachedExplanation(prompt);
}
/// Persist a new System-AI configuration. The hub writes back /// Persist a new System-AI configuration. The hub writes back
/// to `~/.fai/config.yaml` and hot-reloads the in-memory copy /// to `~/.fai/config.yaml` and hot-reloads the in-memory copy
/// in one round-trip; the caller gets the resulting status /// in one round-trip; the caller gets the resulting status
@ -160,6 +175,7 @@ class HubService {
model: r.model, model: r.model,
privacyMode: r.privacyMode, privacyMode: r.privacyMode,
apiKeyEnv: r.apiKeyEnv, apiKeyEnv: r.apiKeyEnv,
cacheCount: r.cacheCount.toInt(),
); );
} }
@ -687,6 +703,10 @@ class SystemAiStatus {
/// off / redacted / full. /// off / redacted / full.
final String privacyMode; final String privacyMode;
final String apiKeyEnv; final String apiKeyEnv;
/// Number of cached System-AI explanations currently stored.
/// Surfaced in Doctor + Settings so operators see the cache
/// earning its keep.
final int cacheCount;
const SystemAiStatus({ const SystemAiStatus({
required this.enabled, required this.enabled,
@ -695,6 +715,7 @@ class SystemAiStatus {
required this.model, required this.model,
required this.privacyMode, required this.privacyMode,
required this.apiKeyEnv, required this.apiKeyEnv,
this.cacheCount = 0,
}); });
} }
@ -705,13 +726,25 @@ class AskAiResult {
/// On success: the assistant's reply. /// On success: the assistant's reply.
/// On failure: a human-readable diagnostic. /// On failure: a human-readable diagnostic.
final String text; final String text;
/// Round-trip latency in ms (success only). /// Round-trip latency in ms (success only). For cache hits
/// this is the *original* generation latency.
final int latencyMs; final int latencyMs;
/// True iff the answer came from the persistent cache.
final bool cached;
/// ISO-8601 timestamp the cached entry was first generated.
/// Empty for fresh answers.
final String cachedAt;
/// How many times this entry has been served from cache.
/// 0 for fresh answers.
final int cacheHits;
const AskAiResult({ const AskAiResult({
required this.errorKind, required this.errorKind,
required this.text, required this.text,
required this.latencyMs, required this.latencyMs,
this.cached = false,
this.cachedAt = '',
this.cacheHits = 0,
}); });
bool get isSuccess => errorKind.isEmpty; bool get isSuccess => errorKind.isEmpty;

View file

@ -23,7 +23,7 @@ import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the /// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header /// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build. /// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.15.0'; const String kStudioVersion = '0.16.0';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();

View file

@ -401,13 +401,16 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
} }
} }
Future<void> _explain() async { Future<void> _explain({bool forceFresh = false}) async {
setState(() { setState(() {
_explaining = true; _explaining = true;
_explanation = null; _explanation = null;
}); });
final prompt = _buildPrompt(event, _aiStatus?.privacyMode ?? 'off'); 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; if (!mounted) return;
setState(() { setState(() {
_explaining = false; _explaining = false;
@ -536,6 +539,9 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
explaining: _explaining, explaining: _explaining,
result: _explanation, result: _explanation,
privacyMode: _aiStatus?.privacyMode ?? 'off', privacyMode: _aiStatus?.privacyMode ?? 'off',
onRegenerate: _explaining
? null
: () => _explain(forceFresh: true),
), ),
], ],
], ],
@ -626,11 +632,16 @@ class _ExplanationPanel extends StatelessWidget {
final bool explaining; final bool explaining;
final AskAiResult? result; final AskAiResult? result;
final String privacyMode; 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({ const _ExplanationPanel({
required this.explaining, required this.explaining,
required this.result, required this.result,
required this.privacyMode, required this.privacyMode,
required this.onRegenerate,
}); });
@override @override
@ -671,15 +682,42 @@ class _ExplanationPanel extends StatelessWidget {
? FaiPillTone.warning ? FaiPillTone.warning
: FaiPillTone.neutral, : 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(), const Spacer(),
if (result != null && result!.isSuccess && result!.latencyMs > 0) if (result != null && result!.isSuccess && result!.latencyMs > 0)
Text( Text(
'${result!.latencyMs} ms', result!.cached
? 'orig ${result!.latencyMs} ms'
: '${result!.latencyMs} ms',
style: FaiTheme.mono( style: FaiTheme.mono(
size: 10, size: 10,
color: theme.colorScheme.onSurfaceVariant, 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), const SizedBox(height: FaiSpace.sm),

View file

@ -240,6 +240,27 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
} }
} }
Future<void> _clearCache() async {
try {
final purged = await HubService.instance.clearSystemLlmCache();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cleared $purged cached System-AI explanations.')),
);
// Pop with the same status (since the editor's `initial`
// is now stale w.r.t. cacheCount); caller will re-fetch
// status on next open.
setState(() {
// Force a rebuild that hides the row; cheap.
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cache clear failed: $e')),
);
}
}
Future<void> _test() async { Future<void> _test() async {
setState(() { setState(() {
_testing = true; _testing = true;
@ -420,6 +441,13 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
value: _privacyMode, value: _privacyMode,
onChanged: (v) => setState(() => _privacyMode = v), onChanged: (v) => setState(() => _privacyMode = v),
), ),
if (widget.initial.cacheCount > 0) ...[
const SizedBox(height: FaiSpace.md),
_CacheStatusRow(
cacheCount: widget.initial.cacheCount,
onClear: _saving || _testing ? null : _clearCache,
),
],
if (_error != null) ...[ if (_error != null) ...[
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
Text( Text(
@ -1083,3 +1111,56 @@ class _SuitabilityLegend extends StatelessWidget {
); );
} }
} }
/// Displays the System-AI cache count plus a "Clear cache"
/// button. Hidden when the cache is empty so the dialog stays
/// quiet for fresh installs. Lives inline so the operator sees
/// the cache exists, what it costs, and how to drop it without
/// reading docs.
class _CacheStatusRow extends StatelessWidget {
final int cacheCount;
final VoidCallback? onClear;
const _CacheStatusRow({required this.cacheCount, required this.onClear});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: 6,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Row(
children: [
Icon(
Icons.bolt,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Expanded(
child: Text(
'Cache: $cacheCount cached explanation${cacheCount == 1 ? "" : "s"}. '
'Identical prompts hit the cache; switching model or privacy '
'mode flushes automatically.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
TextButton.icon(
onPressed: onClear,
icon: const Icon(Icons.delete_outline, size: 14),
label: const Text('Clear'),
),
],
),
);
}
}

View file

@ -79,7 +79,7 @@ packages:
path: "../fai_dart_sdk" path: "../fai_dart_sdk"
relative: true relative: true
source: path source: path
version: "0.7.1" version: "0.8.0"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:

View file

@ -1,7 +1,7 @@
name: fai_studio name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub" description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none' publish_to: 'none'
version: 0.15.0 version: 0.16.0
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta