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:
parent
b32b4b5bda
commit
d82738e17e
6 changed files with 161 additions and 9 deletions
|
|
@ -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<AskAiResult> askAi(String prompt) async {
|
||||
final r = await _client.askAi(prompt);
|
||||
Future<AskAiResult> 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<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
|
||||
/// 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;
|
||||
|
|
|
|||
|
|
@ -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<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
setState(() {
|
||||
_testing = true;
|
||||
|
|
@ -420,6 +441,13 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
|||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue