feat(studio): per-field ports in flow editor + store-doc scroll fix
Some checks failed
Security / Security check (push) Failing after 1s

Picks up:
- fai_studio_flow_editor 0.9.0: per-field input + output
  ports rendered from ModuleInfo, i18n tooltips, four
  additional canvas patterns ported from jai_client.
- fai_client_sdk 0.18.0: regenerated ModuleField bindings
  for the schema_version 3 / per-field-description proto
  bump in fai/platform.

Wires StudioFlowRunDriver.moduleInfo so the editor can
fetch declared field info per step capability. The hub
maps schema_version 3 manifest fields (inputs/outputs
with description.en/de) through ModuleInfoResponse; this
patch wraps them back into the editor's ModuleSpec /
ModuleField vocabulary so the canvas paints them as
distinct anchors with hover tooltips.

ModuleDetail in hub.dart now carries the same inputs +
outputs lists (with ModuleFieldInfo type + i18n
description map), enabling other Studio surfaces — e.g.
the module-detail sheet — to render bilingual field docs
in a later pass.

Plus: store-doc nested-scroll fix. The module detail
panel's docs section sat inside its own 480px viewport
inside the outer sheet's SingleChildScrollView, producing
the double-scrollbar feel. Setting
physics: NeverScrollableScrollPhysics on the Markdown
widget and dropping the maxHeight lets the docs scroll
as part of the outer sheet — one scrollbar, predictable
mouse-wheel behaviour.

Bumps fai_studio to 0.56.0.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-06-01 22:34:08 +02:00
parent 34d086c29b
commit a698be0b57
5 changed files with 466 additions and 336 deletions

View file

@ -40,6 +40,39 @@ class StudioFlowRunDriver implements editor.FlowRunDriver {
return mapped;
}
@override
Future<editor.ModuleSpec?> moduleInfo(String capability) async {
try {
final detail = await HubService.instance.moduleInfo(capability);
return editor.ModuleSpec(
capability: capability,
inputs: detail.inputs
.map(
(f) => editor.ModuleField(
name: f.name,
type: f.type,
description: f.description,
),
)
.toList(),
outputs: detail.outputs
.map(
(f) => editor.ModuleField(
name: f.name,
type: f.type,
description: f.description,
),
)
.toList(),
);
} catch (_) {
// Module not installed locally, or transient hub error.
// Returning null lets the editor fall back to YAML-
// derived ports instead of failing the whole canvas.
return null;
}
}
@override
Stream<editor.FlowRunEvent> events() {
return HubService.instance
@ -82,10 +115,7 @@ class StudioFlowRunDriver implements editor.FlowRunDriver {
error: e.error ?? '',
);
case 'step.awaiting_approval':
return editor.StepAwaitingApproval(
flowName: flowName,
stepId: stepId,
);
return editor.StepAwaitingApproval(flowName: flowName, stepId: stepId);
}
return null;
}

View file

@ -133,12 +133,9 @@ class HubService {
return ModuleSummary(
name: e.key,
version: caps.first.moduleVersion,
capabilities: caps
.map((c) => '${c.capability}@${c.version}')
.toList(),
capabilities: caps.map((c) => '${c.capability}@${c.version}').toList(),
);
}).toList()
..sort((a, b) => a.name.compareTo(b.name));
}).toList()..sort((a, b) => a.name.compareTo(b.name));
}
/// Every capability the hub can execute, with the `kind` tag
@ -151,15 +148,17 @@ class HubService {
Future<List<CapabilityInfo>> allCapabilities() async {
final caps = await _client.listCapabilities();
return caps
.map((c) => CapabilityInfo(
capability: c.capability,
version: c.version,
moduleName: c.moduleName,
kind: c.kind.isEmpty ? 'wasm' : c.kind,
provider: c.provider,
sourceKind: c.sourceKind,
versionAdvisory: c.versionAdvisory,
))
.map(
(c) => CapabilityInfo(
capability: c.capability,
version: c.version,
moduleName: c.moduleName,
kind: c.kind.isEmpty ? 'wasm' : c.kind,
provider: c.provider,
sourceKind: c.sourceKind,
versionAdvisory: c.versionAdvisory,
),
)
.toList();
}
@ -175,6 +174,24 @@ class HubService {
permissions: r.permissions,
directory: r.directory,
acceptsMime: r.acceptsMime,
inputs: r.inputs
.map(
(f) => ModuleFieldInfo(
name: f.name,
type: f.type,
description: Map<String, String>.from(f.description),
),
)
.toList(),
outputs: r.outputs
.map(
(f) => ModuleFieldInfo(
name: f.name,
type: f.type,
description: Map<String, String>.from(f.description),
),
)
.toList(),
);
}
@ -280,7 +297,7 @@ class HubService {
/// Studio's editor uses this to populate a dropdown instead
/// of forcing the operator to type the id manually.
Future<({String errorKind, String text, List<String> ids})>
listSystemAiModels({
listSystemAiModels({
String provider = '',
String endpoint = '',
String apiKeyEnv = '',
@ -300,8 +317,7 @@ class HubService {
/// Pull (download) an Ollama model via /api/pull.
/// Synchronous; can take minutes for large models. Errors
/// come back as `errorKind`.
Future<({String errorKind, String text, int elapsedMs})>
pullSystemAiModel({
Future<({String errorKind, String text, int elapsedMs})> pullSystemAiModel({
required String endpoint,
required String model,
String apiKeyEnv = '',
@ -311,11 +327,7 @@ class HubService {
model: model,
apiKeyEnv: apiKeyEnv,
);
return (
errorKind: r.errorKind,
text: r.text,
elapsedMs: r.elapsedMs,
);
return (errorKind: r.errorKind, text: r.text, elapsedMs: r.elapsedMs);
}
/// Detected host hardware. Used by the System-AI editor to
@ -368,10 +380,7 @@ class HubService {
required String reviewer,
required String reason,
}) async {
final r = await _client.clearEventLog(
reviewer: reviewer,
reason: reason,
);
final r = await _client.clearEventLog(reviewer: reviewer, reason: reason);
return (purged: r.purged.toInt(), channel: r.channel);
}
@ -407,15 +416,16 @@ class HubService {
/// Read the operator's `default_scope:` plus the catalog-known
/// publisher shortlist. Used by Settings DEFAULT SCOPE panel.
Future<({List<String> scope, List<String> knownProviders})>
getDefaultScope() async {
getDefaultScope() async {
return _client.getDefaultScope();
}
/// Replace the operator's `default_scope:` and hot-swap on the
/// running hub. Caller passes the new ordered list; hub rejects
/// an empty list with InvalidArgument.
Future<({List<String> scope, List<String> knownProviders})>
setDefaultScope(List<String> scope) async {
Future<({List<String> scope, List<String> knownProviders})> setDefaultScope(
List<String> scope,
) async {
return _client.setDefaultScope(scope);
}
@ -425,13 +435,14 @@ class HubService {
/// is installed side-by-side.
Future<List<String>> installedVersions(String moduleName) async {
final caps = await _client.listCapabilities();
final versions = caps
.where((c) => c.moduleName == moduleName)
.where((c) => c.kind.isEmpty || c.kind == 'wasm')
.map((c) => c.moduleVersion)
.toSet()
.toList()
..sort();
final versions =
caps
.where((c) => c.moduleName == moduleName)
.where((c) => c.kind.isEmpty || c.kind == 'wasm')
.map((c) => c.moduleVersion)
.toSet()
.toList()
..sort();
return versions;
}
@ -444,7 +455,7 @@ class HubService {
/// - `'no_docs'` module installed but bundle had no docs
/// - `'not_installed'` module isn't installed at all
Future<({String state, String text, String sourcePath})>
readInstalledModuleDocs(String name, {String locale = ''}) async {
readInstalledModuleDocs(String name, {String locale = ''}) async {
final r = await _client.getInstalledModuleDocs(name, locale: locale);
final String state;
if (r.notInstalled) {
@ -742,7 +753,9 @@ class HubService {
int backfill = 0,
List<String> types = const [],
}) {
return _client.streamEvents(backfill: backfill, types: types).map(
return _client
.streamEvents(backfill: backfill, types: types)
.map(
(e) => AuditEvent(
eventId: e.eventId,
timestamp: DateTime.tryParse(e.timestamp) ?? DateTime.now(),
@ -774,12 +787,9 @@ class HubService {
flowName: e.flowName.isEmpty ? null : e.flowName,
stepId: e.stepId.isEmpty ? null : e.stepId,
moduleName: e.moduleName.isEmpty ? null : e.moduleName,
moduleVersion:
e.moduleVersion.isEmpty ? null : e.moduleVersion,
invocationId:
e.invocationId.isEmpty ? null : e.invocationId,
flowExecution:
e.flowExecution.isEmpty ? null : e.flowExecution,
moduleVersion: e.moduleVersion.isEmpty ? null : e.moduleVersion,
invocationId: e.invocationId.isEmpty ? null : e.invocationId,
flowExecution: e.flowExecution.isEmpty ? null : e.flowExecution,
durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(),
error: e.error.isEmpty ? null : e.error,
detail: e.detail.isEmpty ? null : e.detail,
@ -826,8 +836,7 @@ class HubService {
flowName: e.flowName,
stepId: e.stepId,
prompt: e.prompt,
payloadPreview:
e.payloadPreview.isEmpty ? null : e.payloadPreview,
payloadPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview,
createdAt: DateTime.tryParse(e.createdAt) ?? DateTime.now(),
expiresAt: e.expiresAt.isEmpty
? null
@ -847,26 +856,19 @@ class HubService {
_client.approve(approvalId: id, reviewer: reviewer);
Future<void> reject(String id, String reviewer, String reason) =>
_client.reject(
approvalId: id,
reviewer: reviewer,
reason: reason,
);
_client.reject(approvalId: id, reviewer: reviewer, reason: reason);
/// Composite "doctor" snapshot. One round-trip per piece, run
/// in parallel so the page populates fast.
Future<DoctorSnapshot> doctor() async {
final results = await Future.wait([
_client.listCapabilities().catchError((_) => <CapabilityEntry>[]),
_client.listApprovals(statuses: const ['pending'])
_client
.listApprovals(statuses: const ['pending'])
.catchError((_) => <PendingApprovalEntry>[]),
_client.verifyEventChain().catchError(
(_) => VerifyEventChainResponse(),
),
_client.verifyEventChain().catchError((_) => VerifyEventChainResponse()),
_client.listServices().catchError((_) => <DeclaredService>[]),
_client.checkUpdate().catchError(
(_) => CheckUpdateResponse(),
),
_client.checkUpdate().catchError((_) => CheckUpdateResponse()),
_client.daemonPaths().catchError((_) => DaemonPathsResponse()),
]);
@ -896,15 +898,11 @@ class HubService {
pendingApprovals: approvals.length,
eventChainTotal: chain.total.toInt(),
eventChainVerified: chain.verified.toInt(),
eventChainTamperedAt:
chain.tamperedAt.isEmpty ? null : chain.tamperedAt,
eventChainTamperedAt: chain.tamperedAt.isEmpty ? null : chain.tamperedAt,
services: services
.map(
(s) => ServiceEntry(
name: s.name,
endpoint: s.endpoint,
tags: s.tags,
),
(s) =>
ServiceEntry(name: s.name, endpoint: s.endpoint, tags: s.tags),
)
.toList(),
update: UpdateStatus(
@ -913,8 +911,9 @@ class HubService {
latestVersion: update.latestVersion,
updateAvailable: update.updateAvailable,
manifestReachable: update.manifestReachable,
releaseNotesUrl:
update.releaseNotesUrl.isEmpty ? null : update.releaseNotesUrl,
releaseNotesUrl: update.releaseNotesUrl.isEmpty
? null
: update.releaseNotesUrl,
reason: update.reason.isEmpty ? null : update.reason,
),
paths: DaemonPathsSnapshot(
@ -958,9 +957,11 @@ class DoctorSnapshot {
final String? eventChainTamperedAt;
final List<ServiceEntry> services;
final UpdateStatus update;
/// Filesystem paths the daemon owns. Empty fields when the
/// hub couldn't resolve them (older daemon, no $HOME).
final DaemonPathsSnapshot paths;
/// Per-source-kind capability count: bundle / system / mcp /
/// n8n / temporal / webhook (0.12+). Pre-0.12 hubs return
/// an empty map and the panel falls back to a flat count.
@ -1023,14 +1024,17 @@ class CapabilityInfo {
final String version;
final String moduleName;
final String kind;
/// Publisher segment of the fully-qualified identifier.
/// "fai" for legacy bundles, "fai.system" for built-ins,
/// operator-config service name for federated capabilities.
final String provider;
/// Transport that delivers the capability: bundle / system /
/// mcp / n8n / temporal / webhook. Empty string falls back
/// to deriving from `kind` for pre-0.12 hubs.
final String sourceKind;
/// Whether the version on this capability is a label rather
/// than a hard contract. True for federation sources whose
/// upstream service does not honour semver.
@ -1065,6 +1069,7 @@ class SavedFlow {
final String name;
final String path;
final int sizeBytes;
/// Verbatim `use:` strings of every step in this flow,
/// deduplicated and sorted by the hub. Lets Studio decide
/// whether the flow is currently runnable (every entry
@ -1085,11 +1090,24 @@ class ModuleDetail {
final List<String> capabilities;
final List<String> permissions;
final String directory;
/// MIME allow-list declared in the module's `module.yaml`.
/// Empty when the module didn't declare any — caller falls
/// back to its built-in extension heuristic.
final List<String> acceptsMime;
/// Declared inputs (schema v3 verbose form when present). The
/// flow editor uses these to render per-field input ports with
/// canonical names + i18n tooltips.
final List<ModuleFieldInfo> inputs;
/// Declared outputs (schema v3 verbose form when present).
/// Rendered by the flow editor as distinct output ports so a
/// step like `summarize` exposes its `response`,
/// `model_endpoint`, `model_name`, `model_digest` separately
/// instead of fanning them all out of one anchor.
final List<ModuleFieldInfo> outputs;
const ModuleDetail({
required this.name,
required this.version,
@ -1097,9 +1115,43 @@ class ModuleDetail {
required this.permissions,
required this.directory,
this.acceptsMime = const [],
this.inputs = const [],
this.outputs = const [],
});
}
/// One declared input or output field on a module. Mirrors
/// `proto.ModuleField`: name, type descriptor, and a locale
/// description map for tooltips.
class ModuleFieldInfo {
/// Field name, e.g. "prompt" or "model_endpoint".
final String name;
/// Type descriptor (text / json / bytes / file).
final String type;
/// Locale tag description. Use [descriptionFor] to look up
/// with English fallback.
final Map<String, String> description;
const ModuleFieldInfo({
required this.name,
required this.type,
this.description = const {},
});
/// Returns the description in [locale] when present, else the
/// English peer, else null. Studio passes its active locale's
/// short code (e.g. "de") and shows the result as a tooltip.
String? descriptionFor(String locale) {
final exact = description[locale];
if (exact != null && exact.isNotEmpty) return exact;
final en = description['en'];
if (en != null && en.isNotEmpty) return en;
return null;
}
}
class AuditEvent {
final String eventId;
final DateTime timestamp;
@ -1112,6 +1164,7 @@ class AuditEvent {
final String? flowExecution;
final int? durationMs;
final String? error;
/// Free-form JSON-string carrying event-type-specific fields.
/// Surfaced verbatim in the audit drill-down dialog.
final String? detail;
@ -1163,12 +1216,15 @@ class ApprovalRecord {
final String? payloadPreview;
final DateTime createdAt;
final DateTime? expiresAt;
/// One of: pending / approved / rejected / expired.
final String status;
final DateTime? decidedAt;
/// Reviewer handle (e.g. "alice@studio") or "system" for
/// auto-expired approvals. Empty until decided.
final String decidedBy;
/// Reject reason empty for approve / pending / expired.
final String reason;
@ -1192,9 +1248,11 @@ class SystemAiStatus {
final String provider;
final String endpoint;
final String model;
/// off / redacted / full.
final String privacyMode;
final String apiKeyEnv;
/// Number of cached System-AI explanations currently stored.
/// Surfaced in Doctor + Settings so operators see the cache
/// earning its keep.
@ -1215,17 +1273,22 @@ class AskAiResult {
/// "" on success; otherwise one of:
/// disabled / env_missing / network / http / parse / empty_response.
final String errorKind;
/// On success: the assistant's reply.
/// On failure: a human-readable diagnostic.
final String text;
/// Round-trip latency in ms (success only). For cache hits
/// this is the *original* generation latency.
final int latencyMs;
/// True iff the answer came from the persistent cache.
final bool cached;
/// ISO-8601 timestamp the cached entry was first generated.
/// Empty for fresh answers.
final String cachedAt;
/// How many times this entry has been served from cache.
/// 0 for fresh answers.
final int cacheHits;
@ -1283,10 +1346,7 @@ class ChannelStatusSnapshot {
final String active;
final List<ChannelInfo> channels;
const ChannelStatusSnapshot({
required this.active,
required this.channels,
});
const ChannelStatusSnapshot({required this.active, required this.channels});
}
/// One configured n8n endpoint plus the last-discovery
@ -1320,13 +1380,16 @@ class McpClientInfo {
final String endpoint;
final String apiKeyEnv;
final String description;
/// Number of tools the last discovery returned. 0 before
/// discovery has run or on failure.
final int toolCount;
/// Empty on success; otherwise one of:
/// invalid_endpoint / not_implemented / network / http /
/// server / parse / env_missing.
final String errorKind;
/// Human-readable diagnostic.
final String message;
@ -1375,6 +1438,7 @@ class HardwareSnapshot {
final int ramMb;
final bool appleSilicon;
final bool dedicatedGpu;
/// Human-readable summary the editor renders verbatim.
final String summary;
@ -1414,8 +1478,10 @@ class CuratedModelInfo {
final String id;
final String family;
final double paramsB;
/// basic / good / excellent.
final String qualityTier;
/// tiny / small / balanced / large.
final String minHwTier;
final int contextK;
@ -1456,20 +1522,19 @@ class CuratedSnapshot {
final String lastReviewed;
final List<CuratedModelInfo> models;
const CuratedSnapshot({
required this.lastReviewed,
required this.models,
});
const CuratedSnapshot({required this.lastReviewed, required this.models});
}
/// One row from the store-index search.
class StoreItem {
final String name;
final String taglineEn;
/// German tagline. Falls back to [taglineEn] in the UI when
/// the index entry does not ship a localized one.
final String taglineDe;
final String descriptionEn;
/// German long-form description. Falls back to [descriptionEn]
/// when missing.
final String descriptionDe;
@ -1480,30 +1545,38 @@ class StoreItem {
final String license;
final String repository;
final String bestVersion;
/// "published", "alpha", "planned", or empty when unknown.
final String status;
final bool installed;
/// True iff the store-index marks this entry as editorially
/// featured (curated quarterly in the bundled seed.yaml).
/// Drives the "Featured" strip at the top of the Store page.
final bool featured;
/// Optional icon URL square PNG / SVG. Empty for entries
/// that don't supply one; UI falls back to a category icon.
final String iconUrl;
/// Ordered list of screenshot URLs; empty when none. Studio
/// renders them as a horizontally-scrollable strip in the
/// detail sheet.
final List<String> screenshotUrls;
/// Explicit docs URL override. When empty, the hub falls back
/// to the well-known raw-README paths off [repository].
final String docsUrl;
/// "native" (downloadable bundle) or "federated" (routes
/// through a bridge MCP, n8n, ). Empty falls back to
/// "native".
final String kind;
/// Provider id for federated entries. Empty for native.
/// E.g. "filesystem" for an `mcp.filesystem.read_file` tool.
final String provider;
/// Transport delivering this entry. Distinct from `kind`
/// `sourceKind` answers "HOW does the hub reach this", not
/// "is it installable". Values: "bundle", "mcp", "n8n",