Compare commits
6 commits
140408d26a
...
7888b71f63
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7888b71f63 | ||
|
|
ddac84ce8e | ||
|
|
cf4024a4e2 | ||
|
|
ed2c5ace26 | ||
|
|
09c901b75e | ||
|
|
1f1e050b42 |
16 changed files with 2155 additions and 110 deletions
58
CHANGELOG.md
58
CHANGELOG.md
|
|
@ -5,6 +5,64 @@ version + `kStudioVersion` in `lib/main.dart` stay in lockstep.
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added (guided setup on grade-1 — steps A4/A5/B1 + doc automation)
|
||||||
|
|
||||||
|
- **Clickable next steps.** After apply, the wizard renders real
|
||||||
|
Studio actions instead of CLI text: start-hub button (polls until
|
||||||
|
the daemon answers), per-module install buttons with progress/done
|
||||||
|
states (capability-name install via the hub's store index), and an
|
||||||
|
open-the-starter-flow button navigating to Flows.
|
||||||
|
- **Signature dead end resolved.** Regulated plans explain in plain
|
||||||
|
language that modules come from a signed source; the preview offers
|
||||||
|
"allow installing from the public store" as one deliberate,
|
||||||
|
reversible switch (`allow_unsigned_modules`) that re-assembles the
|
||||||
|
plan. Air-gapped plans point to the offline-bundle path instead.
|
||||||
|
- **Fresh-install auto-open + honest framing.** On a fresh hub (no
|
||||||
|
config, no `setup-plan.yaml`) the wizard opens by itself, once per
|
||||||
|
run; the welcome CTA is now "In 3 Fragen loslegen" / "Get started
|
||||||
|
in 3 questions". After the wizard closes, the onboarding checklist
|
||||||
|
re-probes and states what the assistant already covered (applied
|
||||||
|
profile from `setup-plan.yaml`).
|
||||||
|
- **Free-text AI path (phase 1.2).** "Or just describe what you want
|
||||||
|
to do": the goal goes to the configured system AI, the reply is
|
||||||
|
validated against strict enum whitelists and comes back as an
|
||||||
|
editable "this is how I read your task" reflection feeding the same
|
||||||
|
preview/apply. Privacy line states local vs. provider processing;
|
||||||
|
without a system AI the menu path stands alone.
|
||||||
|
- **Nav manifest guard.** `test/nav_manifest_test.dart` generates
|
||||||
|
`docs/nav.generated.json` from the sidebar truth (ids, order = Cmd
|
||||||
|
numbers, DE+EN labels); the platform repo checks the operator guide
|
||||||
|
against the mirrored copy.
|
||||||
|
- **Guide screenshot harness.** `integration_test/guide_shots_test.dart`
|
||||||
|
boots a hermetic hub, seeds demo projects, walks every nav page,
|
||||||
|
the workspace switcher and the setup wizard, and writes the guide
|
||||||
|
PNGs — driven by the platform repo's `scripts/regen-studio-guide.sh`.
|
||||||
|
- **Fixed:** the integration-test hub fixture still looked for the
|
||||||
|
pre-rename `fai` binary and `chain_platform/` path, so its tests
|
||||||
|
silently skipped since the rename; it now resolves `$CHAIN_BIN`,
|
||||||
|
`chain` on PATH, and `../fai_chain/target/{release,debug}/chain`.
|
||||||
|
|
||||||
|
### Added (guided setup — persona re-audit fixes, grade-1 round)
|
||||||
|
|
||||||
|
- **Regulated path finishes without a terminal.** The post-apply
|
||||||
|
signed-source state now offers "Add a signed source…" (the stores
|
||||||
|
dialog with its pin-a-key field) plus the per-module install
|
||||||
|
buttons, instead of a hint with no affordance; a plain-language
|
||||||
|
trust hint explains why pinning the publisher's key matters.
|
||||||
|
- **Apply warnings surface.** Warning lines from `chain init --apply`
|
||||||
|
(e.g. the empty-trusted-publishers caveat) are shown selectable in
|
||||||
|
the done state instead of being swallowed on success.
|
||||||
|
- **Truthful preview.** New plan lines state which machine is being
|
||||||
|
set up (server/container targets configure THIS machine — said
|
||||||
|
explicitly), that regulated profiles get the hash-chained audit
|
||||||
|
log even when WORM is off, and that a curated reading list is
|
||||||
|
stored with the setup record.
|
||||||
|
- **Language pass.** Onboarding checklist switched to Sie-form and
|
||||||
|
"System-KI" (was du-form + "System-AI" next to the formal wizard);
|
||||||
|
"Audit-Sperre" jargon replaced with "Schreibschutz für das
|
||||||
|
Prüfprotokoll"; answers file now lives in a private per-dialog
|
||||||
|
temp dir instead of a fixed world-readable name.
|
||||||
|
|
||||||
### Added (multi-project, stage ③ — sealed areas)
|
### Added (multi-project, stage ③ — sealed areas)
|
||||||
|
|
||||||
- **Sealed-area connection switch.** The workspace switcher now lists
|
- **Sealed-area connection switch.** The workspace switcher now lists
|
||||||
|
|
|
||||||
53
docs/nav.generated.json
Normal file
53
docs/nav.generated.json
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
{
|
||||||
|
"_generated": "by test/nav_manifest_test.dart — do not edit. Mirror to fai_chain/docs/studio/nav.generated.json (same content).",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"id": "welcome",
|
||||||
|
"cmd": 1,
|
||||||
|
"label_de": "Willkommen",
|
||||||
|
"label_en": "Welcome"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "store",
|
||||||
|
"cmd": 2,
|
||||||
|
"label_de": "Store",
|
||||||
|
"label_en": "Store"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "doctor",
|
||||||
|
"cmd": 3,
|
||||||
|
"label_de": "Diagnose",
|
||||||
|
"label_en": "Doctor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "flows",
|
||||||
|
"cmd": 4,
|
||||||
|
"label_de": "Flows",
|
||||||
|
"label_en": "Flows"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit",
|
||||||
|
"cmd": 5,
|
||||||
|
"label_de": "Protokoll",
|
||||||
|
"label_en": "Audit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "approvals",
|
||||||
|
"cmd": 6,
|
||||||
|
"label_de": "Freigaben",
|
||||||
|
"label_en": "Approvals"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "runs",
|
||||||
|
"cmd": 7,
|
||||||
|
"label_de": "Läufe",
|
||||||
|
"label_en": "Runs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "federation",
|
||||||
|
"cmd": 8,
|
||||||
|
"label_de": "Föderation",
|
||||||
|
"label_en": "Federation"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
249
integration_test/guide_shots_test.dart
Normal file
249
integration_test/guide_shots_test.dart
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
// Deterministic operator-guide screenshot harness (one command
|
||||||
|
// instead of the fragile cliclick/screencapture procedure).
|
||||||
|
//
|
||||||
|
// Boots a hermetic hub (HubFixture: temp data dir, free port),
|
||||||
|
// seeds deterministic demo data via the CLI (an open "Bürgeramt"
|
||||||
|
// project; optionally a sealed "Ratsinformation" when the runner
|
||||||
|
// confirms an isolated $HOME), launches the real app in German +
|
||||||
|
// dark mode against that hub, walks every sidebar destination in
|
||||||
|
// nav order, opens the workspace switcher and the guided-setup
|
||||||
|
// wizard, and writes the guide PNGs via a RepaintBoundary capture
|
||||||
|
// (driverless — works headed on macOS with plain `flutter test`).
|
||||||
|
//
|
||||||
|
// Run through the platform repo's scripts/regen-studio-guide.sh,
|
||||||
|
// which isolates $HOME, builds the hub binary, and copies the
|
||||||
|
// PNGs into fai_chain/docs/studio/images/. Direct invocation:
|
||||||
|
//
|
||||||
|
// flutter test integration_test/guide_shots_test.dart -d macos
|
||||||
|
//
|
||||||
|
// Output lands in build/guide-shots/ unless GUIDE_SHOTS_OUT is
|
||||||
|
// set in the environment.
|
||||||
|
//
|
||||||
|
// Hermeticity notes:
|
||||||
|
// - SharedPreferences is mocked empty — the operator's real Studio
|
||||||
|
// prefs are neither read nor written.
|
||||||
|
// - The app reconnects to the fixture hub with persist: false.
|
||||||
|
// - The sealed seed writes to $HOME/.chain/sealed/ by design, so
|
||||||
|
// it only runs when CHAIN_GUIDE_SHOTS_SEALED=1 confirms the
|
||||||
|
// caller isolated $HOME (the regen script does).
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/services.dart' show LogicalKeyboardKey;
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:chain_client_sdk/chain_client_sdk.dart' show HubEndpoint;
|
||||||
|
import 'package:chain_studio/data/hub.dart';
|
||||||
|
import 'package:chain_studio/main.dart';
|
||||||
|
import 'package:chain_studio/widgets/widgets.dart';
|
||||||
|
|
||||||
|
import '../test/integration/hub_fixture.dart';
|
||||||
|
|
||||||
|
final GlobalKey _shotKey = GlobalKey();
|
||||||
|
|
||||||
|
/// Pump a fixed number of frames instead of pumpAndSettle: the
|
||||||
|
/// sidebar clock ticks every second, so the tree never settles and
|
||||||
|
/// pumpAndSettle degenerates into its multi-minute timeout.
|
||||||
|
Future<void> _pumpFrames(WidgetTester tester, [int frames = 20]) async {
|
||||||
|
for (var i = 0; i < frames; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pump until [finder] matches (page content arrived) or ~10 s
|
||||||
|
/// pass — pages load their data from the hub asynchronously, and a
|
||||||
|
/// blind fixed wait raced the slower ones (the flows editor).
|
||||||
|
Future<void> _pumpUntil(
|
||||||
|
WidgetTester tester,
|
||||||
|
Finder finder, {
|
||||||
|
int maxFrames = 100,
|
||||||
|
}) async {
|
||||||
|
for (var i = 0; i < maxFrames; i++) {
|
||||||
|
if (finder.evaluate().isNotEmpty) return;
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _outDir =>
|
||||||
|
Platform.environment['GUIDE_SHOTS_OUT'] ?? 'build/guide-shots';
|
||||||
|
|
||||||
|
/// Guide images ship dark; GUIDE_SHOTS_THEME=light exists for the
|
||||||
|
/// light-parity proof runs (release gate: both themes must hold).
|
||||||
|
ThemeModeValue get _theme =>
|
||||||
|
Platform.environment['GUIDE_SHOTS_THEME'] == 'light'
|
||||||
|
? ThemeModeValue.light
|
||||||
|
: ThemeModeValue.dark;
|
||||||
|
|
||||||
|
/// Rasterize the app's RepaintBoundary into `<outDir>/<name>.png`.
|
||||||
|
Future<void> _shot(WidgetTester tester, String name) async {
|
||||||
|
// Two extra frames so ripples/route transitions settle visually.
|
||||||
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
|
final boundary =
|
||||||
|
_shotKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
|
||||||
|
final image = await boundary.toImage(pixelRatio: 2.0);
|
||||||
|
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
image.dispose();
|
||||||
|
final file = File('$_outDir/$name.png');
|
||||||
|
file.parent.createSync(recursive: true);
|
||||||
|
file.writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('guide-shot: ${file.path}');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cli(
|
||||||
|
String binary,
|
||||||
|
HubFixture fixture,
|
||||||
|
List<String> args,
|
||||||
|
) async {
|
||||||
|
final r = await Process.run(
|
||||||
|
binary,
|
||||||
|
args,
|
||||||
|
environment: {
|
||||||
|
...Platform.environment,
|
||||||
|
'CHAIN_DATA_DIR': fixture.tempDir.path,
|
||||||
|
'CHAIN_MODULES_DIR': '${fixture.tempDir.path}/modules',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (r.exitCode != 0) {
|
||||||
|
fail('seed command `chain ${args.join(' ')}` failed: ${r.stderr}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The guide's image set, in sidebar (= Cmd number) order. Must
|
||||||
|
/// stay aligned with docs/nav.generated.json — the nav guard in
|
||||||
|
/// the platform repo keeps the guide itself honest.
|
||||||
|
const _pageShots = [
|
||||||
|
('welcome', '01-willkommen'),
|
||||||
|
('store', '02-store'),
|
||||||
|
('doctor', '03-diagnose'),
|
||||||
|
('flows', '04-flows'),
|
||||||
|
('audit', '05-protokoll'),
|
||||||
|
('approvals', '06-freigaben'),
|
||||||
|
('runs', '07-laeufe'),
|
||||||
|
('federation', '08-foederation'),
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
testWidgets('capture the operator-guide screenshots', (tester) async {
|
||||||
|
final fixture = await HubFixture.start(skipIfBinaryMissing: false);
|
||||||
|
addTearDown(fixture!.dispose);
|
||||||
|
final binary = (await HubFixture.binaryPath())!;
|
||||||
|
|
||||||
|
// Deterministic demo data. The slug is explicit — the derived
|
||||||
|
// one would drop the umlaut ("brgeramt").
|
||||||
|
await _cli(binary, fixture, [
|
||||||
|
'project',
|
||||||
|
'create',
|
||||||
|
'Bürgeramt',
|
||||||
|
'--slug',
|
||||||
|
'buergeramt',
|
||||||
|
'--color',
|
||||||
|
'#2e8f9e',
|
||||||
|
]);
|
||||||
|
// The sealed area writes a manifest under $HOME/.chain/sealed/,
|
||||||
|
// so it needs the runner's confirmation that $HOME is isolated.
|
||||||
|
final sealedSeed =
|
||||||
|
Platform.environment['CHAIN_GUIDE_SHOTS_SEALED'] == '1';
|
||||||
|
if (sealedSeed) {
|
||||||
|
await _cli(binary, fixture, [
|
||||||
|
'project',
|
||||||
|
'create',
|
||||||
|
'Ratsinformation',
|
||||||
|
'--slug',
|
||||||
|
'ratsinformation',
|
||||||
|
'--isolation',
|
||||||
|
'sealed',
|
||||||
|
]);
|
||||||
|
// Seed one audit event the guide can show (setup.applied) by
|
||||||
|
// applying a minimal guided setup against the fixture's data
|
||||||
|
// dir. Writes $HOME/.chain/config.yaml, hence the isolated-
|
||||||
|
// $HOME guard: only the regen script's throwaway HOME is
|
||||||
|
// ever touched.
|
||||||
|
final answers = File('${fixture.tempDir.path}/seed-answers.yaml');
|
||||||
|
answers.writeAsStringSync(
|
||||||
|
'scenario: trying-out\nintent: hello-world\ntarget: this-laptop\n',
|
||||||
|
);
|
||||||
|
await _cli(binary, fixture, [
|
||||||
|
'init',
|
||||||
|
'--answers',
|
||||||
|
answers.path,
|
||||||
|
'--apply',
|
||||||
|
'--force',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hermetic prefs: never read or write the operator's real ones.
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
RepaintBoundary(
|
||||||
|
key: _shotKey,
|
||||||
|
child: StudioApp(
|
||||||
|
initialThemeMode: _theme,
|
||||||
|
initialLocale: const Locale('de'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await HubService.instance.reconnect(
|
||||||
|
HubEndpoint(host: '127.0.0.1', port: fixture.port),
|
||||||
|
authToken: null,
|
||||||
|
persist: false,
|
||||||
|
);
|
||||||
|
await _pumpFrames(tester, 30);
|
||||||
|
|
||||||
|
final shell =
|
||||||
|
tester.state<StudioShellState>(find.byType(StudioShell));
|
||||||
|
|
||||||
|
// 01–08: every sidebar destination, in Cmd order. Where a page
|
||||||
|
// loads slower content from the hub, wait for a sentinel that
|
||||||
|
// proves the content arrived before capturing.
|
||||||
|
for (final (id, shotName) in _pageShots) {
|
||||||
|
shell.navigateTo(id);
|
||||||
|
await _pumpFrames(tester);
|
||||||
|
if (id == 'flows') {
|
||||||
|
// The imported sample flows populate the file list.
|
||||||
|
await _pumpUntil(tester, find.textContaining('extract-summarize'));
|
||||||
|
} else if (id == 'audit') {
|
||||||
|
// The seeded setup.applied event (isolated-$HOME runs).
|
||||||
|
await _pumpUntil(tester, find.textContaining('setup.applied'));
|
||||||
|
}
|
||||||
|
await _shot(tester, shotName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 09: the workspace switcher, opened (shows the demo projects;
|
||||||
|
// with the sealed seed also the shield entry).
|
||||||
|
shell.navigateTo('audit');
|
||||||
|
await _pumpFrames(tester);
|
||||||
|
final switcher = find.byType(ChainWorkspaceSwitcher);
|
||||||
|
if (switcher.evaluate().isNotEmpty) {
|
||||||
|
await tester.tap(switcher.first);
|
||||||
|
await _pumpFrames(tester);
|
||||||
|
await _shot(tester, '09-projekt-umschalter');
|
||||||
|
// Close the menu again before moving on.
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
|
await _pumpFrames(tester, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11: the guided-setup wizard, step 1 (localized option cards).
|
||||||
|
// 10 (inside a sealed area) still needs the manual procedure —
|
||||||
|
// it requires switching to a second, isolated hub instance.
|
||||||
|
shell.navigateTo('welcome');
|
||||||
|
await _pumpFrames(tester);
|
||||||
|
final cta = find.text('In 3 Fragen loslegen');
|
||||||
|
if (cta.evaluate().isNotEmpty) {
|
||||||
|
await tester.tap(cta.first);
|
||||||
|
await _pumpFrames(tester);
|
||||||
|
await _shot(tester, '11-einrichtung');
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
|
await _pumpFrames(tester, 5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"errModuleDownloadHint": "Die Bundle-URL des Moduls ist nicht erreichbar (Statuscode unten). Der Hub läuft — URL/Host korrigieren oder anderen Store wählen.",
|
"errModuleDownloadHint": "Die Bundle-URL des Moduls ist nicht erreichbar (Statuscode unten). Der Hub läuft — URL/Host korrigieren oder anderen Store wählen.",
|
||||||
"storeFromSource": "aus {store}",
|
"storeFromSource": "aus {store}",
|
||||||
"guidedSetupTitle": "Setup-Assistent",
|
"guidedSetupTitle": "Setup-Assistent",
|
||||||
"guidedSetupIntro": "Beantworte drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Start-Flow zusammen.",
|
"guidedSetupIntro": "Beantworten Sie drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Beispiel-Flow zusammen.",
|
||||||
"guidedSetupScenario": "Szenario",
|
"guidedSetupScenario": "Szenario",
|
||||||
"guidedSetupIntent": "Aufgabe",
|
"guidedSetupIntent": "Aufgabe",
|
||||||
"guidedSetupTarget": "Wo läuft es?",
|
"guidedSetupTarget": "Wo läuft es?",
|
||||||
|
|
@ -72,14 +72,14 @@
|
||||||
},
|
},
|
||||||
"welcomeChecklistHeader": "ERSTE SCHRITTE",
|
"welcomeChecklistHeader": "ERSTE SCHRITTE",
|
||||||
"welcomeChecklistBody": "Vier Schritte zu einem funktionierenden Hub. Live-Zustand — nach jeder Änderung aktualisieren.",
|
"welcomeChecklistBody": "Vier Schritte zu einem funktionierenden Hub. Live-Zustand — nach jeder Änderung aktualisieren.",
|
||||||
"welcomeChecklistAi": "System-AI konfigurieren",
|
"welcomeChecklistAi": "System-KI konfigurieren",
|
||||||
"welcomeChecklistAiHint": "Einstellungen → System-AI. Wird für die KI-Suche und Fehlererklärungen gebraucht.",
|
"welcomeChecklistAiHint": "Einstellungen → System-KI. Wird für die KI-Suche und Fehlererklärungen gebraucht.",
|
||||||
"welcomeChecklistMcp": "Öffentliche Capability-Quelle hinzufügen",
|
"welcomeChecklistMcp": "Öffentliche Capability-Quelle hinzufügen",
|
||||||
"welcomeChecklistMcpHint": "Im Store eine MCP-Quelle hinzufügen oder unter Einstellungen → MCP-Clients konfigurieren.",
|
"welcomeChecklistMcpHint": "Verbindet einen öffentlichen Werkzeug-Katalog (MCP) — dessen Bausteine erscheinen dann im Store. Dort hinzufügen oder unter Einstellungen → MCP-Clients.",
|
||||||
"welcomeChecklistModule": "Ein Text-Modul installieren",
|
"welcomeChecklistModule": "Ein Text-Modul installieren",
|
||||||
"welcomeChecklistModuleHint": "Im Store ein Modul aus der Kategorie deiner Wahl installieren. Module sind sandboxed Ch∆In-Komponenten.",
|
"welcomeChecklistModuleHint": "Im Store ein Modul aus der Kategorie Ihrer Wahl installieren. Module sind isolierte (sandboxed) Ch∆In-Komponenten.",
|
||||||
"welcomeChecklistFlow": "Einen gespeicherten Flow starten",
|
"welcomeChecklistFlow": "Einen gespeicherten Flow starten",
|
||||||
"welcomeChecklistFlowHint": "Flows → einen wählen → Starten. Studio bringt einige Demo-Flows mit; auch ein eigener tut's.",
|
"welcomeChecklistFlowHint": "Flows → einen wählen → Starten. Studio bringt einige Demo-Flows mit; ein eigener funktioniert genauso.",
|
||||||
"welcomeChecklistAllDone": "Alle vier Schritte erledigt.",
|
"welcomeChecklistAllDone": "Alle vier Schritte erledigt.",
|
||||||
"welcomeChecklistDismiss": "Checkliste ausblenden",
|
"welcomeChecklistDismiss": "Checkliste ausblenden",
|
||||||
"welcomeChecklistRefresh": "Aktualisieren",
|
"welcomeChecklistRefresh": "Aktualisieren",
|
||||||
|
|
@ -349,7 +349,7 @@
|
||||||
"storeReloadTooltip": "Aktualisieren",
|
"storeReloadTooltip": "Aktualisieren",
|
||||||
"storesManagerButton": "Store hinzufügen",
|
"storesManagerButton": "Store hinzufügen",
|
||||||
"storesManagerTitle": "Module-Stores",
|
"storesManagerTitle": "Module-Stores",
|
||||||
"storesManagerIntro": "Der Hub führt die Module aller Stores unten zu einem Index zusammen. Füge die Index-URL eines Stores hinzu, damit seine Module hier installierbar werden.",
|
"storesManagerIntro": "Der Hub führt die Module aller Stores unten zu einem Index zusammen. Fügen Sie die Index-URL eines Stores hinzu, damit seine Module hier installierbar werden.",
|
||||||
"storesManagerAddSection": "Store hinzufügen",
|
"storesManagerAddSection": "Store hinzufügen",
|
||||||
"storesManagerNameLabel": "Name",
|
"storesManagerNameLabel": "Name",
|
||||||
"storesManagerUrlLabel": "Index-URL (https:// oder file://)",
|
"storesManagerUrlLabel": "Index-URL (https:// oder file://)",
|
||||||
|
|
@ -360,8 +360,8 @@
|
||||||
"storesManagerRemoveFailed": "Store konnte nicht entfernt werden",
|
"storesManagerRemoveFailed": "Store konnte nicht entfernt werden",
|
||||||
"storesManagerSuggested": "Vorgeschlagene Stores",
|
"storesManagerSuggested": "Vorgeschlagene Stores",
|
||||||
"storesManagerAddShort": "Hinzufügen",
|
"storesManagerAddShort": "Hinzufügen",
|
||||||
"storesManagerPinnedKeyLabel": "Angepinnter Public-Key — PEM (optional)",
|
"storesManagerPinnedKeyLabel": "Signatur-Schlüssel anheften (PEM, optional)",
|
||||||
"storesManagerPinnedKeyHint": "Signierte Module dieses Stores werden gegen diesen Key geprüft statt gegen den eingebauten Vendor-Key. Füge einen PEM-Public-Key ein.",
|
"storesManagerPinnedKeyHint": "Nur Module, die mit diesem Schlüssel signiert sind, werden aus dieser Quelle angenommen. Fügen Sie den öffentlichen Schlüssel (PEM) des Herausgebers ein — statt des eingebauten Herstellerschlüssels.",
|
||||||
"storesManagerNotAvailable": "Noch nicht verfügbar",
|
"storesManagerNotAvailable": "Noch nicht verfügbar",
|
||||||
"storesManagerNotAvailableHint": "Der Anbieter hat diesen Store-Index noch nicht veröffentlicht. Sobald er das tut, ist er installierbar.",
|
"storesManagerNotAvailableHint": "Der Anbieter hat diesen Store-Index noch nicht veröffentlicht. Sobald er das tut, ist er installierbar.",
|
||||||
"storesSuggestedReclaimDesc": "Recl∆Im — Module für Rechts- und Pflichten-Reform-Mapping",
|
"storesSuggestedReclaimDesc": "Recl∆Im — Module für Rechts- und Pflichten-Reform-Mapping",
|
||||||
|
|
@ -886,16 +886,16 @@
|
||||||
"maintenanceResetConfirmTitle": "Hub zurücksetzen?",
|
"maintenanceResetConfirmTitle": "Hub zurücksetzen?",
|
||||||
"maintenanceResetConfirmBody": "Stoppt jeden Daemon, verschiebt ~/.chain/ in ein Backup mit Zeitstempel und startet sauber neu. Das Backup ist vollständig wiederherstellbar. Fortfahren?",
|
"maintenanceResetConfirmBody": "Stoppt jeden Daemon, verschiebt ~/.chain/ in ein Backup mit Zeitstempel und startet sauber neu. Das Backup ist vollständig wiederherstellbar. Fortfahren?",
|
||||||
"maintenanceResetConfirmButton": "Zurücksetzen",
|
"maintenanceResetConfirmButton": "Zurücksetzen",
|
||||||
"welcomeChecklistAllSetTitle": "Du bist eingerichtet.",
|
"welcomeChecklistAllSetTitle": "Sie sind eingerichtet.",
|
||||||
"welcomeChecklistAllSetBody": "Drei Stränge, an denen du als nächstes ziehen kannst:",
|
"welcomeChecklistAllSetBody": "Drei Stränge, an denen Sie als Nächstes ziehen können:",
|
||||||
"welcomeChecklistNextAuditTitle": "Audit-Log lesen",
|
"welcomeChecklistNextAuditTitle": "Audit-Log lesen",
|
||||||
"welcomeChecklistNextAuditBody": "Jede Installation, jeder Flow-Lauf, jede Freigabe landet im hash-verketteten Log. Im Protokoll-Tab siehst du, was dein Hub gemacht hat.",
|
"welcomeChecklistNextAuditBody": "Jede Installation, jeder Flow-Lauf, jede Freigabe landet im hash-verketteten Protokoll. Im Protokoll-Tab sehen Sie, was Ihr Hub gemacht hat.",
|
||||||
"welcomeChecklistNextAuditButton": "Protokoll öffnen",
|
"welcomeChecklistNextAuditButton": "Protokoll öffnen",
|
||||||
"welcomeChecklistNextTodayTitle": "Daily-Today-Story einrichten",
|
"welcomeChecklistNextTodayTitle": "Daily-Today-Story einrichten",
|
||||||
"welcomeChecklistNextTodayBody": "tools/today/propose.sh per Cron laufen lassen und die Editorial-Karte im Store füllt sich mit operator-kuratierten Stories.",
|
"welcomeChecklistNextTodayBody": "Für Fortgeschrittene: das Skript tools/today/propose.sh regelmäßig (Cron) laufen lassen — die Editorial-Karte im Store füllt sich dann mit kuratierten Stories.",
|
||||||
"welcomeChecklistNextTodayButton": "Today-Doku öffnen",
|
"welcomeChecklistNextTodayButton": "Today-Doku öffnen",
|
||||||
"welcomeChecklistNextModuleTitle": "Eigenes Modul bauen",
|
"welcomeChecklistNextModuleTitle": "Eigenes Modul bauen",
|
||||||
"welcomeChecklistNextModuleBody": "Mit `chain new module <name>` ein Rust+WASM-Scaffold erzeugen. Das module-sdk übernimmt die WIT-Plumbing; du schreibst nur ein typisiertes invoke.",
|
"welcomeChecklistNextModuleBody": "Für Entwickler: Mit `chain new module <name>` entsteht ein fertiges Modul-Gerüst (Rust+WASM). Das module-sdk übernimmt die technische Anbindung; Sie schreiben nur die eigentliche Logik.",
|
||||||
"welcomeChecklistNextModuleButton": "Doku lesen",
|
"welcomeChecklistNextModuleButton": "Doku lesen",
|
||||||
"auditGroupToday": "HEUTE",
|
"auditGroupToday": "HEUTE",
|
||||||
"auditGroupYesterday": "GESTERN",
|
"auditGroupYesterday": "GESTERN",
|
||||||
|
|
@ -1704,7 +1704,7 @@
|
||||||
"setupReviewTitle": "Das wird Ch∆In einrichten",
|
"setupReviewTitle": "Das wird Ch∆In einrichten",
|
||||||
"setupChooseFreeText": "Oder beschreiben Sie einfach, was Sie vorhaben",
|
"setupChooseFreeText": "Oder beschreiben Sie einfach, was Sie vorhaben",
|
||||||
"setupScenTryingOut": "Erst mal ausprobieren",
|
"setupScenTryingOut": "Erst mal ausprobieren",
|
||||||
"setupScenTryingOutSub": "Ein unkomplizierter Arbeitsplatz auf diesem Rechner — ohne Signaturen, ohne Audit-Sperre.",
|
"setupScenTryingOutSub": "Ein unkomplizierter Arbeitsplatz auf diesem Rechner — ohne Signaturpflicht, ohne Schreibschutz für das Prüfprotokoll.",
|
||||||
"setupScenTeamHub": "Ein Team-Server",
|
"setupScenTeamHub": "Ein Team-Server",
|
||||||
"setupScenTeamHubSub": "Ein abgesicherter Hub für mehrere Personen — signierte Module, Ressourcen-Grenzen.",
|
"setupScenTeamHubSub": "Ein abgesicherter Hub für mehrere Personen — signierte Module, Ressourcen-Grenzen.",
|
||||||
"setupScenRegulated": "Regulierter Produktivbetrieb",
|
"setupScenRegulated": "Regulierter Produktivbetrieb",
|
||||||
|
|
@ -1747,7 +1747,53 @@
|
||||||
"setupApplied": "Fertig — Ch∆In ist eingerichtet.",
|
"setupApplied": "Fertig — Ch∆In ist eingerichtet.",
|
||||||
"setupNextTitle": "Nächste Schritte",
|
"setupNextTitle": "Nächste Schritte",
|
||||||
"setupNextHubStart": "Hub starten",
|
"setupNextHubStart": "Hub starten",
|
||||||
"setupNextInstall": "Die Module installieren",
|
"setupHubRunning": "Der Hub läuft.",
|
||||||
"setupNextRunFlow": "Den Beispiel-Flow ausführen",
|
"setupStartHubFirst": "Starten Sie zuerst den Hub — dann lassen sich die Module hier installieren.",
|
||||||
"setupStartCta": "In 3 Fragen loslegen"
|
"setupActionInstall": "{module} installieren",
|
||||||
|
"@setupActionInstall": {"placeholders": {"module": {"type": "String"}}},
|
||||||
|
"setupActionInstalled": "{module} ist installiert.",
|
||||||
|
"@setupActionInstalled": {"placeholders": {"module": {"type": "String"}}},
|
||||||
|
"setupActionOpenFlow": "Beispiel-Flow „{flow}“ öffnen",
|
||||||
|
"@setupActionOpenFlow": {"placeholders": {"flow": {"type": "String"}}},
|
||||||
|
"setupModulesOfflineHint": "Die Module kommen ohne Internet als Offline-Paket auf den Server — die Anleitung dazu steht in der Dokumentation unter „Betrieb ohne Internet“.",
|
||||||
|
"setupModulesSignedHint": "Die Module kommen aus einer eigenen, signierten Quelle — nicht aus dem öffentlichen Store.",
|
||||||
|
"setupSigPublicStoreNotice": "Diese Einrichtung lässt nur signierte Module zu. Der öffentliche Store liefert zurzeit unsignierte Pakete — die vorbereiteten Module kommen daher aus einer eigenen, signierten Quelle.",
|
||||||
|
"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",
|
||||||
|
"setupPlanRunbookLocal": "Eingerichtet wird dieser Rechner; der Hub läuft lokal und wird bei Bedarf gestartet.",
|
||||||
|
"setupPlanRunbookService": "Eingerichtet wird dieser Rechner: Der Hub startet künftig automatisch beim Hochfahren (Hintergrund-Dienst). Für einen anderen Server führen Sie die Einrichtung dort aus.",
|
||||||
|
"setupPlanRunbookAirgap": "Vorgesehen für einen isolierten Server ohne Internet — Module und Updates kommen als Offline-Paket.",
|
||||||
|
"setupPlanRunbookContainer": "Vorgesehen für den Betrieb im Container — ein fertiges Compose-Beispiel wird mitgeliefert.",
|
||||||
|
"setupPlanAuditChain": "Jeder Schritt landet in einem lückenlosen, hash-verketteten Prüfprotokoll — Manipulation wird erkennbar.",
|
||||||
|
"setupPlanDocs": "Eine passende Leseliste ({n} Kapitel) wird mit dem Einrichtungs-Protokoll unter ~/.chain/ gespeichert.",
|
||||||
|
"@setupPlanDocs": {"placeholders": {"n": {"type": "int"}}},
|
||||||
|
"setupTrustedPublishersHint": "Wichtig: Hinterlegen Sie beim Hinzufügen der Quelle den Schlüssel des Herausgebers („Signatur-Schlüssel anheften“) — sonst akzeptiert der Hub jede formal gültige Signatur, egal von wem.",
|
||||||
|
"setupAddSignedSource": "Signierte Quelle hinzufügen…",
|
||||||
|
"setupApplyNotes": "Hinweise aus der Einrichtung",
|
||||||
|
"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",
|
||||||
|
"welcomeChecklistSetupDone": "Die Grundeinrichtung ({profile}) hat der Einrichtungs-Assistent bereits erledigt — hier sehen Sie, was auf dem Hub schon passiert ist.",
|
||||||
|
"@welcomeChecklistSetupDone": {"placeholders": {"profile": {"type": "String"}}}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -378,8 +378,8 @@
|
||||||
"storesManagerRemoveFailed": "Could not remove store",
|
"storesManagerRemoveFailed": "Could not remove store",
|
||||||
"storesManagerSuggested": "Suggested stores",
|
"storesManagerSuggested": "Suggested stores",
|
||||||
"storesManagerAddShort": "Add",
|
"storesManagerAddShort": "Add",
|
||||||
"storesManagerPinnedKeyLabel": "Pinned public key — PEM (optional)",
|
"storesManagerPinnedKeyLabel": "Pin a signing key (PEM, optional)",
|
||||||
"storesManagerPinnedKeyHint": "Signed modules from this store verify against this key instead of the built-in vendor key. Paste a PEM public key.",
|
"storesManagerPinnedKeyHint": "Only modules signed with this key are accepted from this source. Paste the publisher's public key (PEM) — used instead of the built-in vendor key.",
|
||||||
"storesManagerNotAvailable": "Not available yet",
|
"storesManagerNotAvailable": "Not available yet",
|
||||||
"storesManagerNotAvailableHint": "The provider hasn't published this store index yet. It will become installable once they do.",
|
"storesManagerNotAvailableHint": "The provider hasn't published this store index yet. It will become installable once they do.",
|
||||||
"storesSuggestedReclaimDesc": "Recl∆Im — modules for law & duty reform-mapping",
|
"storesSuggestedReclaimDesc": "Recl∆Im — modules for law & duty reform-mapping",
|
||||||
|
|
@ -1743,7 +1743,7 @@
|
||||||
"setupReviewTitle": "This is what Ch∆In will set up",
|
"setupReviewTitle": "This is what Ch∆In will set up",
|
||||||
"setupChooseFreeText": "Or just describe what you want to do",
|
"setupChooseFreeText": "Or just describe what you want to do",
|
||||||
"setupScenTryingOut": "Just trying it out",
|
"setupScenTryingOut": "Just trying it out",
|
||||||
"setupScenTryingOutSub": "A relaxed workspace on this machine — no signing, no audit locking.",
|
"setupScenTryingOutSub": "A straightforward workstation on this machine — no signature requirement, no write-protected audit log.",
|
||||||
"setupScenTeamHub": "A shared team hub",
|
"setupScenTeamHub": "A shared team hub",
|
||||||
"setupScenTeamHubSub": "A secured hub for several people — signed modules, resource limits.",
|
"setupScenTeamHubSub": "A secured hub for several people — signed modules, resource limits.",
|
||||||
"setupScenRegulated": "Regulated production",
|
"setupScenRegulated": "Regulated production",
|
||||||
|
|
@ -1786,7 +1786,53 @@
|
||||||
"setupApplied": "Done — Ch∆In is set up.",
|
"setupApplied": "Done — Ch∆In is set up.",
|
||||||
"setupNextTitle": "Next steps",
|
"setupNextTitle": "Next steps",
|
||||||
"setupNextHubStart": "Start the hub",
|
"setupNextHubStart": "Start the hub",
|
||||||
"setupNextInstall": "Install the modules",
|
"setupHubRunning": "The hub is running.",
|
||||||
"setupNextRunFlow": "Run the example flow",
|
"setupStartHubFirst": "Start the hub first — then the modules can be installed right here.",
|
||||||
"setupStartCta": "Get started in 3 questions"
|
"setupActionInstall": "Install {module}",
|
||||||
|
"@setupActionInstall": {"placeholders": {"module": {"type": "String"}}},
|
||||||
|
"setupActionInstalled": "{module} is installed.",
|
||||||
|
"@setupActionInstalled": {"placeholders": {"module": {"type": "String"}}},
|
||||||
|
"setupActionOpenFlow": "Open the “{flow}” example flow",
|
||||||
|
"@setupActionOpenFlow": {"placeholders": {"flow": {"type": "String"}}},
|
||||||
|
"setupModulesOfflineHint": "The modules arrive as an offline bundle, without internet — the documentation explains this under “running without internet”.",
|
||||||
|
"setupModulesSignedHint": "The modules come from your own, signed source — not from the public store.",
|
||||||
|
"setupSigPublicStoreNotice": "This setup only accepts signed modules. The public store currently ships unsigned bundles — the prepared modules therefore come from your own, signed source.",
|
||||||
|
"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",
|
||||||
|
"setupPlanRunbookLocal": "This machine is being set up; the hub runs locally and starts on demand.",
|
||||||
|
"setupPlanRunbookService": "This machine is being set up: the hub will start automatically on boot (background service). For a different server, run the setup there.",
|
||||||
|
"setupPlanRunbookAirgap": "Intended for an isolated server without internet — modules and updates arrive as offline bundles.",
|
||||||
|
"setupPlanRunbookContainer": "Intended for container operation — a ready-made compose example is included.",
|
||||||
|
"setupPlanAuditChain": "Every step lands in a gapless, hash-chained audit log — tampering becomes detectable.",
|
||||||
|
"setupPlanDocs": "A matching reading list ({n} chapters) is stored with the setup record under ~/.chain/.",
|
||||||
|
"@setupPlanDocs": {"placeholders": {"n": {"type": "int"}}},
|
||||||
|
"setupTrustedPublishersHint": "Important: when adding the source, pin the publisher's key (\"pin signing key\") — otherwise the hub accepts any formally valid signature, no matter whose.",
|
||||||
|
"setupAddSignedSource": "Add a signed source…",
|
||||||
|
"setupApplyNotes": "Notes from the setup",
|
||||||
|
"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",
|
||||||
|
"welcomeChecklistSetupDone": "The setup assistant already took care of the basics ({profile}) — this list shows what has actually happened on your hub.",
|
||||||
|
"@welcomeChecklistSetupDone": {"placeholders": {"profile": {"type": "String"}}}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1685,13 +1685,13 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @storesManagerPinnedKeyLabel.
|
/// No description provided for @storesManagerPinnedKeyLabel.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Pinned public key — PEM (optional)'**
|
/// **'Pin a signing key (PEM, optional)'**
|
||||||
String get storesManagerPinnedKeyLabel;
|
String get storesManagerPinnedKeyLabel;
|
||||||
|
|
||||||
/// No description provided for @storesManagerPinnedKeyHint.
|
/// No description provided for @storesManagerPinnedKeyHint.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Signed modules from this store verify against this key instead of the built-in vendor key. Paste a PEM public key.'**
|
/// **'Only modules signed with this key are accepted from this source. Paste the publisher\'s public key (PEM) — used instead of the built-in vendor key.'**
|
||||||
String get storesManagerPinnedKeyHint;
|
String get storesManagerPinnedKeyHint;
|
||||||
|
|
||||||
/// No description provided for @storesManagerNotAvailable.
|
/// No description provided for @storesManagerNotAvailable.
|
||||||
|
|
@ -5092,7 +5092,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @setupScenTryingOutSub.
|
/// No description provided for @setupScenTryingOutSub.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'A relaxed workspace on this machine — no signing, no audit locking.'**
|
/// **'A straightforward workstation on this machine — no signature requirement, no write-protected audit log.'**
|
||||||
String get setupScenTryingOutSub;
|
String get setupScenTryingOutSub;
|
||||||
|
|
||||||
/// No description provided for @setupScenTeamHub.
|
/// No description provided for @setupScenTeamHub.
|
||||||
|
|
@ -5329,23 +5329,239 @@ abstract class AppLocalizations {
|
||||||
/// **'Start the hub'**
|
/// **'Start the hub'**
|
||||||
String get setupNextHubStart;
|
String get setupNextHubStart;
|
||||||
|
|
||||||
/// No description provided for @setupNextInstall.
|
/// No description provided for @setupHubRunning.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Install the modules'**
|
/// **'The hub is running.'**
|
||||||
String get setupNextInstall;
|
String get setupHubRunning;
|
||||||
|
|
||||||
/// No description provided for @setupNextRunFlow.
|
/// No description provided for @setupStartHubFirst.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Run the example flow'**
|
/// **'Start the hub first — then the modules can be installed right here.'**
|
||||||
String get setupNextRunFlow;
|
String get setupStartHubFirst;
|
||||||
|
|
||||||
|
/// No description provided for @setupActionInstall.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Install {module}'**
|
||||||
|
String setupActionInstall(String module);
|
||||||
|
|
||||||
|
/// No description provided for @setupActionInstalled.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{module} is installed.'**
|
||||||
|
String setupActionInstalled(String module);
|
||||||
|
|
||||||
|
/// No description provided for @setupActionOpenFlow.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Open the “{flow}” example flow'**
|
||||||
|
String setupActionOpenFlow(String flow);
|
||||||
|
|
||||||
|
/// No description provided for @setupModulesOfflineHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The modules arrive as an offline bundle, without internet — the documentation explains this under “running without internet”.'**
|
||||||
|
String get setupModulesOfflineHint;
|
||||||
|
|
||||||
|
/// No description provided for @setupModulesSignedHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The modules come from your own, signed source — not from the public store.'**
|
||||||
|
String get setupModulesSignedHint;
|
||||||
|
|
||||||
|
/// No description provided for @setupSigPublicStoreNotice.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This setup only accepts signed modules. The public store currently ships unsigned bundles — the prepared modules therefore come from your own, signed source.'**
|
||||||
|
String get setupSigPublicStoreNotice;
|
||||||
|
|
||||||
|
/// No description provided for @setupAllowUnsigned.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Allow installing from the public store'**
|
||||||
|
String get setupAllowUnsigned;
|
||||||
|
|
||||||
|
/// No description provided for @setupAllowUnsignedSub.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.'**
|
||||||
|
String get setupAllowUnsignedSub;
|
||||||
|
|
||||||
/// No description provided for @setupStartCta.
|
/// No description provided for @setupStartCta.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Get started in 3 questions'**
|
/// **'Get started in 3 questions'**
|
||||||
String get setupStartCta;
|
String get setupStartCta;
|
||||||
|
|
||||||
|
/// No description provided for @setupPlanRunbookLocal.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This machine is being set up; the hub runs locally and starts on demand.'**
|
||||||
|
String get setupPlanRunbookLocal;
|
||||||
|
|
||||||
|
/// No description provided for @setupPlanRunbookService.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This machine is being set up: the hub will start automatically on boot (background service). For a different server, run the setup there.'**
|
||||||
|
String get setupPlanRunbookService;
|
||||||
|
|
||||||
|
/// No description provided for @setupPlanRunbookAirgap.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Intended for an isolated server without internet — modules and updates arrive as offline bundles.'**
|
||||||
|
String get setupPlanRunbookAirgap;
|
||||||
|
|
||||||
|
/// No description provided for @setupPlanRunbookContainer.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Intended for container operation — a ready-made compose example is included.'**
|
||||||
|
String get setupPlanRunbookContainer;
|
||||||
|
|
||||||
|
/// No description provided for @setupPlanAuditChain.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Every step lands in a gapless, hash-chained audit log — tampering becomes detectable.'**
|
||||||
|
String get setupPlanAuditChain;
|
||||||
|
|
||||||
|
/// No description provided for @setupPlanDocs.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'A matching reading list ({n} chapters) is stored with the setup record under ~/.chain/.'**
|
||||||
|
String setupPlanDocs(int n);
|
||||||
|
|
||||||
|
/// No description provided for @setupTrustedPublishersHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Important: when adding the source, pin the publisher\'s key (\"pin signing key\") — otherwise the hub accepts any formally valid signature, no matter whose.'**
|
||||||
|
String get setupTrustedPublishersHint;
|
||||||
|
|
||||||
|
/// No description provided for @setupAddSignedSource.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add a signed source…'**
|
||||||
|
String get setupAddSignedSource;
|
||||||
|
|
||||||
|
/// No description provided for @setupApplyNotes.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Notes from the setup'**
|
||||||
|
String get setupApplyNotes;
|
||||||
|
|
||||||
|
/// 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:
|
||||||
|
/// **'straightforward workstation'**
|
||||||
|
String get setupProfileDev;
|
||||||
|
|
||||||
|
/// No description provided for @setupProfileEnterprise.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'hardened team operation'**
|
||||||
|
String get setupProfileEnterprise;
|
||||||
|
|
||||||
|
/// No description provided for @setupProfileAirgapped.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'fully local, regulated operation'**
|
||||||
|
String get setupProfileAirgapped;
|
||||||
|
|
||||||
|
/// No description provided for @welcomeChecklistSetupDone.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The setup assistant already took care of the basics ({profile}) — this list shows what has actually happened on your hub.'**
|
||||||
|
String welcomeChecklistSetupDone(String profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get guidedSetupIntro =>
|
String get guidedSetupIntro =>
|
||||||
'Beantworte drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Start-Flow zusammen.';
|
'Beantworten Sie drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Beispiel-Flow zusammen.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get guidedSetupScenario => 'Szenario';
|
String get guidedSetupScenario => 'Szenario';
|
||||||
|
|
@ -235,32 +235,32 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
'Vier Schritte zu einem funktionierenden Hub. Live-Zustand — nach jeder Änderung aktualisieren.';
|
'Vier Schritte zu einem funktionierenden Hub. Live-Zustand — nach jeder Änderung aktualisieren.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistAi => 'System-AI konfigurieren';
|
String get welcomeChecklistAi => 'System-KI konfigurieren';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistAiHint =>
|
String get welcomeChecklistAiHint =>
|
||||||
'Einstellungen → System-AI. Wird für die KI-Suche und Fehlererklärungen gebraucht.';
|
'Einstellungen → System-KI. Wird für die KI-Suche und Fehlererklärungen gebraucht.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistMcp => 'Öffentliche Capability-Quelle hinzufügen';
|
String get welcomeChecklistMcp => 'Öffentliche Capability-Quelle hinzufügen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistMcpHint =>
|
String get welcomeChecklistMcpHint =>
|
||||||
'Im Store eine MCP-Quelle hinzufügen oder unter Einstellungen → MCP-Clients konfigurieren.';
|
'Verbindet einen öffentlichen Werkzeug-Katalog (MCP) — dessen Bausteine erscheinen dann im Store. Dort hinzufügen oder unter Einstellungen → MCP-Clients.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistModule => 'Ein Text-Modul installieren';
|
String get welcomeChecklistModule => 'Ein Text-Modul installieren';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistModuleHint =>
|
String get welcomeChecklistModuleHint =>
|
||||||
'Im Store ein Modul aus der Kategorie deiner Wahl installieren. Module sind sandboxed Ch∆In-Komponenten.';
|
'Im Store ein Modul aus der Kategorie Ihrer Wahl installieren. Module sind isolierte (sandboxed) Ch∆In-Komponenten.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistFlow => 'Einen gespeicherten Flow starten';
|
String get welcomeChecklistFlow => 'Einen gespeicherten Flow starten';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistFlowHint =>
|
String get welcomeChecklistFlowHint =>
|
||||||
'Flows → einen wählen → Starten. Studio bringt einige Demo-Flows mit; auch ein eigener tut\'s.';
|
'Flows → einen wählen → Starten. Studio bringt einige Demo-Flows mit; ein eigener funktioniert genauso.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistAllDone => 'Alle vier Schritte erledigt.';
|
String get welcomeChecklistAllDone => 'Alle vier Schritte erledigt.';
|
||||||
|
|
@ -869,7 +869,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storesManagerIntro =>
|
String get storesManagerIntro =>
|
||||||
'Der Hub führt die Module aller Stores unten zu einem Index zusammen. Füge die Index-URL eines Stores hinzu, damit seine Module hier installierbar werden.';
|
'Der Hub führt die Module aller Stores unten zu einem Index zusammen. Fügen Sie die Index-URL eines Stores hinzu, damit seine Module hier installierbar werden.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storesManagerAddSection => 'Store hinzufügen';
|
String get storesManagerAddSection => 'Store hinzufügen';
|
||||||
|
|
@ -907,11 +907,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storesManagerPinnedKeyLabel =>
|
String get storesManagerPinnedKeyLabel =>
|
||||||
'Angepinnter Public-Key — PEM (optional)';
|
'Signatur-Schlüssel anheften (PEM, optional)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storesManagerPinnedKeyHint =>
|
String get storesManagerPinnedKeyHint =>
|
||||||
'Signierte Module dieses Stores werden gegen diesen Key geprüft statt gegen den eingebauten Vendor-Key. Füge einen PEM-Public-Key ein.';
|
'Nur Module, die mit diesem Schlüssel signiert sind, werden aus dieser Quelle angenommen. Fügen Sie den öffentlichen Schlüssel (PEM) des Herausgebers ein — statt des eingebauten Herstellerschlüssels.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storesManagerNotAvailable => 'Noch nicht verfügbar';
|
String get storesManagerNotAvailable => 'Noch nicht verfügbar';
|
||||||
|
|
@ -1619,18 +1619,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get maintenanceResetConfirmButton => 'Zurücksetzen';
|
String get maintenanceResetConfirmButton => 'Zurücksetzen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistAllSetTitle => 'Du bist eingerichtet.';
|
String get welcomeChecklistAllSetTitle => 'Sie sind eingerichtet.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistAllSetBody =>
|
String get welcomeChecklistAllSetBody =>
|
||||||
'Drei Stränge, an denen du als nächstes ziehen kannst:';
|
'Drei Stränge, an denen Sie als Nächstes ziehen können:';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistNextAuditTitle => 'Audit-Log lesen';
|
String get welcomeChecklistNextAuditTitle => 'Audit-Log lesen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistNextAuditBody =>
|
String get welcomeChecklistNextAuditBody =>
|
||||||
'Jede Installation, jeder Flow-Lauf, jede Freigabe landet im hash-verketteten Log. Im Protokoll-Tab siehst du, was dein Hub gemacht hat.';
|
'Jede Installation, jeder Flow-Lauf, jede Freigabe landet im hash-verketteten Protokoll. Im Protokoll-Tab sehen Sie, was Ihr Hub gemacht hat.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistNextAuditButton => 'Protokoll öffnen';
|
String get welcomeChecklistNextAuditButton => 'Protokoll öffnen';
|
||||||
|
|
@ -1640,7 +1640,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistNextTodayBody =>
|
String get welcomeChecklistNextTodayBody =>
|
||||||
'tools/today/propose.sh per Cron laufen lassen und die Editorial-Karte im Store füllt sich mit operator-kuratierten Stories.';
|
'Für Fortgeschrittene: das Skript tools/today/propose.sh regelmäßig (Cron) laufen lassen — die Editorial-Karte im Store füllt sich dann mit kuratierten Stories.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistNextTodayButton => 'Today-Doku öffnen';
|
String get welcomeChecklistNextTodayButton => 'Today-Doku öffnen';
|
||||||
|
|
@ -1650,7 +1650,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistNextModuleBody =>
|
String get welcomeChecklistNextModuleBody =>
|
||||||
'Mit `chain new module <name>` ein Rust+WASM-Scaffold erzeugen. Das module-sdk übernimmt die WIT-Plumbing; du schreibst nur ein typisiertes invoke.';
|
'Für Entwickler: Mit `chain new module <name>` entsteht ein fertiges Modul-Gerüst (Rust+WASM). Das module-sdk übernimmt die technische Anbindung; Sie schreiben nur die eigentliche Logik.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeChecklistNextModuleButton => 'Doku lesen';
|
String get welcomeChecklistNextModuleButton => 'Doku lesen';
|
||||||
|
|
@ -2991,7 +2991,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupScenTryingOutSub =>
|
String get setupScenTryingOutSub =>
|
||||||
'Ein unkomplizierter Arbeitsplatz auf diesem Rechner — ohne Signaturen, ohne Audit-Sperre.';
|
'Ein unkomplizierter Arbeitsplatz auf diesem Rechner — ohne Signaturpflicht, ohne Schreibschutz für das Prüfprotokoll.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupScenTeamHub => 'Ein Team-Server';
|
String get setupScenTeamHub => 'Ein Team-Server';
|
||||||
|
|
@ -3138,11 +3138,157 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get setupNextHubStart => 'Hub starten';
|
String get setupNextHubStart => 'Hub starten';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupNextInstall => 'Die Module installieren';
|
String get setupHubRunning => 'Der Hub läuft.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupNextRunFlow => 'Den Beispiel-Flow ausführen';
|
String get setupStartHubFirst =>
|
||||||
|
'Starten Sie zuerst den Hub — dann lassen sich die Module hier installieren.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String setupActionInstall(String module) {
|
||||||
|
return '$module installieren';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String setupActionInstalled(String module) {
|
||||||
|
return '$module ist installiert.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String setupActionOpenFlow(String flow) {
|
||||||
|
return 'Beispiel-Flow „$flow“ öffnen';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupModulesOfflineHint =>
|
||||||
|
'Die Module kommen ohne Internet als Offline-Paket auf den Server — die Anleitung dazu steht in der Dokumentation unter „Betrieb ohne Internet“.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupModulesSignedHint =>
|
||||||
|
'Die Module kommen aus einer eigenen, signierten Quelle — nicht aus dem öffentlichen Store.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupSigPublicStoreNotice =>
|
||||||
|
'Diese Einrichtung lässt nur signierte Module zu. Der öffentliche Store liefert zurzeit unsignierte Pakete — die vorbereiteten Module kommen daher aus einer eigenen, signierten Quelle.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupAllowUnsigned =>
|
||||||
|
'Installation aus dem öffentlichen Store erlauben';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupAllowUnsignedSub =>
|
||||||
|
'Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupStartCta => 'In 3 Fragen loslegen';
|
String get setupStartCta => 'In 3 Fragen loslegen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupPlanRunbookLocal =>
|
||||||
|
'Eingerichtet wird dieser Rechner; der Hub läuft lokal und wird bei Bedarf gestartet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupPlanRunbookService =>
|
||||||
|
'Eingerichtet wird dieser Rechner: Der Hub startet künftig automatisch beim Hochfahren (Hintergrund-Dienst). Für einen anderen Server führen Sie die Einrichtung dort aus.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupPlanRunbookAirgap =>
|
||||||
|
'Vorgesehen für einen isolierten Server ohne Internet — Module und Updates kommen als Offline-Paket.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupPlanRunbookContainer =>
|
||||||
|
'Vorgesehen für den Betrieb im Container — ein fertiges Compose-Beispiel wird mitgeliefert.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupPlanAuditChain =>
|
||||||
|
'Jeder Schritt landet in einem lückenlosen, hash-verketteten Prüfprotokoll — Manipulation wird erkennbar.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String setupPlanDocs(int n) {
|
||||||
|
return 'Eine passende Leseliste ($n Kapitel) wird mit dem Einrichtungs-Protokoll unter ~/.chain/ gespeichert.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupTrustedPublishersHint =>
|
||||||
|
'Wichtig: Hinterlegen Sie beim Hinzufügen der Quelle den Schlüssel des Herausgebers („Signatur-Schlüssel anheften“) — sonst akzeptiert der Hub jede formal gültige Signatur, egal von wem.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupAddSignedSource => 'Signierte Quelle hinzufügen…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupApplyNotes => 'Hinweise aus der Einrichtung';
|
||||||
|
|
||||||
|
@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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupProfileEnterprise => 'abgesicherter Team-Betrieb';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupProfileAirgapped =>
|
||||||
|
'vollständig lokaler, regulierter Betrieb';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String welcomeChecklistSetupDone(String profile) {
|
||||||
|
return 'Die Grundeinrichtung ($profile) hat der Einrichtungs-Assistent bereits erledigt — hier sehen Sie, was auf dem Hub schon passiert ist.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -925,12 +925,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get storesManagerAddShort => 'Add';
|
String get storesManagerAddShort => 'Add';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storesManagerPinnedKeyLabel =>
|
String get storesManagerPinnedKeyLabel => 'Pin a signing key (PEM, optional)';
|
||||||
'Pinned public key — PEM (optional)';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storesManagerPinnedKeyHint =>
|
String get storesManagerPinnedKeyHint =>
|
||||||
'Signed modules from this store verify against this key instead of the built-in vendor key. Paste a PEM public key.';
|
'Only modules signed with this key are accepted from this source. Paste the publisher\'s public key (PEM) — used instead of the built-in vendor key.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storesManagerNotAvailable => 'Not available yet';
|
String get storesManagerNotAvailable => 'Not available yet';
|
||||||
|
|
@ -2993,7 +2992,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupScenTryingOutSub =>
|
String get setupScenTryingOutSub =>
|
||||||
'A relaxed workspace on this machine — no signing, no audit locking.';
|
'A straightforward workstation on this machine — no signature requirement, no write-protected audit log.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupScenTeamHub => 'A shared team hub';
|
String get setupScenTeamHub => 'A shared team hub';
|
||||||
|
|
@ -3136,11 +3135,155 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get setupNextHubStart => 'Start the hub';
|
String get setupNextHubStart => 'Start the hub';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupNextInstall => 'Install the modules';
|
String get setupHubRunning => 'The hub is running.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupNextRunFlow => 'Run the example flow';
|
String get setupStartHubFirst =>
|
||||||
|
'Start the hub first — then the modules can be installed right here.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String setupActionInstall(String module) {
|
||||||
|
return 'Install $module';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String setupActionInstalled(String module) {
|
||||||
|
return '$module is installed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String setupActionOpenFlow(String flow) {
|
||||||
|
return 'Open the “$flow” example flow';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupModulesOfflineHint =>
|
||||||
|
'The modules arrive as an offline bundle, without internet — the documentation explains this under “running without internet”.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupModulesSignedHint =>
|
||||||
|
'The modules come from your own, signed source — not from the public store.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupSigPublicStoreNotice =>
|
||||||
|
'This setup only accepts signed modules. The public store currently ships unsigned bundles — the prepared modules therefore come from your own, signed source.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupAllowUnsigned => 'Allow installing from the public store';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupAllowUnsignedSub =>
|
||||||
|
'Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupStartCta => 'Get started in 3 questions';
|
String get setupStartCta => 'Get started in 3 questions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupPlanRunbookLocal =>
|
||||||
|
'This machine is being set up; the hub runs locally and starts on demand.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupPlanRunbookService =>
|
||||||
|
'This machine is being set up: the hub will start automatically on boot (background service). For a different server, run the setup there.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupPlanRunbookAirgap =>
|
||||||
|
'Intended for an isolated server without internet — modules and updates arrive as offline bundles.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupPlanRunbookContainer =>
|
||||||
|
'Intended for container operation — a ready-made compose example is included.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupPlanAuditChain =>
|
||||||
|
'Every step lands in a gapless, hash-chained audit log — tampering becomes detectable.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String setupPlanDocs(int n) {
|
||||||
|
return 'A matching reading list ($n chapters) is stored with the setup record under ~/.chain/.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupTrustedPublishersHint =>
|
||||||
|
'Important: when adding the source, pin the publisher\'s key (\"pin signing key\") — otherwise the hub accepts any formally valid signature, no matter whose.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupAddSignedSource => 'Add a signed source…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupApplyNotes => 'Notes from the setup';
|
||||||
|
|
||||||
|
@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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupProfileEnterprise => 'hardened team operation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupProfileAirgapped => 'fully local, regulated operation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String welcomeChecklistSetupDone(String profile) {
|
||||||
|
return 'The setup assistant already took care of the basics ($profile) — this list shows what has actually happened on your hub.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,44 @@ import '../theme/tokens.dart';
|
||||||
import '../widgets/guided_setup_dialog.dart';
|
import '../widgets/guided_setup_dialog.dart';
|
||||||
import '../widgets/widgets.dart';
|
import '../widgets/widgets.dart';
|
||||||
|
|
||||||
class WelcomePage extends StatelessWidget {
|
class WelcomePage extends StatefulWidget {
|
||||||
const WelcomePage({super.key});
|
const WelcomePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WelcomePage> createState() => _WelcomePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WelcomePageState extends State<WelcomePage> {
|
||||||
|
/// Bumped when the guided-setup dialog closes so the onboarding
|
||||||
|
/// checklist remounts and re-probes — the assistant may have just
|
||||||
|
/// installed the modules the checklist looks for.
|
||||||
|
int _checklistEpoch = 0;
|
||||||
|
|
||||||
|
/// One auto-open per app run: a fresh install that dismisses the
|
||||||
|
/// wizard should not have it spring back on every rebuild.
|
||||||
|
static bool _autoOpenedThisRun = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _maybeAutoOpen());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On a fresh hub (no config, no recorded setup plan) the wizard
|
||||||
|
/// IS the onboarding — open it rather than hoping the operator
|
||||||
|
/// finds the button. Once a setup exists it steps back for good.
|
||||||
|
Future<void> _maybeAutoOpen() async {
|
||||||
|
if (_autoOpenedThisRun || !mounted) return;
|
||||||
|
if (!GuidedSetupDialog.isFreshInstall()) return;
|
||||||
|
_autoOpenedThisRun = true;
|
||||||
|
await _openSetup();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openSetup() async {
|
||||||
|
await GuidedSetupDialog.show(context);
|
||||||
|
if (mounted) setState(() => _checklistEpoch++);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -65,11 +100,12 @@ class WelcomePage extends StatelessWidget {
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
// High-emphasis: the guided setup is the fastest path
|
// High-emphasis: the guided setup is the fastest path
|
||||||
// to a working first run, so lead with a filled button
|
// to a working first run, so lead with a filled button
|
||||||
// rather than a low-key outlined one.
|
// rather than a low-key outlined one. Honest framing:
|
||||||
|
// "get started in 3 questions", not tool-speak.
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: () => GuidedSetupDialog.show(context),
|
onPressed: _openSetup,
|
||||||
icon: const Icon(Icons.auto_fix_high),
|
icon: const Icon(Icons.auto_fix_high),
|
||||||
label: Text(AppLocalizations.of(context)!.guidedSetupTitle),
|
label: Text(l.setupStartCta),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: ChainSpace.xxl),
|
const SizedBox(height: ChainSpace.xxl),
|
||||||
|
|
@ -81,7 +117,7 @@ class WelcomePage extends StatelessWidget {
|
||||||
const _HubDownHero(),
|
const _HubDownHero(),
|
||||||
const SizedBox(height: ChainSpace.xxl),
|
const SizedBox(height: ChainSpace.xxl),
|
||||||
] else
|
] else
|
||||||
const _OnboardingChecklist(),
|
_OnboardingChecklist(key: ValueKey(_checklistEpoch)),
|
||||||
const _PillarRow(),
|
const _PillarRow(),
|
||||||
const SizedBox(height: ChainSpace.xxl),
|
const SizedBox(height: ChainSpace.xxl),
|
||||||
const _SectionLabel(textKey: _SectionLabelKey.trust),
|
const _SectionLabel(textKey: _SectionLabelKey.trust),
|
||||||
|
|
@ -515,7 +551,7 @@ const String _kChecklistDismissedKey = 'welcome.checklist.dismissed';
|
||||||
/// parallel; failures stay false and the checklist still
|
/// parallel; failures stay false and the checklist still
|
||||||
/// renders (with the "Refresh" button for retry).
|
/// renders (with the "Refresh" button for retry).
|
||||||
class _OnboardingChecklist extends StatefulWidget {
|
class _OnboardingChecklist extends StatefulWidget {
|
||||||
const _OnboardingChecklist();
|
const _OnboardingChecklist({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_OnboardingChecklist> createState() => _OnboardingChecklistState();
|
State<_OnboardingChecklist> createState() => _OnboardingChecklistState();
|
||||||
|
|
@ -530,6 +566,12 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> {
|
||||||
bool _moduleOk = false;
|
bool _moduleOk = false;
|
||||||
bool _flowOk = false;
|
bool _flowOk = false;
|
||||||
|
|
||||||
|
/// Profile the guided setup applied (from `setup-plan.yaml`), or
|
||||||
|
/// null when the assistant never ran. Connects the two onboarding
|
||||||
|
/// surfaces: the checklist says what the assistant already covered
|
||||||
|
/// instead of ignoring it.
|
||||||
|
String? _setupProfile;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -541,6 +583,7 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_dismissed = prefs.getBool(_kChecklistDismissedKey) ?? false;
|
_dismissed = prefs.getBool(_kChecklistDismissedKey) ?? false;
|
||||||
|
_setupProfile = GuidedSetupDialog.appliedSetupProfile();
|
||||||
_initialised = true;
|
_initialised = true;
|
||||||
});
|
});
|
||||||
if (!_dismissed) {
|
if (!_dismissed) {
|
||||||
|
|
@ -598,6 +641,13 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> {
|
||||||
setState(() => _dismissed = true);
|
setState(() => _dismissed = true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Localized plain-language label for the applied setup profile.
|
||||||
|
String _profileLabel(AppLocalizations l) => switch (_setupProfile) {
|
||||||
|
'enterprise' => l.setupProfileEnterprise,
|
||||||
|
'air-gapped' => l.setupProfileAirgapped,
|
||||||
|
_ => l.setupProfileDev,
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!_initialised || _dismissed) return const SizedBox.shrink();
|
if (!_initialised || _dismissed) return const SizedBox.shrink();
|
||||||
|
|
@ -674,6 +724,27 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> {
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// The guided setup already ran: say so, so the checklist
|
||||||
|
// reads as "what's left", not as a second, disconnected
|
||||||
|
// onboarding that ignores the assistant's work.
|
||||||
|
if (_setupProfile != null) ...[
|
||||||
|
const SizedBox(height: ChainSpace.sm),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, size: 16, color: ChainColors.success),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l.welcomeChecklistSetupDone(_profileLabel(l)),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: ChainSpace.md),
|
const SizedBox(height: ChainSpace.md),
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,74 @@
|
||||||
// plain-language summary — never the CLI's English prose. Every
|
// plain-language summary — never the CLI's English prose. Every
|
||||||
// option carries a one-line explanation of what it configures.
|
// option carries a one-line explanation of what it configures.
|
||||||
|
|
||||||
|
import 'dart:async' show unawaited;
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../data/error_presentation.dart';
|
import '../data/error_presentation.dart';
|
||||||
|
import '../data/hub.dart';
|
||||||
import '../data/system_actions.dart';
|
import '../data/system_actions.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../main.dart' show StudioShellState;
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
import 'chain_stores_dialog.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.
|
||||||
|
|
@ -29,12 +88,80 @@ class _Option {
|
||||||
}
|
}
|
||||||
|
|
||||||
class GuidedSetupDialog extends StatefulWidget {
|
class GuidedSetupDialog extends StatefulWidget {
|
||||||
const GuidedSetupDialog({super.key});
|
/// The shell, resolved by [show] before the dialog route detaches
|
||||||
|
/// from the shell's subtree. Drives the post-apply "open the
|
||||||
|
/// starter flow" navigation; null in tests.
|
||||||
|
final StudioShellState? shell;
|
||||||
|
|
||||||
static Future<void> show(BuildContext context) => showDialog<void>(
|
/// Test seam: start on the review step with this parsed plan
|
||||||
|
/// instead of shelling out to `chain init --plan-json`.
|
||||||
|
@visibleForTesting
|
||||||
|
final Map<String, dynamic>? debugPlan;
|
||||||
|
|
||||||
|
/// Test seam: combined with [debugPlan], start in the applied
|
||||||
|
/// (next-steps) state.
|
||||||
|
@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) {
|
||||||
|
final shell = StudioShellState.of(context);
|
||||||
|
return showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => const GuidedSetupDialog(),
|
builder: (_) => GuidedSetupDialog(shell: shell),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path of the operator dir (`~/.chain`), or null when no home
|
||||||
|
/// directory can be resolved.
|
||||||
|
static String? _chainDir() {
|
||||||
|
final home =
|
||||||
|
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
||||||
|
if (home == null) return null;
|
||||||
|
return '$home${Platform.pathSeparator}.chain';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True on a fresh install: neither a config nor a recorded setup
|
||||||
|
/// plan exists yet. Drives the welcome page's auto-open.
|
||||||
|
static bool isFreshInstall() {
|
||||||
|
final dir = _chainDir();
|
||||||
|
if (dir == null) return false;
|
||||||
|
final sep = Platform.pathSeparator;
|
||||||
|
return !File('$dir${sep}config.yaml').existsSync() &&
|
||||||
|
!File('$dir${sep}setup-plan.yaml').existsSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `profile:` of the recorded setup plan (`setup-plan.yaml`),
|
||||||
|
/// or null when the guided setup never applied one. Lets the
|
||||||
|
/// onboarding checklist say "the assistant covered this" instead
|
||||||
|
/// of presenting a second, disconnected onboarding surface.
|
||||||
|
static String? appliedSetupProfile() {
|
||||||
|
final dir = _chainDir();
|
||||||
|
if (dir == null) return null;
|
||||||
|
final f = File('$dir${Platform.pathSeparator}setup-plan.yaml');
|
||||||
|
if (!f.existsSync()) return null;
|
||||||
|
try {
|
||||||
|
for (final line in f.readAsLinesSync()) {
|
||||||
|
final m = RegExp(r'^profile:\s*(\S+)').firstMatch(line.trim());
|
||||||
|
if (m != null) return m.group(1);
|
||||||
|
}
|
||||||
|
} on FileSystemException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<GuidedSetupDialog> createState() => _GuidedSetupDialogState();
|
State<GuidedSetupDialog> createState() => _GuidedSetupDialogState();
|
||||||
|
|
@ -71,19 +198,89 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
String _target = 'this-laptop';
|
String _target = 'this-laptop';
|
||||||
bool _requireApproval = false;
|
bool _requireApproval = false;
|
||||||
bool _dataLocal = false;
|
bool _dataLocal = false;
|
||||||
|
bool _allowUnsigned = false;
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
Map<String, dynamic>? _plan; // parsed SetupPlan from --plan-json
|
Map<String, dynamic>? _plan; // parsed SetupPlan from --plan-json
|
||||||
bool _applied = false;
|
bool _applied = false;
|
||||||
|
|
||||||
|
// Post-apply action state: hub reachability + per-module install
|
||||||
|
// progress, so the next steps are buttons that report back instead
|
||||||
|
// of CLI commands to retype.
|
||||||
|
bool _hubUp = false;
|
||||||
|
bool _startingHub = false;
|
||||||
|
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();
|
||||||
|
final seeded = widget.debugPlan;
|
||||||
|
if (seeded != null) {
|
||||||
|
_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();
|
||||||
|
try {
|
||||||
|
_answersDir?.deleteSync(recursive: true);
|
||||||
|
} on FileSystemException {
|
||||||
|
// Best-effort cleanup; the OS temp reaper covers the rest.
|
||||||
|
}
|
||||||
|
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'
|
||||||
'target: $_target\n'
|
'target: $_target\n'
|
||||||
'require_approval: $_requireApproval\n'
|
'require_approval: $_requireApproval\n'
|
||||||
'data_must_stay_local: $_dataLocal\n';
|
'data_must_stay_local: $_dataLocal\n'
|
||||||
|
'allow_unsigned_modules: $_allowUnsigned\n';
|
||||||
|
|
||||||
|
/// Per-dialog private temp dir for the answers file — a fixed
|
||||||
|
/// name in the shared system temp would be world-readable and
|
||||||
|
/// racy on multi-user machines. Cleaned up in [dispose].
|
||||||
|
Directory? _answersDir;
|
||||||
|
|
||||||
Future<String> _writeAnswers() async {
|
Future<String> _writeAnswers() async {
|
||||||
final f = File('${Directory.systemTemp.path}/chain-setup-answers.yaml');
|
_answersDir ??= await Directory.systemTemp.createTemp('chain-setup-');
|
||||||
|
final f = File(
|
||||||
|
'${_answersDir!.path}${Platform.pathSeparator}answers.yaml',
|
||||||
|
);
|
||||||
await f.writeAsString(_answersYaml());
|
await f.writeAsString(_answersYaml());
|
||||||
return f.path;
|
return f.path;
|
||||||
}
|
}
|
||||||
|
|
@ -121,6 +318,11 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Warning lines the apply emitted on success (e.g. the empty
|
||||||
|
/// trusted_publishers caveat). Swallowing them made the wizard
|
||||||
|
/// claim more than the config delivers — show them instead.
|
||||||
|
String _applyWarnings = '';
|
||||||
|
|
||||||
Future<void> _apply() async {
|
Future<void> _apply() async {
|
||||||
setState(() => _busy = true);
|
setState(() => _busy = true);
|
||||||
final path = await _writeAnswers();
|
final path = await _writeAnswers();
|
||||||
|
|
@ -130,22 +332,147 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _busy = false);
|
setState(() => _busy = false);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
setState(() => _applied = true);
|
final warnings = r.stderr
|
||||||
|
.split('\n')
|
||||||
|
.where((line) => line.toLowerCase().contains('warn'))
|
||||||
|
.join('\n')
|
||||||
|
.trim();
|
||||||
|
setState(() {
|
||||||
|
_applied = true;
|
||||||
|
_applyWarnings = warnings;
|
||||||
|
});
|
||||||
|
unawaited(_probeHub());
|
||||||
} else {
|
} else {
|
||||||
showFaiProcessError(context, 'chain init --apply', r.stdout, r.stderr);
|
showFaiProcessError(context, 'chain init --apply', r.stdout, r.stderr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refresh the "is the hub reachable" signal driving the
|
||||||
|
/// post-apply action rows.
|
||||||
|
Future<void> _probeHub() async {
|
||||||
|
final up = await HubService.instance
|
||||||
|
.healthy()
|
||||||
|
.catchError((Object _) => false);
|
||||||
|
if (mounted) setState(() => _hubUp = up);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the local daemon, then poll until it answers (the start
|
||||||
|
/// command returns before the gRPC endpoint is up). A failed start
|
||||||
|
/// often just means "already running", so probe before reporting.
|
||||||
|
Future<void> _startHub() async {
|
||||||
|
setState(() => _startingHub = true);
|
||||||
|
final r = await SystemActions.chainDaemon(['start']);
|
||||||
|
var up = false;
|
||||||
|
for (var i = 0; i < 6 && !up; i++) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||||
|
up = await HubService.instance
|
||||||
|
.healthy()
|
||||||
|
.catchError((Object _) => false);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_startingHub = false;
|
||||||
|
_hubUp = up;
|
||||||
|
});
|
||||||
|
if (!up) {
|
||||||
|
await showFaiProcessErrorDialog(
|
||||||
|
context,
|
||||||
|
'chain daemon start',
|
||||||
|
r.stdout,
|
||||||
|
r.stderr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
setState(() => _installing.add(module));
|
||||||
|
try {
|
||||||
|
await HubService.instance.installModule(source: module);
|
||||||
|
if (mounted) setState(() => _installed.add(module));
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
await showChainErrorDialog(context, 'install $module', e);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _installing.remove(module));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
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),
|
||||||
|
|
@ -201,35 +528,92 @@ 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),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _reviewStep(AppLocalizations l) {
|
/// True when the configured system AI runs on this machine — the
|
||||||
final plan = _plan;
|
/// privacy line says so instead of naming a cloud provider.
|
||||||
if (plan == null) return const SizedBox.shrink();
|
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);
|
final theme = Theme.of(context);
|
||||||
final lines = _planLines(l, plan);
|
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(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (_applied) ...[
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.check_circle, color: ChainColors.success, size: 20),
|
|
||||||
const SizedBox(width: ChainSpace.sm),
|
|
||||||
Text(l.setupApplied, style: theme.textTheme.titleSmall),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: ChainSpace.md),
|
|
||||||
Text(l.setupNextTitle, style: theme.textTheme.labelLarge),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
_next(l.setupNextHubStart),
|
|
||||||
if ((plan['modules'] as List?)?.isNotEmpty ?? false)
|
|
||||||
_next(l.setupNextInstall),
|
|
||||||
_next(l.setupNextRunFlow),
|
|
||||||
] else ...[
|
|
||||||
for (final line in lines)
|
for (final line in lines)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
|
|
@ -243,6 +627,83 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: ChainSpace.xs),
|
||||||
|
Text(
|
||||||
|
l.setupReflectionEditHint,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _reviewStep(AppLocalizations l) {
|
||||||
|
final plan = _plan;
|
||||||
|
if (plan == null) return const SizedBox.shrink();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final lines = _planLines(l, plan);
|
||||||
|
final modules = (plan['modules'] as List?)?.cast<String>() ?? const [];
|
||||||
|
final airGapped = (plan['profile'] as String? ?? '') == 'air-gapped';
|
||||||
|
final sigGate = plan['require_signatures'] == true;
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_applied)
|
||||||
|
..._appliedSteps(l, plan)
|
||||||
|
else ...[
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Signature dead end (SEC-01): a regulated connected plan
|
||||||
|
// refuses the public store's unsigned bundles — the very
|
||||||
|
// installs suggested after apply. Explain it and offer the
|
||||||
|
// relaxation as one deliberate, reversible switch. The plan
|
||||||
|
// is re-assembled on toggle so the preview stays truthful.
|
||||||
|
if (!airGapped && modules.isNotEmpty && (sigGate || _allowUnsigned)) ...[
|
||||||
|
const SizedBox(height: ChainSpace.sm),
|
||||||
|
if (sigGate)
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.verified_user_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.tertiary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l.setupSigPublicStoreNotice,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.tertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(l.setupAllowUnsigned),
|
||||||
|
subtitle: Text(l.setupAllowUnsignedSub),
|
||||||
|
value: _allowUnsigned,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _allowUnsigned = v);
|
||||||
|
_goReview();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
if (_configExists()) ...[
|
if (_configExists()) ...[
|
||||||
const SizedBox(height: ChainSpace.sm),
|
const SizedBox(height: ChainSpace.sm),
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -266,13 +727,147 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _next(String label) => Padding(
|
/// Post-apply next steps as real Studio actions: start the hub,
|
||||||
padding: const EdgeInsets.only(top: 4),
|
/// install each plan module, open the starter flow — no CLI text.
|
||||||
|
List<Widget> _appliedSteps(AppLocalizations l, Map<String, dynamic> plan) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final modules = (plan['modules'] as List?)?.cast<String>() ?? const [];
|
||||||
|
final airGapped = (plan['profile'] as String? ?? '') == 'air-gapped';
|
||||||
|
final sigGate = plan['require_signatures'] == true;
|
||||||
|
final flow = plan['starter_flow'] as String? ?? '';
|
||||||
|
return [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, color: ChainColors.success, size: 20),
|
||||||
|
const SizedBox(width: ChainSpace.sm),
|
||||||
|
Text(l.setupApplied, style: theme.textTheme.titleSmall),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: ChainSpace.md),
|
||||||
|
Text(l.setupNextTitle, style: theme.textTheme.labelLarge),
|
||||||
|
const SizedBox(height: ChainSpace.sm),
|
||||||
|
// 1 — the hub. Everything else needs it.
|
||||||
|
if (_hubUp)
|
||||||
|
_doneRow(l.setupHubRunning)
|
||||||
|
else
|
||||||
|
_actionRow(
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
onPressed: _startingHub ? null : _startHub,
|
||||||
|
icon: _startingHub
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.play_arrow, size: 18),
|
||||||
|
label: Text(l.setupNextHubStart),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 2 — the plan's modules. On a signature-strict plan the path
|
||||||
|
// stays clickable: add a signed source (the stores dialog with
|
||||||
|
// its pin-a-key field), then install — no terminal, no dead end.
|
||||||
|
if (modules.isNotEmpty) ...[
|
||||||
|
if (airGapped)
|
||||||
|
_hintRow(l.setupModulesOfflineHint)
|
||||||
|
else ...[
|
||||||
|
if (sigGate) ...[
|
||||||
|
_hintRow(l.setupModulesSignedHint),
|
||||||
|
_hintRow(l.setupTrustedPublishersHint),
|
||||||
|
_actionRow(
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => ChainStoresDialog.show(context),
|
||||||
|
icon: const Icon(Icons.add_moderator_outlined, size: 18),
|
||||||
|
label: Text(l.setupAddSignedSource),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (!_hubUp) _hintRow(l.setupStartHubFirst),
|
||||||
|
for (final m in modules)
|
||||||
|
_installed.contains(m)
|
||||||
|
? _doneRow(l.setupActionInstalled(m))
|
||||||
|
: _actionRow(
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
onPressed: (!_hubUp || _installing.contains(m))
|
||||||
|
? null
|
||||||
|
: () => _install(m),
|
||||||
|
icon: _installing.contains(m)
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.download, size: 18),
|
||||||
|
label: Text(l.setupActionInstall(m)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
// 3 — the starter flow (auto-imported on first hub start).
|
||||||
|
if (flow.isNotEmpty)
|
||||||
|
_actionRow(
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
widget.shell?.navigateTo('flows');
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.account_tree_outlined, size: 18),
|
||||||
|
label: Text(l.setupActionOpenFlow(flow)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Honesty: warnings the apply emitted (e.g. the empty
|
||||||
|
// trusted-publishers caveat), selectable so they can go
|
||||||
|
// verbatim into a compliance note.
|
||||||
|
if (_applyWarnings.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: ChainSpace.sm),
|
||||||
|
Text(l.setupApplyNotes, style: theme.textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SelectableText(
|
||||||
|
_applyWarnings,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _actionRow(Widget child) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: ChainSpace.xs),
|
||||||
|
child: Align(alignment: Alignment.centerLeft, child: child),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _doneRow(String label) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: ChainSpace.xs),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.arrow_right, size: 18, color: Theme.of(context).colorScheme.primary),
|
Icon(Icons.check_circle, size: 18, color: ChainColors.success),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 6),
|
||||||
Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)),
|
Expanded(
|
||||||
|
child: Text(label, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _hintRow(String text) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: ChainSpace.xs),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -280,8 +875,9 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
/// Build the localized plain-language plan lines from the structured
|
/// Build the localized plain-language plan lines from the structured
|
||||||
/// SetupPlan (never the CLI prose).
|
/// SetupPlan (never the CLI prose).
|
||||||
List<String> _planLines(AppLocalizations l, Map<String, dynamic> plan) {
|
List<String> _planLines(AppLocalizations l, Map<String, dynamic> plan) {
|
||||||
|
final profile = plan['profile'] as String? ?? '';
|
||||||
final lines = <String>[];
|
final lines = <String>[];
|
||||||
switch (plan['profile'] as String? ?? '') {
|
switch (profile) {
|
||||||
case 'air-gapped':
|
case 'air-gapped':
|
||||||
lines.add(l.setupPlanIntroAirgapped);
|
lines.add(l.setupPlanIntroAirgapped);
|
||||||
case 'enterprise':
|
case 'enterprise':
|
||||||
|
|
@ -289,13 +885,34 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
default:
|
default:
|
||||||
lines.add(l.setupPlanIntroDev);
|
lines.add(l.setupPlanIntroDev);
|
||||||
}
|
}
|
||||||
|
// Where this actually lands: the wizard always configures the
|
||||||
|
// machine Studio runs on — say so, especially when the operator
|
||||||
|
// picked a server/container target.
|
||||||
|
switch (plan['runbook'] as String? ?? '') {
|
||||||
|
case 'service':
|
||||||
|
lines.add(l.setupPlanRunbookService);
|
||||||
|
case 'air-gap-transfer':
|
||||||
|
lines.add(l.setupPlanRunbookAirgap);
|
||||||
|
case 'container':
|
||||||
|
lines.add(l.setupPlanRunbookContainer);
|
||||||
|
default:
|
||||||
|
lines.add(l.setupPlanRunbookLocal);
|
||||||
|
}
|
||||||
if (plan['require_signatures'] == true) lines.add(l.setupPlanSignatures);
|
if (plan['require_signatures'] == true) lines.add(l.setupPlanSignatures);
|
||||||
|
// The audit chain is always on for regulated profiles — the
|
||||||
|
// scenario card promises a tamper-evident log, so the preview
|
||||||
|
// states what is delivered (and the WORM line covers the rest).
|
||||||
|
if (profile == 'enterprise' || profile == 'air-gapped') {
|
||||||
|
lines.add(l.setupPlanAuditChain);
|
||||||
|
}
|
||||||
if (plan['worm_audit'] == true) lines.add(l.setupPlanWorm);
|
if (plan['worm_audit'] == true) lines.add(l.setupPlanWorm);
|
||||||
if (plan['approval_step'] == true) lines.add(l.setupPlanApproval);
|
if (plan['approval_step'] == true) lines.add(l.setupPlanApproval);
|
||||||
final modules = (plan['modules'] as List?)?.cast<String>() ?? const [];
|
final modules = (plan['modules'] as List?)?.cast<String>() ?? const [];
|
||||||
if (modules.isNotEmpty) lines.add(l.setupPlanModules(modules.join(', ')));
|
if (modules.isNotEmpty) lines.add(l.setupPlanModules(modules.join(', ')));
|
||||||
final flow = plan['starter_flow'] as String? ?? '';
|
final flow = plan['starter_flow'] as String? ?? '';
|
||||||
if (flow.isNotEmpty) lines.add(l.setupPlanFlow(flow));
|
if (flow.isNotEmpty) lines.add(l.setupPlanFlow(flow));
|
||||||
|
final docs = (plan['curated_docs'] as List?)?.length ?? 0;
|
||||||
|
if (docs > 0) lines.add(l.setupPlanDocs(docs));
|
||||||
lines.add(l.setupPlanFileChanged('~/.chain/config.yaml'));
|
lines.add(l.setupPlanFileChanged('~/.chain/config.yaml'));
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
@ -321,6 +938,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(
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,4 @@ SPEC CHECKSUMS:
|
||||||
|
|
||||||
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.17.0
|
||||||
|
|
|
||||||
39
pubspec.lock
39
pubspec.lock
|
|
@ -172,6 +172,11 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.5"
|
version: "0.3.5"
|
||||||
|
flutter_driver:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
flutter_highlight:
|
flutter_highlight:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -219,6 +224,11 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
fuchsia_remote_debug_protocol:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
google_cloud:
|
google_cloud:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -291,6 +301,11 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
integration_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -435,6 +450,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
process:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: process
|
||||||
|
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.5"
|
||||||
protobuf:
|
protobuf:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -544,6 +567,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
sync_http:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sync_http
|
||||||
|
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.1"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -664,6 +695,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
webdriver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webdriver
|
||||||
|
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.0"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ dependencies:
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
integration_test:
|
||||||
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
|
||||||
# Local-development override: while editing the swappable flow
|
# Local-development override: while editing the swappable flow
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,41 @@ Widget _host() => MaterialApp(
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Host that opens the dialog pre-seeded on the review / applied
|
||||||
|
/// step with [plan] (the parsed `--plan-json` shape), bypassing the
|
||||||
|
/// CLI subprocess.
|
||||||
|
Widget _seededHost(Map<String, dynamic> plan, {bool applied = false}) =>
|
||||||
|
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: (_) =>
|
||||||
|
GuidedSetupDialog(debugPlan: plan, debugApplied: applied),
|
||||||
|
),
|
||||||
|
child: const Text('open'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _regulatedPlan({bool signed = true}) => {
|
||||||
|
'profile': 'enterprise',
|
||||||
|
'modules': ['text.extract', 'text.summarize'],
|
||||||
|
'starter_flow': 'extract-summarize',
|
||||||
|
'runbook': 'service',
|
||||||
|
'curated_docs': <String>[],
|
||||||
|
'require_signatures': signed,
|
||||||
|
'worm_audit': false,
|
||||||
|
'approval_step': true,
|
||||||
|
};
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('step 1 shows scenario options in German with explanations', (
|
testWidgets('step 1 shows scenario options in German with explanations', (
|
||||||
tester,
|
tester,
|
||||||
|
|
@ -75,4 +110,183 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Schritt 2 von 3'), findsOneWidget);
|
expect(find.text('Schritt 2 von 3'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'review of a regulated plan explains the signature dead end and offers the relaxation switch',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(_seededHost(_regulatedPlan()));
|
||||||
|
await tester.tap(find.text('open'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The plain-language notice + the deliberate one-click switch.
|
||||||
|
expect(
|
||||||
|
find.textContaining('Der öffentliche Store liefert zurzeit unsignierte Pakete'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.text('Installation aus dem öffentlichen Store erlauben'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
// No raw CLI prose anywhere.
|
||||||
|
expect(find.textContaining('chain install'), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'applied state renders clickable next steps — start hub, per-module install, open flow',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_seededHost(_regulatedPlan(signed: false), applied: true),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('open'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Fertig — Ch∆In ist eingerichtet.'), findsOneWidget);
|
||||||
|
// Hub not probed up in tests → the start action + the
|
||||||
|
// install-needs-hub hint show, and install buttons exist
|
||||||
|
// (disabled) for each plan module.
|
||||||
|
expect(find.text('Hub starten'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.textContaining('Starten Sie zuerst den Hub'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.text('text.extract installieren'), findsOneWidget);
|
||||||
|
expect(find.text('text.summarize installieren'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.text('Beispiel-Flow „extract-summarize“ öffnen'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
// No CLI text in the GUI path.
|
||||||
|
expect(find.textContaining('chain serve'), findsNothing);
|
||||||
|
expect(find.textContaining('chain install'), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'applied regulated (signed) state stays clickable — signed-source dialog, trust hint, install buttons',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(_seededHost(_regulatedPlan(), applied: true));
|
||||||
|
await tester.tap(find.text('open'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The plain-language story + the pin-the-publisher trust hint.
|
||||||
|
expect(find.textContaining('signierten Quelle'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.textContaining('Schlüssel des Herausgebers'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
// No dead end: the stores dialog (with its pin-a-key field) is
|
||||||
|
// one click away, and the installs stay available for after
|
||||||
|
// the source is added.
|
||||||
|
expect(find.text('Signierte Quelle hinzufügen…'), findsOneWidget);
|
||||||
|
expect(find.text('text.extract installieren'), findsOneWidget);
|
||||||
|
expect(find.textContaining('chain store add'), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'review preview names the machine being set up and the audit chain',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(_seededHost(_regulatedPlan()));
|
||||||
|
await tester.tap(find.text('open'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Runbook honesty: a "home server" target still configures
|
||||||
|
// THIS machine — the preview must say so.
|
||||||
|
expect(
|
||||||
|
find.textContaining('Eingerichtet wird dieser Rechner'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
// The regulated promise is stated even when WORM is off:
|
||||||
|
// the hash-chained audit log line is always there.
|
||||||
|
expect(
|
||||||
|
find.textContaining('hash-verketteten Prüfprotokoll'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,13 +71,13 @@ class HubFixture {
|
||||||
if (binary == null) {
|
if (binary == null) {
|
||||||
if (skipIfBinaryMissing) {
|
if (skipIfBinaryMissing) {
|
||||||
markTestSkipped(
|
markTestSkipped(
|
||||||
'Hub fixture skipped: no `fai` binary found on PATH or in '
|
'Hub fixture skipped: no `chain` binary found via \$CHAIN_BIN, '
|
||||||
'../chain_platform/target/release/fai. Build it with '
|
'PATH, or ../fai_chain/target/{release,debug}/chain. Build it '
|
||||||
'`cargo build --release` and re-run.',
|
'with `cargo build` in the platform repo and re-run.',
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
throw StateError('fai binary not found');
|
throw StateError('chain binary not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
final tempDir = await Directory.systemTemp.createTemp('chain_studio_test_');
|
final tempDir = await Directory.systemTemp.createTemp('chain_studio_test_');
|
||||||
|
|
@ -157,27 +157,37 @@ class HubFixture {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look for `fai` first on PATH (production-ish), then in
|
/// Resolved hub binary path, for tests that also drive the CLI
|
||||||
/// the standard `target/release/` build output relative to
|
/// against the fixture's data dir (e.g. seeding projects).
|
||||||
/// the platform checkout (developer flow). Returns null when
|
static Future<String?> binaryPath() => _resolveBinary();
|
||||||
/// neither path resolves.
|
|
||||||
|
/// Look for the hub binary: $CHAIN_BIN override first, then
|
||||||
|
/// `chain` on PATH (production-ish), then the release/debug
|
||||||
|
/// build outputs relative to the platform checkout (developer
|
||||||
|
/// flow, repo dir `fai_chain`). Returns null when nothing
|
||||||
|
/// resolves.
|
||||||
static Future<String?> _resolveBinary() async {
|
static Future<String?> _resolveBinary() async {
|
||||||
|
final fromEnv = Platform.environment['CHAIN_BIN'];
|
||||||
|
if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) {
|
||||||
|
return fromEnv;
|
||||||
|
}
|
||||||
|
|
||||||
final pathResult = await Process.run(
|
final pathResult = await Process.run(
|
||||||
Platform.isWindows ? 'where' : 'which',
|
Platform.isWindows ? 'where' : 'which',
|
||||||
['fai'],
|
[Platform.isWindows ? 'chain.exe' : 'chain'],
|
||||||
);
|
);
|
||||||
if (pathResult.exitCode == 0) {
|
if (pathResult.exitCode == 0) {
|
||||||
final s = (pathResult.stdout as String).trim();
|
final s = (pathResult.stdout as String).trim();
|
||||||
if (s.isNotEmpty) return s.split('\n').first.trim();
|
if (s.isNotEmpty) return s.split('\n').first.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
final candidate = Platform.isWindows
|
final exe = Platform.isWindows ? 'chain.exe' : 'chain';
|
||||||
? '../chain_platform/target/release/fai.exe'
|
for (final dir in ['release', 'debug']) {
|
||||||
: '../chain_platform/target/release/fai';
|
final f = File('../fai_chain/target/$dir/$exe');
|
||||||
final f = File(candidate);
|
|
||||||
if (await f.exists()) {
|
if (await f.exists()) {
|
||||||
return f.absolute.path;
|
return f.absolute.path;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
118
test/nav_manifest_test.dart
Normal file
118
test/nav_manifest_test.dart
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
// Nav-manifest guard. The sidebar truth lives in three places in
|
||||||
|
// this repo: the `_pages` list (ids + order = Cmd numbers), the
|
||||||
|
// `labelOf` switch (id → l10n key), and the two .arb files (key →
|
||||||
|
// DE/EN label). The operator guide documenting that nav lives in
|
||||||
|
// the PLATFORM repo (fai_chain/docs/studio/operator-guide.md),
|
||||||
|
// which this repo's CI cannot see — so the truth is exported as a
|
||||||
|
// committed manifest, `docs/nav.generated.json`, and the platform
|
||||||
|
// repo's docs_consistency test checks the guide against it.
|
||||||
|
//
|
||||||
|
// This test derives the manifest from the sources. When it differs
|
||||||
|
// from the committed file it REWRITES the file and fails once with
|
||||||
|
// instructions — commit the regenerated manifest and mirror it to
|
||||||
|
// fai_chain/docs/studio/nav.generated.json so the cross-repo guard
|
||||||
|
// stays truthful. Add/rename/reorder a page and forget the guide:
|
||||||
|
// the platform gate goes loudly red instead of the docs quietly
|
||||||
|
// rotting.
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
const _manifestPath = 'docs/nav.generated.json';
|
||||||
|
const _mirrorHint =
|
||||||
|
'fai_chain/docs/studio/nav.generated.json (same content)';
|
||||||
|
|
||||||
|
/// Extract the ordered page ids from the `_pages` list in main.dart.
|
||||||
|
List<String> _pageIds(String mainDart) {
|
||||||
|
final block = RegExp(
|
||||||
|
r'static const _pages = <_NavPage>\[(.*?)\n \];',
|
||||||
|
dotAll: true,
|
||||||
|
).firstMatch(mainDart);
|
||||||
|
if (block == null) {
|
||||||
|
fail('could not locate the _pages block in lib/main.dart');
|
||||||
|
}
|
||||||
|
return RegExp(r"id: '([a-z-]+)'")
|
||||||
|
.allMatches(block.group(1)!)
|
||||||
|
.map((m) => m.group(1)!)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the id → l10n-key mapping from the `labelOf` switch.
|
||||||
|
Map<String, String> _labelKeys(String mainDart) {
|
||||||
|
final keys = <String, String>{};
|
||||||
|
for (final m in RegExp(
|
||||||
|
r"case '([a-z-]+)':\s*\n\s*return l\.(nav\w+);",
|
||||||
|
).allMatches(mainDart)) {
|
||||||
|
keys[m.group(1)!] = m.group(2)!;
|
||||||
|
}
|
||||||
|
if (keys.isEmpty) {
|
||||||
|
fail('could not parse the labelOf switch in lib/main.dart');
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _arb(String path) =>
|
||||||
|
jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('docs/nav.generated.json matches the sidebar truth', () {
|
||||||
|
final mainDart = File('lib/main.dart').readAsStringSync();
|
||||||
|
final ids = _pageIds(mainDart);
|
||||||
|
final labelKeys = _labelKeys(mainDart);
|
||||||
|
final de = _arb('lib/l10n/app_de.arb');
|
||||||
|
final en = _arb('lib/l10n/app_en.arb');
|
||||||
|
|
||||||
|
final pages = <Map<String, dynamic>>[];
|
||||||
|
for (var i = 0; i < ids.length; i++) {
|
||||||
|
final key = labelKeys[ids[i]];
|
||||||
|
expect(
|
||||||
|
key,
|
||||||
|
isNotNull,
|
||||||
|
reason:
|
||||||
|
"page '${ids[i]}' has no labelOf case — add it so the "
|
||||||
|
'sidebar can localize the label',
|
||||||
|
);
|
||||||
|
final labelDe = de[key] as String?;
|
||||||
|
final labelEn = en[key] as String?;
|
||||||
|
expect(
|
||||||
|
labelDe,
|
||||||
|
isNotNull,
|
||||||
|
reason: "l10n key '$key' missing from app_de.arb",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
labelEn,
|
||||||
|
isNotNull,
|
||||||
|
reason: "l10n key '$key' missing from app_en.arb",
|
||||||
|
);
|
||||||
|
pages.add({
|
||||||
|
'id': ids[i],
|
||||||
|
'cmd': i + 1,
|
||||||
|
'label_de': labelDe,
|
||||||
|
'label_en': labelEn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = JsonEncoder.withIndent(' ');
|
||||||
|
final manifest =
|
||||||
|
'${encoder.convert({
|
||||||
|
'_generated': 'by test/nav_manifest_test.dart — do not edit. '
|
||||||
|
'Mirror to $_mirrorHint.',
|
||||||
|
'pages': pages,
|
||||||
|
})}\n';
|
||||||
|
|
||||||
|
final file = File(_manifestPath);
|
||||||
|
final current = file.existsSync() ? file.readAsStringSync() : null;
|
||||||
|
if (current != manifest) {
|
||||||
|
file.parent.createSync(recursive: true);
|
||||||
|
file.writeAsStringSync(manifest);
|
||||||
|
fail(
|
||||||
|
'the sidebar nav changed: $_manifestPath was regenerated. '
|
||||||
|
'Commit it, mirror it to $_mirrorHint, and update the '
|
||||||
|
'operator guide — the platform repo\'s docs_consistency '
|
||||||
|
'test checks the guide against this manifest.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue