From 0879a95aba68168eca0c8e7c8794529ccd63efd4 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 25 May 2026 11:17:31 +0200 Subject: [PATCH 1/5] feat(hub-client): GetInstalledModuleDocs + per-file MIME - New getInstalledModuleDocs(name, locale) wraps the new gRPC RPC; callers get text + source path + a not-installed flag so Studio can decide whether to show a Documentation section at all. - runSavedFlow gains an optional fileMimeTypes map keyed parallel to fileInputs. Hard-coded application/octet-stream remains as the fallback, but text.extract and other MIME-gating modules now get an accurate type when the caller knows one. - fetchModuleDocs is marked @Deprecated; it still works for older consumers but Studio no longer calls it. - Proto bindings regenerated against fai/platform proto. Signed-off-by: flemming-it Signed-off-by: flemming-it --- lib/src/generated/fai/v1/hub.pb.dart | 145 +++++++++++++++++++++++ lib/src/generated/fai/v1/hub.pbgrpc.dart | 26 ++++ lib/src/generated/fai/v1/hub.pbjson.dart | 30 +++++ lib/src/hub_client.dart | 48 ++++++-- 4 files changed, 240 insertions(+), 9 deletions(-) diff --git a/lib/src/generated/fai/v1/hub.pb.dart b/lib/src/generated/fai/v1/hub.pb.dart index 3341919..5602d9b 100644 --- a/lib/src/generated/fai/v1/hub.pb.dart +++ b/lib/src/generated/fai/v1/hub.pb.dart @@ -2055,6 +2055,151 @@ class FetchModuleDocsResponse extends $pb.GeneratedMessage { void clearSourceUrl() => $_clearField(3); } +class GetInstalledModuleDocsRequest extends $pb.GeneratedMessage { + factory GetInstalledModuleDocsRequest({ + $core.String? name, + $core.String? locale, + }) { + final $result = create(); + if (name != null) { + $result.name = name; + } + if (locale != null) { + $result.locale = locale; + } + return $result; + } + GetInstalledModuleDocsRequest._() : super(); + factory GetInstalledModuleDocsRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory GetInstalledModuleDocsRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'GetInstalledModuleDocsRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'fai.v1'), createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'name') + ..aOS(2, _omitFieldNames ? '' : 'locale') + ..hasRequiredFields = false + ; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + GetInstalledModuleDocsRequest clone() => GetInstalledModuleDocsRequest()..mergeFromMessage(this); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + GetInstalledModuleDocsRequest copyWith(void Function(GetInstalledModuleDocsRequest) updates) => super.copyWith((message) => updates(message as GetInstalledModuleDocsRequest)) as GetInstalledModuleDocsRequest; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static GetInstalledModuleDocsRequest create() => GetInstalledModuleDocsRequest._(); + GetInstalledModuleDocsRequest createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static GetInstalledModuleDocsRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static GetInstalledModuleDocsRequest? _defaultInstance; + + /// Capability id (e.g. `text.extract`) or module-manifest name + /// (e.g. `text-extract`). The hub maps capability → module via + /// the registry; falls back to treating the name as a + /// directory name. + @$pb.TagNumber(1) + $core.String get name => $_getSZ(0); + @$pb.TagNumber(1) + set name($core.String v) { $_setString(0, v); } + @$pb.TagNumber(1) + $core.bool hasName() => $_has(0); + @$pb.TagNumber(1) + void clearName() => $_clearField(1); + + /// Preferred locale code (e.g. "de", "en"). When set and not + /// "en", `MODULE..md` is tried first; otherwise the + /// generic `MODULE.md` is used. Empty string = no preference. + @$pb.TagNumber(2) + $core.String get locale => $_getSZ(1); + @$pb.TagNumber(2) + set locale($core.String v) { $_setString(1, v); } + @$pb.TagNumber(2) + $core.bool hasLocale() => $_has(1); + @$pb.TagNumber(2) + void clearLocale() => $_clearField(2); +} + +class GetInstalledModuleDocsResponse extends $pb.GeneratedMessage { + factory GetInstalledModuleDocsResponse({ + $core.String? text, + $core.String? sourcePath, + $core.bool? notInstalled, + }) { + final $result = create(); + if (text != null) { + $result.text = text; + } + if (sourcePath != null) { + $result.sourcePath = sourcePath; + } + if (notInstalled != null) { + $result.notInstalled = notInstalled; + } + return $result; + } + GetInstalledModuleDocsResponse._() : super(); + factory GetInstalledModuleDocsResponse.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory GetInstalledModuleDocsResponse.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'GetInstalledModuleDocsResponse', package: const $pb.PackageName(_omitMessageNames ? '' : 'fai.v1'), createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'text') + ..aOS(2, _omitFieldNames ? '' : 'sourcePath') + ..aOB(3, _omitFieldNames ? '' : 'notInstalled') + ..hasRequiredFields = false + ; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + GetInstalledModuleDocsResponse clone() => GetInstalledModuleDocsResponse()..mergeFromMessage(this); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + GetInstalledModuleDocsResponse copyWith(void Function(GetInstalledModuleDocsResponse) updates) => super.copyWith((message) => updates(message as GetInstalledModuleDocsResponse)) as GetInstalledModuleDocsResponse; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static GetInstalledModuleDocsResponse create() => GetInstalledModuleDocsResponse._(); + GetInstalledModuleDocsResponse createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static GetInstalledModuleDocsResponse getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static GetInstalledModuleDocsResponse? _defaultInstance; + + /// Markdown body. Empty when the module is installed but ships + /// no inline docs — Studio uses this to hide its docs button. + @$pb.TagNumber(1) + $core.String get text => $_getSZ(0); + @$pb.TagNumber(1) + set text($core.String v) { $_setString(0, v); } + @$pb.TagNumber(1) + $core.bool hasText() => $_has(0); + @$pb.TagNumber(1) + void clearText() => $_clearField(1); + + /// Absolute path on the hub host that the body was read from, + /// for "loaded from /path/MODULE.md" hints in Studio. Empty + /// when text is empty. + @$pb.TagNumber(2) + $core.String get sourcePath => $_getSZ(1); + @$pb.TagNumber(2) + set sourcePath($core.String v) { $_setString(1, v); } + @$pb.TagNumber(2) + $core.bool hasSourcePath() => $_has(1); + @$pb.TagNumber(2) + void clearSourcePath() => $_clearField(2); + + /// True when the module wasn't installed at all. Distinct from + /// "installed but no inline docs" so Studio can show different + /// affordances ("install first" vs "no inline docs available"). + @$pb.TagNumber(3) + $core.bool get notInstalled => $_getBF(2); + @$pb.TagNumber(3) + set notInstalled($core.bool v) { $_setBool(2, v); } + @$pb.TagNumber(3) + $core.bool hasNotInstalled() => $_has(2); + @$pb.TagNumber(3) + void clearNotInstalled() => $_clearField(3); +} + class ListApprovalsRequest extends $pb.GeneratedMessage { factory ListApprovalsRequest({ $core.Iterable<$core.String>? statuses, diff --git a/lib/src/generated/fai/v1/hub.pbgrpc.dart b/lib/src/generated/fai/v1/hub.pbgrpc.dart index b643be5..e225ef6 100644 --- a/lib/src/generated/fai/v1/hub.pbgrpc.dart +++ b/lib/src/generated/fai/v1/hub.pbgrpc.dart @@ -175,6 +175,10 @@ class HubAdminClient extends $grpc.Client { '/fai.v1.HubAdmin/FetchModuleDocs', ($0.FetchModuleDocsRequest value) => value.writeToBuffer(), ($core.List<$core.int> value) => $0.FetchModuleDocsResponse.fromBuffer(value)); + static final _$getInstalledModuleDocs = $grpc.ClientMethod<$0.GetInstalledModuleDocsRequest, $0.GetInstalledModuleDocsResponse>( + '/fai.v1.HubAdmin/GetInstalledModuleDocs', + ($0.GetInstalledModuleDocsRequest value) => value.writeToBuffer(), + ($core.List<$core.int> value) => $0.GetInstalledModuleDocsResponse.fromBuffer(value)); static final _$listApprovals = $grpc.ClientMethod<$0.ListApprovalsRequest, $0.ApprovalList>( '/fai.v1.HubAdmin/ListApprovals', ($0.ListApprovalsRequest value) => value.writeToBuffer(), @@ -356,6 +360,16 @@ class HubAdminClient extends $grpc.Client { return $createUnaryCall(_$fetchModuleDocs, request, options: options); } + /// Read the inline-docs (MODULE.md / MODULE..md) of an + /// installed module from disk. No network fetch, no provider- + /// specific URL probing — the docs ship in the .fai bundle and + /// live next to module.wasm on disk after install. Returns an + /// empty body when the module is installed but ships no inline + /// docs; Studio uses that to hide the docs button entirely. + $grpc.ResponseFuture<$0.GetInstalledModuleDocsResponse> getInstalledModuleDocs($0.GetInstalledModuleDocsRequest request, {$grpc.CallOptions? options}) { + return $createUnaryCall(_$getInstalledModuleDocs, request, options: options); + } + /// List system.approval@^0 entries, optionally filtered by status. $grpc.ResponseFuture<$0.ApprovalList> listApprovals($0.ListApprovalsRequest request, {$grpc.CallOptions? options}) { return $createUnaryCall(_$listApprovals, request, options: options); @@ -639,6 +653,13 @@ abstract class HubAdminServiceBase extends $grpc.Service { false, ($core.List<$core.int> value) => $0.FetchModuleDocsRequest.fromBuffer(value), ($0.FetchModuleDocsResponse value) => value.writeToBuffer())); + $addMethod($grpc.ServiceMethod<$0.GetInstalledModuleDocsRequest, $0.GetInstalledModuleDocsResponse>( + 'GetInstalledModuleDocs', + getInstalledModuleDocs_Pre, + false, + false, + ($core.List<$core.int> value) => $0.GetInstalledModuleDocsRequest.fromBuffer(value), + ($0.GetInstalledModuleDocsResponse value) => value.writeToBuffer())); $addMethod($grpc.ServiceMethod<$0.ListApprovalsRequest, $0.ApprovalList>( 'ListApprovals', listApprovals_Pre, @@ -875,6 +896,10 @@ abstract class HubAdminServiceBase extends $grpc.Service { return fetchModuleDocs($call, await $request); } + $async.Future<$0.GetInstalledModuleDocsResponse> getInstalledModuleDocs_Pre($grpc.ServiceCall $call, $async.Future<$0.GetInstalledModuleDocsRequest> $request) async { + return getInstalledModuleDocs($call, await $request); + } + $async.Future<$0.ApprovalList> listApprovals_Pre($grpc.ServiceCall $call, $async.Future<$0.ListApprovalsRequest> $request) async { return listApprovals($call, await $request); } @@ -992,6 +1017,7 @@ abstract class HubAdminServiceBase extends $grpc.Service { $async.Future<$0.InstallModuleResponse> installModule($grpc.ServiceCall call, $0.InstallModuleRequest request); $async.Future<$0.UninstallModuleResponse> uninstallModule($grpc.ServiceCall call, $0.UninstallModuleRequest request); $async.Future<$0.FetchModuleDocsResponse> fetchModuleDocs($grpc.ServiceCall call, $0.FetchModuleDocsRequest request); + $async.Future<$0.GetInstalledModuleDocsResponse> getInstalledModuleDocs($grpc.ServiceCall call, $0.GetInstalledModuleDocsRequest request); $async.Future<$0.ApprovalList> listApprovals($grpc.ServiceCall call, $0.ListApprovalsRequest request); $async.Future<$0.DecideApprovalResponse> decideApproval($grpc.ServiceCall call, $0.DecideApprovalRequest request); $async.Future<$0.VerifyEventChainResponse> verifyEventChain($grpc.ServiceCall call, $1.Empty request); diff --git a/lib/src/generated/fai/v1/hub.pbjson.dart b/lib/src/generated/fai/v1/hub.pbjson.dart index 48b8631..04132a5 100644 --- a/lib/src/generated/fai/v1/hub.pbjson.dart +++ b/lib/src/generated/fai/v1/hub.pbjson.dart @@ -532,6 +532,36 @@ final $typed_data.Uint8List fetchModuleDocsResponseDescriptor = $convert.base64D 'ChdGZXRjaE1vZHVsZURvY3NSZXNwb25zZRIdCgplcnJvcl9raW5kGAEgASgJUgllcnJvcktpbm' 'QSEgoEdGV4dBgCIAEoCVIEdGV4dBIdCgpzb3VyY2VfdXJsGAMgASgJUglzb3VyY2VVcmw='); +@$core.Deprecated('Use getInstalledModuleDocsRequestDescriptor instead') +const GetInstalledModuleDocsRequest$json = { + '1': 'GetInstalledModuleDocsRequest', + '2': [ + {'1': 'name', '3': 1, '4': 1, '5': 9, '10': 'name'}, + {'1': 'locale', '3': 2, '4': 1, '5': 9, '10': 'locale'}, + ], +}; + +/// Descriptor for `GetInstalledModuleDocsRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List getInstalledModuleDocsRequestDescriptor = $convert.base64Decode( + 'Ch1HZXRJbnN0YWxsZWRNb2R1bGVEb2NzUmVxdWVzdBISCgRuYW1lGAEgASgJUgRuYW1lEhYKBm' + 'xvY2FsZRgCIAEoCVIGbG9jYWxl'); + +@$core.Deprecated('Use getInstalledModuleDocsResponseDescriptor instead') +const GetInstalledModuleDocsResponse$json = { + '1': 'GetInstalledModuleDocsResponse', + '2': [ + {'1': 'text', '3': 1, '4': 1, '5': 9, '10': 'text'}, + {'1': 'source_path', '3': 2, '4': 1, '5': 9, '10': 'sourcePath'}, + {'1': 'not_installed', '3': 3, '4': 1, '5': 8, '10': 'notInstalled'}, + ], +}; + +/// Descriptor for `GetInstalledModuleDocsResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List getInstalledModuleDocsResponseDescriptor = $convert.base64Decode( + 'Ch5HZXRJbnN0YWxsZWRNb2R1bGVEb2NzUmVzcG9uc2USEgoEdGV4dBgBIAEoCVIEdGV4dBIfCg' + 'tzb3VyY2VfcGF0aBgCIAEoCVIKc291cmNlUGF0aBIjCg1ub3RfaW5zdGFsbGVkGAMgASgIUgxu' + 'b3RJbnN0YWxsZWQ='); + @$core.Deprecated('Use listApprovalsRequestDescriptor instead') const ListApprovalsRequest$json = { '1': 'ListApprovalsRequest', diff --git a/lib/src/hub_client.dart b/lib/src/hub_client.dart index 82d84a7..9da5025 100644 --- a/lib/src/hub_client.dart +++ b/lib/src/hub_client.dart @@ -59,6 +59,7 @@ typedef ClearEventLogResponse = pb.ClearEventLogResponse; typedef DaemonPathsResponse = pb.DaemonPathsResponse; typedef UninstallModuleResponse = pb.UninstallModuleResponse; typedef FetchModuleDocsResponse = pb.FetchModuleDocsResponse; +typedef GetInstalledModuleDocsResponse = pb.GetInstalledModuleDocsResponse; typedef ListMcpClientsResponse = pb.ListMcpClientsResponse; typedef McpClientStatus = pb.McpClientStatus; typedef McpClientConfigEntry = pb.McpClientConfigEntry; @@ -240,6 +241,10 @@ class HubClient { /// when set, so Studio doesn't have to handle credentials. /// Errors come back inside [FetchModuleDocsResponse.errorKind] /// (not as exceptions) so the UI can render fallbacks. + /// + /// Deprecated: prefer [getInstalledModuleDocs], which reads + /// `MODULE.md`/`MODULE..md` shipped inside the bundle. + @Deprecated('Use getInstalledModuleDocs (reads from bundle, no network)') Future fetchModuleDocs( String name, { String locale = '', @@ -249,6 +254,25 @@ class HubClient { ); } + /// Read the installed module's `MODULE.md` (or `MODULE..md`) + /// from disk under `~/.fai/modules//`. No network. Works in + /// air-gapped installs. The response distinguishes three states via + /// [GetInstalledModuleDocsResponse.notInstalled] and an empty + /// [GetInstalledModuleDocsResponse.text]: + /// + /// - text non-empty → docs found, render them + /// - text empty + notInstalled=false → installed but bundle had + /// no inline docs + /// - text empty + notInstalled=true → module is not installed + Future getInstalledModuleDocs( + String name, { + String locale = '', + }) { + return _admin.getInstalledModuleDocs( + pb.GetInstalledModuleDocsRequest(name: name, locale: locale), + ); + } + /// List every configured MCP server with the last-discovery /// snapshot. Drives Studio's MCP-clients editor. Future listMcpClients() { @@ -489,27 +513,33 @@ class HubClient { /// flow that mixes text and binary inputs (e.g. extract /// taking a `document: bytes`) flows through one RPC: /// * [textInputs] — string values wrapped as text Payloads - /// * [fileInputs] — raw bytes wrapped as bytes Payloads, - /// with `application/octet-stream` MIME - /// type. Caller can override the MIME by - /// passing a [bytePayloads] entry instead. - /// Both maps default to empty so existing text-only callers + /// * [fileInputs] — raw bytes wrapped as bytes Payloads. + /// MIME type defaults to + /// `application/octet-stream`; clients that + /// know the real type pass it via + /// [fileMimeTypes] (same key). Modules + /// typically gate on MIME — e.g. + /// `text.extract` rejects octet-stream — so + /// accurate types matter. + /// All maps default to empty so existing text-only callers /// are backward-compatible. Keys must not collide between - /// the maps; on collision the bytes-shaped entry wins (the - /// less-common case is more likely to be the operator's - /// intent). + /// [textInputs] and [fileInputs]; on collision the bytes-shaped + /// entry wins (the less-common case is more likely to be the + /// operator's intent). Future runSavedFlow({ required String name, Map textInputs = const {}, Map fileInputs = const {}, + Map fileMimeTypes = const {}, }) async { final req = pb.RunSavedFlowRequest()..name = name; for (final entry in textInputs.entries) { req.inputs[entry.key] = pb_common.Payload()..text = entry.value; } for (final entry in fileInputs.entries) { + final mime = fileMimeTypes[entry.key] ?? 'application/octet-stream'; final bytes = pb_common.Bytes() - ..mimeType = 'application/octet-stream' + ..mimeType = mime ..data = entry.value; req.inputs[entry.key] = pb_common.Payload()..bytes = bytes; } From 6ebec5e3aeede5c1f35b1ed0730d411d56f34c65 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 25 May 2026 12:35:45 +0200 Subject: [PATCH 2/5] chore(proto): regenerate after CapabilityEntry.kind + accepts_mime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracks the platform's hub.proto v0.11 update — adds the new fields to the generated Dart bindings: - `CapabilityEntry.kind` (string) — wasm / builtin / federated. Studio uses it to gate the Install button so built-ins like `system.approval` and federated MCP/n8n tools never get offered an install path that doesn't exist. - `ModuleInfoResponse.accepts_mime` (repeated string) — MIME allow-list declared in `module.yaml`. Studio reads it for file-picker filtering. No hand-edited code in this commit — `tools/generate.sh` output only. Signed-off-by: flemming-it Signed-off-by: flemming-it --- lib/src/generated/fai/v1/hub.pb.dart | 36 ++++++++++++++++++++++++ lib/src/generated/fai/v1/hub.pbjson.dart | 7 +++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/lib/src/generated/fai/v1/hub.pb.dart b/lib/src/generated/fai/v1/hub.pb.dart index 5602d9b..52c8fea 100644 --- a/lib/src/generated/fai/v1/hub.pb.dart +++ b/lib/src/generated/fai/v1/hub.pb.dart @@ -336,6 +336,7 @@ class CapabilityEntry extends $pb.GeneratedMessage { $core.String? version, $core.String? moduleName, $core.String? moduleVersion, + $core.String? kind, }) { final $result = create(); if (capability != null) { @@ -350,6 +351,9 @@ class CapabilityEntry extends $pb.GeneratedMessage { if (moduleVersion != null) { $result.moduleVersion = moduleVersion; } + if (kind != null) { + $result.kind = kind; + } return $result; } CapabilityEntry._() : super(); @@ -361,6 +365,7 @@ class CapabilityEntry extends $pb.GeneratedMessage { ..aOS(2, _omitFieldNames ? '' : 'version') ..aOS(3, _omitFieldNames ? '' : 'moduleName') ..aOS(4, _omitFieldNames ? '' : 'moduleVersion') + ..aOS(5, _omitFieldNames ? '' : 'kind') ..hasRequiredFields = false ; @@ -418,6 +423,24 @@ class CapabilityEntry extends $pb.GeneratedMessage { $core.bool hasModuleVersion() => $_has(3); @$pb.TagNumber(4) void clearModuleVersion() => $_clearField(4); + + /// Where this capability comes from. Studio gates install + /// affordances on this — only "wasm" caps have a bundle to + /// install. Empty string is treated as "wasm" for + /// backwards-compat with older hubs. + /// "wasm" — declared by an installed WASM module + /// "builtin" — implemented in the hub itself + /// (e.g. system.approval) + /// "federated" — discovered from an external MCP/n8n server; + /// not installable via the module-store path + @$pb.TagNumber(5) + $core.String get kind => $_getSZ(4); + @$pb.TagNumber(5) + set kind($core.String v) { $_setString(4, v); } + @$pb.TagNumber(5) + $core.bool hasKind() => $_has(4); + @$pb.TagNumber(5) + void clearKind() => $_clearField(5); } class ModuleInfoRequest extends $pb.GeneratedMessage { @@ -471,6 +494,7 @@ class ModuleInfoResponse extends $pb.GeneratedMessage { $core.Iterable<$4.CapabilityRef>? provides, $core.Iterable<$core.String>? permissions, $core.String? directory, + $core.Iterable<$core.String>? acceptsMime, }) { final $result = create(); if (name != null) { @@ -488,6 +512,9 @@ class ModuleInfoResponse extends $pb.GeneratedMessage { if (directory != null) { $result.directory = directory; } + if (acceptsMime != null) { + $result.acceptsMime.addAll(acceptsMime); + } return $result; } ModuleInfoResponse._() : super(); @@ -500,6 +527,7 @@ class ModuleInfoResponse extends $pb.GeneratedMessage { ..pc<$4.CapabilityRef>(3, _omitFieldNames ? '' : 'provides', $pb.PbFieldType.PM, subBuilder: $4.CapabilityRef.create) ..pPS(4, _omitFieldNames ? '' : 'permissions') ..aOS(5, _omitFieldNames ? '' : 'directory') + ..pPS(6, _omitFieldNames ? '' : 'acceptsMime') ..hasRequiredFields = false ; @@ -552,6 +580,14 @@ class ModuleInfoResponse extends $pb.GeneratedMessage { $core.bool hasDirectory() => $_has(4); @$pb.TagNumber(5) void clearDirectory() => $_clearField(5); + + /// MIME allow-list for bytes-shaped inputs, copied verbatim + /// from `module.yaml`'s `accepts_mime:` field. Empty means + /// "no declared restriction". Studio uses this to pre-filter + /// the OS file-picker so operators don't pick a file the + /// module would reject. + @$pb.TagNumber(6) + $pb.PbList<$core.String> get acceptsMime => $_getList(5); } class CheckPermissionRequest extends $pb.GeneratedMessage { diff --git a/lib/src/generated/fai/v1/hub.pbjson.dart b/lib/src/generated/fai/v1/hub.pbjson.dart index 04132a5..9cb289d 100644 --- a/lib/src/generated/fai/v1/hub.pbjson.dart +++ b/lib/src/generated/fai/v1/hub.pbjson.dart @@ -143,6 +143,7 @@ const CapabilityEntry$json = { {'1': 'version', '3': 2, '4': 1, '5': 9, '10': 'version'}, {'1': 'module_name', '3': 3, '4': 1, '5': 9, '10': 'moduleName'}, {'1': 'module_version', '3': 4, '4': 1, '5': 9, '10': 'moduleVersion'}, + {'1': 'kind', '3': 5, '4': 1, '5': 9, '10': 'kind'}, ], }; @@ -150,7 +151,8 @@ const CapabilityEntry$json = { final $typed_data.Uint8List capabilityEntryDescriptor = $convert.base64Decode( 'Cg9DYXBhYmlsaXR5RW50cnkSHgoKY2FwYWJpbGl0eRgBIAEoCVIKY2FwYWJpbGl0eRIYCgd2ZX' 'JzaW9uGAIgASgJUgd2ZXJzaW9uEh8KC21vZHVsZV9uYW1lGAMgASgJUgptb2R1bGVOYW1lEiUK' - 'Dm1vZHVsZV92ZXJzaW9uGAQgASgJUg1tb2R1bGVWZXJzaW9u'); + 'Dm1vZHVsZV92ZXJzaW9uGAQgASgJUg1tb2R1bGVWZXJzaW9uEhIKBGtpbmQYBSABKAlSBGtpbm' + 'Q='); @$core.Deprecated('Use moduleInfoRequestDescriptor instead') const ModuleInfoRequest$json = { @@ -173,6 +175,7 @@ const ModuleInfoResponse$json = { {'1': 'provides', '3': 3, '4': 3, '5': 11, '6': '.fai.v1.CapabilityRef', '10': 'provides'}, {'1': 'permissions', '3': 4, '4': 3, '5': 9, '10': 'permissions'}, {'1': 'directory', '3': 5, '4': 1, '5': 9, '10': 'directory'}, + {'1': 'accepts_mime', '3': 6, '4': 3, '5': 9, '10': 'acceptsMime'}, ], }; @@ -181,7 +184,7 @@ final $typed_data.Uint8List moduleInfoResponseDescriptor = $convert.base64Decode 'ChJNb2R1bGVJbmZvUmVzcG9uc2USEgoEbmFtZRgBIAEoCVIEbmFtZRIYCgd2ZXJzaW9uGAIgAS' 'gJUgd2ZXJzaW9uEjEKCHByb3ZpZGVzGAMgAygLMhUuZmFpLnYxLkNhcGFiaWxpdHlSZWZSCHBy' 'b3ZpZGVzEiAKC3Blcm1pc3Npb25zGAQgAygJUgtwZXJtaXNzaW9ucxIcCglkaXJlY3RvcnkYBS' - 'ABKAlSCWRpcmVjdG9yeQ=='); + 'ABKAlSCWRpcmVjdG9yeRIhCgxhY2NlcHRzX21pbWUYBiADKAlSC2FjY2VwdHNNaW1l'); @$core.Deprecated('Use checkPermissionRequestDescriptor instead') const CheckPermissionRequest$json = { From 4a582f2b8d99fddf99f3c05ec4e9ac5d18028083 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 25 May 2026 21:49:39 +0200 Subject: [PATCH 3/5] chore(proto): regenerate after InvokePluginTheme RPC Tracks fai/platform's new gRPC RPC + matching request / response messages. Adds HubClient.invokePluginTheme as a typed Dart method. Signed-off-by: flemming-it Signed-off-by: flemming-it --- lib/src/generated/fai/v1/hub.pb.dart | 128 +++++++++++++++++++++++ lib/src/generated/fai/v1/hub.pbgrpc.dart | 26 +++++ lib/src/generated/fai/v1/hub.pbjson.dart | 28 +++++ lib/src/hub_client.dart | 29 +++++ 4 files changed, 211 insertions(+) diff --git a/lib/src/generated/fai/v1/hub.pb.dart b/lib/src/generated/fai/v1/hub.pb.dart index 52c8fea..fd84c73 100644 --- a/lib/src/generated/fai/v1/hub.pb.dart +++ b/lib/src/generated/fai/v1/hub.pb.dart @@ -2236,6 +2236,134 @@ class GetInstalledModuleDocsResponse extends $pb.GeneratedMessage { void clearNotInstalled() => $_clearField(3); } +class InvokePluginThemeRequest extends $pb.GeneratedMessage { + factory InvokePluginThemeRequest({ + $core.String? capability, + $core.String? brightness, + }) { + final $result = create(); + if (capability != null) { + $result.capability = capability; + } + if (brightness != null) { + $result.brightness = brightness; + } + return $result; + } + InvokePluginThemeRequest._() : super(); + factory InvokePluginThemeRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory InvokePluginThemeRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'InvokePluginThemeRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'fai.v1'), createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'capability') + ..aOS(2, _omitFieldNames ? '' : 'brightness') + ..hasRequiredFields = false + ; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + InvokePluginThemeRequest clone() => InvokePluginThemeRequest()..mergeFromMessage(this); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + InvokePluginThemeRequest copyWith(void Function(InvokePluginThemeRequest) updates) => super.copyWith((message) => updates(message as InvokePluginThemeRequest)) as InvokePluginThemeRequest; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static InvokePluginThemeRequest create() => InvokePluginThemeRequest._(); + InvokePluginThemeRequest createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static InvokePluginThemeRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static InvokePluginThemeRequest? _defaultInstance; + + /// Capability name the operator asked for, e.g. + /// `studio.theme.solarized`. The hub maps this to the + /// installed plugin directory. + @$pb.TagNumber(1) + $core.String get capability => $_getSZ(0); + @$pb.TagNumber(1) + set capability($core.String v) { $_setString(0, v); } + @$pb.TagNumber(1) + $core.bool hasCapability() => $_has(0); + @$pb.TagNumber(1) + void clearCapability() => $_clearField(1); + + /// "light" or "dark" — the plugin returns a ColorScheme + /// for this brightness. Plugins that don't recognise the + /// value return PluginError::declined and the hub + /// surfaces that as a friendly error. + @$pb.TagNumber(2) + $core.String get brightness => $_getSZ(1); + @$pb.TagNumber(2) + set brightness($core.String v) { $_setString(1, v); } + @$pb.TagNumber(2) + $core.bool hasBrightness() => $_has(1); + @$pb.TagNumber(2) + void clearBrightness() => $_clearField(2); +} + +class InvokePluginThemeResponse extends $pb.GeneratedMessage { + factory InvokePluginThemeResponse({ + $core.String? brightness, + $core.Iterable<$core.int>? tokens, + }) { + final $result = create(); + if (brightness != null) { + $result.brightness = brightness; + } + if (tokens != null) { + $result.tokens.addAll(tokens); + } + return $result; + } + InvokePluginThemeResponse._() : super(); + factory InvokePluginThemeResponse.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory InvokePluginThemeResponse.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'InvokePluginThemeResponse', package: const $pb.PackageName(_omitMessageNames ? '' : 'fai.v1'), createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'brightness') + ..p<$core.int>(2, _omitFieldNames ? '' : 'tokens', $pb.PbFieldType.KU3) + ..hasRequiredFields = false + ; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + InvokePluginThemeResponse clone() => InvokePluginThemeResponse()..mergeFromMessage(this); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + InvokePluginThemeResponse copyWith(void Function(InvokePluginThemeResponse) updates) => super.copyWith((message) => updates(message as InvokePluginThemeResponse)) as InvokePluginThemeResponse; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static InvokePluginThemeResponse create() => InvokePluginThemeResponse._(); + InvokePluginThemeResponse createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static InvokePluginThemeResponse getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static InvokePluginThemeResponse? _defaultInstance; + + /// Echo of the brightness input. Plugins are expected to + /// round-trip it; if they don't the client treats that as + /// a contract violation. + @$pb.TagNumber(1) + $core.String get brightness => $_getSZ(0); + @$pb.TagNumber(1) + set brightness($core.String v) { $_setString(0, v); } + @$pb.TagNumber(1) + $core.bool hasBrightness() => $_has(0); + @$pb.TagNumber(1) + void clearBrightness() => $_clearField(1); + + /// 14 ARGB tokens in Material 3 order: + /// primary, on_primary, secondary, on_secondary, + /// tertiary, on_tertiary, error, on_error, + /// surface, on_surface, surface_variant, + /// on_surface_variant, outline, outline_variant. + /// Studio merges any missing slot (well-behaved plugins + /// send all 14) with its built-in fallback before + /// applying the scheme. + @$pb.TagNumber(2) + $pb.PbList<$core.int> get tokens => $_getList(1); +} + class ListApprovalsRequest extends $pb.GeneratedMessage { factory ListApprovalsRequest({ $core.Iterable<$core.String>? statuses, diff --git a/lib/src/generated/fai/v1/hub.pbgrpc.dart b/lib/src/generated/fai/v1/hub.pbgrpc.dart index e225ef6..e518459 100644 --- a/lib/src/generated/fai/v1/hub.pbgrpc.dart +++ b/lib/src/generated/fai/v1/hub.pbgrpc.dart @@ -179,6 +179,10 @@ class HubAdminClient extends $grpc.Client { '/fai.v1.HubAdmin/GetInstalledModuleDocs', ($0.GetInstalledModuleDocsRequest value) => value.writeToBuffer(), ($core.List<$core.int> value) => $0.GetInstalledModuleDocsResponse.fromBuffer(value)); + static final _$invokePluginTheme = $grpc.ClientMethod<$0.InvokePluginThemeRequest, $0.InvokePluginThemeResponse>( + '/fai.v1.HubAdmin/InvokePluginTheme', + ($0.InvokePluginThemeRequest value) => value.writeToBuffer(), + ($core.List<$core.int> value) => $0.InvokePluginThemeResponse.fromBuffer(value)); static final _$listApprovals = $grpc.ClientMethod<$0.ListApprovalsRequest, $0.ApprovalList>( '/fai.v1.HubAdmin/ListApprovals', ($0.ListApprovalsRequest value) => value.writeToBuffer(), @@ -370,6 +374,16 @@ class HubAdminClient extends $grpc.Client { return $createUnaryCall(_$getInstalledModuleDocs, request, options: options); } + /// Invoke an installed Studio plugin's `theme` hook. Returns + /// the plugin's proposed Material 3 ColorScheme for the + /// brightness the caller asked for ("light" or "dark"). The + /// first end-to-end RPC of the Studio-plugin subsystem; the + /// `translate` and `output-view` hooks get their own RPCs as + /// the matching Studio surfaces ship. + $grpc.ResponseFuture<$0.InvokePluginThemeResponse> invokePluginTheme($0.InvokePluginThemeRequest request, {$grpc.CallOptions? options}) { + return $createUnaryCall(_$invokePluginTheme, request, options: options); + } + /// List system.approval@^0 entries, optionally filtered by status. $grpc.ResponseFuture<$0.ApprovalList> listApprovals($0.ListApprovalsRequest request, {$grpc.CallOptions? options}) { return $createUnaryCall(_$listApprovals, request, options: options); @@ -660,6 +674,13 @@ abstract class HubAdminServiceBase extends $grpc.Service { false, ($core.List<$core.int> value) => $0.GetInstalledModuleDocsRequest.fromBuffer(value), ($0.GetInstalledModuleDocsResponse value) => value.writeToBuffer())); + $addMethod($grpc.ServiceMethod<$0.InvokePluginThemeRequest, $0.InvokePluginThemeResponse>( + 'InvokePluginTheme', + invokePluginTheme_Pre, + false, + false, + ($core.List<$core.int> value) => $0.InvokePluginThemeRequest.fromBuffer(value), + ($0.InvokePluginThemeResponse value) => value.writeToBuffer())); $addMethod($grpc.ServiceMethod<$0.ListApprovalsRequest, $0.ApprovalList>( 'ListApprovals', listApprovals_Pre, @@ -900,6 +921,10 @@ abstract class HubAdminServiceBase extends $grpc.Service { return getInstalledModuleDocs($call, await $request); } + $async.Future<$0.InvokePluginThemeResponse> invokePluginTheme_Pre($grpc.ServiceCall $call, $async.Future<$0.InvokePluginThemeRequest> $request) async { + return invokePluginTheme($call, await $request); + } + $async.Future<$0.ApprovalList> listApprovals_Pre($grpc.ServiceCall $call, $async.Future<$0.ListApprovalsRequest> $request) async { return listApprovals($call, await $request); } @@ -1018,6 +1043,7 @@ abstract class HubAdminServiceBase extends $grpc.Service { $async.Future<$0.UninstallModuleResponse> uninstallModule($grpc.ServiceCall call, $0.UninstallModuleRequest request); $async.Future<$0.FetchModuleDocsResponse> fetchModuleDocs($grpc.ServiceCall call, $0.FetchModuleDocsRequest request); $async.Future<$0.GetInstalledModuleDocsResponse> getInstalledModuleDocs($grpc.ServiceCall call, $0.GetInstalledModuleDocsRequest request); + $async.Future<$0.InvokePluginThemeResponse> invokePluginTheme($grpc.ServiceCall call, $0.InvokePluginThemeRequest request); $async.Future<$0.ApprovalList> listApprovals($grpc.ServiceCall call, $0.ListApprovalsRequest request); $async.Future<$0.DecideApprovalResponse> decideApproval($grpc.ServiceCall call, $0.DecideApprovalRequest request); $async.Future<$0.VerifyEventChainResponse> verifyEventChain($grpc.ServiceCall call, $1.Empty request); diff --git a/lib/src/generated/fai/v1/hub.pbjson.dart b/lib/src/generated/fai/v1/hub.pbjson.dart index 9cb289d..571bbfd 100644 --- a/lib/src/generated/fai/v1/hub.pbjson.dart +++ b/lib/src/generated/fai/v1/hub.pbjson.dart @@ -565,6 +565,34 @@ final $typed_data.Uint8List getInstalledModuleDocsResponseDescriptor = $convert. 'tzb3VyY2VfcGF0aBgCIAEoCVIKc291cmNlUGF0aBIjCg1ub3RfaW5zdGFsbGVkGAMgASgIUgxu' 'b3RJbnN0YWxsZWQ='); +@$core.Deprecated('Use invokePluginThemeRequestDescriptor instead') +const InvokePluginThemeRequest$json = { + '1': 'InvokePluginThemeRequest', + '2': [ + {'1': 'capability', '3': 1, '4': 1, '5': 9, '10': 'capability'}, + {'1': 'brightness', '3': 2, '4': 1, '5': 9, '10': 'brightness'}, + ], +}; + +/// Descriptor for `InvokePluginThemeRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List invokePluginThemeRequestDescriptor = $convert.base64Decode( + 'ChhJbnZva2VQbHVnaW5UaGVtZVJlcXVlc3QSHgoKY2FwYWJpbGl0eRgBIAEoCVIKY2FwYWJpbG' + 'l0eRIeCgpicmlnaHRuZXNzGAIgASgJUgpicmlnaHRuZXNz'); + +@$core.Deprecated('Use invokePluginThemeResponseDescriptor instead') +const InvokePluginThemeResponse$json = { + '1': 'InvokePluginThemeResponse', + '2': [ + {'1': 'brightness', '3': 1, '4': 1, '5': 9, '10': 'brightness'}, + {'1': 'tokens', '3': 2, '4': 3, '5': 13, '10': 'tokens'}, + ], +}; + +/// Descriptor for `InvokePluginThemeResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List invokePluginThemeResponseDescriptor = $convert.base64Decode( + 'ChlJbnZva2VQbHVnaW5UaGVtZVJlc3BvbnNlEh4KCmJyaWdodG5lc3MYASABKAlSCmJyaWdodG' + '5lc3MSFgoGdG9rZW5zGAIgAygNUgZ0b2tlbnM='); + @$core.Deprecated('Use listApprovalsRequestDescriptor instead') const ListApprovalsRequest$json = { '1': 'ListApprovalsRequest', diff --git a/lib/src/hub_client.dart b/lib/src/hub_client.dart index 9da5025..36ef62e 100644 --- a/lib/src/hub_client.dart +++ b/lib/src/hub_client.dart @@ -60,6 +60,7 @@ typedef DaemonPathsResponse = pb.DaemonPathsResponse; typedef UninstallModuleResponse = pb.UninstallModuleResponse; typedef FetchModuleDocsResponse = pb.FetchModuleDocsResponse; typedef GetInstalledModuleDocsResponse = pb.GetInstalledModuleDocsResponse; +typedef InvokePluginThemeResponse = pb.InvokePluginThemeResponse; typedef ListMcpClientsResponse = pb.ListMcpClientsResponse; typedef McpClientStatus = pb.McpClientStatus; typedef McpClientConfigEntry = pb.McpClientConfigEntry; @@ -273,6 +274,34 @@ class HubClient { ); } + /// Invoke an installed Studio plugin's `theme` hook and + /// receive a Material 3 ColorScheme for the brightness + /// ("light" or "dark") the caller asked for. + /// + /// [capability] is the full Studio-plugin capability name + /// (e.g. "studio.theme.solarized"). [brightness] must be + /// "light" or "dark"; other values surface as + /// `INVALID_ARGUMENT` from the hub. A plugin that doesn't + /// recognise the brightness returns Declined, which the + /// hub maps to `FAILED_PRECONDITION` — Studio's + /// friendly-error mapper renders it with a recovery hint. + /// + /// The first end-to-end RPC of the Studio-plugin + /// subsystem; `translate` and `output-view` hooks get + /// their own methods once the matching Studio surfaces + /// ship. + Future invokePluginTheme({ + required String capability, + required String brightness, + }) { + return _admin.invokePluginTheme( + pb.InvokePluginThemeRequest( + capability: capability, + brightness: brightness, + ), + ); + } + /// List every configured MCP server with the last-discovery /// snapshot. Drives Studio's MCP-clients editor. Future listMcpClients() { From 5604e5eaf3aeee7ecdf8f0e0383eb4464873f747 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 25 May 2026 23:02:21 +0200 Subject: [PATCH 4/5] chore(proto): regenerate after InvokePluginTranslate RPC Adds HubClient.invokePluginTranslate as a typed method. Same shape as invokePluginTheme: capability + args in, typed response out. Signed-off-by: flemming-it Signed-off-by: flemming-it --- lib/src/generated/fai/v1/hub.pb.dart | 137 +++++++++++++++++++++++ lib/src/generated/fai/v1/hub.pbgrpc.dart | 27 +++++ lib/src/generated/fai/v1/hub.pbjson.dart | 30 +++++ lib/src/hub_client.dart | 28 +++++ 4 files changed, 222 insertions(+) diff --git a/lib/src/generated/fai/v1/hub.pb.dart b/lib/src/generated/fai/v1/hub.pb.dart index fd84c73..7104862 100644 --- a/lib/src/generated/fai/v1/hub.pb.dart +++ b/lib/src/generated/fai/v1/hub.pb.dart @@ -2301,6 +2301,143 @@ class InvokePluginThemeRequest extends $pb.GeneratedMessage { void clearBrightness() => $_clearField(2); } +class InvokePluginTranslateRequest extends $pb.GeneratedMessage { + factory InvokePluginTranslateRequest({ + $core.String? capability, + $core.String? text, + $core.String? fromLocale, + $core.String? toLocale, + }) { + final $result = create(); + if (capability != null) { + $result.capability = capability; + } + if (text != null) { + $result.text = text; + } + if (fromLocale != null) { + $result.fromLocale = fromLocale; + } + if (toLocale != null) { + $result.toLocale = toLocale; + } + return $result; + } + InvokePluginTranslateRequest._() : super(); + factory InvokePluginTranslateRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory InvokePluginTranslateRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'InvokePluginTranslateRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'fai.v1'), createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'capability') + ..aOS(2, _omitFieldNames ? '' : 'text') + ..aOS(3, _omitFieldNames ? '' : 'fromLocale') + ..aOS(4, _omitFieldNames ? '' : 'toLocale') + ..hasRequiredFields = false + ; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + InvokePluginTranslateRequest clone() => InvokePluginTranslateRequest()..mergeFromMessage(this); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + InvokePluginTranslateRequest copyWith(void Function(InvokePluginTranslateRequest) updates) => super.copyWith((message) => updates(message as InvokePluginTranslateRequest)) as InvokePluginTranslateRequest; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static InvokePluginTranslateRequest create() => InvokePluginTranslateRequest._(); + InvokePluginTranslateRequest createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static InvokePluginTranslateRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static InvokePluginTranslateRequest? _defaultInstance; + + /// Capability name (e.g. `studio.translate.ollama`). + @$pb.TagNumber(1) + $core.String get capability => $_getSZ(0); + @$pb.TagNumber(1) + set capability($core.String v) { $_setString(0, v); } + @$pb.TagNumber(1) + $core.bool hasCapability() => $_has(0); + @$pb.TagNumber(1) + void clearCapability() => $_clearField(1); + + /// Text to translate. UTF-8, arbitrary length the plugin + /// tolerates. + @$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); + + /// BCP-47 source locale. Empty = let the plugin auto-detect. + @$pb.TagNumber(3) + $core.String get fromLocale => $_getSZ(2); + @$pb.TagNumber(3) + set fromLocale($core.String v) { $_setString(2, v); } + @$pb.TagNumber(3) + $core.bool hasFromLocale() => $_has(2); + @$pb.TagNumber(3) + void clearFromLocale() => $_clearField(3); + + /// BCP-47 target locale. Required; the host always knows it. + @$pb.TagNumber(4) + $core.String get toLocale => $_getSZ(3); + @$pb.TagNumber(4) + set toLocale($core.String v) { $_setString(3, v); } + @$pb.TagNumber(4) + $core.bool hasToLocale() => $_has(3); + @$pb.TagNumber(4) + void clearToLocale() => $_clearField(4); +} + +class InvokePluginTranslateResponse extends $pb.GeneratedMessage { + factory InvokePluginTranslateResponse({ + $core.String? translated, + }) { + final $result = create(); + if (translated != null) { + $result.translated = translated; + } + return $result; + } + InvokePluginTranslateResponse._() : super(); + factory InvokePluginTranslateResponse.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory InvokePluginTranslateResponse.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'InvokePluginTranslateResponse', package: const $pb.PackageName(_omitMessageNames ? '' : 'fai.v1'), createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'translated') + ..hasRequiredFields = false + ; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + InvokePluginTranslateResponse clone() => InvokePluginTranslateResponse()..mergeFromMessage(this); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + InvokePluginTranslateResponse copyWith(void Function(InvokePluginTranslateResponse) updates) => super.copyWith((message) => updates(message as InvokePluginTranslateResponse)) as InvokePluginTranslateResponse; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static InvokePluginTranslateResponse create() => InvokePluginTranslateResponse._(); + InvokePluginTranslateResponse createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static InvokePluginTranslateResponse getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static InvokePluginTranslateResponse? _defaultInstance; + + /// Translated text. Returns verbatim what the plugin + /// emits — no normalisation by the hub. + @$pb.TagNumber(1) + $core.String get translated => $_getSZ(0); + @$pb.TagNumber(1) + set translated($core.String v) { $_setString(0, v); } + @$pb.TagNumber(1) + $core.bool hasTranslated() => $_has(0); + @$pb.TagNumber(1) + void clearTranslated() => $_clearField(1); +} + class InvokePluginThemeResponse extends $pb.GeneratedMessage { factory InvokePluginThemeResponse({ $core.String? brightness, diff --git a/lib/src/generated/fai/v1/hub.pbgrpc.dart b/lib/src/generated/fai/v1/hub.pbgrpc.dart index e518459..659fe01 100644 --- a/lib/src/generated/fai/v1/hub.pbgrpc.dart +++ b/lib/src/generated/fai/v1/hub.pbgrpc.dart @@ -183,6 +183,10 @@ class HubAdminClient extends $grpc.Client { '/fai.v1.HubAdmin/InvokePluginTheme', ($0.InvokePluginThemeRequest value) => value.writeToBuffer(), ($core.List<$core.int> value) => $0.InvokePluginThemeResponse.fromBuffer(value)); + static final _$invokePluginTranslate = $grpc.ClientMethod<$0.InvokePluginTranslateRequest, $0.InvokePluginTranslateResponse>( + '/fai.v1.HubAdmin/InvokePluginTranslate', + ($0.InvokePluginTranslateRequest value) => value.writeToBuffer(), + ($core.List<$core.int> value) => $0.InvokePluginTranslateResponse.fromBuffer(value)); static final _$listApprovals = $grpc.ClientMethod<$0.ListApprovalsRequest, $0.ApprovalList>( '/fai.v1.HubAdmin/ListApprovals', ($0.ListApprovalsRequest value) => value.writeToBuffer(), @@ -384,6 +388,17 @@ class HubAdminClient extends $grpc.Client { return $createUnaryCall(_$invokePluginTheme, request, options: options); } + /// Invoke an installed Studio plugin's `translate` hook. + /// The first user surface that consumes this is Studio's + /// FaiEnBadge — when an operator has a translate plugin + /// installed, server-supplied English text (MCP tool + /// descriptions, n8n endpoint names) gets translated + /// on-the-fly into the active locale instead of carrying + /// the honest-but-rough [EN] badge. + $grpc.ResponseFuture<$0.InvokePluginTranslateResponse> invokePluginTranslate($0.InvokePluginTranslateRequest request, {$grpc.CallOptions? options}) { + return $createUnaryCall(_$invokePluginTranslate, request, options: options); + } + /// List system.approval@^0 entries, optionally filtered by status. $grpc.ResponseFuture<$0.ApprovalList> listApprovals($0.ListApprovalsRequest request, {$grpc.CallOptions? options}) { return $createUnaryCall(_$listApprovals, request, options: options); @@ -681,6 +696,13 @@ abstract class HubAdminServiceBase extends $grpc.Service { false, ($core.List<$core.int> value) => $0.InvokePluginThemeRequest.fromBuffer(value), ($0.InvokePluginThemeResponse value) => value.writeToBuffer())); + $addMethod($grpc.ServiceMethod<$0.InvokePluginTranslateRequest, $0.InvokePluginTranslateResponse>( + 'InvokePluginTranslate', + invokePluginTranslate_Pre, + false, + false, + ($core.List<$core.int> value) => $0.InvokePluginTranslateRequest.fromBuffer(value), + ($0.InvokePluginTranslateResponse value) => value.writeToBuffer())); $addMethod($grpc.ServiceMethod<$0.ListApprovalsRequest, $0.ApprovalList>( 'ListApprovals', listApprovals_Pre, @@ -925,6 +947,10 @@ abstract class HubAdminServiceBase extends $grpc.Service { return invokePluginTheme($call, await $request); } + $async.Future<$0.InvokePluginTranslateResponse> invokePluginTranslate_Pre($grpc.ServiceCall $call, $async.Future<$0.InvokePluginTranslateRequest> $request) async { + return invokePluginTranslate($call, await $request); + } + $async.Future<$0.ApprovalList> listApprovals_Pre($grpc.ServiceCall $call, $async.Future<$0.ListApprovalsRequest> $request) async { return listApprovals($call, await $request); } @@ -1044,6 +1070,7 @@ abstract class HubAdminServiceBase extends $grpc.Service { $async.Future<$0.FetchModuleDocsResponse> fetchModuleDocs($grpc.ServiceCall call, $0.FetchModuleDocsRequest request); $async.Future<$0.GetInstalledModuleDocsResponse> getInstalledModuleDocs($grpc.ServiceCall call, $0.GetInstalledModuleDocsRequest request); $async.Future<$0.InvokePluginThemeResponse> invokePluginTheme($grpc.ServiceCall call, $0.InvokePluginThemeRequest request); + $async.Future<$0.InvokePluginTranslateResponse> invokePluginTranslate($grpc.ServiceCall call, $0.InvokePluginTranslateRequest request); $async.Future<$0.ApprovalList> listApprovals($grpc.ServiceCall call, $0.ListApprovalsRequest request); $async.Future<$0.DecideApprovalResponse> decideApproval($grpc.ServiceCall call, $0.DecideApprovalRequest request); $async.Future<$0.VerifyEventChainResponse> verifyEventChain($grpc.ServiceCall call, $1.Empty request); diff --git a/lib/src/generated/fai/v1/hub.pbjson.dart b/lib/src/generated/fai/v1/hub.pbjson.dart index 571bbfd..476fb4d 100644 --- a/lib/src/generated/fai/v1/hub.pbjson.dart +++ b/lib/src/generated/fai/v1/hub.pbjson.dart @@ -579,6 +579,36 @@ final $typed_data.Uint8List invokePluginThemeRequestDescriptor = $convert.base64 'ChhJbnZva2VQbHVnaW5UaGVtZVJlcXVlc3QSHgoKY2FwYWJpbGl0eRgBIAEoCVIKY2FwYWJpbG' 'l0eRIeCgpicmlnaHRuZXNzGAIgASgJUgpicmlnaHRuZXNz'); +@$core.Deprecated('Use invokePluginTranslateRequestDescriptor instead') +const InvokePluginTranslateRequest$json = { + '1': 'InvokePluginTranslateRequest', + '2': [ + {'1': 'capability', '3': 1, '4': 1, '5': 9, '10': 'capability'}, + {'1': 'text', '3': 2, '4': 1, '5': 9, '10': 'text'}, + {'1': 'from_locale', '3': 3, '4': 1, '5': 9, '10': 'fromLocale'}, + {'1': 'to_locale', '3': 4, '4': 1, '5': 9, '10': 'toLocale'}, + ], +}; + +/// Descriptor for `InvokePluginTranslateRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List invokePluginTranslateRequestDescriptor = $convert.base64Decode( + 'ChxJbnZva2VQbHVnaW5UcmFuc2xhdGVSZXF1ZXN0Eh4KCmNhcGFiaWxpdHkYASABKAlSCmNhcG' + 'FiaWxpdHkSEgoEdGV4dBgCIAEoCVIEdGV4dBIfCgtmcm9tX2xvY2FsZRgDIAEoCVIKZnJvbUxv' + 'Y2FsZRIbCgl0b19sb2NhbGUYBCABKAlSCHRvTG9jYWxl'); + +@$core.Deprecated('Use invokePluginTranslateResponseDescriptor instead') +const InvokePluginTranslateResponse$json = { + '1': 'InvokePluginTranslateResponse', + '2': [ + {'1': 'translated', '3': 1, '4': 1, '5': 9, '10': 'translated'}, + ], +}; + +/// Descriptor for `InvokePluginTranslateResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List invokePluginTranslateResponseDescriptor = $convert.base64Decode( + 'Ch1JbnZva2VQbHVnaW5UcmFuc2xhdGVSZXNwb25zZRIeCgp0cmFuc2xhdGVkGAEgASgJUgp0cm' + 'Fuc2xhdGVk'); + @$core.Deprecated('Use invokePluginThemeResponseDescriptor instead') const InvokePluginThemeResponse$json = { '1': 'InvokePluginThemeResponse', diff --git a/lib/src/hub_client.dart b/lib/src/hub_client.dart index 36ef62e..5765b8e 100644 --- a/lib/src/hub_client.dart +++ b/lib/src/hub_client.dart @@ -61,6 +61,7 @@ typedef UninstallModuleResponse = pb.UninstallModuleResponse; typedef FetchModuleDocsResponse = pb.FetchModuleDocsResponse; typedef GetInstalledModuleDocsResponse = pb.GetInstalledModuleDocsResponse; typedef InvokePluginThemeResponse = pb.InvokePluginThemeResponse; +typedef InvokePluginTranslateResponse = pb.InvokePluginTranslateResponse; typedef ListMcpClientsResponse = pb.ListMcpClientsResponse; typedef McpClientStatus = pb.McpClientStatus; typedef McpClientConfigEntry = pb.McpClientConfigEntry; @@ -302,6 +303,33 @@ class HubClient { ); } + /// Invoke an installed Studio plugin's `translate` hook and + /// receive translated text. [fromLocale] may be empty + /// (auto-detect); [toLocale] must be a BCP-47 code (the + /// host always knows the target). + /// + /// Errors surface as gRPC exceptions: + /// * NOT_FOUND — plugin not installed. + /// * FAILED_PRECONDITION — plugin returned Declined or + /// Misconfigured (e.g. unreachable + /// LLM endpoint). + /// * INTERNAL — generic catch-all. + Future invokePluginTranslate({ + required String capability, + required String text, + required String toLocale, + String fromLocale = '', + }) { + return _admin.invokePluginTranslate( + pb.InvokePluginTranslateRequest( + capability: capability, + text: text, + fromLocale: fromLocale, + toLocale: toLocale, + ), + ); + } + /// List every configured MCP server with the last-discovery /// snapshot. Drives Studio's MCP-clients editor. Future listMcpClients() { From f481eee687dee464b63b809b938ebbd4d60d48b1 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Tue, 26 May 2026 13:34:26 +0200 Subject: [PATCH 5/5] feat(client): GrpcWebClientChannel on web targets via conditional import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits channel construction into two files selected at compile-time via `if (dart.library.html)`: channel_factory_io.dart — native ClientChannel (HTTP/2) channel_factory_web.dart — GrpcWebClientChannel.xhr (HTTP/1.1) HubClient._channel is now `ClientChannelBase` so both implementations satisfy the same field type. dart:html-only imports (`package:grpc/grpc_web.dart`) stay isolated from the native compile path. Pairs with the hub-side `fai_grpc_web` adapter crate (fai/platform 0d07892) — `tonic-web` on the same port as native gRPC, so one `fai serve` listener handles both surfaces. Signed-off-by: flemming-it --- lib/src/channel_factory_io.dart | 21 +++++++++++++++++++++ lib/src/channel_factory_web.dart | 17 +++++++++++++++++ lib/src/hub_client.dart | 24 +++++++++++------------- 3 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 lib/src/channel_factory_io.dart create mode 100644 lib/src/channel_factory_web.dart diff --git a/lib/src/channel_factory_io.dart b/lib/src/channel_factory_io.dart new file mode 100644 index 0000000..4b593da --- /dev/null +++ b/lib/src/channel_factory_io.dart @@ -0,0 +1,21 @@ +// Native channel factory — plain gRPC over HTTP/2. +// Selected on every non-web target (macOS, Linux, Windows, iOS, +// Android) via the conditional import in hub_client.dart. + +import 'package:grpc/grpc.dart'; +import 'package:grpc/grpc_connection_interface.dart'; + +import 'hub_client.dart' show HubEndpoint; + +ClientChannelBase createChannel(HubEndpoint endpoint) { + return ClientChannel( + endpoint.host, + port: endpoint.port, + options: ChannelOptions( + credentials: endpoint.secure + ? const ChannelCredentials.secure() + : const ChannelCredentials.insecure(), + idleTimeout: const Duration(minutes: 5), + ), + ); +} diff --git a/lib/src/channel_factory_web.dart b/lib/src/channel_factory_web.dart new file mode 100644 index 0000000..565f4be --- /dev/null +++ b/lib/src/channel_factory_web.dart @@ -0,0 +1,17 @@ +// Web channel factory — gRPC-Web over HTTP/1.1. +// Selected on dart:html targets (Flutter web, Dart-for-web) via +// the conditional import in hub_client.dart. The hub must be +// running with `tonic-web` enabled — see fai_platform docs +// architecture/protocol-surfaces.md. + +import 'package:grpc/grpc_connection_interface.dart'; +import 'package:grpc/grpc_web.dart'; + +import 'hub_client.dart' show HubEndpoint; + +ClientChannelBase createChannel(HubEndpoint endpoint) { + final scheme = endpoint.secure ? 'https' : 'http'; + return GrpcWebClientChannel.xhr( + Uri.parse('$scheme://${endpoint.host}:${endpoint.port}'), + ); +} diff --git a/lib/src/hub_client.dart b/lib/src/hub_client.dart index 5765b8e..7b4fb02 100644 --- a/lib/src/hub_client.dart +++ b/lib/src/hub_client.dart @@ -6,8 +6,10 @@ import 'dart:typed_data'; -import 'package:grpc/grpc.dart'; +import 'package:grpc/grpc_connection_interface.dart'; +import 'channel_factory_io.dart' + if (dart.library.html) 'channel_factory_web.dart' as channel_factory; import 'generated/fai/v1/common.pb.dart' as pb_common; import 'generated/fai/v1/hub.pb.dart' as pb; import 'generated/fai/v1/hub.pbgrpc.dart' as grpc_hub; @@ -78,9 +80,15 @@ typedef SubmitResponse = pb.SubmitResponse; /// Typed client for the F∆I Hub gRPC surface. Construct once, /// reuse for the process lifetime, call [close] on shutdown. +/// +/// The channel type is picked at compile time: +/// * native targets → plain HTTP/2 gRPC via [ClientChannel]. +/// * web targets → HTTP/1.1 gRPC-Web via [GrpcWebClientChannel]. +/// Requires the hub to be running with the +/// `tonic-web` layer enabled. class HubClient { final HubEndpoint endpoint; - final ClientChannel _channel; + final ClientChannelBase _channel; final grpc_hub.HubClient _hub; final grpc_hub.HubAdminClient _admin; @@ -89,17 +97,7 @@ class HubClient { _admin = grpc_hub.HubAdminClient(_channel); factory HubClient({HubEndpoint endpoint = const HubEndpoint()}) { - final channel = ClientChannel( - endpoint.host, - port: endpoint.port, - options: ChannelOptions( - credentials: endpoint.secure - ? const ChannelCredentials.secure() - : const ChannelCredentials.insecure(), - idleTimeout: const Duration(minutes: 5), - ), - ); - return HubClient._(endpoint, channel); + return HubClient._(endpoint, channel_factory.createChannel(endpoint)); } /// Liveness probe. Returns true when the hub responds with