From 09c901b75ebff798c5f2a075ce8fd6673f609be3 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sun, 12 Jul 2026 23:24:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20guided=20setup=20=E2=80=94=20free-text?= =?UTF-8?q?=20AI=20path=20(phase=201.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wizard's first step now offers 'or just describe what you want to do': the goal goes to the configured system AI, which maps it onto the menu answers (validated against strict enum whitelists — a hallucinated value can never reach the engine). The suggestion comes back as an editable plain-language reflection ('this is how I read your task') the operator can adjust step-by-step or take to the same preview/apply the menu path uses. Trust rules per guided-setup.md: suggestion only (never auto-apply), a privacy line states whether the description is processed locally or sent to a provider, and without a configured system AI the section explains that the menu always works — no dead end. Signed-off-by: flemming-it --- lib/l10n/app_de.arb | 20 ++ lib/l10n/app_en.arb | 20 ++ lib/l10n/app_localizations.dart | 90 +++++++++ lib/l10n/app_localizations_de.dart | 60 ++++++ lib/l10n/app_localizations_en.dart | 60 ++++++ lib/widgets/guided_setup_dialog.dart | 287 ++++++++++++++++++++++++++- test/guided_setup_test.dart | 84 ++++++++ 7 files changed, 619 insertions(+), 2 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 75b8838..4933d00 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1761,6 +1761,26 @@ "setupAllowUnsigned": "Installation aus dem öffentlichen Store erlauben", "setupAllowUnsignedSub": "Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.", "setupStartCta": "In 3 Fragen loslegen", + "setupFreeTextHint": "z. B.: Eingehende Anträge vorprüfen und unvollständige markieren", + "setupFreeTextSuggest": "Vorschlagen lassen", + "setupFreeTextPrivacyLocal": "Ihre Beschreibung wird lokal auf diesem Rechner verarbeitet ({model}).", + "@setupFreeTextPrivacyLocal": {"placeholders": {"model": {"type": "String"}}}, + "setupFreeTextPrivacyRemote": "Ihre Beschreibung wird an die eingerichtete System-KI gesendet: {model} ({provider}).", + "@setupFreeTextPrivacyRemote": {"placeholders": {"model": {"type": "String"}, "provider": {"type": "String"}}}, + "setupFreeTextUnavailable": "Für den Freitext-Weg braucht Ch∆In eine eingerichtete System-KI (Einstellungen → System-KI). Die Auswahl oben funktioniert immer — ganz ohne KI.", + "setupFreeTextParseError": "Die System-KI hat keinen verwertbaren Vorschlag geliefert. Wählen Sie oben aus dem Menü — oder versuchen Sie es noch einmal.", + "setupReflectionTitle": "So verstehe ich Ihre Aufgabe", + "setupReflectionScenario": "Worum es geht: {label}", + "@setupReflectionScenario": {"placeholders": {"label": {"type": "String"}}}, + "setupReflectionIntent": "Erste Aufgabe: {label}", + "@setupReflectionIntent": {"placeholders": {"label": {"type": "String"}}}, + "setupReflectionTarget": "Umgebung: {label}", + "@setupReflectionTarget": {"placeholders": {"label": {"type": "String"}}}, + "setupReflectionApproval": "Vor jedem KI-Schritt wird ein Mensch um Freigabe gebeten.", + "setupReflectionDataLocal": "Daten bleiben vollständig im Haus.", + "setupReflectionEditHint": "Es wird nichts ohne Ihre Bestätigung geändert. Passen Sie die Punkte an oder gehen Sie direkt zur Vorschau.", + "setupReflectionAdjust": "Anpassen", + "setupReflectionToPreview": "Zur Vorschau", "setupProfileDev": "unkomplizierter Arbeitsplatz", "setupProfileEnterprise": "abgesicherter Team-Betrieb", "setupProfileAirgapped": "vollständig lokaler, regulierter Betrieb", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6314d57..d8c90fa 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1800,6 +1800,26 @@ "setupAllowUnsigned": "Allow installing from the public store", "setupAllowUnsignedSub": "Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.", "setupStartCta": "Get started in 3 questions", + "setupFreeTextHint": "e.g.: pre-screen incoming applications and flag incomplete ones", + "setupFreeTextSuggest": "Suggest a setup", + "setupFreeTextPrivacyLocal": "Your description is processed locally on this machine ({model}).", + "@setupFreeTextPrivacyLocal": {"placeholders": {"model": {"type": "String"}}}, + "setupFreeTextPrivacyRemote": "Your description is sent to the configured system AI: {model} ({provider}).", + "@setupFreeTextPrivacyRemote": {"placeholders": {"model": {"type": "String"}, "provider": {"type": "String"}}}, + "setupFreeTextUnavailable": "The free-text path needs a configured system AI (Settings → System AI). The choices above always work — no AI required.", + "setupFreeTextParseError": "The system AI did not return a usable suggestion. Pick from the menu above — or try again.", + "setupReflectionTitle": "This is how I read your task", + "setupReflectionScenario": "What it is about: {label}", + "@setupReflectionScenario": {"placeholders": {"label": {"type": "String"}}}, + "setupReflectionIntent": "First task: {label}", + "@setupReflectionIntent": {"placeholders": {"label": {"type": "String"}}}, + "setupReflectionTarget": "Environment: {label}", + "@setupReflectionTarget": {"placeholders": {"label": {"type": "String"}}}, + "setupReflectionApproval": "A human is asked for approval before every AI step.", + "setupReflectionDataLocal": "Data never leaves the building.", + "setupReflectionEditHint": "Nothing is changed without your confirmation. Adjust any point, or go straight to the preview.", + "setupReflectionAdjust": "Adjust", + "setupReflectionToPreview": "To the preview", "setupProfileDev": "straightforward workstation", "setupProfileEnterprise": "hardened team operation", "setupProfileAirgapped": "fully local, regulated operation", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5b7ec8e..7b44495 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5395,6 +5395,96 @@ abstract class AppLocalizations { /// **'Get started in 3 questions'** String get setupStartCta; + /// No description provided for @setupFreeTextHint. + /// + /// In en, this message translates to: + /// **'e.g.: pre-screen incoming applications and flag incomplete ones'** + String get setupFreeTextHint; + + /// No description provided for @setupFreeTextSuggest. + /// + /// In en, this message translates to: + /// **'Suggest a setup'** + String get setupFreeTextSuggest; + + /// No description provided for @setupFreeTextPrivacyLocal. + /// + /// In en, this message translates to: + /// **'Your description is processed locally on this machine ({model}).'** + String setupFreeTextPrivacyLocal(String model); + + /// No description provided for @setupFreeTextPrivacyRemote. + /// + /// In en, this message translates to: + /// **'Your description is sent to the configured system AI: {model} ({provider}).'** + String setupFreeTextPrivacyRemote(String model, String provider); + + /// No description provided for @setupFreeTextUnavailable. + /// + /// In en, this message translates to: + /// **'The free-text path needs a configured system AI (Settings → System AI). The choices above always work — no AI required.'** + String get setupFreeTextUnavailable; + + /// No description provided for @setupFreeTextParseError. + /// + /// In en, this message translates to: + /// **'The system AI did not return a usable suggestion. Pick from the menu above — or try again.'** + String get setupFreeTextParseError; + + /// No description provided for @setupReflectionTitle. + /// + /// In en, this message translates to: + /// **'This is how I read your task'** + String get setupReflectionTitle; + + /// No description provided for @setupReflectionScenario. + /// + /// In en, this message translates to: + /// **'What it is about: {label}'** + String setupReflectionScenario(String label); + + /// No description provided for @setupReflectionIntent. + /// + /// In en, this message translates to: + /// **'First task: {label}'** + String setupReflectionIntent(String label); + + /// No description provided for @setupReflectionTarget. + /// + /// In en, this message translates to: + /// **'Environment: {label}'** + String setupReflectionTarget(String label); + + /// No description provided for @setupReflectionApproval. + /// + /// In en, this message translates to: + /// **'A human is asked for approval before every AI step.'** + String get setupReflectionApproval; + + /// No description provided for @setupReflectionDataLocal. + /// + /// In en, this message translates to: + /// **'Data never leaves the building.'** + String get setupReflectionDataLocal; + + /// No description provided for @setupReflectionEditHint. + /// + /// In en, this message translates to: + /// **'Nothing is changed without your confirmation. Adjust any point, or go straight to the preview.'** + String get setupReflectionEditHint; + + /// No description provided for @setupReflectionAdjust. + /// + /// In en, this message translates to: + /// **'Adjust'** + String get setupReflectionAdjust; + + /// No description provided for @setupReflectionToPreview. + /// + /// In en, this message translates to: + /// **'To the preview'** + String get setupReflectionToPreview; + /// No description provided for @setupProfileDev. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 2c3bc1b..ef59997 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3182,6 +3182,66 @@ class AppLocalizationsDe extends AppLocalizations { @override String get setupStartCta => 'In 3 Fragen loslegen'; + @override + String get setupFreeTextHint => + 'z. B.: Eingehende Anträge vorprüfen und unvollständige markieren'; + + @override + String get setupFreeTextSuggest => 'Vorschlagen lassen'; + + @override + String setupFreeTextPrivacyLocal(String model) { + return 'Ihre Beschreibung wird lokal auf diesem Rechner verarbeitet ($model).'; + } + + @override + String setupFreeTextPrivacyRemote(String model, String provider) { + return 'Ihre Beschreibung wird an die eingerichtete System-KI gesendet: $model ($provider).'; + } + + @override + String get setupFreeTextUnavailable => + 'Für den Freitext-Weg braucht Ch∆In eine eingerichtete System-KI (Einstellungen → System-KI). Die Auswahl oben funktioniert immer — ganz ohne KI.'; + + @override + String get setupFreeTextParseError => + 'Die System-KI hat keinen verwertbaren Vorschlag geliefert. Wählen Sie oben aus dem Menü — oder versuchen Sie es noch einmal.'; + + @override + String get setupReflectionTitle => 'So verstehe ich Ihre Aufgabe'; + + @override + String setupReflectionScenario(String label) { + return 'Worum es geht: $label'; + } + + @override + String setupReflectionIntent(String label) { + return 'Erste Aufgabe: $label'; + } + + @override + String setupReflectionTarget(String label) { + return 'Umgebung: $label'; + } + + @override + String get setupReflectionApproval => + 'Vor jedem KI-Schritt wird ein Mensch um Freigabe gebeten.'; + + @override + String get setupReflectionDataLocal => 'Daten bleiben vollständig im Haus.'; + + @override + String get setupReflectionEditHint => + 'Es wird nichts ohne Ihre Bestätigung geändert. Passen Sie die Punkte an oder gehen Sie direkt zur Vorschau.'; + + @override + String get setupReflectionAdjust => 'Anpassen'; + + @override + String get setupReflectionToPreview => 'Zur Vorschau'; + @override String get setupProfileDev => 'unkomplizierter Arbeitsplatz'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4342406..f114fa4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3179,6 +3179,66 @@ class AppLocalizationsEn extends AppLocalizations { @override String get setupStartCta => 'Get started in 3 questions'; + @override + String get setupFreeTextHint => + 'e.g.: pre-screen incoming applications and flag incomplete ones'; + + @override + String get setupFreeTextSuggest => 'Suggest a setup'; + + @override + String setupFreeTextPrivacyLocal(String model) { + return 'Your description is processed locally on this machine ($model).'; + } + + @override + String setupFreeTextPrivacyRemote(String model, String provider) { + return 'Your description is sent to the configured system AI: $model ($provider).'; + } + + @override + String get setupFreeTextUnavailable => + 'The free-text path needs a configured system AI (Settings → System AI). The choices above always work — no AI required.'; + + @override + String get setupFreeTextParseError => + 'The system AI did not return a usable suggestion. Pick from the menu above — or try again.'; + + @override + String get setupReflectionTitle => 'This is how I read your task'; + + @override + String setupReflectionScenario(String label) { + return 'What it is about: $label'; + } + + @override + String setupReflectionIntent(String label) { + return 'First task: $label'; + } + + @override + String setupReflectionTarget(String label) { + return 'Environment: $label'; + } + + @override + String get setupReflectionApproval => + 'A human is asked for approval before every AI step.'; + + @override + String get setupReflectionDataLocal => 'Data never leaves the building.'; + + @override + String get setupReflectionEditHint => + 'Nothing is changed without your confirmation. Adjust any point, or go straight to the preview.'; + + @override + String get setupReflectionAdjust => 'Adjust'; + + @override + String get setupReflectionToPreview => 'To the preview'; + @override String get setupProfileDev => 'straightforward workstation'; diff --git a/lib/widgets/guided_setup_dialog.dart b/lib/widgets/guided_setup_dialog.dart index 6ad01ff..9ca9ed7 100644 --- a/lib/widgets/guided_setup_dialog.dart +++ b/lib/widgets/guided_setup_dialog.dart @@ -22,6 +22,61 @@ import '../l10n/app_localizations.dart'; import '../main.dart' show StudioShellState; import '../theme/tokens.dart'; +/// Allowed wire values per answer — an AI suggestion is validated +/// against these; anything else is rejected as a parse failure so a +/// hallucinated enum can never reach the engine. +const _kScenarioValues = [ + 'trying-out', + 'team-hub', + 'regulated-production', + 'building-modules', +]; +const _kIntentValues = [ + 'hello-world', + 'extract-text', + 'extract-summarize', + 'classify-documents', + 'build-module', +]; +const _kTargetValues = [ + 'this-laptop', + 'home-server', + 'air-gapped-server', + 'container', +]; + +/// Extract + validate the system AI's setup suggestion: the first +/// JSON object in [text] with valid enum values for all three +/// answers. Returns null when nothing safe could be parsed. +@visibleForTesting +Map? parseSetupSuggestion(String text) { + final start = text.indexOf('{'); + final end = text.lastIndexOf('}'); + if (start < 0 || end <= start) return null; + final Object? decoded; + try { + decoded = jsonDecode(text.substring(start, end + 1)); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + final scenario = decoded['scenario']; + final intent = decoded['intent']; + final target = decoded['target']; + if (!_kScenarioValues.contains(scenario) || + !_kIntentValues.contains(intent) || + !_kTargetValues.contains(target)) { + return null; + } + return { + 'scenario': scenario, + 'intent': intent, + 'target': target, + 'require_approval': decoded['require_approval'] == true, + 'data_must_stay_local': decoded['data_must_stay_local'] == true, + }; +} + /// A selectable setup option: the stable kebab wire value plus the /// localized label + one-line explanation resolved at build time. class _Option { @@ -47,11 +102,17 @@ class GuidedSetupDialog extends StatefulWidget { @visibleForTesting final bool debugApplied; + /// Test seam: skip the live system-AI probe so the free-text + /// section renders its deterministic "no AI configured" state. + @visibleForTesting + final bool debugSkipAiProbe; + const GuidedSetupDialog({ super.key, this.shell, this.debugPlan, this.debugApplied = false, + this.debugSkipAiProbe = false, }); static Future show(BuildContext context) { @@ -149,6 +210,15 @@ class _GuidedSetupDialogState extends State { final Set _installing = {}; final Set _installed = {}; + // Free-text (AI) path state. The menu path is always complete on + // its own; the AI merely PRE-SELECTS answers for review — it never + // applies anything (trust rule from guided-setup.md). + final TextEditingController _goalCtl = TextEditingController(); + SystemAiStatus? _aiStatus; // null until probed / when unreachable + bool _aiProbed = false; + bool _suggestBusy = false; + bool _showReflection = false; + @override void initState() { super.initState(); @@ -157,9 +227,36 @@ class _GuidedSetupDialogState extends State { _plan = seeded; _step = _totalSteps; _applied = widget.debugApplied; + _aiProbed = true; // tests: skip the live probe + } else if (widget.debugSkipAiProbe) { + _aiProbed = true; + } else { + unawaited(_probeAi()); } } + @override + void dispose() { + _goalCtl.dispose(); + super.dispose(); + } + + /// Probe whether a system AI is configured — the free-text path + /// needs one; without it the menu path stands alone (no dead end). + Future _probeAi() async { + SystemAiStatus? status; + try { + status = await HubService.instance.systemAiStatus(); + } catch (_) { + status = null; + } + if (!mounted) return; + setState(() { + _aiStatus = status; + _aiProbed = true; + }); + } + String _answersYaml() => 'scenario: $_scenario\n' 'intent: $_intent\n' @@ -260,6 +357,60 @@ class _GuidedSetupDialogState extends State { } } + /// Structured prompt for the system AI. English, fixed shape, and + /// the reply is validated against the enum whitelists — the model + /// only ever pre-selects menu answers, it cannot inject config. + String _suggestionPrompt(String goal) => + 'You configure the Ch∆In workflow platform. Map the operator\'s ' + 'goal to setup answers.\n' + 'Goal: """$goal"""\n' + 'Reply with ONLY one JSON object, no prose, of this exact shape:\n' + '{"scenario":"trying-out|team-hub|regulated-production|building-modules",' + '"intent":"hello-world|extract-text|extract-summarize|classify-documents|build-module",' + '"target":"this-laptop|home-server|air-gapped-server|container",' + '"require_approval":true|false,"data_must_stay_local":true|false}\n' + 'Pick the closest match for each field.'; + + /// Ask the system AI to map the free-text goal onto the menu + /// answers, then show the editable reflection. Never applies — + /// the suggestion only pre-selects; preview + apply stay manual. + Future _suggest() async { + final goal = _goalCtl.text.trim(); + if (goal.isEmpty) return; + setState(() => _suggestBusy = true); + final l = AppLocalizations.of(context)!; + try { + final r = await HubService.instance.askAi(_suggestionPrompt(goal)); + if (!mounted) return; + setState(() => _suggestBusy = false); + if (r.errorKind.isNotEmpty) { + await showChainErrorDialog(context, 'system-ai', r.text); + return; + } + final parsed = parseSetupSuggestion(r.text); + if (parsed == null) { + await showChainErrorDialog( + context, + 'system-ai', + '${l.setupFreeTextParseError}\n\n${r.text}', + ); + return; + } + setState(() { + _scenario = parsed['scenario'] as String; + _intent = parsed['intent'] as String; + _target = parsed['target'] as String; + _requireApproval = parsed['require_approval'] as bool; + _dataLocal = parsed['data_must_stay_local'] as bool; + _showReflection = true; + }); + } catch (e) { + if (!mounted) return; + setState(() => _suggestBusy = false); + await showChainErrorDialog(context, 'system-ai', e); + } + } + /// Install one plan module by capability name — the hub resolves /// the bundle URL from its store index. Future _install(String module) async { @@ -280,12 +431,21 @@ class _GuidedSetupDialogState extends State { Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; final reviewing = _step >= _totalSteps; + final title = reviewing + ? l.setupReviewTitle + : _showReflection + ? l.setupReflectionTitle + : _stepTitle(l); return AlertDialog( - title: Text(reviewing ? l.setupReviewTitle : _stepTitle(l)), + title: Text(title), content: SizedBox( width: 480, child: SingleChildScrollView( - child: reviewing ? _reviewStep(l) : _answerStep(l), + child: reviewing + ? _reviewStep(l) + : _showReflection + ? _reflectionView(l) + : _answerStep(l), ), ), actions: _actions(l), @@ -341,6 +501,112 @@ class _GuidedSetupDialogState extends State { onChanged: (v) => setState(() => _dataLocal = v), ), ], + // The free-text alternative lives on the first step: describe + // the goal, the system AI pre-selects the menu answers. Menu + // path always stands alone (air-gap-safe, no dead end). + if (_step == 0 && _aiProbed) ...[ + const SizedBox(height: ChainSpace.md), + Text( + l.setupChooseFreeText, + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: ChainSpace.sm), + if (_aiStatus?.enabled == true) ...[ + TextField( + controller: _goalCtl, + minLines: 2, + maxLines: 4, + decoration: InputDecoration( + hintText: l.setupFreeTextHint, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: ChainSpace.xs), + // Trust rule: be transparent about where the description + // goes before the operator types anything sensitive. + Text( + _aiIsLocal() + ? l.setupFreeTextPrivacyLocal(_aiStatus?.model ?? '') + : l.setupFreeTextPrivacyRemote( + _aiStatus?.model ?? '', + _aiStatus?.provider ?? '', + ), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: ChainSpace.sm), + Align( + alignment: Alignment.centerLeft, + child: FilledButton.tonalIcon( + onPressed: _suggestBusy ? null : _suggest, + icon: _suggestBusy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.auto_awesome, size: 18), + label: Text(l.setupFreeTextSuggest), + ), + ), + ] else + _hintRow(l.setupFreeTextUnavailable), + ], + ], + ); + } + + /// True when the configured system AI runs on this machine — the + /// privacy line says so instead of naming a cloud provider. + bool _aiIsLocal() { + final s = _aiStatus; + if (s == null) return false; + return s.provider == 'ollama' || + s.endpoint.contains('localhost') || + s.endpoint.contains('127.0.0.1'); + } + + /// Editable reflection of the AI suggestion: "this is how I read + /// your task", in the same localized labels the menu uses. The + /// operator either adjusts (steps, pre-selected) or goes straight + /// to the same preview the menu path uses. Nothing auto-applies. + Widget _reflectionView(AppLocalizations l) { + final theme = Theme.of(context); + String labelFor(List<_Option> opts, String value) => + opts.firstWhere((o) => o.value == value, orElse: () => opts.first) + .label(l); + final lines = [ + l.setupReflectionScenario(labelFor(_scenarios, _scenario)), + l.setupReflectionIntent(labelFor(_intents, _intent)), + l.setupReflectionTarget(labelFor(_targets, _target)), + if (_requireApproval) l.setupReflectionApproval, + if (_dataLocal) l.setupReflectionDataLocal, + ]; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final line in lines) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('· ', style: theme.textTheme.bodyMedium), + Expanded( + child: Text(line, style: theme.textTheme.bodyMedium), + ), + ], + ), + ), + const SizedBox(height: ChainSpace.xs), + Text( + l.setupReflectionEditHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), ], ); } @@ -598,6 +864,23 @@ class _GuidedSetupDialogState extends State { ), ]; } + if (_showReflection && _step < _totalSteps) { + // AI reflection: adjust step-by-step (answers stay pre-selected) + // or continue to the same preview the menu path uses. + return [ + TextButton( + onPressed: () => setState(() => _showReflection = false), + child: Text(l.setupReflectionAdjust), + ), + FilledButton( + onPressed: () { + setState(() => _showReflection = false); + _goReview(); + }, + child: Text(l.setupReflectionToPreview), + ), + ]; + } if (_step < _totalSteps) { return [ TextButton( diff --git a/test/guided_setup_test.dart b/test/guided_setup_test.dart index 326484f..0faf348 100644 --- a/test/guided_setup_test.dart +++ b/test/guided_setup_test.dart @@ -176,4 +176,88 @@ void main() { expect(find.text('text.extract installieren'), findsNothing); }, ); + + testWidgets( + 'step 1 offers the free-text path with an honest no-AI fallback hint', + (tester) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => showDialog( + context: context, + builder: (_) => + const GuidedSetupDialog(debugSkipAiProbe: true), + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect( + find.text('Oder beschreiben Sie einfach, was Sie vorhaben'), + findsOneWidget, + ); + // No system AI configured → honest fallback: the menu always + // works; no dead end, no disabled mystery button. + expect( + find.textContaining('Die Auswahl oben funktioniert immer'), + findsOneWidget, + ); + expect(find.text('Vorschlagen lassen'), findsNothing); + }, + ); + + group('parseSetupSuggestion', () { + test('accepts a valid JSON object embedded in prose', () { + final r = parseSetupSuggestion( + 'Sure! Here is the mapping:\n' + '{"scenario":"regulated-production","intent":"classify-documents",' + '"target":"home-server","require_approval":true,' + '"data_must_stay_local":false}\n' + 'Let me know if you need anything else.', + ); + expect(r, isNotNull); + expect(r!['scenario'], 'regulated-production'); + expect(r['intent'], 'classify-documents'); + expect(r['target'], 'home-server'); + expect(r['require_approval'], true); + expect(r['data_must_stay_local'], false); + }); + + test('rejects hallucinated enum values', () { + expect( + parseSetupSuggestion( + '{"scenario":"world-domination","intent":"hello-world",' + '"target":"this-laptop"}', + ), + isNull, + ); + }); + + test('rejects non-JSON replies', () { + expect(parseSetupSuggestion('I cannot help with that.'), isNull); + expect(parseSetupSuggestion('{broken json'), isNull); + }); + + test('missing booleans default to false', () { + final r = parseSetupSuggestion( + '{"scenario":"trying-out","intent":"hello-world",' + '"target":"this-laptop"}', + ); + expect(r, isNotNull); + expect(r!['require_approval'], false); + expect(r['data_must_stay_local'], false); + }); + }); }