diff --git a/lib/data/flow_run_driver.dart b/lib/data/flow_run_driver.dart index 3d45f2b..832f5e6 100644 --- a/lib/data/flow_run_driver.dart +++ b/lib/data/flow_run_driver.dart @@ -40,6 +40,39 @@ class StudioFlowRunDriver implements editor.FlowRunDriver { return mapped; } + @override + Future 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 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; } diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 444cb2b..1431e66 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -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> 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.from(f.description), + ), + ) + .toList(), + outputs: r.outputs + .map( + (f) => ModuleFieldInfo( + name: f.name, + type: f.type, + description: Map.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 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 scope, List 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 scope, List knownProviders})> - setDefaultScope(List scope) async { + Future<({List scope, List knownProviders})> setDefaultScope( + List scope, + ) async { return _client.setDefaultScope(scope); } @@ -425,13 +435,14 @@ class HubService { /// is installed side-by-side. Future> 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 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 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 doctor() async { final results = await Future.wait([ _client.listCapabilities().catchError((_) => []), - _client.listApprovals(statuses: const ['pending']) + _client + .listApprovals(statuses: const ['pending']) .catchError((_) => []), - _client.verifyEventChain().catchError( - (_) => VerifyEventChainResponse(), - ), + _client.verifyEventChain().catchError((_) => VerifyEventChainResponse()), _client.listServices().catchError((_) => []), - _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 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 capabilities; final List 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 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 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 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 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 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 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 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", diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 92a44d1..99c5474 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -31,11 +31,13 @@ class _StorePageState extends State { 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 { /// 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 _addingSources = {}; + /// 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 _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 { } return [loaded]; })(); + /// Carousel index. Wraps around modulo `_todayStories.length`. int _todayIndex = 0; late Future> _future; @@ -82,11 +89,13 @@ class _StorePageState extends State { List<_AiMatch>? _aiMatches; Set? _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 { // 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 { // 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 { // 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 { 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 { 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(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: >{ @@ -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 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 _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 _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 _kFallbackTodayStories = [ 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..`). 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; diff --git a/pubspec.lock b/pubspec.lock index 1012c50..2e23bfd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 93c1bb5..750d2e5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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