feat: System-AI cache surface (v0.8.0)

Picks up cache-related additions in HubAdmin:
- AskAi gains `forceFresh` request flag and `cached` /
  `cachedAt` / `cacheHits` response fields.
- New ClearSystemLlmCache + ForgetCachedExplanation RPCs.
- SystemAiStatus carries `cacheCount` so the UI can render
  "Cache: N cached explanations".

HubClient exposes `clearSystemLlmCache()` returning the
purged count and `forgetCachedExplanation(prompt)` for the
Regenerate flow.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-07 17:29:26 +02:00
parent 26d9d1f502
commit 6dc058344b
5 changed files with 227 additions and 7 deletions

View file

@ -2797,6 +2797,7 @@ class SystemAiStatusResponse extends $pb.GeneratedMessage {
$core.String? model,
$core.String? privacyMode,
$core.String? apiKeyEnv,
$fixnum.Int64? cacheCount,
}) {
final $result = create();
if (enabled != null) {
@ -2817,6 +2818,9 @@ class SystemAiStatusResponse extends $pb.GeneratedMessage {
if (apiKeyEnv != null) {
$result.apiKeyEnv = apiKeyEnv;
}
if (cacheCount != null) {
$result.cacheCount = cacheCount;
}
return $result;
}
SystemAiStatusResponse._() : super();
@ -2830,6 +2834,7 @@ class SystemAiStatusResponse extends $pb.GeneratedMessage {
..aOS(4, _omitFieldNames ? '' : 'model')
..aOS(5, _omitFieldNames ? '' : 'privacyMode')
..aOS(6, _omitFieldNames ? '' : 'apiKeyEnv')
..a<$fixnum.Int64>(7, _omitFieldNames ? '' : 'cacheCount', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO)
..hasRequiredFields = false
;
@ -2908,16 +2913,32 @@ class SystemAiStatusResponse extends $pb.GeneratedMessage {
$core.bool hasApiKeyEnv() => $_has(5);
@$pb.TagNumber(6)
void clearApiKeyEnv() => $_clearField(6);
/// Number of cached System-AI explanations currently stored.
/// Surfaced in Doctor + Settings so operators see the cache
/// earning its keep.
@$pb.TagNumber(7)
$fixnum.Int64 get cacheCount => $_getI64(6);
@$pb.TagNumber(7)
set cacheCount($fixnum.Int64 v) { $_setInt64(6, v); }
@$pb.TagNumber(7)
$core.bool hasCacheCount() => $_has(6);
@$pb.TagNumber(7)
void clearCacheCount() => $_clearField(7);
}
class AskAiRequest extends $pb.GeneratedMessage {
factory AskAiRequest({
$core.String? prompt,
$core.bool? forceFresh,
}) {
final $result = create();
if (prompt != null) {
$result.prompt = prompt;
}
if (forceFresh != null) {
$result.forceFresh = forceFresh;
}
return $result;
}
AskAiRequest._() : super();
@ -2926,6 +2947,7 @@ class AskAiRequest extends $pb.GeneratedMessage {
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'AskAiRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'fai.v1'), createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'prompt')
..aOB(2, _omitFieldNames ? '' : 'forceFresh')
..hasRequiredFields = false
;
@ -2955,6 +2977,18 @@ class AskAiRequest extends $pb.GeneratedMessage {
$core.bool hasPrompt() => $_has(0);
@$pb.TagNumber(1)
void clearPrompt() => $_clearField(1);
/// When true, skip the persistent cache and always hit the
/// live provider. Studio's "Regenerate" button passes this so
/// the operator can ask the model to think again.
@$pb.TagNumber(2)
$core.bool get forceFresh => $_getBF(1);
@$pb.TagNumber(2)
set forceFresh($core.bool v) { $_setBool(1, v); }
@$pb.TagNumber(2)
$core.bool hasForceFresh() => $_has(1);
@$pb.TagNumber(2)
void clearForceFresh() => $_clearField(2);
}
class AskAiResponse extends $pb.GeneratedMessage {
@ -2962,6 +2996,9 @@ class AskAiResponse extends $pb.GeneratedMessage {
$core.String? errorKind,
$core.String? text,
$core.int? latencyMs,
$core.bool? cached,
$core.String? cachedAt,
$core.int? cacheHits,
}) {
final $result = create();
if (errorKind != null) {
@ -2973,6 +3010,15 @@ class AskAiResponse extends $pb.GeneratedMessage {
if (latencyMs != null) {
$result.latencyMs = latencyMs;
}
if (cached != null) {
$result.cached = cached;
}
if (cachedAt != null) {
$result.cachedAt = cachedAt;
}
if (cacheHits != null) {
$result.cacheHits = cacheHits;
}
return $result;
}
AskAiResponse._() : super();
@ -2983,6 +3029,9 @@ class AskAiResponse extends $pb.GeneratedMessage {
..aOS(1, _omitFieldNames ? '' : 'errorKind')
..aOS(2, _omitFieldNames ? '' : 'text')
..a<$core.int>(3, _omitFieldNames ? '' : 'latencyMs', $pb.PbFieldType.OU3)
..aOB(4, _omitFieldNames ? '' : 'cached')
..aOS(5, _omitFieldNames ? '' : 'cachedAt')
..a<$core.int>(6, _omitFieldNames ? '' : 'cacheHits', $pb.PbFieldType.OU3)
..hasRequiredFields = false
;
@ -3026,7 +3075,10 @@ class AskAiResponse extends $pb.GeneratedMessage {
@$pb.TagNumber(2)
void clearText() => $_clearField(2);
/// Round-trip latency in milliseconds (success only).
/// Round-trip latency in milliseconds (success only). For
/// cache hits this is the *original* generation latency, not
/// the cache-lookup time the UI renders it as "originally
/// took X" so the operator sees what a live call would cost.
@$pb.TagNumber(3)
$core.int get latencyMs => $_getIZ(2);
@$pb.TagNumber(3)
@ -3035,6 +3087,84 @@ class AskAiResponse extends $pb.GeneratedMessage {
$core.bool hasLatencyMs() => $_has(2);
@$pb.TagNumber(3)
void clearLatencyMs() => $_clearField(3);
/// True iff the answer came from the persistent cache rather
/// than a fresh provider round-trip.
@$pb.TagNumber(4)
$core.bool get cached => $_getBF(3);
@$pb.TagNumber(4)
set cached($core.bool v) { $_setBool(3, v); }
@$pb.TagNumber(4)
$core.bool hasCached() => $_has(3);
@$pb.TagNumber(4)
void clearCached() => $_clearField(4);
/// ISO-8601 timestamp the cached entry was first generated.
/// Empty for fresh answers.
@$pb.TagNumber(5)
$core.String get cachedAt => $_getSZ(4);
@$pb.TagNumber(5)
set cachedAt($core.String v) { $_setString(4, v); }
@$pb.TagNumber(5)
$core.bool hasCachedAt() => $_has(4);
@$pb.TagNumber(5)
void clearCachedAt() => $_clearField(5);
/// How many times this entry has been served from cache.
/// 0 for fresh answers.
@$pb.TagNumber(6)
$core.int get cacheHits => $_getIZ(5);
@$pb.TagNumber(6)
set cacheHits($core.int v) { $_setUnsignedInt32(5, v); }
@$pb.TagNumber(6)
$core.bool hasCacheHits() => $_has(5);
@$pb.TagNumber(6)
void clearCacheHits() => $_clearField(6);
}
class ClearSystemLlmCacheResponse extends $pb.GeneratedMessage {
factory ClearSystemLlmCacheResponse({
$fixnum.Int64? purged,
}) {
final $result = create();
if (purged != null) {
$result.purged = purged;
}
return $result;
}
ClearSystemLlmCacheResponse._() : super();
factory ClearSystemLlmCacheResponse.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory ClearSystemLlmCacheResponse.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ClearSystemLlmCacheResponse', package: const $pb.PackageName(_omitMessageNames ? '' : 'fai.v1'), createEmptyInstance: create)
..a<$fixnum.Int64>(1, _omitFieldNames ? '' : 'purged', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO)
..hasRequiredFields = false
;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
ClearSystemLlmCacheResponse clone() => ClearSystemLlmCacheResponse()..mergeFromMessage(this);
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
ClearSystemLlmCacheResponse copyWith(void Function(ClearSystemLlmCacheResponse) updates) => super.copyWith((message) => updates(message as ClearSystemLlmCacheResponse)) as ClearSystemLlmCacheResponse;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static ClearSystemLlmCacheResponse create() => ClearSystemLlmCacheResponse._();
ClearSystemLlmCacheResponse createEmptyInstance() => create();
static $pb.PbList<ClearSystemLlmCacheResponse> createRepeated() => $pb.PbList<ClearSystemLlmCacheResponse>();
@$core.pragma('dart2js:noInline')
static ClearSystemLlmCacheResponse getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<ClearSystemLlmCacheResponse>(create);
static ClearSystemLlmCacheResponse? _defaultInstance;
/// Number of cached entries that were dropped.
@$pb.TagNumber(1)
$fixnum.Int64 get purged => $_getI64(0);
@$pb.TagNumber(1)
set purged($fixnum.Int64 v) { $_setInt64(0, v); }
@$pb.TagNumber(1)
$core.bool hasPurged() => $_has(0);
@$pb.TagNumber(1)
void clearPurged() => $_clearField(1);
}
class UpdateSystemAiRequest extends $pb.GeneratedMessage {

View file

@ -215,6 +215,14 @@ class HubAdminClient extends $grpc.Client {
'/fai.v1.HubAdmin/PullSystemAiModel',
($0.PullSystemAiModelRequest value) => value.writeToBuffer(),
($core.List<$core.int> value) => $0.PullSystemAiModelResponse.fromBuffer(value));
static final _$clearSystemLlmCache = $grpc.ClientMethod<$1.Empty, $0.ClearSystemLlmCacheResponse>(
'/fai.v1.HubAdmin/ClearSystemLlmCache',
($1.Empty value) => value.writeToBuffer(),
($core.List<$core.int> value) => $0.ClearSystemLlmCacheResponse.fromBuffer(value));
static final _$forgetCachedExplanation = $grpc.ClientMethod<$0.AskAiRequest, $1.Empty>(
'/fai.v1.HubAdmin/ForgetCachedExplanation',
($0.AskAiRequest value) => value.writeToBuffer(),
($core.List<$core.int> value) => $1.Empty.fromBuffer(value));
static final _$hardwareInfo = $grpc.ClientMethod<$1.Empty, $0.HardwareInfoResponse>(
'/fai.v1.HubAdmin/HardwareInfo',
($1.Empty value) => value.writeToBuffer(),
@ -376,6 +384,20 @@ class HubAdminClient extends $grpc.Client {
return $createUnaryCall(_$pullSystemAiModel, request, options: options);
}
/// Drop every cached System-AI explanation. Surfaced in
/// Studio's Settings so operators can force a clean slate
/// without changing model/privacy mode (which also flushes).
$grpc.ResponseFuture<$0.ClearSystemLlmCacheResponse> clearSystemLlmCache($1.Empty request, {$grpc.CallOptions? options}) {
return $createUnaryCall(_$clearSystemLlmCache, request, options: options);
}
/// Drop a single cached entry by prompt the same prompt
/// Studio sent in AskAi. Used by the "Regenerate" button so
/// the next AskAi call falls through to the live provider.
$grpc.ResponseFuture<$1.Empty> forgetCachedExplanation($0.AskAiRequest request, {$grpc.CallOptions? options}) {
return $createUnaryCall(_$forgetCachedExplanation, request, options: options);
}
/// Detected host hardware tier + summary line. Studio's
/// System-AI editor uses this to mark models as "may be slow
/// on your hardware" without requiring a benchmark.
@ -559,6 +581,20 @@ abstract class HubAdminServiceBase extends $grpc.Service {
false,
($core.List<$core.int> value) => $0.PullSystemAiModelRequest.fromBuffer(value),
($0.PullSystemAiModelResponse value) => value.writeToBuffer()));
$addMethod($grpc.ServiceMethod<$1.Empty, $0.ClearSystemLlmCacheResponse>(
'ClearSystemLlmCache',
clearSystemLlmCache_Pre,
false,
false,
($core.List<$core.int> value) => $1.Empty.fromBuffer(value),
($0.ClearSystemLlmCacheResponse value) => value.writeToBuffer()));
$addMethod($grpc.ServiceMethod<$0.AskAiRequest, $1.Empty>(
'ForgetCachedExplanation',
forgetCachedExplanation_Pre,
false,
false,
($core.List<$core.int> value) => $0.AskAiRequest.fromBuffer(value),
($1.Empty value) => value.writeToBuffer()));
$addMethod($grpc.ServiceMethod<$1.Empty, $0.HardwareInfoResponse>(
'HardwareInfo',
hardwareInfo_Pre,
@ -667,6 +703,14 @@ abstract class HubAdminServiceBase extends $grpc.Service {
return pullSystemAiModel($call, await $request);
}
$async.Future<$0.ClearSystemLlmCacheResponse> clearSystemLlmCache_Pre($grpc.ServiceCall $call, $async.Future<$1.Empty> $request) async {
return clearSystemLlmCache($call, await $request);
}
$async.Future<$1.Empty> forgetCachedExplanation_Pre($grpc.ServiceCall $call, $async.Future<$0.AskAiRequest> $request) async {
return forgetCachedExplanation($call, await $request);
}
$async.Future<$0.HardwareInfoResponse> hardwareInfo_Pre($grpc.ServiceCall $call, $async.Future<$1.Empty> $request) async {
return hardwareInfo($call, await $request);
}
@ -698,6 +742,8 @@ abstract class HubAdminServiceBase extends $grpc.Service {
$async.Future<$0.AskAiResponse> testSystemAi($grpc.ServiceCall call, $0.TestSystemAiRequest request);
$async.Future<$0.ListSystemAiModelsResponse> listSystemAiModels($grpc.ServiceCall call, $0.TestSystemAiRequest request);
$async.Future<$0.PullSystemAiModelResponse> pullSystemAiModel($grpc.ServiceCall call, $0.PullSystemAiModelRequest request);
$async.Future<$0.ClearSystemLlmCacheResponse> clearSystemLlmCache($grpc.ServiceCall call, $1.Empty request);
$async.Future<$1.Empty> forgetCachedExplanation($grpc.ServiceCall call, $0.AskAiRequest request);
$async.Future<$0.HardwareInfoResponse> hardwareInfo($grpc.ServiceCall call, $1.Empty request);
$async.Future<$0.ListSystemAiCuratedModelsResponse> listSystemAiCuratedModels($grpc.ServiceCall call, $1.Empty request);
}

View file

@ -679,6 +679,7 @@ const SystemAiStatusResponse$json = {
{'1': 'model', '3': 4, '4': 1, '5': 9, '10': 'model'},
{'1': 'privacy_mode', '3': 5, '4': 1, '5': 9, '10': 'privacyMode'},
{'1': 'api_key_env', '3': 6, '4': 1, '5': 9, '10': 'apiKeyEnv'},
{'1': 'cache_count', '3': 7, '4': 1, '5': 4, '10': 'cacheCount'},
],
};
@ -687,19 +688,22 @@ final $typed_data.Uint8List systemAiStatusResponseDescriptor = $convert.base64De
'ChZTeXN0ZW1BaVN0YXR1c1Jlc3BvbnNlEhgKB2VuYWJsZWQYASABKAhSB2VuYWJsZWQSGgoIcH'
'JvdmlkZXIYAiABKAlSCHByb3ZpZGVyEhoKCGVuZHBvaW50GAMgASgJUghlbmRwb2ludBIUCgVt'
'b2RlbBgEIAEoCVIFbW9kZWwSIQoMcHJpdmFjeV9tb2RlGAUgASgJUgtwcml2YWN5TW9kZRIeCg'
'thcGlfa2V5X2VudhgGIAEoCVIJYXBpS2V5RW52');
'thcGlfa2V5X2VudhgGIAEoCVIJYXBpS2V5RW52Eh8KC2NhY2hlX2NvdW50GAcgASgEUgpjYWNo'
'ZUNvdW50');
@$core.Deprecated('Use askAiRequestDescriptor instead')
const AskAiRequest$json = {
'1': 'AskAiRequest',
'2': [
{'1': 'prompt', '3': 1, '4': 1, '5': 9, '10': 'prompt'},
{'1': 'force_fresh', '3': 2, '4': 1, '5': 8, '10': 'forceFresh'},
],
};
/// Descriptor for `AskAiRequest`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List askAiRequestDescriptor = $convert.base64Decode(
'CgxBc2tBaVJlcXVlc3QSFgoGcHJvbXB0GAEgASgJUgZwcm9tcHQ=');
'CgxBc2tBaVJlcXVlc3QSFgoGcHJvbXB0GAEgASgJUgZwcm9tcHQSHwoLZm9yY2VfZnJlc2gYAi'
'ABKAhSCmZvcmNlRnJlc2g=');
@$core.Deprecated('Use askAiResponseDescriptor instead')
const AskAiResponse$json = {
@ -708,13 +712,30 @@ const AskAiResponse$json = {
{'1': 'error_kind', '3': 1, '4': 1, '5': 9, '10': 'errorKind'},
{'1': 'text', '3': 2, '4': 1, '5': 9, '10': 'text'},
{'1': 'latency_ms', '3': 3, '4': 1, '5': 13, '10': 'latencyMs'},
{'1': 'cached', '3': 4, '4': 1, '5': 8, '10': 'cached'},
{'1': 'cached_at', '3': 5, '4': 1, '5': 9, '10': 'cachedAt'},
{'1': 'cache_hits', '3': 6, '4': 1, '5': 13, '10': 'cacheHits'},
],
};
/// Descriptor for `AskAiResponse`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List askAiResponseDescriptor = $convert.base64Decode(
'Cg1Bc2tBaVJlc3BvbnNlEh0KCmVycm9yX2tpbmQYASABKAlSCWVycm9yS2luZBISCgR0ZXh0GA'
'IgASgJUgR0ZXh0Eh0KCmxhdGVuY3lfbXMYAyABKA1SCWxhdGVuY3lNcw==');
'IgASgJUgR0ZXh0Eh0KCmxhdGVuY3lfbXMYAyABKA1SCWxhdGVuY3lNcxIWCgZjYWNoZWQYBCAB'
'KAhSBmNhY2hlZBIbCgljYWNoZWRfYXQYBSABKAlSCGNhY2hlZEF0Eh0KCmNhY2hlX2hpdHMYBi'
'ABKA1SCWNhY2hlSGl0cw==');
@$core.Deprecated('Use clearSystemLlmCacheResponseDescriptor instead')
const ClearSystemLlmCacheResponse$json = {
'1': 'ClearSystemLlmCacheResponse',
'2': [
{'1': 'purged', '3': 1, '4': 1, '5': 4, '10': 'purged'},
],
};
/// Descriptor for `ClearSystemLlmCacheResponse`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List clearSystemLlmCacheResponseDescriptor = $convert.base64Decode(
'ChtDbGVhclN5c3RlbUxsbUNhY2hlUmVzcG9uc2USFgoGcHVyZ2VkGAEgASgEUgZwdXJnZWQ=');
@$core.Deprecated('Use updateSystemAiRequestDescriptor instead')
const UpdateSystemAiRequest$json = {

View file

@ -217,8 +217,31 @@ class HubClient {
/// One-shot prompt to the configured System AI. Errors come
/// back inside [AskAiResponse.errorKind] (not as exceptions)
/// so Studio can map each category to its inline-fix copy.
Future<AskAiResponse> askAi(String prompt) {
return _admin.askAi(pb.AskAiRequest(prompt: prompt));
///
/// Cache-aware: identical prompts hit the persistent
/// per-(model, privacy_mode, prompt) cache; the response
/// carries `cached: true` plus the original generation
/// latency. Pass [forceFresh] to skip the cache (Studio's
/// Regenerate button).
Future<AskAiResponse> askAi(String prompt, {bool forceFresh = false}) {
return _admin.askAi(
pb.AskAiRequest(prompt: prompt, forceFresh: forceFresh),
);
}
/// Drop every cached System-AI explanation. Used by Settings
/// when the operator wants a clean slate without changing
/// model/privacy mode (which would also flush).
Future<int> clearSystemLlmCache() async {
final r = await _admin.clearSystemLlmCache(Empty());
return r.purged.toInt();
}
/// Drop a single cached entry by prompt. Studio's Regenerate
/// button calls this so the next askAi falls through to the
/// live provider.
Future<void> forgetCachedExplanation(String prompt) async {
await _admin.forgetCachedExplanation(pb.AskAiRequest(prompt: prompt));
}
/// Persist a new System-AI configuration. Validates +