feat: guided setup — free-text AI path (phase 1.2)
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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
1f1e050b42
commit
09c901b75e
7 changed files with 619 additions and 2 deletions
|
|
@ -1761,6 +1761,26 @@
|
||||||
"setupAllowUnsigned": "Installation aus dem öffentlichen Store erlauben",
|
"setupAllowUnsigned": "Installation aus dem öffentlichen Store erlauben",
|
||||||
"setupAllowUnsignedSub": "Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.",
|
"setupAllowUnsignedSub": "Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.",
|
||||||
"setupStartCta": "In 3 Fragen loslegen",
|
"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",
|
"setupProfileDev": "unkomplizierter Arbeitsplatz",
|
||||||
"setupProfileEnterprise": "abgesicherter Team-Betrieb",
|
"setupProfileEnterprise": "abgesicherter Team-Betrieb",
|
||||||
"setupProfileAirgapped": "vollständig lokaler, regulierter Betrieb",
|
"setupProfileAirgapped": "vollständig lokaler, regulierter Betrieb",
|
||||||
|
|
|
||||||
|
|
@ -1800,6 +1800,26 @@
|
||||||
"setupAllowUnsigned": "Allow installing from the public store",
|
"setupAllowUnsigned": "Allow installing from the public store",
|
||||||
"setupAllowUnsignedSub": "Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.",
|
"setupAllowUnsignedSub": "Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.",
|
||||||
"setupStartCta": "Get started in 3 questions",
|
"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",
|
"setupProfileDev": "straightforward workstation",
|
||||||
"setupProfileEnterprise": "hardened team operation",
|
"setupProfileEnterprise": "hardened team operation",
|
||||||
"setupProfileAirgapped": "fully local, regulated operation",
|
"setupProfileAirgapped": "fully local, regulated operation",
|
||||||
|
|
|
||||||
|
|
@ -5395,6 +5395,96 @@ abstract class AppLocalizations {
|
||||||
/// **'Get started in 3 questions'**
|
/// **'Get started in 3 questions'**
|
||||||
String get setupStartCta;
|
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.
|
/// No description provided for @setupProfileDev.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -3182,6 +3182,66 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get setupStartCta => 'In 3 Fragen loslegen';
|
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
|
@override
|
||||||
String get setupProfileDev => 'unkomplizierter Arbeitsplatz';
|
String get setupProfileDev => 'unkomplizierter Arbeitsplatz';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3179,6 +3179,66 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get setupStartCta => 'Get started in 3 questions';
|
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
|
@override
|
||||||
String get setupProfileDev => 'straightforward workstation';
|
String get setupProfileDev => 'straightforward workstation';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,61 @@ import '../l10n/app_localizations.dart';
|
||||||
import '../main.dart' show StudioShellState;
|
import '../main.dart' show StudioShellState;
|
||||||
import '../theme/tokens.dart';
|
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<String, dynamic>? 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<String, dynamic>) 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
|
/// A selectable setup option: the stable kebab wire value plus the
|
||||||
/// localized label + one-line explanation resolved at build time.
|
/// localized label + one-line explanation resolved at build time.
|
||||||
class _Option {
|
class _Option {
|
||||||
|
|
@ -47,11 +102,17 @@ class GuidedSetupDialog extends StatefulWidget {
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
final bool debugApplied;
|
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({
|
const GuidedSetupDialog({
|
||||||
super.key,
|
super.key,
|
||||||
this.shell,
|
this.shell,
|
||||||
this.debugPlan,
|
this.debugPlan,
|
||||||
this.debugApplied = false,
|
this.debugApplied = false,
|
||||||
|
this.debugSkipAiProbe = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
static Future<void> show(BuildContext context) {
|
static Future<void> show(BuildContext context) {
|
||||||
|
|
@ -149,6 +210,15 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
final Set<String> _installing = {};
|
final Set<String> _installing = {};
|
||||||
final Set<String> _installed = {};
|
final Set<String> _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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -157,9 +227,36 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
_plan = seeded;
|
_plan = seeded;
|
||||||
_step = _totalSteps;
|
_step = _totalSteps;
|
||||||
_applied = widget.debugApplied;
|
_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<void> _probeAi() async {
|
||||||
|
SystemAiStatus? status;
|
||||||
|
try {
|
||||||
|
status = await HubService.instance.systemAiStatus();
|
||||||
|
} catch (_) {
|
||||||
|
status = null;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_aiStatus = status;
|
||||||
|
_aiProbed = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
String _answersYaml() =>
|
String _answersYaml() =>
|
||||||
'scenario: $_scenario\n'
|
'scenario: $_scenario\n'
|
||||||
'intent: $_intent\n'
|
'intent: $_intent\n'
|
||||||
|
|
@ -260,6 +357,60 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<void> _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
|
/// Install one plan module by capability name — the hub resolves
|
||||||
/// the bundle URL from its store index.
|
/// the bundle URL from its store index.
|
||||||
Future<void> _install(String module) async {
|
Future<void> _install(String module) async {
|
||||||
|
|
@ -280,12 +431,21 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
final reviewing = _step >= _totalSteps;
|
final reviewing = _step >= _totalSteps;
|
||||||
|
final title = reviewing
|
||||||
|
? l.setupReviewTitle
|
||||||
|
: _showReflection
|
||||||
|
? l.setupReflectionTitle
|
||||||
|
: _stepTitle(l);
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text(reviewing ? l.setupReviewTitle : _stepTitle(l)),
|
title: Text(title),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: 480,
|
width: 480,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: reviewing ? _reviewStep(l) : _answerStep(l),
|
child: reviewing
|
||||||
|
? _reviewStep(l)
|
||||||
|
: _showReflection
|
||||||
|
? _reflectionView(l)
|
||||||
|
: _answerStep(l),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: _actions(l),
|
actions: _actions(l),
|
||||||
|
|
@ -341,6 +501,112 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
onChanged: (v) => setState(() => _dataLocal = v),
|
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 = <String>[
|
||||||
|
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<GuidedSetupDialog> {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
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) {
|
if (_step < _totalSteps) {
|
||||||
return [
|
return [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|
|
||||||
|
|
@ -176,4 +176,88 @@ void main() {
|
||||||
expect(find.text('text.extract installieren'), findsNothing);
|
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<void>(
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue