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:
parent
b0f09260e1
commit
c7869870c4
8 changed files with 1010 additions and 145 deletions
|
|
@ -103,6 +103,24 @@
|
|||
"channelsDisableAutostart": "Autostart deaktivieren",
|
||||
|
||||
"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",
|
||||
"storeFeaturedHint": "kuratierte Auswahl · klicken für Details",
|
||||
"storeNoMatches": "Keine Treffer",
|
||||
|
|
|
|||
|
|
@ -104,6 +104,24 @@
|
|||
"channelsDisableAutostart": "Disable autostart",
|
||||
|
||||
"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",
|
||||
"storeFeaturedHint": "curated picks · click for details",
|
||||
"storeNoMatches": "No matches",
|
||||
|
|
|
|||
|
|
@ -620,6 +620,108 @@ abstract class AppLocalizations {
|
|||
/// **'Search modules — name, tagline, tag, capability…'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -306,6 +306,62 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get storeSearchHint =>
|
||||
'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
|
||||
String get storeFeatured => 'EMPFOHLEN';
|
||||
|
||||
|
|
|
|||
|
|
@ -318,6 +318,62 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get storeSearchHint =>
|
||||
'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
|
||||
String get storeFeatured => 'FEATURED';
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import 'widgets/widgets.dart';
|
|||
/// Studio's own build version. Bump on every UI commit so the
|
||||
/// running app self-identifies — visible in the sidebar header
|
||||
/// 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 {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@
|
|||
// renders the full bilingual description, requires-list, and
|
||||
// repository link.
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
|
||||
import '../data/hub.dart';
|
||||
|
|
@ -46,6 +49,22 @@ class _StorePageState extends State<StorePage> {
|
|||
TodayStoryLoader.loadOrFallback(_kFallbackTodayStory);
|
||||
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
|
||||
/// `locale` setting. Drives which side of the bilingual
|
||||
/// store-index entries renders (taglineEn vs taglineDe,
|
||||
|
|
@ -57,6 +76,18 @@ class _StorePageState extends State<StorePage> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
_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
|
||||
|
|
@ -144,47 +175,53 @@ class _StorePageState extends State<StorePage> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SearchField(
|
||||
_AskBar(
|
||||
controller: _queryCtrl,
|
||||
onSubmit: _runSearch,
|
||||
onClear: () {
|
||||
_queryCtrl.clear();
|
||||
_runSearch();
|
||||
},
|
||||
thinking: _aiThinking,
|
||||
aiAvailable: _systemAiEnabled,
|
||||
onSubmit: _onAskSubmit,
|
||||
onClear: _clearQuery,
|
||||
onTextChange: _onAskTyping,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
FutureBuilder<List<StoreItem>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
final raw = snap.data ?? const <StoreItem>[];
|
||||
final items = _applySourceFilter(raw);
|
||||
final items = _applyAllFilters(raw);
|
||||
final categories = _categoriesIn(raw);
|
||||
final activeFilters = _activeFilterCount();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_CategoryStrip(
|
||||
categories: categories,
|
||||
selected: _category,
|
||||
onSelected: (v) {
|
||||
setState(() => _category = v);
|
||||
_runSearch();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
_FilterRow(
|
||||
status: _status,
|
||||
source: _source,
|
||||
installedOnly: _installedOnly,
|
||||
onStatus: (v) {
|
||||
setState(() => _status = v);
|
||||
_runSearch();
|
||||
},
|
||||
onSource: (v) => setState(() => _source = v),
|
||||
onInstalledOnly: (v) {
|
||||
setState(() => _installedOnly = v);
|
||||
_runSearch();
|
||||
},
|
||||
resultCount: items.length,
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_FilterButton(
|
||||
activeCount: activeFilters,
|
||||
onPressed: _openFilterDialog,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.md),
|
||||
if (categories.isNotEmpty)
|
||||
Expanded(
|
||||
child: _CategoryStrip(
|
||||
categories: categories,
|
||||
selected: _category,
|
||||
onSelected: (v) {
|
||||
setState(() => _category = v);
|
||||
_runSearch();
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
const Spacer(),
|
||||
Text(
|
||||
l.storeNResults(items.length),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -211,7 +248,7 @@ class _StorePageState extends State<StorePage> {
|
|||
);
|
||||
}
|
||||
final raw = snap.data ?? const <StoreItem>[];
|
||||
final items = _applySourceFilter(raw);
|
||||
final items = _applyAllFilters(raw);
|
||||
if (items.isEmpty) {
|
||||
return FaiEmptyState(
|
||||
icon: Icons.search_off,
|
||||
|
|
@ -296,6 +333,18 @@ class _StorePageState extends State<StorePage> {
|
|||
),
|
||||
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(
|
||||
items: items,
|
||||
locale: _locale,
|
||||
|
|
@ -329,6 +378,144 @@ class _StorePageState extends State<StorePage> {
|
|||
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 {
|
||||
if (_addingSources.contains(s.name)) return;
|
||||
setState(() => _addingSources.add(s.name));
|
||||
|
|
@ -378,46 +565,497 @@ String _sourceForItem(StoreItem i) {
|
|||
return 'federated';
|
||||
}
|
||||
|
||||
/// Big search input that owns the page's primary action.
|
||||
class _SearchField extends StatelessWidget {
|
||||
/// Multi-line ask-and-search input. Drives the toolbar's
|
||||
/// 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 bool thinking;
|
||||
final bool aiAvailable;
|
||||
final ValueChanged<String> onTextChange;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onClear;
|
||||
|
||||
const _SearchField({
|
||||
const _AskBar({
|
||||
required this.controller,
|
||||
required this.thinking,
|
||||
required this.aiAvailable,
|
||||
required this.onTextChange,
|
||||
required this.onSubmit,
|
||||
required this.onClear,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: controller,
|
||||
builder: (_, value, _) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
hintText: l.storeSearchHint,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
suffixIcon: value.text.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
onPressed: onClear,
|
||||
),
|
||||
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,
|
||||
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(
|
||||
isCollapsed: true,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(vertical: 6),
|
||||
hintText:
|
||||
aiAvailable ? l.storeAskHint : l.storeSearchHint,
|
||||
hintMaxLines: 2,
|
||||
hintStyle: theme.textTheme.bodyMedium?.copyWith(
|
||||
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,
|
||||
),
|
||||
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
|
||||
/// "All" chip first, then categories the current result set
|
||||
/// contains. Disappears when the index is empty.
|
||||
|
|
@ -458,93 +1096,70 @@ class _CategoryStrip extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class _FilterRow extends StatelessWidget {
|
||||
final String status;
|
||||
final String source;
|
||||
final bool installedOnly;
|
||||
final void Function(String) onStatus;
|
||||
final void Function(String) onSource;
|
||||
final void Function(bool) onInstalledOnly;
|
||||
final int resultCount;
|
||||
/// Builds the JSON-output prompt fed to the System-AI when the
|
||||
/// operator submits a question-shaped query. Pinned to a strict
|
||||
/// reply schema so the parser has stable ground to walk on; the
|
||||
/// prompt also hand-encodes the operator's locale so the LLM
|
||||
/// answers in the right language.
|
||||
String _buildAiSearchPrompt(
|
||||
String query,
|
||||
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({
|
||||
required this.status,
|
||||
required this.source,
|
||||
required this.installedOnly,
|
||||
required this.onStatus,
|
||||
required this.onSource,
|
||||
required this.onInstalledOnly,
|
||||
required this.resultCount,
|
||||
});
|
||||
"$query"
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: FaiSpace.sm,
|
||||
runSpacing: FaiSpace.xs,
|
||||
children: [
|
||||
_ChoiceChip(
|
||||
label: l.storeStatusAll,
|
||||
selected: status.isEmpty,
|
||||
onSelected: () => onStatus(''),
|
||||
),
|
||||
_ChoiceChip(
|
||||
label: l.storeStatusPublished,
|
||||
selected: status == 'published',
|
||||
onSelected: () => onStatus('published'),
|
||||
),
|
||||
_ChoiceChip(
|
||||
label: l.storeStatusAlpha,
|
||||
selected: status == 'alpha',
|
||||
onSelected: () => onStatus('alpha'),
|
||||
),
|
||||
_ChoiceChip(
|
||||
label: l.storeStatusPlanned,
|
||||
selected: status == 'planned',
|
||||
onSelected: () => onStatus('planned'),
|
||||
),
|
||||
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(
|
||||
label: Text(l.storeInstalledOnly),
|
||||
selected: installedOnly,
|
||||
onSelected: onInstalledOnly,
|
||||
showCheckmark: true,
|
||||
),
|
||||
Text(
|
||||
l.storeNResults(resultCount),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
These are the modules currently in the store, as JSON:
|
||||
${jsonEncode(modules)}
|
||||
|
||||
Reply with strict JSON only — no markdown fences, no commentary:
|
||||
{
|
||||
"answer": "<one short sentence in $lang explaining the recommendation>",
|
||||
"matches": [
|
||||
{"name": "<exact module name from the list above>", "why": "<one short phrase in $lang>"}
|
||||
]
|
||||
}
|
||||
|
||||
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.''';
|
||||
}
|
||||
|
||||
/// Pulls the JSON object out of the LLM's reply. Models often
|
||||
/// add chatty preamble or markdown fences; we slice from the
|
||||
/// first `{` to the last `}` and try to decode that. Unknown
|
||||
/// module names are dropped silently — the operator should not
|
||||
/// see hallucinated entries that don't actually exist.
|
||||
({String answer, List<_AiMatch> matches}) _parseAiSearchResponse(
|
||||
String text,
|
||||
List<StoreItem> all,
|
||||
) {
|
||||
final start = text.indexOf('{');
|
||||
final end = text.lastIndexOf('}');
|
||||
if (start < 0 || end <= start) {
|
||||
return (answer: text.trim(), matches: const <_AiMatch>[]);
|
||||
}
|
||||
try {
|
||||
final dynamic decoded = jsonDecode(text.substring(start, end + 1));
|
||||
if (decoded is! Map) {
|
||||
return (answer: text.trim(), matches: const <_AiMatch>[]);
|
||||
}
|
||||
final answer = (decoded['answer'] ?? '').toString();
|
||||
final matches = <_AiMatch>[];
|
||||
final knownNames = all.map((e) => e.name).toSet();
|
||||
final list = (decoded['matches'] as List?) ?? const [];
|
||||
for (final m in list) {
|
||||
if (m is Map) {
|
||||
final name = (m['name'] ?? '').toString();
|
||||
final why = (m['why'] ?? '').toString();
|
||||
if (knownNames.contains(name)) {
|
||||
matches.add(_AiMatch(name: name, why: why));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (answer: answer, matches: matches);
|
||||
} catch (_) {
|
||||
return (answer: text.trim(), matches: const <_AiMatch>[]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1950,16 +2565,16 @@ class _ScreenshotPlaceholder extends StatelessWidget {
|
|||
/// validation. The pipeline operator can override at any time
|
||||
/// without recompiling Studio — see docs/today-pipeline.md.
|
||||
const TodayStoryData _kFallbackTodayStory = TodayStoryData(
|
||||
badgeEn: 'CAPABILITY FEDERATION',
|
||||
badgeDe: 'CAPABILITY-FEDERATION',
|
||||
titleEn: 'Two MCP servers away from a richer hub',
|
||||
titleDe: 'Zwei MCP-Server bis zu einem reicheren Hub',
|
||||
badgeEn: 'PUBLIC SOURCES',
|
||||
badgeDe: 'ÖFFENTLICHE QUELLEN',
|
||||
titleEn: 'Fill your store in two clicks',
|
||||
titleDe: 'Den Store in zwei Klicks auffüllen',
|
||||
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:
|
||||
'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.',
|
||||
ctaLabelEn: 'Open MCP settings',
|
||||
ctaLabelDe: 'MCP-Einstellungen öffnen',
|
||||
'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: 'Manage sources',
|
||||
ctaLabelDe: 'Quellen verwalten',
|
||||
icon: Icons.hub_outlined,
|
||||
cta: TodayCta.openSettings,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
name: fai_studio
|
||||
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
||||
publish_to: 'none'
|
||||
version: 0.31.1
|
||||
version: 0.32.0
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0-200.1.beta
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue