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:
flemming-it 2026-07-12 23:24:27 +02:00
parent 1f1e050b42
commit 09c901b75e
7 changed files with 619 additions and 2 deletions

View file

@ -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<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
/// 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<void> show(BuildContext context) {
@ -149,6 +210,15 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
final Set<String> _installing = {};
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
void initState() {
super.initState();
@ -157,9 +227,36 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
_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<void> _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<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
/// the bundle URL from its store index.
Future<void> _install(String module) async {
@ -280,12 +431,21 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
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<GuidedSetupDialog> {
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) {
return [
TextButton(