feat(studio): store-side source filter, provenance pills, one-click recommended sources (v0.29.0)

The Store is now the centre of capability discovery — provider
configuration moves out of Settings into the Store itself.
Three changes work together:

1. Source filter chips next to the existing status chips:
   All sources / Native / MCP / n8n. Applied client-side after
   the hub returns results so toggling is instant. The result
   count and the "Clear filters" reset both account for the
   source filter too.

2. Per-card provenance pill (`_ProvenancePill`): shows whether
   each entry is native, mcp · <provider>, or n8n · <provider>
   so the operator can triage the source at a glance — same
   role the "verified" badge plays in commercial app stores.

3. `_RecommendedSourcesStrip` replaces the older
   `_FederationNudge`. Renders curated public MCP servers
   (DeepWiki, Semgrep) as one-click cards with an inline
   `[+ Add]` button — no Settings detour, no form. Both servers
   are HTTPS Streamable-HTTP, no API key, no subprocess.
   Useful AI was considered but its 340+ tools would drown the
   index — kept out of the curated list. The strip auto-hides
   the moment any federated entry exists.

Removed the now-orphaned storeFederationNudge* ARB keys.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-08 12:25:59 +02:00
parent b5a4c74c4b
commit 581ebbeaf7
8 changed files with 603 additions and 79 deletions

View file

@ -177,9 +177,36 @@
"@storeDocsErrorNetwork": { "placeholders": { "detail": { "type": "String" } } }, "@storeDocsErrorNetwork": { "placeholders": { "detail": { "type": "String" } } },
"storeDocsErrorParse": "README ist kein gültiges UTF-8.", "storeDocsErrorParse": "README ist kein gültiges UTF-8.",
"storeDocsErrorGeneric": "README konnte nicht geladen werden.", "storeDocsErrorGeneric": "README konnte nicht geladen werden.",
"storeFederationNudgeTitle": "Mehr Capabilities?", "storeSourceLabel": "Quelle",
"storeFederationNudgeBody": "MCP-Server oder n8n-Endpunkt unter Einstellungen → MCP-Clients / N8N-Endpunkte konfigurieren. Entdeckte Tools und Workflows tauchen hier als `mcp.<server>.<tool>`- und `n8n.<endpoint>.<workflow>`-Capabilities auf — jeder Anthropic-/Community-MCP-Server bringt mehrere auf einmal.", "storeSourceAll": "Alle Quellen",
"storeFederationNudgeButton": "Einstellungen öffnen", "storeSourceNative": "Nativ",
"storeSourceMcp": "MCP",
"storeSourceN8n": "n8n",
"storeProvenanceNative": "nativ",
"storeProvenanceMcp": "mcp · {provider}",
"@storeProvenanceMcp": { "placeholders": { "provider": { "type": "String" } } },
"storeProvenanceN8n": "n8n · {provider}",
"@storeProvenanceN8n": { "placeholders": { "provider": { "type": "String" } } },
"storeRecommendedTitle": "Öffentliche Quelle hinzufügen — ein Klick",
"storeRecommendedBody": "Kuratierte MCP-Server über HTTPS erreichbar. Kein Subprozess, kein API-Key, kein Node. Föderierte Capabilities erscheinen sofort im Store.",
"storeRecommendedAddTooltip": "Hinzufügen und entdecken",
"storeRecommendedAdding": "Wird hinzugefügt…",
"storeRecommendedDeepWikiTagline": "GitHub-Repo-Doku-Suche (KI-gestützt)",
"storeRecommendedSemgrepTagline": "Security-Scanning für Code-Schwachstellen",
"storeProviderAddedToast": "{name} hinzugefügt — {n} {n, plural, =1{föderierte Capability} other{föderierte Capabilities}}.",
"@storeProviderAddedToast": {
"placeholders": {
"name": { "type": "String" },
"n": { "type": "int" }
}
},
"storeProviderAddFailed": "{name} konnte nicht hinzugefügt werden: {error}",
"@storeProviderAddFailed": {
"placeholders": {
"name": { "type": "String" },
"error": { "type": "String" }
}
},
"auditTitle": "Protokoll", "auditTitle": "Protokoll",
"auditNoEvents": "Noch keine Ereignisse", "auditNoEvents": "Noch keine Ereignisse",

View file

@ -178,9 +178,36 @@
"@storeDocsErrorNetwork": { "placeholders": { "detail": { "type": "String" } } }, "@storeDocsErrorNetwork": { "placeholders": { "detail": { "type": "String" } } },
"storeDocsErrorParse": "README is not valid UTF-8.", "storeDocsErrorParse": "README is not valid UTF-8.",
"storeDocsErrorGeneric": "Could not load README.", "storeDocsErrorGeneric": "Could not load README.",
"storeFederationNudgeTitle": "Want more capabilities?", "storeSourceLabel": "Source",
"storeFederationNudgeBody": "Configure an MCP server or n8n endpoint in Settings → MCP Clients / N8N Endpoints. Discovered tools and workflows surface here as `mcp.<server>.<tool>` and `n8n.<endpoint>.<workflow>` capabilities — every Anthropic / community MCP server adds a handful at once.", "storeSourceAll": "All sources",
"storeFederationNudgeButton": "Open Settings", "storeSourceNative": "Native",
"storeSourceMcp": "MCP",
"storeSourceN8n": "n8n",
"storeProvenanceNative": "native",
"storeProvenanceMcp": "mcp · {provider}",
"@storeProvenanceMcp": { "placeholders": { "provider": { "type": "String" } } },
"storeProvenanceN8n": "n8n · {provider}",
"@storeProvenanceN8n": { "placeholders": { "provider": { "type": "String" } } },
"storeRecommendedTitle": "Add a public source — one click",
"storeRecommendedBody": "Curated MCP servers reachable over HTTPS. No subprocess, no API key, no Node. Federated capabilities appear in the store immediately.",
"storeRecommendedAddTooltip": "Add and discover",
"storeRecommendedAdding": "Adding…",
"storeRecommendedDeepWikiTagline": "GitHub repo documentation search (AI-powered)",
"storeRecommendedSemgrepTagline": "Security scanning for code vulnerabilities",
"storeProviderAddedToast": "Added {name} — {n} federated {n, plural, =1{capability} other{capabilities}}.",
"@storeProviderAddedToast": {
"placeholders": {
"name": { "type": "String" },
"n": { "type": "int" }
}
},
"storeProviderAddFailed": "Could not add {name}: {error}",
"@storeProviderAddFailed": {
"placeholders": {
"name": { "type": "String" },
"error": { "type": "String" }
}
},
"auditTitle": "Audit", "auditTitle": "Audit",
"auditNoEvents": "No events yet", "auditNoEvents": "No events yet",

View file

@ -890,23 +890,101 @@ abstract class AppLocalizations {
/// **'Could not load README.'** /// **'Could not load README.'**
String get storeDocsErrorGeneric; String get storeDocsErrorGeneric;
/// No description provided for @storeFederationNudgeTitle. /// No description provided for @storeSourceLabel.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Want more capabilities?'** /// **'Source'**
String get storeFederationNudgeTitle; String get storeSourceLabel;
/// No description provided for @storeFederationNudgeBody. /// No description provided for @storeSourceAll.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Configure an MCP server or n8n endpoint in Settings → MCP Clients / N8N Endpoints. Discovered tools and workflows surface here as `mcp.<server>.<tool>` and `n8n.<endpoint>.<workflow>` capabilities — every Anthropic / community MCP server adds a handful at once.'** /// **'All sources'**
String get storeFederationNudgeBody; String get storeSourceAll;
/// No description provided for @storeFederationNudgeButton. /// No description provided for @storeSourceNative.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Open Settings'** /// **'Native'**
String get storeFederationNudgeButton; String get storeSourceNative;
/// No description provided for @storeSourceMcp.
///
/// In en, this message translates to:
/// **'MCP'**
String get storeSourceMcp;
/// No description provided for @storeSourceN8n.
///
/// In en, this message translates to:
/// **'n8n'**
String get storeSourceN8n;
/// No description provided for @storeProvenanceNative.
///
/// In en, this message translates to:
/// **'native'**
String get storeProvenanceNative;
/// No description provided for @storeProvenanceMcp.
///
/// In en, this message translates to:
/// **'mcp · {provider}'**
String storeProvenanceMcp(String provider);
/// No description provided for @storeProvenanceN8n.
///
/// In en, this message translates to:
/// **'n8n · {provider}'**
String storeProvenanceN8n(String provider);
/// No description provided for @storeRecommendedTitle.
///
/// In en, this message translates to:
/// **'Add a public source — one click'**
String get storeRecommendedTitle;
/// No description provided for @storeRecommendedBody.
///
/// In en, this message translates to:
/// **'Curated MCP servers reachable over HTTPS. No subprocess, no API key, no Node. Federated capabilities appear in the store immediately.'**
String get storeRecommendedBody;
/// No description provided for @storeRecommendedAddTooltip.
///
/// In en, this message translates to:
/// **'Add and discover'**
String get storeRecommendedAddTooltip;
/// No description provided for @storeRecommendedAdding.
///
/// In en, this message translates to:
/// **'Adding…'**
String get storeRecommendedAdding;
/// No description provided for @storeRecommendedDeepWikiTagline.
///
/// In en, this message translates to:
/// **'GitHub repo documentation search (AI-powered)'**
String get storeRecommendedDeepWikiTagline;
/// No description provided for @storeRecommendedSemgrepTagline.
///
/// In en, this message translates to:
/// **'Security scanning for code vulnerabilities'**
String get storeRecommendedSemgrepTagline;
/// No description provided for @storeProviderAddedToast.
///
/// In en, this message translates to:
/// **'Added {name} — {n} federated {n, plural, =1{capability} other{capabilities}}.'**
String storeProviderAddedToast(String name, int n);
/// No description provided for @storeProviderAddFailed.
///
/// In en, this message translates to:
/// **'Could not add {name}: {error}'**
String storeProviderAddFailed(String name, String error);
/// No description provided for @auditTitle. /// No description provided for @auditTitle.
/// ///

View file

@ -471,14 +471,70 @@ class AppLocalizationsDe extends AppLocalizations {
String get storeDocsErrorGeneric => 'README konnte nicht geladen werden.'; String get storeDocsErrorGeneric => 'README konnte nicht geladen werden.';
@override @override
String get storeFederationNudgeTitle => 'Mehr Capabilities?'; String get storeSourceLabel => 'Quelle';
@override @override
String get storeFederationNudgeBody => String get storeSourceAll => 'Alle Quellen';
'MCP-Server oder n8n-Endpunkt unter Einstellungen → MCP-Clients / N8N-Endpunkte konfigurieren. Entdeckte Tools und Workflows tauchen hier als `mcp.<server>.<tool>`- und `n8n.<endpoint>.<workflow>`-Capabilities auf — jeder Anthropic-/Community-MCP-Server bringt mehrere auf einmal.';
@override @override
String get storeFederationNudgeButton => 'Einstellungen öffnen'; String get storeSourceNative => 'Nativ';
@override
String get storeSourceMcp => 'MCP';
@override
String get storeSourceN8n => 'n8n';
@override
String get storeProvenanceNative => 'nativ';
@override
String storeProvenanceMcp(String provider) {
return 'mcp · $provider';
}
@override
String storeProvenanceN8n(String provider) {
return 'n8n · $provider';
}
@override
String get storeRecommendedTitle =>
'Öffentliche Quelle hinzufügen — ein Klick';
@override
String get storeRecommendedBody =>
'Kuratierte MCP-Server über HTTPS erreichbar. Kein Subprozess, kein API-Key, kein Node. Föderierte Capabilities erscheinen sofort im Store.';
@override
String get storeRecommendedAddTooltip => 'Hinzufügen und entdecken';
@override
String get storeRecommendedAdding => 'Wird hinzugefügt…';
@override
String get storeRecommendedDeepWikiTagline =>
'GitHub-Repo-Doku-Suche (KI-gestützt)';
@override
String get storeRecommendedSemgrepTagline =>
'Security-Scanning für Code-Schwachstellen';
@override
String storeProviderAddedToast(String name, int n) {
String _temp0 = intl.Intl.pluralLogic(
n,
locale: localeName,
other: 'föderierte Capabilities',
one: 'föderierte Capability',
);
return '$name hinzugefügt — $n $_temp0.';
}
@override
String storeProviderAddFailed(String name, String error) {
return '$name konnte nicht hinzugefügt werden: $error';
}
@override @override
String get auditTitle => 'Protokoll'; String get auditTitle => 'Protokoll';

View file

@ -489,14 +489,69 @@ class AppLocalizationsEn extends AppLocalizations {
String get storeDocsErrorGeneric => 'Could not load README.'; String get storeDocsErrorGeneric => 'Could not load README.';
@override @override
String get storeFederationNudgeTitle => 'Want more capabilities?'; String get storeSourceLabel => 'Source';
@override @override
String get storeFederationNudgeBody => String get storeSourceAll => 'All sources';
'Configure an MCP server or n8n endpoint in Settings → MCP Clients / N8N Endpoints. Discovered tools and workflows surface here as `mcp.<server>.<tool>` and `n8n.<endpoint>.<workflow>` capabilities — every Anthropic / community MCP server adds a handful at once.';
@override @override
String get storeFederationNudgeButton => 'Open Settings'; String get storeSourceNative => 'Native';
@override
String get storeSourceMcp => 'MCP';
@override
String get storeSourceN8n => 'n8n';
@override
String get storeProvenanceNative => 'native';
@override
String storeProvenanceMcp(String provider) {
return 'mcp · $provider';
}
@override
String storeProvenanceN8n(String provider) {
return 'n8n · $provider';
}
@override
String get storeRecommendedTitle => 'Add a public source — one click';
@override
String get storeRecommendedBody =>
'Curated MCP servers reachable over HTTPS. No subprocess, no API key, no Node. Federated capabilities appear in the store immediately.';
@override
String get storeRecommendedAddTooltip => 'Add and discover';
@override
String get storeRecommendedAdding => 'Adding…';
@override
String get storeRecommendedDeepWikiTagline =>
'GitHub repo documentation search (AI-powered)';
@override
String get storeRecommendedSemgrepTagline =>
'Security scanning for code vulnerabilities';
@override
String storeProviderAddedToast(String name, int n) {
String _temp0 = intl.Intl.pluralLogic(
n,
locale: localeName,
other: 'capabilities',
one: 'capability',
);
return 'Added $name$n federated $_temp0.';
}
@override
String storeProviderAddFailed(String name, String error) {
return 'Could not add $name: $error';
}
@override @override
String get auditTitle => 'Audit'; String get auditTitle => 'Audit';

View file

@ -26,7 +26,7 @@ import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the /// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header /// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build. /// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.28.0'; const String kStudioVersion = '0.29.0';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();

View file

@ -26,7 +26,14 @@ 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
/// client-side after the hub returns results the search RPC
/// has no source field.
String _source = '';
bool _installedOnly = false; bool _installedOnly = false;
/// Tracks per-recommended-source add buttons to disable them
/// while the request is in flight.
final Set<String> _addingSources = <String>{};
late Future<List<StoreItem>> _future; late Future<List<StoreItem>> _future;
/// Active UI locale, read from the app-wide MaterialApp /// Active UI locale, read from the app-wide MaterialApp
@ -139,8 +146,9 @@ class _StorePageState extends State<StorePage> {
FutureBuilder<List<StoreItem>>( FutureBuilder<List<StoreItem>>(
future: _future, future: _future,
builder: (context, snap) { builder: (context, snap) {
final items = snap.data ?? const <StoreItem>[]; final raw = snap.data ?? const <StoreItem>[];
final categories = _categoriesIn(items); final items = _applySourceFilter(raw);
final categories = _categoriesIn(raw);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -155,11 +163,13 @@ class _StorePageState extends State<StorePage> {
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
_FilterRow( _FilterRow(
status: _status, status: _status,
source: _source,
installedOnly: _installedOnly, installedOnly: _installedOnly,
onStatus: (v) { onStatus: (v) {
setState(() => _status = v); setState(() => _status = v);
_runSearch(); _runSearch();
}, },
onSource: (v) => setState(() => _source = v),
onInstalledOnly: (v) { onInstalledOnly: (v) {
setState(() => _installedOnly = v); setState(() => _installedOnly = v);
_runSearch(); _runSearch();
@ -190,7 +200,8 @@ class _StorePageState extends State<StorePage> {
), ),
); );
} }
final items = snap.data ?? const <StoreItem>[]; final raw = snap.data ?? const <StoreItem>[];
final items = _applySourceFilter(raw);
if (items.isEmpty) { if (items.isEmpty) {
return FaiEmptyState( return FaiEmptyState(
icon: Icons.search_off, icon: Icons.search_off,
@ -201,6 +212,7 @@ class _StorePageState extends State<StorePage> {
setState(() { setState(() {
_category = ''; _category = '';
_status = ''; _status = '';
_source = '';
_installedOnly = false; _installedOnly = false;
_queryCtrl.clear(); _queryCtrl.clear();
}); });
@ -219,25 +231,31 @@ class _StorePageState extends State<StorePage> {
final showFeatured = _queryCtrl.text.trim().isEmpty && final showFeatured = _queryCtrl.text.trim().isEmpty &&
_category.isEmpty && _category.isEmpty &&
_status.isEmpty && _status.isEmpty &&
_source.isEmpty &&
!_installedOnly && !_installedOnly &&
items.any((e) => e.featured); items.any((e) => e.featured);
// Sparse-store nudge: when no filter is set // Sparse-store nudge: when no filter is set
// and the store carries no federated entries, // and the store carries no federated entries
// hint that adding an MCP / n8n endpoint // yet, surface curated public MCP servers as
// brings dozens of capabilities at once. // one-click add cards. Hides the moment any
// Hides the moment any federated entry shows // federated entry shows up the strip has
// up the nudge has done its job. // done its job.
final showFederationNudge = final showRecommendedStrip =
_queryCtrl.text.trim().isEmpty && _queryCtrl.text.trim().isEmpty &&
_category.isEmpty && _category.isEmpty &&
_status.isEmpty && _status.isEmpty &&
_source.isEmpty &&
!_installedOnly && !_installedOnly &&
!items.any((e) => e.isFederated); !raw.any((e) => e.isFederated);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (showFederationNudge) ...[ if (showRecommendedStrip) ...[
const _FederationNudge(), _RecommendedSourcesStrip(
adding: _addingSources,
locale: _locale,
onAdd: _addRecommendedSource,
),
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
], ],
if (showFeatured) ...[ if (showFeatured) ...[
@ -277,6 +295,59 @@ class _StorePageState extends State<StorePage> {
final list = set.toList()..sort(); final list = set.toList()..sort();
return list; return list;
} }
List<StoreItem> _applySourceFilter(List<StoreItem> items) {
if (_source.isEmpty) return items;
return items.where((e) => _sourceForItem(e) == _source).toList();
}
Future<void> _addRecommendedSource(_RecommendedSource s) async {
if (_addingSources.contains(s.name)) return;
setState(() => _addingSources.add(s.name));
final l = AppLocalizations.of(context)!;
try {
final list = await HubService.instance.addMcpClient(
name: s.name,
endpoint: s.endpoint,
apiKeyEnv: '',
description: s.descriptionEn,
);
final added = list.firstWhere(
(c) => c.name == s.name,
orElse: () => list.last,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l.storeProviderAddedToast(s.label, added.toolCount),
),
),
);
_runSearch();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l.storeProviderAddFailed(s.label, e.toString())),
),
);
} finally {
if (mounted) setState(() => _addingSources.remove(s.name));
}
}
}
/// Maps a store entry to its provenance bucket: 'native' for
/// non-federated entries, 'mcp' / 'n8n' for federated tools that
/// flow through one of the bridges. Used by the source filter
/// chip and the per-card provenance pill so operators can tell
/// at a glance which entries come from where.
String _sourceForItem(StoreItem i) {
if (!i.isFederated) return 'native';
if (i.name.startsWith('mcp.')) return 'mcp';
if (i.name.startsWith('n8n.')) return 'n8n';
return 'federated';
} }
/// Big search input that owns the page's primary action. /// Big search input that owns the page's primary action.
@ -361,15 +432,19 @@ class _CategoryStrip extends StatelessWidget {
class _FilterRow extends StatelessWidget { class _FilterRow extends StatelessWidget {
final String status; final String status;
final String source;
final bool installedOnly; final bool installedOnly;
final void Function(String) onStatus; final void Function(String) onStatus;
final void Function(String) onSource;
final void Function(bool) onInstalledOnly; final void Function(bool) onInstalledOnly;
final int resultCount; final int resultCount;
const _FilterRow({ const _FilterRow({
required this.status, required this.status,
required this.source,
required this.installedOnly, required this.installedOnly,
required this.onStatus, required this.onStatus,
required this.onSource,
required this.onInstalledOnly, required this.onInstalledOnly,
required this.resultCount, required this.resultCount,
}); });
@ -378,7 +453,10 @@ class _FilterRow extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
return Row( return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: FaiSpace.sm,
runSpacing: FaiSpace.xs,
children: [ children: [
_ChoiceChip( _ChoiceChip(
label: l.storeStatusAll, label: l.storeStatusAll,
@ -400,14 +478,37 @@ class _FilterRow extends StatelessWidget {
selected: status == 'planned', selected: status == 'planned',
onSelected: () => onStatus('planned'), onSelected: () => onStatus('planned'),
), ),
const SizedBox(width: FaiSpace.md), Container(
width: 1,
height: 18,
color: theme.colorScheme.outlineVariant,
),
_ChoiceChip(
label: l.storeSourceAll,
selected: source.isEmpty,
onSelected: () => onSource(''),
),
_ChoiceChip(
label: l.storeSourceNative,
selected: source == 'native',
onSelected: () => onSource('native'),
),
_ChoiceChip(
label: l.storeSourceMcp,
selected: source == 'mcp',
onSelected: () => onSource('mcp'),
),
_ChoiceChip(
label: l.storeSourceN8n,
selected: source == 'n8n',
onSelected: () => onSource('n8n'),
),
FilterChip( FilterChip(
label: Text(l.storeInstalledOnly), label: Text(l.storeInstalledOnly),
selected: installedOnly, selected: installedOnly,
onSelected: onInstalledOnly, onSelected: onInstalledOnly,
showCheckmark: true, showCheckmark: true,
), ),
const Spacer(),
Text( Text(
l.storeNResults(resultCount), l.storeNResults(resultCount),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
@ -816,12 +917,15 @@ class _StoreCard extends StatelessWidget {
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
Row( Row(
children: [ children: [
if (item.bestVersion.isNotEmpty) if (item.bestVersion.isNotEmpty) ...[
FaiPill( FaiPill(
label: 'v${item.bestVersion}', label: 'v${item.bestVersion}',
tone: FaiPillTone.neutral, tone: FaiPillTone.neutral,
monospace: true, monospace: true,
), ),
const SizedBox(width: FaiSpace.xs),
],
_ProvenancePill(item: item),
const Spacer(), const Spacer(),
if (_hasUpdate) if (_hasUpdate)
FilledButton.icon( FilledButton.icon(
@ -1805,15 +1909,73 @@ class _ScreenshotPlaceholder extends StatelessWidget {
} }
} }
/// Sparse-store nudge: the operator's store has only the /// Curated public MCP server that the operator can register
/// bundled native + planned modules and no federated entries. /// with one click no Node, no API key, no subprocess. Fields
/// The single biggest store-fullness lever is configuring an /// are kept const so the strip can be a `const` widget tree.
/// MCP server or n8n endpoint, so the banner says exactly class _RecommendedSource {
/// that. Tap Settings dialog opens straight to the editor. /// Stable lowercase id used as the MCP-client name. Becomes
/// Dismisses automatically the next render after a federated /// the prefix of every synthetic store entry the server emits
/// entry shows up no per-user "don't show again". /// (`mcp.<name>.<tool>`).
class _FederationNudge extends StatelessWidget { final String name;
const _FederationNudge(); /// 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;
final String descriptionDe;
const _RecommendedSource({
required this.name,
required this.label,
required this.icon,
required this.endpoint,
required this.descriptionEn,
required this.descriptionDe,
});
}
/// Curated public MCP servers offered as one-click adds. Two
/// guardrails apply when picking entries: outbound HTTPS only
/// (no subprocess, no npm), and a small-enough tool surface
/// that the store stays browsable after the add (Useful AI was
/// considered but its 340+ tools would drown the index).
const _kRecommendedSources = <_RecommendedSource>[
_RecommendedSource(
name: 'deepwiki',
label: 'DeepWiki',
icon: Icons.menu_book_outlined,
endpoint: 'https://mcp.deepwiki.com/mcp',
descriptionEn: 'GitHub repo documentation search (AI-powered).',
descriptionDe: 'GitHub-Repo-Doku-Suche (KI-gestützt).',
),
_RecommendedSource(
name: 'semgrep',
label: 'Semgrep',
icon: Icons.security,
endpoint: 'https://mcp.semgrep.ai/mcp',
descriptionEn: 'Security scanning for code vulnerabilities.',
descriptionDe: 'Security-Scanning für Code-Schwachstellen.',
),
];
/// Recommended-sources strip replaces the older Settings nudge.
/// Renders one card per [_kRecommendedSources] entry with a
/// single `[+ Add]` button so the operator can fill the store
/// without leaving the Store page. Hides as soon as any
/// federated entry exists.
class _RecommendedSourcesStrip extends StatelessWidget {
final Set<String> adding;
final String locale;
final void Function(_RecommendedSource) onAdd;
const _RecommendedSourcesStrip({
required this.adding,
required this.locale,
required this.onAdd,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -1827,43 +1989,162 @@ class _FederationNudge extends StatelessWidget {
borderRadius: BorderRadius.circular(FaiRadius.md), borderRadius: BorderRadius.circular(FaiRadius.md),
border: Border.all(color: theme.colorScheme.outlineVariant), border: Border.all(color: theme.colorScheme.outlineVariant),
), ),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.hub_outlined,
size: 22,
color: theme.colorScheme.primary,
),
const SizedBox(width: FaiSpace.md),
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row(
children: [
Icon(
Icons.hub_outlined,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: FaiSpace.sm),
Text( Text(
l.storeFederationNudgeTitle, l.storeRecommendedTitle,
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
],
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
l.storeFederationNudgeBody, l.storeRecommendedBody,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: FaiSpace.md),
Wrap(
spacing: FaiSpace.md,
runSpacing: FaiSpace.sm,
children: [
for (final s in _kRecommendedSources)
_RecommendedSourceCard(
source: s,
locale: locale,
busy: adding.contains(s.name),
onAdd: () => onAdd(s),
),
], ],
), ),
),
const SizedBox(width: FaiSpace.md),
FilledButton.tonalIcon(
onPressed: () => FaiSettingsDialog.show(context),
icon: const Icon(Icons.settings_outlined, size: 16),
label: Text(l.storeFederationNudgeButton),
),
], ],
), ),
); );
} }
} }
class _RecommendedSourceCard extends StatelessWidget {
final _RecommendedSource source;
final String locale;
final bool busy;
final VoidCallback onAdd;
const _RecommendedSourceCard({
required this.source,
required this.locale,
required this.busy,
required this.onAdd,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final desc = locale == 'de' ? source.descriptionDe : source.descriptionEn;
return SizedBox(
width: 320,
child: Container(
padding: const EdgeInsets.all(FaiSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Row(
children: [
Icon(source.icon, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
source.label,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
Text(
desc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: FaiSpace.sm),
Tooltip(
message: busy
? l.storeRecommendedAdding
: l.storeRecommendedAddTooltip,
child: IconButton.filledTonal(
onPressed: busy ? null : onAdd,
icon: busy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.add, size: 16),
visualDensity: VisualDensity.compact,
),
),
],
),
),
);
}
}
/// Small color-coded chip that names the entry's source: native
/// (muted), MCP (warning tone), or n8n (accent). The on-card
/// position lets the operator triage at a glance who emitted the
/// capability the same role the "verified" badge plays in
/// commercial app stores.
class _ProvenancePill extends StatelessWidget {
final StoreItem item;
const _ProvenancePill({required this.item});
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final source = _sourceForItem(item);
switch (source) {
case 'mcp':
return FaiPill(
label: l.storeProvenanceMcp(item.provider),
tone: FaiPillTone.warning,
monospace: true,
icon: Icons.hub_outlined,
);
case 'n8n':
return FaiPill(
label: l.storeProvenanceN8n(item.provider),
tone: FaiPillTone.accent,
monospace: true,
icon: Icons.account_tree_outlined,
);
default:
return FaiPill(
label: l.storeProvenanceNative,
tone: FaiPillTone.neutral,
);
}
}
}

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.28.0 version: 0.29.0
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta