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; 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 @override
Stream<editor.FlowRunEvent> events() { Stream<editor.FlowRunEvent> events() {
return HubService.instance return HubService.instance
@ -82,10 +115,7 @@ class StudioFlowRunDriver implements editor.FlowRunDriver {
error: e.error ?? '', error: e.error ?? '',
); );
case 'step.awaiting_approval': case 'step.awaiting_approval':
return editor.StepAwaitingApproval( return editor.StepAwaitingApproval(flowName: flowName, stepId: stepId);
flowName: flowName,
stepId: stepId,
);
} }
return null; return null;
} }

View file

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

View file

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

View file

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

View file

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