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",

View file

@ -31,11 +31,13 @@ class _StorePageState extends State<StorePage> {
final _queryCtrl = TextEditingController();
String _category = '';
String _status = '';
/// Source filter: '' (all), 'native', 'mcp', 'n8n'. Applied
/// client-side after the hub returns results the search RPC
/// has no source field.
String _source = '';
bool _installedOnly = false;
/// "Show installable modules only". On by default: roughly
/// 2/3 of the seed-index entries currently carry status
/// `planned` (no bundle to install), and pre-filter polish
@ -43,20 +45,24 @@ class _StorePageState extends State<StorePage> {
/// install half the cards. Planned modules are one chip-flip
/// away in the filter dialog.
bool _installableOnly = true;
/// Tracks per-recommended-source add buttons to disable them
/// while the request is in flight.
final Set<String> _addingSources = <String>{};
/// Whether the operator dismissed the editorial hero this
/// session. Not persisted the next Studio launch shows it
/// again so a release-bumped story has a chance to be seen.
bool _todayDismissed = false;
/// The carousel of stories rendered in the hero. When an
/// operator-accepted story exists at `~/.fai/today/active.yaml`,
/// it is the only entry (carousel collapses to a single
/// slide). Otherwise the curated fallback list rotates.
late final List<TodayStoryData> _todayStories = (() {
final loaded =
TodayStoryLoader.loadOrFallback(_kFallbackTodayStories.first);
final loaded = TodayStoryLoader.loadOrFallback(
_kFallbackTodayStories.first,
);
// When the loader fell back to the const, it returned the
// first entry of our list surface the whole list so the
// operator can browse. Otherwise the loader produced a real
@ -66,6 +72,7 @@ class _StorePageState extends State<StorePage> {
}
return <TodayStoryData>[loaded];
})();
/// Carousel index. Wraps around modulo `_todayStories.length`.
int _todayIndex = 0;
late Future<List<StoreItem>> _future;
@ -82,11 +89,13 @@ class _StorePageState extends State<StorePage> {
List<_AiMatch>? _aiMatches;
Set<String>? _aiMatchedNames;
String? _aiError;
/// Debounce-Timer für Such-Filterung beim Tippen. Ohne
/// Debounce flackert "Keine Treffer" zwischen Buchstaben
/// (für 2-3 Buchstaben matched z.B. nichts, das nächste
/// dann doch). 250ms ist comfortable für Tippen.
Timer? _typingDebounce;
/// Whether the operator has a System-AI configured. Drives
/// the Ask-bar hint copy and disables the AI path when off.
bool _systemAiEnabled = false;
@ -151,8 +160,10 @@ class _StorePageState extends State<StorePage> {
// An already-installed item counts as "installable" for
// this filter the user can still update / uninstall it.
out = out
.where((e) =>
e.installed || e.status == 'published' || e.status == 'alpha')
.where(
(e) =>
e.installed || e.status == 'published' || e.status == 'alpha',
)
.toList();
}
return out;
@ -292,7 +303,8 @@ class _StorePageState extends State<StorePage> {
// Browsing-only chrome every editorial
// surface hides the moment a filter is set so
// the operator's query stays the focus.
final isBrowsing = _queryCtrl.text.trim().isEmpty &&
final isBrowsing =
_queryCtrl.text.trim().isEmpty &&
_category.isEmpty &&
_status.isEmpty &&
_source.isEmpty &&
@ -312,7 +324,8 @@ class _StorePageState extends State<StorePage> {
// out of the way.
final showStandaloneRecommended =
isBrowsing && _todayDismissed && hasNoFederation;
final showFeatured = isBrowsing &&
final showFeatured =
isBrowsing &&
_todayDismissed &&
items.any((e) => e.featured);
return SingleChildScrollView(
@ -334,28 +347,32 @@ class _StorePageState extends State<StorePage> {
if (showToday) ...[
_StoreTodayHero(
story:
_todayStories[_todayIndex % _todayStories.length],
_todayStories[_todayIndex %
_todayStories.length],
locale: _locale,
currentIndex: _todayIndex,
totalCount: _todayStories.length,
onPrev: _todayStories.length > 1
? () => setState(() {
_todayIndex = (_todayIndex -
1 +
_todayStories.length) %
_todayStories.length;
})
_todayIndex =
(_todayIndex -
1 +
_todayStories.length) %
_todayStories.length;
})
: null,
onNext: _todayStories.length > 1
? () => setState(() {
_todayIndex = (_todayIndex + 1) %
_todayStories.length;
})
_todayIndex =
(_todayIndex + 1) %
_todayStories.length;
})
: null,
onDismiss: () =>
setState(() => _todayDismissed = true),
onCta: _todayStories[
_todayIndex % _todayStories.length]
onCta:
_todayStories[_todayIndex %
_todayStories.length]
.cta ==
TodayCta.openSettings
? () => FaiSettingsDialog.show(context)
@ -538,13 +555,15 @@ class _StorePageState extends State<StorePage> {
try {
final all = await HubService.instance.searchStore(limit: 200);
final modules = all
.map((s) => {
'name': s.name,
'tagline': _locale == 'de' && s.taglineDe.isNotEmpty
? s.taglineDe
: s.taglineEn,
'category': s.category,
})
.map(
(s) => {
'name': s.name,
'tagline': _locale == 'de' && s.taglineDe.isNotEmpty
? s.taglineDe
: s.taglineEn,
'category': s.category,
},
)
.toList();
final prompt = _buildAiSearchPrompt(query, modules, _locale);
final result = await HubService.instance.askAi(prompt);
@ -794,12 +813,14 @@ class _AskBar extends StatelessWidget {
// can write multi-line questions naturally.
child: Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.meta,
LogicalKeyboardKey.enter):
const _SubmitAskIntent(),
LogicalKeySet(LogicalKeyboardKey.control,
LogicalKeyboardKey.enter):
const _SubmitAskIntent(),
LogicalKeySet(
LogicalKeyboardKey.meta,
LogicalKeyboardKey.enter,
): const _SubmitAskIntent(),
LogicalKeySet(
LogicalKeyboardKey.control,
LogicalKeyboardKey.enter,
): const _SubmitAskIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
@ -822,10 +843,10 @@ class _AskBar extends StatelessWidget {
// Cmd/Ctrl+Enter.
decoration: InputDecoration(
isCollapsed: true,
contentPadding:
const EdgeInsets.symmetric(vertical: 6),
hintText:
aiAvailable ? l.storeAskHint : l.storeSearchHint,
contentPadding: const EdgeInsets.symmetric(vertical: 6),
hintText: aiAvailable
? l.storeAskHint
: l.storeSearchHint,
hintMaxLines: 2,
hintStyle: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
@ -857,9 +878,7 @@ class _AskBar extends StatelessWidget {
aiAvailable ? Icons.auto_awesome : Icons.search,
size: 14,
),
label: Text(
thinking ? l.storeAskAiThinking : l.storeAskSubmit,
),
label: Text(thinking ? l.storeAskAiThinking : l.storeAskSubmit),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
@ -883,10 +902,7 @@ class _FilterButton extends StatelessWidget {
final int activeCount;
final VoidCallback onPressed;
const _FilterButton({
required this.activeCount,
required this.onPressed,
});
const _FilterButton({required this.activeCount, required this.onPressed});
@override
Widget build(BuildContext context) {
@ -902,10 +918,7 @@ class _FilterButton extends StatelessWidget {
if (activeCount > 0) ...[
const SizedBox(width: FaiSpace.xs),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(8),
@ -921,9 +934,7 @@ class _FilterButton extends StatelessWidget {
],
],
),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
);
}
}
@ -976,30 +987,32 @@ class _FilterDialogState extends State<_FilterDialog> {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
Widget header(String text) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
text.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
padding: const EdgeInsets.only(bottom: 6),
child: Text(
text.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
);
Widget chipRow(
List<({String value, String label})> items,
String selected,
void Function(String) onSelect,
) => Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
children: [
for (final i in items)
_ChoiceChip(
label: i.label,
selected: selected == i.value,
onSelected: () => onSelect(i.value),
),
);
Widget chipRow(List<({String value, String label})> items, String selected,
void Function(String) onSelect) =>
Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
children: [
for (final i in items)
_ChoiceChip(
label: i.label,
selected: selected == i.value,
onSelected: () => onSelect(i.value),
),
],
);
],
);
return AlertDialog(
title: Text(l.storeFilterLabel),
shape: RoundedRectangleBorder(
@ -1279,10 +1292,7 @@ class _CategoryDropdown extends StatelessWidget {
initialValue: selected,
onSelected: onSelected,
itemBuilder: (_) => [
PopupMenuItem(
value: '',
child: Text(l.storeCategoryAll),
),
PopupMenuItem(value: '', child: Text(l.storeCategoryAll)),
const PopupMenuDivider(),
for (final c in categories)
PopupMenuItem(
@ -1419,6 +1429,7 @@ class _ChoiceChip extends StatelessWidget {
class _StoreGrid extends StatelessWidget {
final List<StoreItem> items;
final String locale;
/// Map of installed module name installed version. Cards
/// compare against `bestVersion` to render an "Update" badge
/// when the store offers something newer.
@ -1594,8 +1605,9 @@ class _FeaturedTile extends StatelessWidget {
children: [
CircleAvatar(
radius: 24,
backgroundColor:
theme.colorScheme.primary.withValues(alpha: 0.18),
backgroundColor: theme.colorScheme.primary.withValues(
alpha: 0.18,
),
child: Icon(
_iconForCategory(item.category),
size: 26,
@ -1720,6 +1732,7 @@ class _FeaturedTile extends StatelessWidget {
class _StoreCard extends StatelessWidget {
final StoreItem item;
final String locale;
/// Installed version for this name, when the operator has it.
/// `null` for not-installed entries.
final String? installedVersion;
@ -1761,148 +1774,149 @@ class _StoreCard extends StatelessWidget {
return Opacity(
opacity: notInstallable ? 0.6 : 1.0,
child: Material(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: InkWell(
onTap: onTap,
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: Padding(
padding: const EdgeInsets.all(FaiSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor:
theme.colorScheme.primary.withValues(alpha: 0.12),
child: Icon(
_iconForCategory(item.category),
size: 18,
color: theme.colorScheme.primary,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: Padding(
padding: const EdgeInsets.all(FaiSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: theme.colorScheme.primary.withValues(
alpha: 0.12,
),
child: Icon(
_iconForCategory(item.category),
size: 18,
color: theme.colorScheme.primary,
),
),
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
item.category.isEmpty
? ''
: _categoryDisplayName(context, item.category),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
Text(
item.category.isEmpty
? ''
: _categoryDisplayName(context, item.category),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
if (_hasUpdate)
Tooltip(
message: l.storeUpdateTooltip(
installedVersion ?? '?',
item.bestVersion,
),
],
),
),
if (_hasUpdate)
Tooltip(
message: l.storeUpdateTooltip(
installedVersion ?? '?',
item.bestVersion,
child: FaiPill(
label: l.storePillUpdate,
tone: FaiPillTone.warning,
icon: Icons.system_update_alt,
),
)
else if (item.installed)
FaiPill(
label: l.storePillInstalled,
tone: FaiPillTone.success,
icon: Icons.check,
)
else if (item.status.isNotEmpty)
FaiPill(
label: _statusDisplayName(context, item.status),
tone: _toneForStatus(item.status),
),
child: FaiPill(
label: l.storePillUpdate,
tone: FaiPillTone.warning,
icon: Icons.system_update_alt,
),
)
else if (item.installed)
FaiPill(
label: l.storePillInstalled,
tone: FaiPillTone.success,
icon: Icons.check,
)
else if (item.status.isNotEmpty)
FaiPill(
label: _statusDisplayName(context, item.status),
tone: _toneForStatus(item.status),
),
],
),
const SizedBox(height: FaiSpace.sm),
Expanded(
child: Text(
tagline.isEmpty ? l.storeNoTagline : tagline,
style: theme.textTheme.bodySmall,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
if (item.bestVersion.isNotEmpty) ...[
Tooltip(
message: item.isFederated
? l.storeAdvisoryVersionTooltip
: '',
child: FaiPill(
label: item.isFederated
? '~v${item.bestVersion}'
: 'v${item.bestVersion}',
tone: FaiPillTone.neutral,
monospace: true,
),
),
const SizedBox(width: FaiSpace.xs),
],
_ProvenancePill(item: item),
const Spacer(),
if (_hasUpdate)
FilledButton.icon(
onPressed: onInstall,
icon: const Icon(Icons.system_update_alt, size: 14),
label: Text(l.storeUpdateButton(item.bestVersion)),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
const SizedBox(height: FaiSpace.sm),
Expanded(
child: Text(
tagline.isEmpty ? l.storeNoTagline : tagline,
style: theme.textTheme.bodySmall,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
if (item.bestVersion.isNotEmpty) ...[
Tooltip(
message: item.isFederated
? l.storeAdvisoryVersionTooltip
: '',
child: FaiPill(
label: item.isFederated
? '~v${item.bestVersion}'
: 'v${item.bestVersion}',
tone: FaiPillTone.neutral,
monospace: true,
),
),
)
else if (item.installed)
TextButton.icon(
onPressed: onTap,
icon: const Icon(Icons.info_outline, size: 14),
label: Text(l.buttonDetails),
)
else if (installable)
FilledButton.icon(
onPressed: onInstall,
icon: const Icon(Icons.download, size: 14),
label: Text(l.buttonInstall),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
const SizedBox(width: FaiSpace.xs),
],
_ProvenancePill(item: item),
const Spacer(),
if (_hasUpdate)
FilledButton.icon(
onPressed: onInstall,
icon: const Icon(Icons.system_update_alt, size: 14),
label: Text(l.storeUpdateButton(item.bestVersion)),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
)
else if (item.installed)
TextButton.icon(
onPressed: onTap,
icon: const Icon(Icons.info_outline, size: 14),
label: Text(l.buttonDetails),
)
else if (installable)
FilledButton.icon(
onPressed: onInstall,
icon: const Icon(Icons.download, size: 14),
label: Text(l.buttonInstall),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
)
else
OutlinedButton.icon(
onPressed: onTap,
icon: const Icon(Icons.info_outline, size: 14),
label: Text(l.buttonDetails),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
)
else
OutlinedButton.icon(
onPressed: onTap,
icon: const Icon(Icons.info_outline, size: 14),
label: Text(l.buttonDetails),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
],
],
),
],
),
),
),
),
),
);
}
}
@ -1944,6 +1958,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
String get _locale => Localizations.localeOf(context).languageCode;
bool _busy = false;
String? _toast;
/// `null` while the initial probe is in flight; non-null once
/// `readInstalledModuleDocs` has returned. The probe is cheap
/// a single file stat on disk so we run it eagerly when the
@ -1951,6 +1966,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
/// behind a "Load docs" click.
({String state, String text, String sourcePath})? _docsResult;
bool _docsProbed = false;
/// Live module-info for installed entries: declared
/// permissions and on-disk directory. Pulled lazily so
/// not-installed entries don't run an unnecessary RPC. Stays
@ -1980,22 +1996,25 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
Future<void> _probeInstalledDocs() async {
final locale = Localizations.localeOf(context).languageCode;
try {
final r = await HubService.instance
.readInstalledModuleDocs(widget.item.name, locale: locale);
final r = await HubService.instance.readInstalledModuleDocs(
widget.item.name,
locale: locale,
);
if (!mounted) return;
setState(() => _docsResult = r);
} catch (_) {
// Treat probe failures as "no docs available" the section
// simply hides, the rest of the sheet still works.
if (!mounted) return;
setState(() => _docsResult = (state: 'no_docs', text: '', sourcePath: ''));
setState(
() => _docsResult = (state: 'no_docs', text: '', sourcePath: ''),
);
}
}
Future<void> _loadModuleDetail() async {
try {
final detail =
await HubService.instance.moduleInfo(widget.item.name);
final detail = await HubService.instance.moduleInfo(widget.item.name);
if (!mounted) return;
setState(() => _moduleDetail = detail);
} catch (_) {
@ -2009,7 +2028,9 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
_toast = null;
});
try {
final r = await HubService.instance.installModule(source: widget.item.name);
final r = await HubService.instance.installModule(
source: widget.item.name,
);
if (!mounted) return;
final l = AppLocalizations.of(context)!;
setState(() {
@ -2033,8 +2054,9 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
// more than one is installed side-by-side. Otherwise the
// hub picks "highest version" and the operator can be
// surprised by what disappeared.
final versions =
await HubService.instance.installedVersions(widget.item.name);
final versions = await HubService.instance.installedVersions(
widget.item.name,
);
if (!mounted) return;
String? targetVersion;
if (versions.length > 1) {
@ -2189,13 +2211,17 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
if (item.status.isNotEmpty)
FaiPill(
label: _statusDisplayName(
context, item.status),
context,
item.status,
),
tone: _toneForStatus(item.status),
),
if (item.category.isNotEmpty)
FaiPill(
label: _categoryDisplayName(
context, item.category),
context,
item.category,
),
tone: FaiPillTone.neutral,
),
if (item.installed)
@ -2322,8 +2348,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
Icon(
_iconForPermission(p),
size: 14,
color:
theme.colorScheme.onSurfaceVariant,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.sm),
SelectableText(
@ -2360,9 +2385,8 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
itemCount: item.screenshotUrls.length,
separatorBuilder: (_, _) =>
const SizedBox(width: FaiSpace.sm),
itemBuilder: (context, i) => _Screenshot(
url: item.screenshotUrls[i],
),
itemBuilder: (context, i) =>
_Screenshot(url: item.screenshotUrls[i]),
),
),
const SizedBox(height: FaiSpace.lg),
@ -2424,16 +2448,18 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
? const SizedBox(
width: 14,
height: 14,
child:
CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.delete_outline, size: 16),
label: Text(l.buttonUninstall),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(
color: theme.colorScheme.error
.withValues(alpha: 0.5),
color: theme.colorScheme.error.withValues(
alpha: 0.5,
),
),
),
),
@ -2444,8 +2470,9 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
? const SizedBox(
width: 14,
height: 14,
child:
CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.download, size: 16),
label: Text(
@ -2480,10 +2507,8 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
Expanded(
child: Text(
l.storeNotInstallableInline(item.status),
style:
theme.textTheme.bodySmall?.copyWith(
color:
theme.colorScheme.onSurfaceVariant,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
@ -2676,9 +2701,13 @@ class _DocsPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// No maxHeight + NeverScrollableScrollPhysics so the docs
// section participates in the outer sheet's scroll instead
// of opening its own viewport. The previous nested-scroll
// form trapped the reader on a 480 px window with two
// scrollbars fighting for the wheel.
return Container(
width: double.infinity,
constraints: const BoxConstraints(maxHeight: 480),
padding: const EdgeInsets.all(FaiSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
@ -2688,6 +2717,7 @@ class _DocsPanel extends StatelessWidget {
child: Markdown(
data: text,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
selectable: true,
onTapLink: (text, href, title) async {
if (href == null || href.isEmpty) return;
@ -2926,6 +2956,7 @@ const List<TodayStoryData> _kFallbackTodayStories = <TodayStoryData>[
class _StoreTodayHero extends StatelessWidget {
final TodayStoryData story;
final String locale;
/// Carousel position (0-based) and total slide count. Drives
/// the dot indicator and the visibility of the prev/next
/// arrows. When `totalCount == 1` the chrome collapses to a
@ -2936,10 +2967,12 @@ class _StoreTodayHero extends StatelessWidget {
final VoidCallback? onPrev;
final VoidCallback? onNext;
final VoidCallback onDismiss;
/// CTA action null hides the button. Wired by the parent
/// because navigation targets live in the store-page state,
/// not in const story data.
final VoidCallback? onCta;
/// Inline one-click "+ Add public source" chips rendered in
/// the hero footer. Empty list hides the row the parent
/// passes [_kRecommendedSources] only when the store has zero
@ -3011,11 +3044,7 @@ class _StoreTodayHero extends StatelessWidget {
color: theme.colorScheme.primary.withValues(alpha: 0.14),
shape: BoxShape.circle,
),
child: Icon(
story.icon,
size: 24,
color: theme.colorScheme.primary,
),
child: Icon(story.icon, size: 24, color: theme.colorScheme.primary),
),
const SizedBox(width: FaiSpace.lg),
Expanded(
@ -3027,10 +3056,7 @@ class _StoreTodayHero extends StatelessWidget {
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
FaiPill(
label: l.storeTodayBadge,
tone: FaiPillTone.accent,
),
FaiPill(label: l.storeTodayBadge, tone: FaiPillTone.accent),
Text(
badge,
style: theme.textTheme.labelSmall?.copyWith(
@ -3091,15 +3117,17 @@ class _StoreTodayHero extends StatelessWidget {
? const SizedBox(
width: 12,
height: 12,
child:
CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Icon(s.icon, size: 14),
label: Text('+ ${s.label}'),
onPressed:
recommendedAdding.contains(s.name) || onAddRecommended == null
? null
: () => onAddRecommended!(s),
recommendedAdding.contains(s.name) ||
onAddRecommended == null
? null
: () => onAddRecommended!(s),
),
],
),
@ -3131,9 +3159,7 @@ class _StoreTodayHero extends StatelessWidget {
onPressed: onPrev,
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
),
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@ -3141,9 +3167,7 @@ class _StoreTodayHero extends StatelessWidget {
Container(
width: 6,
height: 6,
margin: const EdgeInsets.symmetric(
horizontal: 2,
),
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == currentIndex
@ -3178,11 +3202,14 @@ class _RecommendedSource {
/// the prefix of every synthetic store entry the server emits
/// (`mcp.<name>.<tool>`).
final String name;
/// Display label shown on the card.
final String label;
final IconData icon;
/// HTTPS Streamable-HTTP endpoint. No auth required.
final String endpoint;
/// Bilingual description used as the MCP-client `description`
/// field keeps the audit log in the operator's locale.
final String descriptionEn;

View file

@ -119,16 +119,16 @@ packages:
path: "../fai_client_sdk_dart"
relative: true
source: path
version: "0.17.1"
version: "0.18.0"
fai_studio_flow_editor:
dependency: "direct main"
description:
path: "."
ref: main
resolved-ref: "1f5601a461097c62140354fb0e9b9bdea598b89c"
resolved-ref: b1fe765468d83db97344440fcfd6848a09ec06c2
url: "https://git.flemming.ai/fai/studio-flow-editor"
source: git
version: "0.8.0"
version: "0.9.0"
fake_async:
dependency: transitive
description:

View file

@ -1,7 +1,7 @@
name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none'
version: 0.55.0
version: 0.56.0
environment:
sdk: ^3.11.0-200.1.beta