feat: SystemAiStatus + AskAi RPCs (v0.4.0)

`HubClient.systemAiStatus()` reports whether the hub-internal LLM
hook is configured and which provider / privacy mode is active.
`HubClient.askAi(prompt)` issues a one-shot prompt and returns
the answer or a structured error kind so the UI can map each
failure to its inline-fix copy.

LoggedEvent gains `detail` upstream (no SDK change required).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-07 13:18:22 +02:00
parent 73dbdc16ef
commit f0db42264f
5 changed files with 358 additions and 1 deletions

View file

@ -2789,6 +2789,254 @@ class CheckUpdateResponse extends $pb.GeneratedMessage {
void clearManifestUrl() => $_clearField(9);
}
class SystemAiStatusResponse extends $pb.GeneratedMessage {
factory SystemAiStatusResponse({
$core.bool? enabled,
$core.String? provider,
$core.String? endpoint,
$core.String? model,
$core.String? privacyMode,
$core.String? apiKeyEnv,
}) {
final $result = create();
if (enabled != null) {
$result.enabled = enabled;
}
if (provider != null) {
$result.provider = provider;
}
if (endpoint != null) {
$result.endpoint = endpoint;
}
if (model != null) {
$result.model = model;
}
if (privacyMode != null) {
$result.privacyMode = privacyMode;
}
if (apiKeyEnv != null) {
$result.apiKeyEnv = apiKeyEnv;
}
return $result;
}
SystemAiStatusResponse._() : super();
factory SystemAiStatusResponse.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory SystemAiStatusResponse.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SystemAiStatusResponse', package: const $pb.PackageName(_omitMessageNames ? '' : 'fai.v1'), createEmptyInstance: create)
..aOB(1, _omitFieldNames ? '' : 'enabled')
..aOS(2, _omitFieldNames ? '' : 'provider')
..aOS(3, _omitFieldNames ? '' : 'endpoint')
..aOS(4, _omitFieldNames ? '' : 'model')
..aOS(5, _omitFieldNames ? '' : 'privacyMode')
..aOS(6, _omitFieldNames ? '' : 'apiKeyEnv')
..hasRequiredFields = false
;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
SystemAiStatusResponse clone() => SystemAiStatusResponse()..mergeFromMessage(this);
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
SystemAiStatusResponse copyWith(void Function(SystemAiStatusResponse) updates) => super.copyWith((message) => updates(message as SystemAiStatusResponse)) as SystemAiStatusResponse;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static SystemAiStatusResponse create() => SystemAiStatusResponse._();
SystemAiStatusResponse createEmptyInstance() => create();
static $pb.PbList<SystemAiStatusResponse> createRepeated() => $pb.PbList<SystemAiStatusResponse>();
@$core.pragma('dart2js:noInline')
static SystemAiStatusResponse getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<SystemAiStatusResponse>(create);
static SystemAiStatusResponse? _defaultInstance;
/// True iff `system_llm` is configured AND privacy_mode != off.
@$pb.TagNumber(1)
$core.bool get enabled => $_getBF(0);
@$pb.TagNumber(1)
set enabled($core.bool v) { $_setBool(0, v); }
@$pb.TagNumber(1)
$core.bool hasEnabled() => $_has(0);
@$pb.TagNumber(1)
void clearEnabled() => $_clearField(1);
/// openai / anthropic / "" empty when disabled.
@$pb.TagNumber(2)
$core.String get provider => $_getSZ(1);
@$pb.TagNumber(2)
set provider($core.String v) { $_setString(1, v); }
@$pb.TagNumber(2)
$core.bool hasProvider() => $_has(1);
@$pb.TagNumber(2)
void clearProvider() => $_clearField(2);
/// The endpoint URL the operator configured. Empty when disabled.
@$pb.TagNumber(3)
$core.String get endpoint => $_getSZ(2);
@$pb.TagNumber(3)
set endpoint($core.String v) { $_setString(2, v); }
@$pb.TagNumber(3)
$core.bool hasEndpoint() => $_has(2);
@$pb.TagNumber(3)
void clearEndpoint() => $_clearField(3);
/// Model identifier. Empty when disabled.
@$pb.TagNumber(4)
$core.String get model => $_getSZ(3);
@$pb.TagNumber(4)
set model($core.String v) { $_setString(3, v); }
@$pb.TagNumber(4)
$core.bool hasModel() => $_has(3);
@$pb.TagNumber(4)
void clearModel() => $_clearField(4);
/// off / redacted / full never empty.
@$pb.TagNumber(5)
$core.String get privacyMode => $_getSZ(4);
@$pb.TagNumber(5)
set privacyMode($core.String v) { $_setString(4, v); }
@$pb.TagNumber(5)
$core.bool hasPrivacyMode() => $_has(4);
@$pb.TagNumber(5)
void clearPrivacyMode() => $_clearField(5);
/// Name of the env var holding the API key (empty = no auth).
/// Studio's settings page renders the *name*; never the value.
@$pb.TagNumber(6)
$core.String get apiKeyEnv => $_getSZ(5);
@$pb.TagNumber(6)
set apiKeyEnv($core.String v) { $_setString(5, v); }
@$pb.TagNumber(6)
$core.bool hasApiKeyEnv() => $_has(5);
@$pb.TagNumber(6)
void clearApiKeyEnv() => $_clearField(6);
}
class AskAiRequest extends $pb.GeneratedMessage {
factory AskAiRequest({
$core.String? prompt,
}) {
final $result = create();
if (prompt != null) {
$result.prompt = prompt;
}
return $result;
}
AskAiRequest._() : super();
factory AskAiRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory AskAiRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'AskAiRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'fai.v1'), createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'prompt')
..hasRequiredFields = false
;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
AskAiRequest clone() => AskAiRequest()..mergeFromMessage(this);
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
AskAiRequest copyWith(void Function(AskAiRequest) updates) => super.copyWith((message) => updates(message as AskAiRequest)) as AskAiRequest;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static AskAiRequest create() => AskAiRequest._();
AskAiRequest createEmptyInstance() => create();
static $pb.PbList<AskAiRequest> createRepeated() => $pb.PbList<AskAiRequest>();
@$core.pragma('dart2js:noInline')
static AskAiRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<AskAiRequest>(create);
static AskAiRequest? _defaultInstance;
/// The prompt the operator wants answered. Studio assembles
/// this client-side from the audit event being explained
/// and the chosen privacy mode.
@$pb.TagNumber(1)
$core.String get prompt => $_getSZ(0);
@$pb.TagNumber(1)
set prompt($core.String v) { $_setString(0, v); }
@$pb.TagNumber(1)
$core.bool hasPrompt() => $_has(0);
@$pb.TagNumber(1)
void clearPrompt() => $_clearField(1);
}
class AskAiResponse extends $pb.GeneratedMessage {
factory AskAiResponse({
$core.String? errorKind,
$core.String? text,
$core.int? latencyMs,
}) {
final $result = create();
if (errorKind != null) {
$result.errorKind = errorKind;
}
if (text != null) {
$result.text = text;
}
if (latencyMs != null) {
$result.latencyMs = latencyMs;
}
return $result;
}
AskAiResponse._() : super();
factory AskAiResponse.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory AskAiResponse.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'AskAiResponse', package: const $pb.PackageName(_omitMessageNames ? '' : 'fai.v1'), createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'errorKind')
..aOS(2, _omitFieldNames ? '' : 'text')
..a<$core.int>(3, _omitFieldNames ? '' : 'latencyMs', $pb.PbFieldType.OU3)
..hasRequiredFields = false
;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
AskAiResponse clone() => AskAiResponse()..mergeFromMessage(this);
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
AskAiResponse copyWith(void Function(AskAiResponse) updates) => super.copyWith((message) => updates(message as AskAiResponse)) as AskAiResponse;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static AskAiResponse create() => AskAiResponse._();
AskAiResponse createEmptyInstance() => create();
static $pb.PbList<AskAiResponse> createRepeated() => $pb.PbList<AskAiResponse>();
@$core.pragma('dart2js:noInline')
static AskAiResponse getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<AskAiResponse>(create);
static AskAiResponse? _defaultInstance;
/// Failure category, mapped from `system_llm::SystemLlmError`.
/// Empty on success. Studio surfaces each value with a fix hint.
/// Values: "" (success), "disabled", "env_missing", "network",
/// "http", "parse", "empty_response".
@$pb.TagNumber(1)
$core.String get errorKind => $_getSZ(0);
@$pb.TagNumber(1)
set errorKind($core.String v) { $_setString(0, v); }
@$pb.TagNumber(1)
$core.bool hasErrorKind() => $_has(0);
@$pb.TagNumber(1)
void clearErrorKind() => $_clearField(1);
/// Human-readable diagnostic / answer. Always populated:
/// either the assistant text on success, or the error detail
/// on failure.
@$pb.TagNumber(2)
$core.String get text => $_getSZ(1);
@$pb.TagNumber(2)
set text($core.String v) { $_setString(1, v); }
@$pb.TagNumber(2)
$core.bool hasText() => $_has(1);
@$pb.TagNumber(2)
void clearText() => $_clearField(2);
/// Round-trip latency in milliseconds (success only).
@$pb.TagNumber(3)
$core.int get latencyMs => $_getIZ(2);
@$pb.TagNumber(3)
set latencyMs($core.int v) { $_setUnsignedInt32(2, v); }
@$pb.TagNumber(3)
$core.bool hasLatencyMs() => $_has(2);
@$pb.TagNumber(3)
void clearLatencyMs() => $_clearField(3);
}
const _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names');
const _omitMessageNames = $core.bool.fromEnvironment('protobuf.omit_message_names');

View file

@ -187,6 +187,14 @@ class HubAdminClient extends $grpc.Client {
'/fai.v1.HubAdmin/ChannelStatus',
($1.Empty value) => value.writeToBuffer(),
($core.List<$core.int> value) => $0.ChannelStatusResponse.fromBuffer(value));
static final _$systemAiStatus = $grpc.ClientMethod<$1.Empty, $0.SystemAiStatusResponse>(
'/fai.v1.HubAdmin/SystemAiStatus',
($1.Empty value) => value.writeToBuffer(),
($core.List<$core.int> value) => $0.SystemAiStatusResponse.fromBuffer(value));
static final _$askAi = $grpc.ClientMethod<$0.AskAiRequest, $0.AskAiResponse>(
'/fai.v1.HubAdmin/AskAi',
($0.AskAiRequest value) => value.writeToBuffer(),
($core.List<$core.int> value) => $0.AskAiResponse.fromBuffer(value));
HubAdminClient(super.channel, {super.options, super.interceptors});
@ -283,6 +291,21 @@ class HubAdminClient extends $grpc.Client {
$grpc.ResponseFuture<$0.ChannelStatusResponse> channelStatus($1.Empty request, {$grpc.CallOptions? options}) {
return $createUnaryCall(_$channelStatus, request, options: options);
}
/// Read-only system-AI status. Lets Studio render the
/// "configure in Settings" affordance when the feature is
/// off, and the privacy-mode badge when it is on.
$grpc.ResponseFuture<$0.SystemAiStatusResponse> systemAiStatus($1.Empty request, {$grpc.CallOptions? options}) {
return $createUnaryCall(_$systemAiStatus, request, options: options);
}
/// Ask the system AI a one-shot question. Errors carry the
/// failure category (disabled / env-missing / http / parse)
/// so Studio can map them to the inline-fix-hint copy from
/// `docs/architecture/system-ai.md`.
$grpc.ResponseFuture<$0.AskAiResponse> askAi($0.AskAiRequest request, {$grpc.CallOptions? options}) {
return $createUnaryCall(_$askAi, request, options: options);
}
}
@$pb.GrpcServiceName('fai.v1.HubAdmin')
@ -402,6 +425,20 @@ abstract class HubAdminServiceBase extends $grpc.Service {
false,
($core.List<$core.int> value) => $1.Empty.fromBuffer(value),
($0.ChannelStatusResponse value) => value.writeToBuffer()));
$addMethod($grpc.ServiceMethod<$1.Empty, $0.SystemAiStatusResponse>(
'SystemAiStatus',
systemAiStatus_Pre,
false,
false,
($core.List<$core.int> value) => $1.Empty.fromBuffer(value),
($0.SystemAiStatusResponse value) => value.writeToBuffer()));
$addMethod($grpc.ServiceMethod<$0.AskAiRequest, $0.AskAiResponse>(
'AskAi',
askAi_Pre,
false,
false,
($core.List<$core.int> value) => $0.AskAiRequest.fromBuffer(value),
($0.AskAiResponse value) => value.writeToBuffer()));
}
$async.Future<$0.CapabilityList> listCapabilities_Pre($grpc.ServiceCall $call, $async.Future<$1.Empty> $request) async {
@ -468,6 +505,14 @@ abstract class HubAdminServiceBase extends $grpc.Service {
return channelStatus($call, await $request);
}
$async.Future<$0.SystemAiStatusResponse> systemAiStatus_Pre($grpc.ServiceCall $call, $async.Future<$1.Empty> $request) async {
return systemAiStatus($call, await $request);
}
$async.Future<$0.AskAiResponse> askAi_Pre($grpc.ServiceCall $call, $async.Future<$0.AskAiRequest> $request) async {
return askAi($call, await $request);
}
$async.Future<$0.CapabilityList> listCapabilities($grpc.ServiceCall call, $1.Empty request);
$async.Future<$0.ModuleInfoResponse> moduleInfo($grpc.ServiceCall call, $0.ModuleInfoRequest request);
$async.Future<$0.CheckPermissionResponse> checkPermission($grpc.ServiceCall call, $0.CheckPermissionRequest request);
@ -484,4 +529,6 @@ abstract class HubAdminServiceBase extends $grpc.Service {
$async.Future<$0.ServiceList> listServices($grpc.ServiceCall call, $1.Empty request);
$async.Future<$0.CheckUpdateResponse> checkUpdate($grpc.ServiceCall call, $1.Empty request);
$async.Future<$0.ChannelStatusResponse> channelStatus($grpc.ServiceCall call, $1.Empty request);
$async.Future<$0.SystemAiStatusResponse> systemAiStatus($grpc.ServiceCall call, $1.Empty request);
$async.Future<$0.AskAiResponse> askAi($grpc.ServiceCall call, $0.AskAiRequest request);
}

View file

@ -669,3 +669,50 @@ final $typed_data.Uint8List checkUpdateResponseDescriptor = $convert.base64Decod
'Zlc3RSZWFjaGFibGUSFgoGcmVhc29uGAggASgJUgZyZWFzb24SIQoMbWFuaWZlc3RfdXJsGAkg'
'ASgJUgttYW5pZmVzdFVybA==');
@$core.Deprecated('Use systemAiStatusResponseDescriptor instead')
const SystemAiStatusResponse$json = {
'1': 'SystemAiStatusResponse',
'2': [
{'1': 'enabled', '3': 1, '4': 1, '5': 8, '10': 'enabled'},
{'1': 'provider', '3': 2, '4': 1, '5': 9, '10': 'provider'},
{'1': 'endpoint', '3': 3, '4': 1, '5': 9, '10': 'endpoint'},
{'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'},
],
};
/// Descriptor for `SystemAiStatusResponse`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List systemAiStatusResponseDescriptor = $convert.base64Decode(
'ChZTeXN0ZW1BaVN0YXR1c1Jlc3BvbnNlEhgKB2VuYWJsZWQYASABKAhSB2VuYWJsZWQSGgoIcH'
'JvdmlkZXIYAiABKAlSCHByb3ZpZGVyEhoKCGVuZHBvaW50GAMgASgJUghlbmRwb2ludBIUCgVt'
'b2RlbBgEIAEoCVIFbW9kZWwSIQoMcHJpdmFjeV9tb2RlGAUgASgJUgtwcml2YWN5TW9kZRIeCg'
'thcGlfa2V5X2VudhgGIAEoCVIJYXBpS2V5RW52');
@$core.Deprecated('Use askAiRequestDescriptor instead')
const AskAiRequest$json = {
'1': 'AskAiRequest',
'2': [
{'1': 'prompt', '3': 1, '4': 1, '5': 9, '10': 'prompt'},
],
};
/// Descriptor for `AskAiRequest`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List askAiRequestDescriptor = $convert.base64Decode(
'CgxBc2tBaVJlcXVlc3QSFgoGcHJvbXB0GAEgASgJUgZwcm9tcHQ=');
@$core.Deprecated('Use askAiResponseDescriptor instead')
const AskAiResponse$json = {
'1': 'AskAiResponse',
'2': [
{'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'},
],
};
/// Descriptor for `AskAiResponse`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List askAiResponseDescriptor = $convert.base64Decode(
'Cg1Bc2tBaVJlc3BvbnNlEh0KCmVycm9yX2tpbmQYASABKAlSCWVycm9yS2luZBISCgR0ZXh0GA'
'IgASgJUgR0ZXh0Eh0KCmxhdGVuY3lfbXMYAyABKA1SCWxhdGVuY3lNcw==');

View file

@ -44,6 +44,8 @@ typedef VerifyEventChainResponse = pb.VerifyEventChainResponse;
typedef CheckUpdateResponse = pb.CheckUpdateResponse;
typedef ChannelStatusResponse = pb.ChannelStatusResponse;
typedef ChannelEntry = pb.ChannelEntry;
typedef SystemAiStatusResponse = pb.SystemAiStatusResponse;
typedef AskAiResponse = pb.AskAiResponse;
typedef ModuleInfoResponse = pb.ModuleInfoResponse;
typedef FlowSummary = pb.FlowSummary;
typedef StoreEntry = pb.StoreEntry;
@ -184,6 +186,19 @@ class HubClient {
return _admin.channelStatus(Empty());
}
/// Read-only system-AI status. `enabled=false` lets Studio
/// render "configure in Settings" without making a request.
Future<SystemAiStatusResponse> systemAiStatus() {
return _admin.systemAiStatus(Empty());
}
/// 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));
}
/// Search the hub's bundled store index. All filters are
/// optional; an empty query returns the first [limit] entries
/// the index ships with.