From d82738e17e982dce18e222598668f0bff55c78d4 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 7 May 2026 17:29:36 +0200 Subject: [PATCH] feat(studio): cached/Regenerate UX in audit Explain (v0.16.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/data/hub.dart | 39 ++++++++++++- lib/main.dart | 2 +- lib/pages/audit.dart | 44 ++++++++++++++- lib/widgets/fai_system_ai_editor.dart | 81 +++++++++++++++++++++++++++ pubspec.lock | 2 +- pubspec.yaml | 2 +- 6 files changed, 161 insertions(+), 9 deletions(-) diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 175f6f4..5fba61e 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -119,6 +119,7 @@ class HubService { model: r.model, privacyMode: r.privacyMode, apiKeyEnv: r.apiKeyEnv, + cacheCount: r.cacheCount.toInt(), ); } @@ -126,15 +127,29 @@ class HubService { /// 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 askAi(String prompt) async { - final r = await _client.askAi(prompt); + Future askAi(String prompt, {bool forceFresh = false}) async { + final r = await _client.askAi(prompt, forceFresh: forceFresh); return AskAiResult( errorKind: r.errorKind, text: r.text, 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 clearSystemLlmCache() { + return _client.clearSystemLlmCache(); + } + + /// Forget a single cached entry by prompt (for "Regenerate"). + Future forgetCachedExplanation(String prompt) { + return _client.forgetCachedExplanation(prompt); + } + /// Persist a new System-AI configuration. The hub writes back /// to `~/.fai/config.yaml` and hot-reloads the in-memory copy /// in one round-trip; the caller gets the resulting status @@ -160,6 +175,7 @@ class HubService { model: r.model, privacyMode: r.privacyMode, apiKeyEnv: r.apiKeyEnv, + cacheCount: r.cacheCount.toInt(), ); } @@ -687,6 +703,10 @@ class SystemAiStatus { /// off / redacted / full. final String privacyMode; 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({ required this.enabled, @@ -695,6 +715,7 @@ class SystemAiStatus { required this.model, required this.privacyMode, required this.apiKeyEnv, + this.cacheCount = 0, }); } @@ -705,13 +726,25 @@ class AskAiResult { /// On success: the assistant's reply. /// On failure: a human-readable diagnostic. 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; + /// 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({ required this.errorKind, required this.text, required this.latencyMs, + this.cached = false, + this.cachedAt = '', + this.cacheHits = 0, }); bool get isSuccess => errorKind.isEmpty; diff --git a/lib/main.dart b/lib/main.dart index dfe311f..eeba970 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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.15.0'; +const String kStudioVersion = '0.16.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 41ea460..5332ba9 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -401,13 +401,16 @@ class _EventDetailDialogState extends State<_EventDetailDialog> { } } - Future _explain() async { + Future _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), diff --git a/lib/widgets/fai_system_ai_editor.dart b/lib/widgets/fai_system_ai_editor.dart index 87d438d..55d75f4 100644 --- a/lib/widgets/fai_system_ai_editor.dart +++ b/lib/widgets/fai_system_ai_editor.dart @@ -240,6 +240,27 @@ class _FaiSystemAiEditorState extends State { } } + Future _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 _test() async { setState(() { _testing = true; @@ -420,6 +441,13 @@ class _FaiSystemAiEditorState extends State { value: _privacyMode, 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) ...[ const SizedBox(height: FaiSpace.md), 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'), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index e4d6122..91784c9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -79,7 +79,7 @@ packages: path: "../fai_dart_sdk" relative: true source: path - version: "0.7.1" + version: "0.8.0" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ea397cd..23e226b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.15.0 +version: 0.16.0 environment: sdk: ^3.11.0-200.1.beta