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,
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue