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 <sf@flemming.it>
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-25 11:17:31 +02:00
parent b8bcae4b14
commit 0879a95aba
4 changed files with 240 additions and 9 deletions

View file

@ -2055,6 +2055,151 @@ class FetchModuleDocsResponse extends $pb.GeneratedMessage {
void clearSourceUrl() => $_clearField(3); 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<GetInstalledModuleDocsRequest> createRepeated() => $pb.PbList<GetInstalledModuleDocsRequest>();
@$core.pragma('dart2js:noInline')
static GetInstalledModuleDocsRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<GetInstalledModuleDocsRequest>(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.<locale>.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<GetInstalledModuleDocsResponse> createRepeated() => $pb.PbList<GetInstalledModuleDocsResponse>();
@$core.pragma('dart2js:noInline')
static GetInstalledModuleDocsResponse getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<GetInstalledModuleDocsResponse>(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 { class ListApprovalsRequest extends $pb.GeneratedMessage {
factory ListApprovalsRequest({ factory ListApprovalsRequest({
$core.Iterable<$core.String>? statuses, $core.Iterable<$core.String>? statuses,

View file

@ -175,6 +175,10 @@ class HubAdminClient extends $grpc.Client {
'/fai.v1.HubAdmin/FetchModuleDocs', '/fai.v1.HubAdmin/FetchModuleDocs',
($0.FetchModuleDocsRequest value) => value.writeToBuffer(), ($0.FetchModuleDocsRequest value) => value.writeToBuffer(),
($core.List<$core.int> value) => $0.FetchModuleDocsResponse.fromBuffer(value)); ($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>( static final _$listApprovals = $grpc.ClientMethod<$0.ListApprovalsRequest, $0.ApprovalList>(
'/fai.v1.HubAdmin/ListApprovals', '/fai.v1.HubAdmin/ListApprovals',
($0.ListApprovalsRequest value) => value.writeToBuffer(), ($0.ListApprovalsRequest value) => value.writeToBuffer(),
@ -356,6 +360,16 @@ class HubAdminClient extends $grpc.Client {
return $createUnaryCall(_$fetchModuleDocs, request, options: options); return $createUnaryCall(_$fetchModuleDocs, request, options: options);
} }
/// Read the inline-docs (MODULE.md / MODULE.<locale>.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. /// List system.approval@^0 entries, optionally filtered by status.
$grpc.ResponseFuture<$0.ApprovalList> listApprovals($0.ListApprovalsRequest request, {$grpc.CallOptions? options}) { $grpc.ResponseFuture<$0.ApprovalList> listApprovals($0.ListApprovalsRequest request, {$grpc.CallOptions? options}) {
return $createUnaryCall(_$listApprovals, request, options: options); return $createUnaryCall(_$listApprovals, request, options: options);
@ -639,6 +653,13 @@ abstract class HubAdminServiceBase extends $grpc.Service {
false, false,
($core.List<$core.int> value) => $0.FetchModuleDocsRequest.fromBuffer(value), ($core.List<$core.int> value) => $0.FetchModuleDocsRequest.fromBuffer(value),
($0.FetchModuleDocsResponse value) => value.writeToBuffer())); ($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>( $addMethod($grpc.ServiceMethod<$0.ListApprovalsRequest, $0.ApprovalList>(
'ListApprovals', 'ListApprovals',
listApprovals_Pre, listApprovals_Pre,
@ -875,6 +896,10 @@ abstract class HubAdminServiceBase extends $grpc.Service {
return fetchModuleDocs($call, await $request); 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 { $async.Future<$0.ApprovalList> listApprovals_Pre($grpc.ServiceCall $call, $async.Future<$0.ListApprovalsRequest> $request) async {
return listApprovals($call, await $request); 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.InstallModuleResponse> installModule($grpc.ServiceCall call, $0.InstallModuleRequest request);
$async.Future<$0.UninstallModuleResponse> uninstallModule($grpc.ServiceCall call, $0.UninstallModuleRequest 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.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.ApprovalList> listApprovals($grpc.ServiceCall call, $0.ListApprovalsRequest request);
$async.Future<$0.DecideApprovalResponse> decideApproval($grpc.ServiceCall call, $0.DecideApprovalRequest request); $async.Future<$0.DecideApprovalResponse> decideApproval($grpc.ServiceCall call, $0.DecideApprovalRequest request);
$async.Future<$0.VerifyEventChainResponse> verifyEventChain($grpc.ServiceCall call, $1.Empty request); $async.Future<$0.VerifyEventChainResponse> verifyEventChain($grpc.ServiceCall call, $1.Empty request);

View file

@ -532,6 +532,36 @@ final $typed_data.Uint8List fetchModuleDocsResponseDescriptor = $convert.base64D
'ChdGZXRjaE1vZHVsZURvY3NSZXNwb25zZRIdCgplcnJvcl9raW5kGAEgASgJUgllcnJvcktpbm' 'ChdGZXRjaE1vZHVsZURvY3NSZXNwb25zZRIdCgplcnJvcl9raW5kGAEgASgJUgllcnJvcktpbm'
'QSEgoEdGV4dBgCIAEoCVIEdGV4dBIdCgpzb3VyY2VfdXJsGAMgASgJUglzb3VyY2VVcmw='); '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') @$core.Deprecated('Use listApprovalsRequestDescriptor instead')
const ListApprovalsRequest$json = { const ListApprovalsRequest$json = {
'1': 'ListApprovalsRequest', '1': 'ListApprovalsRequest',

View file

@ -59,6 +59,7 @@ typedef ClearEventLogResponse = pb.ClearEventLogResponse;
typedef DaemonPathsResponse = pb.DaemonPathsResponse; typedef DaemonPathsResponse = pb.DaemonPathsResponse;
typedef UninstallModuleResponse = pb.UninstallModuleResponse; typedef UninstallModuleResponse = pb.UninstallModuleResponse;
typedef FetchModuleDocsResponse = pb.FetchModuleDocsResponse; typedef FetchModuleDocsResponse = pb.FetchModuleDocsResponse;
typedef GetInstalledModuleDocsResponse = pb.GetInstalledModuleDocsResponse;
typedef ListMcpClientsResponse = pb.ListMcpClientsResponse; typedef ListMcpClientsResponse = pb.ListMcpClientsResponse;
typedef McpClientStatus = pb.McpClientStatus; typedef McpClientStatus = pb.McpClientStatus;
typedef McpClientConfigEntry = pb.McpClientConfigEntry; typedef McpClientConfigEntry = pb.McpClientConfigEntry;
@ -240,6 +241,10 @@ class HubClient {
/// when set, so Studio doesn't have to handle credentials. /// when set, so Studio doesn't have to handle credentials.
/// Errors come back inside [FetchModuleDocsResponse.errorKind] /// Errors come back inside [FetchModuleDocsResponse.errorKind]
/// (not as exceptions) so the UI can render fallbacks. /// (not as exceptions) so the UI can render fallbacks.
///
/// Deprecated: prefer [getInstalledModuleDocs], which reads
/// `MODULE.md`/`MODULE.<locale>.md` shipped inside the bundle.
@Deprecated('Use getInstalledModuleDocs (reads from bundle, no network)')
Future<FetchModuleDocsResponse> fetchModuleDocs( Future<FetchModuleDocsResponse> fetchModuleDocs(
String name, { String name, {
String locale = '', String locale = '',
@ -249,6 +254,25 @@ class HubClient {
); );
} }
/// Read the installed module's `MODULE.md` (or `MODULE.<locale>.md`)
/// from disk under `~/.fai/modules/<module>/`. 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<GetInstalledModuleDocsResponse> getInstalledModuleDocs(
String name, {
String locale = '',
}) {
return _admin.getInstalledModuleDocs(
pb.GetInstalledModuleDocsRequest(name: name, locale: locale),
);
}
/// List every configured MCP server with the last-discovery /// List every configured MCP server with the last-discovery
/// snapshot. Drives Studio's MCP-clients editor. /// snapshot. Drives Studio's MCP-clients editor.
Future<ListMcpClientsResponse> listMcpClients() { Future<ListMcpClientsResponse> listMcpClients() {
@ -489,27 +513,33 @@ class HubClient {
/// flow that mixes text and binary inputs (e.g. extract /// flow that mixes text and binary inputs (e.g. extract
/// taking a `document: bytes`) flows through one RPC: /// taking a `document: bytes`) flows through one RPC:
/// * [textInputs] string values wrapped as text Payloads /// * [textInputs] string values wrapped as text Payloads
/// * [fileInputs] raw bytes wrapped as bytes Payloads, /// * [fileInputs] raw bytes wrapped as bytes Payloads.
/// with `application/octet-stream` MIME /// MIME type defaults to
/// type. Caller can override the MIME by /// `application/octet-stream`; clients that
/// passing a [bytePayloads] entry instead. /// know the real type pass it via
/// Both maps default to empty so existing text-only callers /// [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 /// are backward-compatible. Keys must not collide between
/// the maps; on collision the bytes-shaped entry wins (the /// [textInputs] and [fileInputs]; on collision the bytes-shaped
/// less-common case is more likely to be the operator's /// entry wins (the less-common case is more likely to be the
/// intent). /// operator's intent).
Future<SubmitResponse> runSavedFlow({ Future<SubmitResponse> runSavedFlow({
required String name, required String name,
Map<String, String> textInputs = const {}, Map<String, String> textInputs = const {},
Map<String, Uint8List> fileInputs = const {}, Map<String, Uint8List> fileInputs = const {},
Map<String, String> fileMimeTypes = const {},
}) async { }) async {
final req = pb.RunSavedFlowRequest()..name = name; final req = pb.RunSavedFlowRequest()..name = name;
for (final entry in textInputs.entries) { for (final entry in textInputs.entries) {
req.inputs[entry.key] = pb_common.Payload()..text = entry.value; req.inputs[entry.key] = pb_common.Payload()..text = entry.value;
} }
for (final entry in fileInputs.entries) { for (final entry in fileInputs.entries) {
final mime = fileMimeTypes[entry.key] ?? 'application/octet-stream';
final bytes = pb_common.Bytes() final bytes = pb_common.Bytes()
..mimeType = 'application/octet-stream' ..mimeType = mime
..data = entry.value; ..data = entry.value;
req.inputs[entry.key] = pb_common.Payload()..bytes = bytes; req.inputs[entry.key] = pb_common.Payload()..bytes = bytes;
} }