feat(studio): plain-language ask bar + collapsible filter + AI search (v0.32.0)

Three changes against the user feedback "der text ist zu
zerhackt, die filter-leiste nimmt zu viel sicht weg, llm-suche
wenn das geht":

1. Today-Hero copy rewritten in flowing prose. The fallback
   story drops the code-fence templates ("`mcp.<server>.<tool>`")
   and reads as a single arc per language. Same trust model,
   gentler register.

2. Filter row collapses behind a single `_FilterButton` with a
   "Filter · N active" count badge. Status / Source / Installed
   chips now live inside `_FilterDialog` — invisible until the
   operator wants them, recovers a full row of viewport. The
   category strip stays inline because category is the most
   common cut and benefits from being one click away.

3. New `_AskBar` replaces the old single-line `_SearchField`:

   - Multi-line input (1-4 lines auto-grow) so questions don't
     overflow horizontally.
   - Cmd+Enter / Ctrl+Enter submits; bare Enter inserts a
     newline so the operator can write multi-line questions
     naturally.
   - Heuristic question detector (ends in `?` or ≥4 words)
     routes the submit to the System-AI's askAi RPC instead of
     the substring search. Live keystrokes still keyword-search
     for short queries; question-shaped input holds the grid
     steady until submit so the visible result set doesn't
     wipe with every space.
   - The LLM is asked for a strict-JSON ranking of up to 5
     modules with a one-phrase reason each. Unknown module
     names are dropped silently — operators must not see
     hallucinated entries.
   - The new `_AiAnswerCard` renders the LLM's answer plus the
     ranked match list above the grid; the grid filters to
     just those names so the answer and the visible cards
     stay coherent. Clearing the question collapses everything
     back.
   - Falls back to plain keyword search when System AI is off
     (with an explanatory hint instead of the AI hint).

The AI path uses the System-AI the operator already configured
in Settings; same privacy mode, same audit-log trail. Nothing
new leaves the hub.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-08 13:21:40 +02:00
parent b0f09260e1
commit c7869870c4
8 changed files with 1010 additions and 145 deletions

View file

@ -103,6 +103,24 @@
"channelsDisableAutostart": "Autostart deaktivieren", "channelsDisableAutostart": "Autostart deaktivieren",
"storeSearchHint": "Module suchen — Name, Tagline, Tag, Capability…", "storeSearchHint": "Module suchen — Name, Tagline, Tag, Capability…",
"storeAskHint": "Was brauchst du? Versuch's mit „ein Modul, das tabellarische Daten liest\" oder tippe einfach einen Begriff.",
"storeAskSubmit": "Suchen",
"storeAskAi": "KI fragen",
"storeAskClear": "Frage zurücksetzen",
"storeAskAiThinking": "Module werden durchsucht…",
"storeAskAiUnavailable": "Konfiguriere eine System-AI in den Einstellungen, um Fragen in normaler Sprache zu stellen.",
"storeAskNoMatches": "Keine Treffer — Frage verfeinern oder im Grid unten stöbern.",
"storeAskAnswerLabel": "KI-Vorschlag",
"storeAskAnswerWhy": "Warum es passt",
"storeAskMatchPill": "Treffer",
"storeFilterLabel": "Filter",
"storeFilterActiveCount": "{n} aktiv",
"@storeFilterActiveCount": { "placeholders": { "n": { "type": "int" } } },
"storeFilterReset": "Filter zurücksetzen",
"storeFilterApply": "Fertig",
"storeFilterStatusGroup": "Status",
"storeFilterSourceGroup": "Quelle",
"storeFilterInstalledGroup": "Nur installierte zeigen",
"storeFeatured": "EMPFOHLEN", "storeFeatured": "EMPFOHLEN",
"storeFeaturedHint": "kuratierte Auswahl · klicken für Details", "storeFeaturedHint": "kuratierte Auswahl · klicken für Details",
"storeNoMatches": "Keine Treffer", "storeNoMatches": "Keine Treffer",

View file

@ -104,6 +104,24 @@
"channelsDisableAutostart": "Disable autostart", "channelsDisableAutostart": "Disable autostart",
"storeSearchHint": "Search modules — name, tagline, tag, capability…", "storeSearchHint": "Search modules — name, tagline, tag, capability…",
"storeAskHint": "What do you need? Try \"a module that reads tabular data\" or just type a keyword.",
"storeAskSubmit": "Search",
"storeAskAi": "Ask the AI",
"storeAskClear": "Clear question",
"storeAskAiThinking": "Looking through modules…",
"storeAskAiUnavailable": "Configure a System AI in Settings to ask questions in plain language.",
"storeAskNoMatches": "No matches yet — refine the question or browse the grid below.",
"storeAskAnswerLabel": "AI suggestion",
"storeAskAnswerWhy": "Why this matches",
"storeAskMatchPill": "match",
"storeFilterLabel": "Filter",
"storeFilterActiveCount": "{n} active",
"@storeFilterActiveCount": { "placeholders": { "n": { "type": "int" } } },
"storeFilterReset": "Reset filters",
"storeFilterApply": "Done",
"storeFilterStatusGroup": "Status",
"storeFilterSourceGroup": "Source",
"storeFilterInstalledGroup": "Show only installed",
"storeFeatured": "FEATURED", "storeFeatured": "FEATURED",
"storeFeaturedHint": "curated picks · click for details", "storeFeaturedHint": "curated picks · click for details",
"storeNoMatches": "No matches", "storeNoMatches": "No matches",

View file

@ -620,6 +620,108 @@ abstract class AppLocalizations {
/// **'Search modules — name, tagline, tag, capability…'** /// **'Search modules — name, tagline, tag, capability…'**
String get storeSearchHint; String get storeSearchHint;
/// No description provided for @storeAskHint.
///
/// In en, this message translates to:
/// **'What do you need? Try \"a module that reads tabular data\" or just type a keyword.'**
String get storeAskHint;
/// No description provided for @storeAskSubmit.
///
/// In en, this message translates to:
/// **'Search'**
String get storeAskSubmit;
/// No description provided for @storeAskAi.
///
/// In en, this message translates to:
/// **'Ask the AI'**
String get storeAskAi;
/// No description provided for @storeAskClear.
///
/// In en, this message translates to:
/// **'Clear question'**
String get storeAskClear;
/// No description provided for @storeAskAiThinking.
///
/// In en, this message translates to:
/// **'Looking through modules…'**
String get storeAskAiThinking;
/// No description provided for @storeAskAiUnavailable.
///
/// In en, this message translates to:
/// **'Configure a System AI in Settings to ask questions in plain language.'**
String get storeAskAiUnavailable;
/// No description provided for @storeAskNoMatches.
///
/// In en, this message translates to:
/// **'No matches yet — refine the question or browse the grid below.'**
String get storeAskNoMatches;
/// No description provided for @storeAskAnswerLabel.
///
/// In en, this message translates to:
/// **'AI suggestion'**
String get storeAskAnswerLabel;
/// No description provided for @storeAskAnswerWhy.
///
/// In en, this message translates to:
/// **'Why this matches'**
String get storeAskAnswerWhy;
/// No description provided for @storeAskMatchPill.
///
/// In en, this message translates to:
/// **'match'**
String get storeAskMatchPill;
/// No description provided for @storeFilterLabel.
///
/// In en, this message translates to:
/// **'Filter'**
String get storeFilterLabel;
/// No description provided for @storeFilterActiveCount.
///
/// In en, this message translates to:
/// **'{n} active'**
String storeFilterActiveCount(int n);
/// No description provided for @storeFilterReset.
///
/// In en, this message translates to:
/// **'Reset filters'**
String get storeFilterReset;
/// No description provided for @storeFilterApply.
///
/// In en, this message translates to:
/// **'Done'**
String get storeFilterApply;
/// No description provided for @storeFilterStatusGroup.
///
/// In en, this message translates to:
/// **'Status'**
String get storeFilterStatusGroup;
/// No description provided for @storeFilterSourceGroup.
///
/// In en, this message translates to:
/// **'Source'**
String get storeFilterSourceGroup;
/// No description provided for @storeFilterInstalledGroup.
///
/// In en, this message translates to:
/// **'Show only installed'**
String get storeFilterInstalledGroup;
/// No description provided for @storeFeatured. /// No description provided for @storeFeatured.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -306,6 +306,62 @@ class AppLocalizationsDe extends AppLocalizations {
String get storeSearchHint => String get storeSearchHint =>
'Module suchen — Name, Tagline, Tag, Capability…'; 'Module suchen — Name, Tagline, Tag, Capability…';
@override
String get storeAskHint =>
'Was brauchst du? Versuch\'s mit „ein Modul, das tabellarische Daten liest\" oder tippe einfach einen Begriff.';
@override
String get storeAskSubmit => 'Suchen';
@override
String get storeAskAi => 'KI fragen';
@override
String get storeAskClear => 'Frage zurücksetzen';
@override
String get storeAskAiThinking => 'Module werden durchsucht…';
@override
String get storeAskAiUnavailable =>
'Konfiguriere eine System-AI in den Einstellungen, um Fragen in normaler Sprache zu stellen.';
@override
String get storeAskNoMatches =>
'Keine Treffer — Frage verfeinern oder im Grid unten stöbern.';
@override
String get storeAskAnswerLabel => 'KI-Vorschlag';
@override
String get storeAskAnswerWhy => 'Warum es passt';
@override
String get storeAskMatchPill => 'Treffer';
@override
String get storeFilterLabel => 'Filter';
@override
String storeFilterActiveCount(int n) {
return '$n aktiv';
}
@override
String get storeFilterReset => 'Filter zurücksetzen';
@override
String get storeFilterApply => 'Fertig';
@override
String get storeFilterStatusGroup => 'Status';
@override
String get storeFilterSourceGroup => 'Quelle';
@override
String get storeFilterInstalledGroup => 'Nur installierte zeigen';
@override @override
String get storeFeatured => 'EMPFOHLEN'; String get storeFeatured => 'EMPFOHLEN';

View file

@ -318,6 +318,62 @@ class AppLocalizationsEn extends AppLocalizations {
String get storeSearchHint => String get storeSearchHint =>
'Search modules — name, tagline, tag, capability…'; 'Search modules — name, tagline, tag, capability…';
@override
String get storeAskHint =>
'What do you need? Try \"a module that reads tabular data\" or just type a keyword.';
@override
String get storeAskSubmit => 'Search';
@override
String get storeAskAi => 'Ask the AI';
@override
String get storeAskClear => 'Clear question';
@override
String get storeAskAiThinking => 'Looking through modules…';
@override
String get storeAskAiUnavailable =>
'Configure a System AI in Settings to ask questions in plain language.';
@override
String get storeAskNoMatches =>
'No matches yet — refine the question or browse the grid below.';
@override
String get storeAskAnswerLabel => 'AI suggestion';
@override
String get storeAskAnswerWhy => 'Why this matches';
@override
String get storeAskMatchPill => 'match';
@override
String get storeFilterLabel => 'Filter';
@override
String storeFilterActiveCount(int n) {
return '$n active';
}
@override
String get storeFilterReset => 'Reset filters';
@override
String get storeFilterApply => 'Done';
@override
String get storeFilterStatusGroup => 'Status';
@override
String get storeFilterSourceGroup => 'Source';
@override
String get storeFilterInstalledGroup => 'Show only installed';
@override @override
String get storeFeatured => 'FEATURED'; String get storeFeatured => 'FEATURED';

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.31.1'; const String kStudioVersion = '0.32.0';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();

View file

@ -5,7 +5,10 @@
// renders the full bilingual description, requires-list, and // renders the full bilingual description, requires-list, and
// repository link. // repository link.
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown/flutter_markdown.dart';
import '../data/hub.dart'; import '../data/hub.dart';
@ -46,6 +49,22 @@ class _StorePageState extends State<StorePage> {
TodayStoryLoader.loadOrFallback(_kFallbackTodayStory); TodayStoryLoader.loadOrFallback(_kFallbackTodayStory);
late Future<List<StoreItem>> _future; late Future<List<StoreItem>> _future;
// AI search state
// The Ask bar runs traditional substring search on every
// keystroke (cheap; same as before). When the operator hits
// Enter on a question-shaped query AND a System-AI is
// configured, the LLM gets a structured ranking call and the
// grid filters to its picks. Clearing the query resets both
// surfaces back to the unfiltered view.
bool _aiThinking = false;
String? _aiAnswer;
List<_AiMatch>? _aiMatches;
Set<String>? _aiMatchedNames;
String? _aiError;
/// Whether the operator has a System-AI configured. Drives
/// the Ask-bar hint copy and disables the AI path when off.
bool _systemAiEnabled = false;
/// Active UI locale, read from the app-wide MaterialApp /// Active UI locale, read from the app-wide MaterialApp
/// `locale` setting. Drives which side of the bilingual /// `locale` setting. Drives which side of the bilingual
/// store-index entries renders (taglineEn vs taglineDe, /// store-index entries renders (taglineEn vs taglineDe,
@ -57,6 +76,18 @@ class _StorePageState extends State<StorePage> {
void initState() { void initState() {
super.initState(); super.initState();
_future = _load(); _future = _load();
_refreshSystemAiStatus();
}
Future<void> _refreshSystemAiStatus() async {
try {
final s = await HubService.instance.systemAiStatus();
if (!mounted) return;
setState(() => _systemAiEnabled = s.enabled);
} catch (_) {
// Stay disabled on any failure Ask bar falls back to
// pure keyword search until next try.
}
} }
@override @override
@ -144,25 +175,36 @@ class _StorePageState extends State<StorePage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_SearchField( _AskBar(
controller: _queryCtrl, controller: _queryCtrl,
onSubmit: _runSearch, thinking: _aiThinking,
onClear: () { aiAvailable: _systemAiEnabled,
_queryCtrl.clear(); onSubmit: _onAskSubmit,
_runSearch(); onClear: _clearQuery,
}, onTextChange: _onAskTyping,
), ),
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.sm),
FutureBuilder<List<StoreItem>>( FutureBuilder<List<StoreItem>>(
future: _future, future: _future,
builder: (context, snap) { builder: (context, snap) {
final raw = snap.data ?? const <StoreItem>[]; final raw = snap.data ?? const <StoreItem>[];
final items = _applySourceFilter(raw); final items = _applyAllFilters(raw);
final categories = _categoriesIn(raw); final categories = _categoriesIn(raw);
final activeFilters = _activeFilterCount();
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_CategoryStrip( Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_FilterButton(
activeCount: activeFilters,
onPressed: _openFilterDialog,
),
const SizedBox(width: FaiSpace.md),
if (categories.isNotEmpty)
Expanded(
child: _CategoryStrip(
categories: categories, categories: categories,
selected: _category, selected: _category,
onSelected: (v) { onSelected: (v) {
@ -170,21 +212,16 @@ class _StorePageState extends State<StorePage> {
_runSearch(); _runSearch();
}, },
), ),
const SizedBox(height: FaiSpace.sm), )
_FilterRow( else
status: _status, const Spacer(),
source: _source, Text(
installedOnly: _installedOnly, l.storeNResults(items.length),
onStatus: (v) { style: theme.textTheme.bodySmall?.copyWith(
setState(() => _status = v); color: theme.colorScheme.onSurfaceVariant,
_runSearch(); ),
}, ),
onSource: (v) => setState(() => _source = v), ],
onInstalledOnly: (v) {
setState(() => _installedOnly = v);
_runSearch();
},
resultCount: items.length,
), ),
], ],
); );
@ -211,7 +248,7 @@ class _StorePageState extends State<StorePage> {
); );
} }
final raw = snap.data ?? const <StoreItem>[]; final raw = snap.data ?? const <StoreItem>[];
final items = _applySourceFilter(raw); final items = _applyAllFilters(raw);
if (items.isEmpty) { if (items.isEmpty) {
return FaiEmptyState( return FaiEmptyState(
icon: Icons.search_off, icon: Icons.search_off,
@ -296,6 +333,18 @@ class _StorePageState extends State<StorePage> {
), ),
const SizedBox(height: FaiSpace.lg), const SizedBox(height: FaiSpace.lg),
], ],
if (_aiThinking ||
_aiAnswer != null ||
_aiError != null) ...[
_AiAnswerCard(
thinking: _aiThinking,
answer: _aiAnswer,
matches: _aiMatches ?? const <_AiMatch>[],
error: _aiError,
onClear: _clearQuery,
),
const SizedBox(height: FaiSpace.lg),
],
_StoreGrid( _StoreGrid(
items: items, items: items,
locale: _locale, locale: _locale,
@ -329,6 +378,144 @@ class _StorePageState extends State<StorePage> {
return items.where((e) => _sourceForItem(e) == _source).toList(); return items.where((e) => _sourceForItem(e) == _source).toList();
} }
/// Source filter + AI-match overlay. AI mode narrows the grid
/// to whatever the LLM ranked above the line so the answer
/// card and the grid stay coherent.
List<StoreItem> _applyAllFilters(List<StoreItem> items) {
var out = _applySourceFilter(items);
final ai = _aiMatchedNames;
if (ai != null) {
out = out.where((e) => ai.contains(e.name)).toList();
}
return out;
}
int _activeFilterCount() {
var n = 0;
if (_status.isNotEmpty) n++;
if (_source.isNotEmpty) n++;
if (_installedOnly) n++;
return n;
}
/// On every keystroke. Short keyword-shaped input runs the
/// hub's substring search live so the grid follows along. A
/// question-shaped input holds the grid steady until the
/// operator submits otherwise typing "ein Modul, das …"
/// wipes the visible result set with every space.
void _onAskTyping(String value) {
final trimmed = value.trim();
if (_aiAnswer != null || _aiMatches != null) {
setState(() {
_aiAnswer = null;
_aiMatches = null;
_aiMatchedNames = null;
_aiError = null;
});
}
if (_systemAiEnabled && _looksLikeQuestion(trimmed)) return;
_runSearch();
}
Future<void> _onAskSubmit() async {
final q = _queryCtrl.text.trim();
if (q.isEmpty) {
_clearQuery();
return;
}
if (_systemAiEnabled && _looksLikeQuestion(q)) {
await _runAiQuery(q);
} else {
_runSearch();
}
}
void _clearQuery() {
setState(() {
_queryCtrl.clear();
_aiAnswer = null;
_aiMatches = null;
_aiMatchedNames = null;
_aiError = null;
});
_runSearch();
}
/// Heuristic question detector kept generous so a sentence
/// "modul für tabellen lesen" triggers the LLM path even
/// without a question mark. Single-word and two-word inputs
/// stay on substring search where they belong.
bool _looksLikeQuestion(String q) {
if (q.endsWith('?')) return true;
final words = q.split(RegExp(r'\s+'));
return words.length >= 4;
}
Future<void> _runAiQuery(String query) async {
setState(() {
_aiThinking = true;
_aiAnswer = null;
_aiMatches = null;
_aiMatchedNames = null;
_aiError = null;
});
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,
})
.toList();
final prompt = _buildAiSearchPrompt(query, modules, _locale);
final result = await HubService.instance.askAi(prompt);
if (!mounted) return;
if (!result.isSuccess) {
setState(() {
_aiThinking = false;
_aiError = result.text;
});
return;
}
final parsed = _parseAiSearchResponse(result.text, all);
setState(() {
_aiThinking = false;
_aiAnswer = parsed.answer;
_aiMatches = parsed.matches;
_aiMatchedNames = parsed.matches.isEmpty
? null
: parsed.matches.map((m) => m.name).toSet();
});
} catch (e) {
if (!mounted) return;
setState(() {
_aiThinking = false;
_aiError = e.toString();
});
}
}
Future<void> _openFilterDialog() async {
final outcome = await showDialog<_FilterOutcome>(
context: context,
builder: (_) => _FilterDialog(
status: _status,
source: _source,
installedOnly: _installedOnly,
),
);
if (outcome == null) return;
setState(() {
_status = outcome.status;
_source = outcome.source;
_installedOnly = outcome.installedOnly;
});
_runSearch();
}
Future<void> _addRecommendedSource(_RecommendedSource s) async { Future<void> _addRecommendedSource(_RecommendedSource s) async {
if (_addingSources.contains(s.name)) return; if (_addingSources.contains(s.name)) return;
setState(() => _addingSources.add(s.name)); setState(() => _addingSources.add(s.name));
@ -378,46 +565,497 @@ String _sourceForItem(StoreItem i) {
return 'federated'; return 'federated';
} }
/// Big search input that owns the page's primary action. /// Multi-line ask-and-search input. Drives the toolbar's
class _SearchField extends StatelessWidget { /// primary action: substring keyword search on every keystroke
/// (cheap, identical to the legacy `_SearchField`) plus an
/// LLM-routed "ask the AI" path when the operator submits a
/// question-shaped query and a System-AI is configured.
class _AskBar extends StatelessWidget {
final TextEditingController controller; final TextEditingController controller;
final bool thinking;
final bool aiAvailable;
final ValueChanged<String> onTextChange;
final VoidCallback onSubmit; final VoidCallback onSubmit;
final VoidCallback onClear; final VoidCallback onClear;
const _SearchField({ const _AskBar({
required this.controller, required this.controller,
required this.thinking,
required this.aiAvailable,
required this.onTextChange,
required this.onSubmit, required this.onSubmit,
required this.onClear, required this.onClear,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
return ValueListenableBuilder<TextEditingValue>( return ValueListenableBuilder<TextEditingValue>(
valueListenable: controller, valueListenable: controller,
builder: (_, value, _) { builder: (_, value, _) {
return TextField( final empty = value.text.trim().isEmpty;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: FaiSpace.sm,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.md),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 6),
child: Icon(
Icons.search,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: FaiSpace.sm),
Expanded(
// Cmd+Enter (or Ctrl+Enter on Linux) submits;
// bare Enter inserts a newline so the operator
// can write multi-line questions naturally.
child: Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.meta,
LogicalKeyboardKey.enter):
const _SubmitAskIntent(),
LogicalKeySet(LogicalKeyboardKey.control,
LogicalKeyboardKey.enter):
const _SubmitAskIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
_SubmitAskIntent: CallbackAction<_SubmitAskIntent>(
onInvoke: (_) {
onSubmit();
return null;
},
),
},
child: TextField(
controller: controller, controller: controller,
autofocus: false,
minLines: 1,
maxLines: 4,
onChanged: onTextChange,
// No onSubmitted bare Enter would
// collide with multi-line authoring.
// Submit happens via the button or
// Cmd/Ctrl+Enter.
decoration: InputDecoration( decoration: InputDecoration(
prefixIcon: const Icon(Icons.search, size: 20), isCollapsed: true,
hintText: l.storeSearchHint, contentPadding:
border: const OutlineInputBorder(), const EdgeInsets.symmetric(vertical: 6),
isDense: true, hintText:
suffixIcon: value.text.isEmpty aiAvailable ? l.storeAskHint : l.storeSearchHint,
? null hintMaxLines: 2,
: IconButton( hintStyle: theme.textTheme.bodyMedium?.copyWith(
icon: const Icon(Icons.close, size: 18), color: theme.colorScheme.onSurfaceVariant,
),
border: InputBorder.none,
),
style: theme.textTheme.bodyLarge,
),
),
),
),
if (!empty)
IconButton(
icon: const Icon(Icons.close, size: 16),
visualDensity: VisualDensity.compact,
tooltip: l.storeAskClear,
onPressed: onClear, onPressed: onClear,
), ),
const SizedBox(width: FaiSpace.xs),
FilledButton.icon(
onPressed: thinking ? null : onSubmit,
icon: thinking
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
aiAvailable ? Icons.auto_awesome : Icons.search,
size: 14,
),
label: Text(
thinking ? l.storeAskAiThinking : l.storeAskSubmit,
),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
), ),
onChanged: (_) => onSubmit(),
onSubmitted: (_) => onSubmit(),
); );
}, },
); );
} }
} }
class _SubmitAskIntent extends Intent {
const _SubmitAskIntent();
}
/// Compact Filter-popover trigger. Shows a small badge with the
/// number of active filters so the operator never has to open
/// the dialog to know what's set.
class _FilterButton extends StatelessWidget {
final int activeCount;
final VoidCallback onPressed;
const _FilterButton({
required this.activeCount,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return OutlinedButton.icon(
onPressed: onPressed,
icon: const Icon(Icons.tune, size: 14),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l.storeFilterLabel),
if (activeCount > 0) ...[
const SizedBox(width: FaiSpace.xs),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$activeCount',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
);
}
}
/// Outcome of `_FilterDialog`. `null` from the dialog means
/// "operator cancelled" caller leaves filter state untouched.
class _FilterOutcome {
final String status;
final String source;
final bool installedOnly;
const _FilterOutcome({
required this.status,
required this.source,
required this.installedOnly,
});
}
/// Modal that hosts the previously-inline status / source /
/// installed chips. Lives behind a button so the toolbar stays
/// quiet operators can browse for a long time without
/// touching filters and shouldn't have a filter row eating
/// their viewport while doing so.
class _FilterDialog extends StatefulWidget {
final String status;
final String source;
final bool installedOnly;
const _FilterDialog({
required this.status,
required this.source,
required this.installedOnly,
});
@override
State<_FilterDialog> createState() => _FilterDialogState();
}
class _FilterDialogState extends State<_FilterDialog> {
late String _status = widget.status;
late String _source = widget.source;
late bool _installedOnly = widget.installedOnly;
@override
Widget build(BuildContext context) {
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,
),
),
);
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(
borderRadius: BorderRadius.circular(FaiRadius.md),
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
header(l.storeFilterStatusGroup),
chipRow(
[
(value: '', label: l.storeStatusAll),
(value: 'published', label: l.storeStatusPublished),
(value: 'alpha', label: l.storeStatusAlpha),
(value: 'planned', label: l.storeStatusPlanned),
],
_status,
(v) => setState(() => _status = v),
),
const SizedBox(height: FaiSpace.lg),
header(l.storeFilterSourceGroup),
chipRow(
[
(value: '', label: l.storeSourceAll),
(value: 'native', label: l.storeSourceNative),
(value: 'mcp', label: l.storeSourceMcp),
(value: 'n8n', label: l.storeSourceN8n),
],
_source,
(v) => setState(() => _source = v),
),
const SizedBox(height: FaiSpace.lg),
header(l.storeFilterInstalledGroup),
FilterChip(
label: Text(l.storeInstalledOnly),
selected: _installedOnly,
onSelected: (v) => setState(() => _installedOnly = v),
showCheckmark: true,
),
],
),
),
actions: [
TextButton(
onPressed: () {
setState(() {
_status = '';
_source = '';
_installedOnly = false;
});
},
child: Text(l.storeFilterReset),
),
const Spacer(),
TextButton(
onPressed: () => Navigator.pop(context, null),
child: Text(l.buttonCancel),
),
FilledButton(
onPressed: () => Navigator.pop(
context,
_FilterOutcome(
status: _status,
source: _source,
installedOnly: _installedOnly,
),
),
child: Text(l.storeFilterApply),
),
],
);
}
}
/// One LLM-suggested module. Held as a small class (not a
/// record) because we map it through StatelessWidget params and
/// records make the type signatures noisy.
class _AiMatch {
final String name;
final String why;
const _AiMatch({required this.name, required this.why});
}
/// Card that surfaces the LLM's free-form answer plus a list of
/// matched module names. Renders only when the operator is in
/// AI mode clearing the question collapses it.
class _AiAnswerCard extends StatelessWidget {
final bool thinking;
final String? answer;
final List<_AiMatch> matches;
final String? error;
final VoidCallback onClear;
const _AiAnswerCard({
required this.thinking,
required this.answer,
required this.matches,
required this.error,
required this.onClear,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final accent = error != null
? theme.colorScheme.error
: theme.colorScheme.primary;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.md),
border: Border.all(color: accent.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.auto_awesome, size: 14, color: accent),
const SizedBox(width: FaiSpace.xs),
Text(
l.storeAskAnswerLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 14),
visualDensity: VisualDensity.compact,
tooltip: l.storeAskClear,
onPressed: onClear,
),
],
),
const SizedBox(height: FaiSpace.xs),
if (thinking)
Row(
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: FaiSpace.sm),
Text(
l.storeAskAiThinking,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
)
else if (error != null)
Text(
error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
)
else ...[
SelectableText(
answer ?? l.storeAskNoMatches,
style: theme.textTheme.bodyMedium,
),
if (matches.isNotEmpty) ...[
const SizedBox(height: FaiSpace.sm),
Text(
l.storeAskAnswerWhy,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 4),
for (final m in matches)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: const EdgeInsets.only(top: 6, right: 8),
width: 6,
height: 6,
decoration: BoxDecoration(
color: accent,
shape: BoxShape.circle,
),
),
Expanded(
child: Wrap(
spacing: FaiSpace.xs,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
m.name,
style: FaiTheme.mono(
size: 12,
weight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
Text(
'${m.why}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
],
],
],
),
);
}
}
/// Horizontally-scrollable category chip strip. Always-on /// Horizontally-scrollable category chip strip. Always-on
/// "All" chip first, then categories the current result set /// "All" chip first, then categories the current result set
/// contains. Disappears when the index is empty. /// contains. Disappears when the index is empty.
@ -458,93 +1096,70 @@ class _CategoryStrip extends StatelessWidget {
} }
} }
class _FilterRow extends StatelessWidget { /// Builds the JSON-output prompt fed to the System-AI when the
final String status; /// operator submits a question-shaped query. Pinned to a strict
final String source; /// reply schema so the parser has stable ground to walk on; the
final bool installedOnly; /// prompt also hand-encodes the operator's locale so the LLM
final void Function(String) onStatus; /// answers in the right language.
final void Function(String) onSource; String _buildAiSearchPrompt(
final void Function(bool) onInstalledOnly; String query,
final int resultCount; List<Map<String, dynamic>> modules,
String locale,
) {
final lang = locale == 'de' ? 'German' : 'English';
return '''You are the F∆I Store assistant. The operator just asked, in $lang:
const _FilterRow({ "$query"
required this.status,
required this.source,
required this.installedOnly,
required this.onStatus,
required this.onSource,
required this.onInstalledOnly,
required this.resultCount,
});
@override These are the modules currently in the store, as JSON:
Widget build(BuildContext context) { ${jsonEncode(modules)}
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!; Reply with strict JSON only no markdown fences, no commentary:
return Wrap( {
crossAxisAlignment: WrapCrossAlignment.center, "answer": "<one short sentence in $lang explaining the recommendation>",
spacing: FaiSpace.sm, "matches": [
runSpacing: FaiSpace.xs, {"name": "<exact module name from the list above>", "why": "<one short phrase in $lang>"}
children: [ ]
_ChoiceChip( }
label: l.storeStatusAll,
selected: status.isEmpty, Up to 5 matches, ordered by best fit first. Use only module names that appear in the list above. If nothing fits, return matches: [] and explain in answer.''';
onSelected: () => onStatus(''), }
),
_ChoiceChip( /// Pulls the JSON object out of the LLM's reply. Models often
label: l.storeStatusPublished, /// add chatty preamble or markdown fences; we slice from the
selected: status == 'published', /// first `{` to the last `}` and try to decode that. Unknown
onSelected: () => onStatus('published'), /// module names are dropped silently the operator should not
), /// see hallucinated entries that don't actually exist.
_ChoiceChip( ({String answer, List<_AiMatch> matches}) _parseAiSearchResponse(
label: l.storeStatusAlpha, String text,
selected: status == 'alpha', List<StoreItem> all,
onSelected: () => onStatus('alpha'), ) {
), final start = text.indexOf('{');
_ChoiceChip( final end = text.lastIndexOf('}');
label: l.storeStatusPlanned, if (start < 0 || end <= start) {
selected: status == 'planned', return (answer: text.trim(), matches: const <_AiMatch>[]);
onSelected: () => onStatus('planned'), }
), try {
Container( final dynamic decoded = jsonDecode(text.substring(start, end + 1));
width: 1, if (decoded is! Map) {
height: 18, return (answer: text.trim(), matches: const <_AiMatch>[]);
color: theme.colorScheme.outlineVariant, }
), final answer = (decoded['answer'] ?? '').toString();
_ChoiceChip( final matches = <_AiMatch>[];
label: l.storeSourceAll, final knownNames = all.map((e) => e.name).toSet();
selected: source.isEmpty, final list = (decoded['matches'] as List?) ?? const [];
onSelected: () => onSource(''), for (final m in list) {
), if (m is Map) {
_ChoiceChip( final name = (m['name'] ?? '').toString();
label: l.storeSourceNative, final why = (m['why'] ?? '').toString();
selected: source == 'native', if (knownNames.contains(name)) {
onSelected: () => onSource('native'), matches.add(_AiMatch(name: name, why: why));
), }
_ChoiceChip( }
label: l.storeSourceMcp, }
selected: source == 'mcp', return (answer: answer, matches: matches);
onSelected: () => onSource('mcp'), } catch (_) {
), return (answer: text.trim(), matches: const <_AiMatch>[]);
_ChoiceChip(
label: l.storeSourceN8n,
selected: source == 'n8n',
onSelected: () => onSource('n8n'),
),
FilterChip(
label: Text(l.storeInstalledOnly),
selected: installedOnly,
onSelected: onInstalledOnly,
showCheckmark: true,
),
Text(
l.storeNResults(resultCount),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
} }
} }
@ -1950,16 +2565,16 @@ class _ScreenshotPlaceholder extends StatelessWidget {
/// validation. The pipeline operator can override at any time /// validation. The pipeline operator can override at any time
/// without recompiling Studio see docs/today-pipeline.md. /// without recompiling Studio see docs/today-pipeline.md.
const TodayStoryData _kFallbackTodayStory = TodayStoryData( const TodayStoryData _kFallbackTodayStory = TodayStoryData(
badgeEn: 'CAPABILITY FEDERATION', badgeEn: 'PUBLIC SOURCES',
badgeDe: 'CAPABILITY-FEDERATION', badgeDe: 'ÖFFENTLICHE QUELLEN',
titleEn: 'Two MCP servers away from a richer hub', titleEn: 'Fill your store in two clicks',
titleDe: 'Zwei MCP-Server bis zu einem reicheren Hub', titleDe: 'Den Store in zwei Klicks auffüllen',
bodyEn: bodyEn:
'The store ships with bundled native modules and curated planned bridges. Click a recommended public source below — DeepWiki for repository docs, Semgrep for security scans — and synthetic `mcp.<server>.<tool>` capabilities appear here within seconds. No Node, no API key, no subprocess.', 'Right now the store shows only the modules that ship with the hub. Pick a public source from the buttons below and the store fills with whatever tools that server offers — straight over HTTPS, no install steps, nothing to configure.',
bodyDe: bodyDe:
'Der Store bringt native Module und kuratierte Bridge-Vorschläge mit. Klicke unten auf eine öffentliche Quelle — DeepWiki für Repo-Doku, Semgrep für Security-Scans — und synthetische `mcp.<server>.<tool>`-Capabilities erscheinen binnen Sekunden hier. Kein Node, kein API-Key, kein Subprozess.', 'Im Moment zeigt der Store nur die Module, die mit dem Hub mitkommen. Wähle unten eine öffentliche Quelle aus, und der Store füllt sich mit allem, was dieser Server an Tools anbietet — direkt über HTTPS, ohne Installation, ohne weitere Konfiguration.',
ctaLabelEn: 'Open MCP settings', ctaLabelEn: 'Manage sources',
ctaLabelDe: 'MCP-Einstellungen öffnen', ctaLabelDe: 'Quellen verwalten',
icon: Icons.hub_outlined, icon: Icons.hub_outlined,
cta: TodayCta.openSettings, cta: TodayCta.openSettings,
); );

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