From 634fd526352c8f4a284511d5591e93502f7bb436 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 1 Jun 2026 00:52:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(studio):=20Flows=20page=20IS=20the=20edito?= =?UTF-8?q?r=20=E2=80=94=20three-tab=20graph+text+run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio's Flows destination is now the swappable fai_studio_flow_editor's full surface: a drag-and-drop graph view, the raw YAML text editor, and the run tab with inputs form + live step progress + outputs. What's gone: - The old saved-flows list + per-row run dialog. - The separate "flow-editor" navigation destination (one icon, not two — operators reach the editor by clicking Flows, period). - _FlowEditorAdapter (no longer needed; FlowsPage hands the runDriver to the editor directly). What's new: - lib/data/flow_run_driver.dart — StudioFlowRunDriver: adapter implementing the editor's FlowRunDriver. Maps runFlow() to HubService.runSavedFlow and events() to HubService.streamEvents filtered to step.* event types. Converts proto-shaped FlowOutput variants to the editor's host-agnostic FlowOutputValue family. - lib/pages/flows.dart — replaced with a 50-line wrapper that loads capabilities into the editor's picker dropdown and forwards the active locale. Editor pinned to fai_studio_flow_editor 0.2.0 (commit 870cbc2). All editor state — file list, dirty tracking, tab selection, graph layout sidecar, properties panel — lives in the editor package now; Studio just supplies the hub bridge. Version 0.51.10 -> 0.52.0 (minor bump — Flows-page contract changed, separate flow-editor destination removed). flutter analyze: 0 issues. flutter test: 12/12 green. Signed-off-by: flemming-it --- lib/data/flow_run_driver.dart | 112 +++ lib/main.dart | 37 +- lib/pages/flows.dart | 1463 ++------------------------------- pubspec.lock | 4 +- pubspec.yaml | 2 +- 5 files changed, 179 insertions(+), 1439 deletions(-) create mode 100644 lib/data/flow_run_driver.dart diff --git a/lib/data/flow_run_driver.dart b/lib/data/flow_run_driver.dart new file mode 100644 index 0000000..3d45f2b --- /dev/null +++ b/lib/data/flow_run_driver.dart @@ -0,0 +1,112 @@ +// StudioFlowRunDriver — adapter that bridges the editor +// package's FlowRunDriver interface to Studio's HubService. +// +// Lives in Studio (not in the editor package) so the package +// stays host-agnostic. The driver maps: +// +// - editor.runFlow(...) -> HubService.runSavedFlow +// - editor.events() -> HubService.streamEvents filtered +// to step.* events and reshaped +// into the editor's FlowRunEvent +// value classes. + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:fai_studio_flow_editor/fai_studio_flow_editor.dart' as editor; + +import 'hub.dart'; + +class StudioFlowRunDriver implements editor.FlowRunDriver { + StudioFlowRunDriver(); + + @override + Future> runFlow({ + required String flowName, + required Map textInputs, + required Map fileInputs, + required Map fileMimes, + }) async { + final outputs = await HubService.instance.runSavedFlow( + name: flowName, + textInputs: textInputs, + fileInputs: fileInputs, + fileMimeTypes: fileMimes, + ); + final mapped = {}; + for (final entry in outputs.entries) { + mapped[entry.key] = _convertOutput(entry.value); + } + return mapped; + } + + @override + Stream events() { + return HubService.instance + .streamEvents( + backfill: 0, + types: const [ + 'step.started', + 'step.completed', + 'step.failed', + 'step.awaiting_approval', + 'step.approved', + 'step.rejected', + ], + ) + .map(_convertEvent) + .where((e) => e != null) + .cast(); + } + + editor.FlowRunEvent? _convertEvent(AuditEvent e) { + final flowName = e.flowName; + final stepId = e.stepId; + if (flowName == null || flowName.isEmpty) return null; + if (stepId == null || stepId.isEmpty) return null; + switch (e.type) { + case 'step.started': + case 'step.approved': + return editor.StepStarted(flowName: flowName, stepId: stepId); + case 'step.completed': + return editor.StepCompleted( + flowName: flowName, + stepId: stepId, + durationMs: e.durationMs ?? 0, + ); + case 'step.failed': + case 'step.rejected': + return editor.StepFailed( + flowName: flowName, + stepId: stepId, + error: e.error ?? '', + ); + case 'step.awaiting_approval': + return editor.StepAwaitingApproval( + flowName: flowName, + stepId: stepId, + ); + } + return null; + } + + /// Studio's FlowOutput hierarchy is gRPC-shaped (one class + /// per payload variant); the editor's is host-agnostic + /// (text / json / bytes only). File outputs degrade to + /// JSON containing the URI string — the editor's run-tab + /// renderer is read-only so the operator can still see + /// where the file went; Studio's richer FaiFlowOutput + /// widget keeps file-open affordances available elsewhere. + editor.FlowOutputValue _convertOutput(FlowOutput out) { + return switch (out) { + FlowOutputText() => editor.FlowOutputText(out.text), + FlowOutputJson() => editor.FlowOutputJson(out.pretty), + FlowOutputBytes() => editor.FlowOutputBytes(out.bytes, out.mimeType), + FlowOutputFile() => editor.FlowOutputJson({ + 'file_uri': out.uri, + 'mime_type': out.mimeType, + }), + FlowOutputUnknown() => const editor.FlowOutputText(''), + }; + } +} diff --git a/lib/main.dart b/lib/main.dart index a00b665..df94c29 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,7 +5,6 @@ import 'dart:async'; -import 'package:fai_studio_flow_editor/fai_studio_flow_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -27,7 +26,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.51.10'; +const String kStudioVersion = '0.52.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -228,18 +227,17 @@ class _StudioShellState extends State { // the Store's detail sheet, and the Store with the // "Installed" filter covers the listing role. Cmd+K still // opens FaiModuleSheet for quick info. + // Flows destination IS the editor as of 0.52.0 — the + // graph / text / run tabs all live behind one icon. The + // separate "flow-editor" destination is gone; the Cmd+K + // palette still navigates to flows when the operator + // wants the editor surface. _NavPage( id: 'flows', icon: Icons.account_tree_outlined, selectedIcon: Icons.account_tree, page: FlowsPage(), ), - _NavPage( - id: 'flow-editor', - icon: Icons.code_outlined, - selectedIcon: Icons.code, - page: _FlowEditorAdapter(), - ), _NavPage( id: 'audit', icon: Icons.timeline_outlined, @@ -1322,22 +1320,7 @@ class _LanguageToggle extends StatelessWidget { } } -/// Adapter that resolves the swappable FlowEditorPage's -/// runtime dependencies (locale, hub onRun callback) from -/// Studio's BuildContext. The _NavPage destination list -/// holds widgets as const-ish, so the adapter sits in -/// between to grab live data. -class _FlowEditorAdapter extends StatelessWidget { - const _FlowEditorAdapter(); - - @override - Widget build(BuildContext context) { - final lang = Localizations.localeOf(context).languageCode; - return FlowEditorPage( - locale: lang == 'de' ? FlowEditorLocale.de : FlowEditorLocale.en, - onRun: (name) async => - (await HubService.instance.runSavedFlow(name: name)) - .cast(), - ); - } -} +// _FlowEditorAdapter removed in 0.52.0 — FlowsPage now hosts +// the editor directly with the full runDriver bridge. There +// is one Flows destination; the standalone editor route is +// gone. diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index 69c53c0..b84f68b 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -1,1436 +1,81 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:fai_client_sdk/fai_client_sdk.dart' show FlowInputDef; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; +// FlowsPage — Studio's flow surface. Thin host wrapper around +// the swappable fai_studio_flow_editor package: loads the +// hub's installed capabilities, hands them + a Studio-side +// FlowRunDriver to the editor, and lets the editor own +// everything else (file list, three-tab body, properties +// panel, run progress, …). +// +// Pre-0.52 versions split file-listing and run-dialog state +// across this page. v0.52 moves all of that into the editor +// package so a swap is one pubspec change — Studio doesn't +// need to know what's inside the editor any more. import 'package:fai_studio_flow_editor/fai_studio_flow_editor.dart'; +import 'package:flutter/material.dart'; -import '../data/format.dart'; +import '../data/flow_run_driver.dart'; import '../data/hub.dart'; -import '../l10n/app_localizations.dart'; -import '../theme/theme.dart'; -import '../theme/tokens.dart'; -import '../widgets/widgets.dart'; class FlowsPage extends StatefulWidget { - const FlowsPage({super.key}); + /// Pre-load this flow when the editor first builds. Studio + /// keeps the parameter so the Cmd+K palette + welcome page + /// can deep-link straight into a specific flow. + final String? initialFlowName; + + const FlowsPage({super.key, this.initialFlowName}); @override State createState() => _FlowsPageState(); } class _FlowsPageState extends State { - late Future<_FlowsBundle> _future; + late Future> _capabilities; + late final StudioFlowRunDriver _driver; @override void initState() { super.initState(); - _future = _load(); + _driver = StudioFlowRunDriver(); + _capabilities = _loadCapabilities(); } - /// Pulls the saved flows AND every capability the hub can - /// execute (WASM modules + built-ins like `system.approval` + - /// federated MCP/n8n tools) in parallel so the page can - /// compute "is this flow runnable" per row. - /// - /// Using `allCapabilities()` instead of `listModules()` is - /// load-bearing: built-ins are not WASM modules, so they would - /// never appear in a module-grouped list — and a flow that - /// uses `system.approval@^0` would stay stuck on "missing - /// dependency" forever. - Future<_FlowsBundle> _load() async { - final results = await Future.wait([ - HubService.instance.listFlows(), - HubService.instance - .allCapabilities() - .catchError((_) => []), - ]); - return _FlowsBundle( - flows: results[0] as List, - installedCapabilities: (results[1] as List) - .map((c) => c.capability) - .toSet(), - ); - } - - void _refresh() => setState(() { - _future = _load(); - }); - - /// Returns the verbatim `use:` strings whose bare capability - /// name is not provided by any installed module. The bare - /// name is everything left of the first `@`; version - /// constraints are tolerated at this stage — the hub does - /// the full resolution at run time. - List _missing(SavedFlow flow, Set installed) { - final out = []; - for (final cap in flow.requiredCapabilities) { - final at = cap.indexOf('@'); - final name = at >= 0 ? cap.substring(0, at) : cap; - if (!installed.contains(name)) out.add(cap); + /// Pulls the capability identifiers the picker dialog + /// offers when adding a step. `@` so + /// the operator can see exactly what they'll be wiring in, + /// matching the format the `use:` field expects. Soft-fails + /// to an empty list if the hub is unreachable — the editor + /// falls back to a free-form text field in that case. + Future> _loadCapabilities() async { + try { + final caps = await HubService.instance.allCapabilities(); + final entries = caps + .map((c) => '${c.capability}@${c.version}') + .toSet() + .toList() + ..sort(); + return entries; + } catch (_) { + return const []; } - return out; - } - - Future _runFlow(SavedFlow flow) async { - final inputs = await _FlowInputDialog.show(context, flow); - if (inputs == null) return; - if (!mounted) return; - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => _FlowRunDialog( - flow: flow, - textInputs: inputs.textInputs, - fileInputs: inputs.fileInputs, - fileMimeTypes: inputs.fileMimeTypes, - ), - ); - } - - /// Walk through one or more capability specs (e.g. - /// `text.extract@^0`) sequentially, showing per-item progress. - /// Refreshes the flows page on dismiss so the row updates if - /// any install actually changed the installed-set. - Future _installDeps(List specs) async { - final changed = await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => _InstallDependenciesDialog(specs: specs), - ); - if (changed == true && mounted) _refresh(); - } - - /// Push Studio's built-in YAML editor as a route with the - /// selected flow preloaded. The editor's AppBar back arrow - /// returns to this page. Falls back to opening the file in - /// the OS's default editor only when explicitly asked (the - /// secondary "open in OS" entry, not the pencil). - Future _openInEditor(SavedFlow flow) async { - final lang = Localizations.localeOf(context).languageCode; - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => FlowEditorPage( - initialFlowName: flow.name, - locale: - lang == 'de' ? FlowEditorLocale.de : FlowEditorLocale.en, - onRun: (name) async => - (await HubService.instance.runSavedFlow(name: name)) - .cast(), - ), - ), - ); - // Refresh the list when we come back — operator may have - // saved + renamed, or deleted, or just edited capabilities. - if (mounted) _refresh(); } @override Widget build(BuildContext context) { - final l = AppLocalizations.of(context)!; - return Scaffold( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - appBar: AppBar( - title: Text(l.flowsTitle), - actions: [ - IconButton( - icon: const Icon(Icons.refresh, size: 18), - tooltip: l.flowsReloadTooltip, - onPressed: _refresh, - ), - const SizedBox(width: FaiSpace.sm), - ], - ), - body: FutureBuilder<_FlowsBundle>( - future: _future, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return FaiEmptyState( - icon: Icons.cloud_off_outlined, - iconColor: Theme.of(context).colorScheme.error, - title: l.hubUnreachable, - hint: l.hubUnreachableHint, - action: FilledButton.tonal( - onPressed: _refresh, - child: Text(l.buttonRetry), - ), - ); - } - final bundle = snapshot.data ?? const _FlowsBundle.empty(); - final flows = bundle.flows; - if (flows.isEmpty) { - return FaiEmptyState( - icon: Icons.account_tree_outlined, - title: l.flowsNoneTitle, - hint: l.flowsNoneHint, - ); - } - return ListView.separated( - padding: const EdgeInsets.all(FaiSpace.xl), - itemCount: flows.length, - separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md), - itemBuilder: (context, i) { - final flow = flows[i]; - final missing = _missing(flow, bundle.installedCapabilities); - return _FlowCard( - flow: flow, - missingCapabilities: missing, - onRun: missing.isEmpty ? () => _runFlow(flow) : null, - onInstallSingle: (spec) => _installDeps([spec]), - onInstallAll: missing.length > 1 - ? () => _installDeps(missing) - : null, - onOpenInEditor: () => _openInEditor(flow), - ); - }, - ); - }, - ), - ); - } -} - -/// Snapshot of everything the Flows page needs in one render -/// pass: the saved flows themselves and the set of installed -/// capability names. Stored as a single Future so the -/// FutureBuilder transitions atomically (no flicker between -/// "flows loaded but modules pending"). -class _FlowsBundle { - final List flows; - final Set installedCapabilities; - const _FlowsBundle({ - required this.flows, - required this.installedCapabilities, - }); - const _FlowsBundle.empty() - : flows = const [], - installedCapabilities = const {}; -} - -class _FlowCard extends StatelessWidget { - final SavedFlow flow; - final List missingCapabilities; - /// Null when the flow can't run (missing modules); the - /// Run button greys out + a tooltip points the operator - /// at the install path. Non-null otherwise. - final VoidCallback? onRun; - /// Install a single capability spec (verbatim `use:` string, - /// version constraint included — the dialog strips the `@` - /// suffix before calling installModule). - final ValueChanged onInstallSingle; - /// Install every missing capability in sequence. Null when - /// only one is missing (the single-install pill is enough). - final VoidCallback? onInstallAll; - /// Hand the flow YAML to the OS so the operator's default - /// editor opens it. - final VoidCallback onOpenInEditor; - - const _FlowCard({ - required this.flow, - required this.missingCapabilities, - required this.onRun, - required this.onInstallSingle, - required this.onInstallAll, - required this.onOpenInEditor, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l = AppLocalizations.of(context)!; - final isRunnable = missingCapabilities.isEmpty; - return FaiCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.account_tree_outlined, - size: 20, - color: theme.colorScheme.primary, - ), - const SizedBox(width: FaiSpace.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - flow.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - flow.path, - style: FaiTheme.mono( - size: 11, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - FaiPill( - label: humanBytes(flow.sizeBytes), - tone: FaiPillTone.neutral, - monospace: true, - ), - const SizedBox(width: FaiSpace.sm), - IconButton( - icon: const Icon(Icons.edit_outlined, size: 18), - tooltip: l.flowsOpenInEditorTooltip, - onPressed: onOpenInEditor, - ), - const SizedBox(width: FaiSpace.xs), - Tooltip( - message: isRunnable ? '' : l.flowsRunDisabledTooltip, - child: FilledButton.icon( - onPressed: onRun, - icon: const Icon(Icons.play_arrow, size: 16), - label: Text(l.flowsRunButton), - ), - ), - ], - ), - if (!isRunnable) ...[ - const SizedBox(height: FaiSpace.sm), - Padding( - padding: const EdgeInsets.only(left: 32), - child: Wrap( - spacing: FaiSpace.xs, - runSpacing: FaiSpace.xs, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - l.flowsMissingModulesLabel, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.error, - letterSpacing: 0.4, - ), - ), - for (final cap in missingCapabilities) - _ClickablePill( - label: cap, - tooltip: l.flowsInstallPillTooltip, - onTap: () => onInstallSingle(cap), - ), - if (onInstallAll != null) - Padding( - padding: const EdgeInsets.only(left: FaiSpace.sm), - child: FilledButton.tonalIcon( - onPressed: onInstallAll, - icon: const Icon(Icons.download_outlined, size: 14), - label: Text( - l.flowsInstallAllButton(missingCapabilities.length), - ), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: FaiSpace.md, - vertical: 0, - ), - minimumSize: const Size(0, 28), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - ), - ], - ), - ), - ], - ], - ), - ); - } -} - -/// Tappable variant of `FaiPill` used for the missing-capability -/// row. Wraps the pill in `InkWell` + `Tooltip` so the operator -/// gets a click affordance and a "Click to install" hint. -class _ClickablePill extends StatelessWidget { - final String label; - final String tooltip; - final VoidCallback onTap; - - const _ClickablePill({ - required this.label, - required this.tooltip, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Tooltip( - message: tooltip, - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(FaiRadius.sm), - child: InkWell( - borderRadius: BorderRadius.circular(FaiRadius.sm), - onTap: onTap, - child: FaiPill( - label: label, - tone: FaiPillTone.danger, - icon: Icons.download_outlined, - monospace: true, - ), - ), - ), - ); - } -} - -/// Inputs collected by `_FlowInputDialog` and forwarded to -/// the run dialog. Text and file values arrive in separate -/// maps so the SDK can wrap each as the appropriate Payload -/// variant. -class _FlowRunInputs { - final Map textInputs; - final Map fileInputs; - /// Parallel to [fileInputs]; same keys. Modules like - /// `text.extract` reject `application/octet-stream`, so we - /// derive a real MIME type from the picked file's extension - /// and forward it to the hub. - final Map fileMimeTypes; - const _FlowRunInputs({ - required this.textInputs, - required this.fileInputs, - required this.fileMimeTypes, - }); -} - -class _PickedFile { - final String name; - final Uint8List bytes; - /// MIME derived from the file extension at pick time. Empty - /// when the extension is unknown — the SDK then falls back to - /// `application/octet-stream`. - final String mimeType; - const _PickedFile({ - required this.name, - required this.bytes, - required this.mimeType, - }); -} - -/// Minimal extension → MIME map covering the file types that -/// shipped modules actually accept (PDF, DOCX for text.extract; -/// plain text, json, csv for upcoming modules). Keeping it tiny -/// avoids pulling the `mime` package as a new dependency. Returns -/// empty string for unknown extensions so the SDK default -/// (`application/octet-stream`) still applies. -String _mimeForFilename(String filename) { - final dot = filename.lastIndexOf('.'); - if (dot < 0 || dot == filename.length - 1) return ''; - final ext = filename.substring(dot + 1).toLowerCase(); - switch (ext) { - case 'pdf': - return 'application/pdf'; - case 'docx': - return 'application/vnd.openxmlformats-officedocument' - '.wordprocessingml.document'; - case 'doc': - return 'application/msword'; - case 'txt': - return 'text/plain'; - case 'md': - case 'markdown': - return 'text/markdown'; - case 'json': - return 'application/json'; - case 'csv': - return 'text/csv'; - case 'xml': - return 'application/xml'; - case 'yaml': - case 'yml': - return 'application/yaml'; - case 'html': - case 'htm': - return 'text/html'; - case 'png': - return 'image/png'; - case 'jpg': - case 'jpeg': - return 'image/jpeg'; - default: - return ''; - } -} - -/// Run-flow input dialog. Fetches the flow's declared input -/// schema first via `getFlowDefinition`, then renders one -/// type-aware field per declared input: -/// -/// * `bytes` / `file` → "Choose file" button + picked-file -/// readout. Routes through the OS -/// file dialog so sandboxed Studio -/// still gets read access to the -/// chosen file. `withData: true` so -/// the bytes flow through the -/// FilePicker result instead of a -/// path that may be unreadable. -/// * everything else → TextField. Opaque type tag is -/// shown as a pill next to the input -/// name so a flow author who picks -/// a less-common type still gets a -/// hint. -/// -/// The Run button enables only when every input has a value -/// — required-field validation happens client-side before -/// the hub sees an empty inputs map. -class _FlowInputDialog extends StatefulWidget { - final SavedFlow flow; - - const _FlowInputDialog({required this.flow}); - - static Future<_FlowRunInputs?> show( - BuildContext context, - SavedFlow flow, - ) { - return showDialog<_FlowRunInputs>( - context: context, - builder: (_) => _FlowInputDialog(flow: flow), - ); - } - - @override - State<_FlowInputDialog> createState() => _FlowInputDialogState(); -} - -class _FlowInputDialogState extends State<_FlowInputDialog> { - late final Future> _defFuture; - final Map _textControllers = {}; - final Map _pickedFiles = {}; - - @override - void initState() { - super.initState(); - _defFuture = HubService.instance.getFlowDefinition(widget.flow.name); - } - - @override - void dispose() { - for (final c in _textControllers.values) { - c.dispose(); - } - super.dispose(); - } - - bool _isBytesType(String type) => type == 'bytes' || type == 'file'; - - bool _isComplete(List defs) { - for (final d in defs) { - if (_isBytesType(d.type)) { - if (_pickedFiles[d.name] == null) return false; - } else { - final v = _textControllers[d.name]?.text.trim() ?? ''; - if (v.isEmpty) return false; - } - } - return true; - } - - _FlowRunInputs _collect(List defs) { - final textInputs = {}; - final fileInputs = {}; - final fileMimeTypes = {}; - for (final d in defs) { - if (_isBytesType(d.type)) { - final picked = _pickedFiles[d.name]; - if (picked != null) { - fileInputs[d.name] = picked.bytes; - if (picked.mimeType.isNotEmpty) { - fileMimeTypes[d.name] = picked.mimeType; - } - } - } else { - final c = _textControllers[d.name]; - if (c != null) textInputs[d.name] = c.text; - } - } - return _FlowRunInputs( - textInputs: textInputs, - fileInputs: fileInputs, - fileMimeTypes: fileMimeTypes, - ); - } - - Future _pickFile(String inputName) async { - final result = await FilePicker.pickFiles(withData: true); - if (result == null || result.files.isEmpty) return; - final f = result.files.first; - var bytes = f.bytes; - if (bytes == null && f.path != null) { - // Some Linux configurations don't honour `withData: true` - // for large files; fall back to reading via path. - try { - bytes = await File(f.path!).readAsBytes(); - } catch (e) { - if (!mounted) return; - final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.flowsFileReadFailed(f.path!, '$e'))), + final locale = Localizations.localeOf(context); + final editorLocale = locale.languageCode == 'de' + ? FlowEditorLocale.de + : FlowEditorLocale.en; + return FutureBuilder>( + future: _capabilities, + builder: (context, snap) { + final caps = snap.data ?? const []; + return FlowEditorPage( + initialFlowName: widget.initialFlowName, + locale: editorLocale, + runDriver: _driver, + availableCapabilities: caps, ); - return; - } - } - if (bytes == null || !mounted) return; - setState(() { - _pickedFiles[inputName] = _PickedFile( - name: f.name, - bytes: bytes!, - mimeType: _mimeForFilename(f.name), - ); - }); - } - - /// Lazily ensure each text-shaped input has a controller + - /// a setState-triggering listener so the Run button reflects - /// validity as the operator types. - void _ensureTextControllers(List defs) { - for (final d in defs) { - if (_isBytesType(d.type)) continue; - _textControllers.putIfAbsent(d.name, () { - final c = TextEditingController(); - c.addListener(() { - if (mounted) setState(() {}); - }); - return c; - }); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l = AppLocalizations.of(context)!; - return AlertDialog( - title: Text(l.flowsRunDialogTitle(widget.flow.name)), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(FaiRadius.md), - ), - content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 540, maxHeight: 540), - child: FutureBuilder>( - future: _defFuture, - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: FaiSpace.sm), - Text(l.flowsLoadingDefinition), - ], - ); - } - if (snap.hasError) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l.flowsDefinitionFailed, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.error, - ), - ), - const SizedBox(height: FaiSpace.sm), - FaiErrorBox( - error: snap.error, - isError: true, - maxHeight: 200, - ), - ], - ); - } - final defs = snap.data ?? const []; - _ensureTextControllers(defs); - return SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l.flowsRunDialogBody, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: FaiSpace.md), - if (defs.isEmpty) - Text( - l.flowsNoInputs, - style: theme.textTheme.bodyMedium, - ), - for (final d in defs) ...[ - _InputField( - def: d, - isBytes: _isBytesType(d.type), - textController: _textControllers[d.name], - pickedFile: _pickedFiles[d.name], - onPick: () => _pickFile(d.name), - ), - const SizedBox(height: FaiSpace.md), - ], - ], - ), - ); - }, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, null), - child: Text(l.buttonCancel), - ), - FutureBuilder>( - future: _defFuture, - builder: (context, snap) { - final defs = snap.data ?? const []; - final canRun = snap.hasData && _isComplete(defs); - return FilledButton.icon( - icon: const Icon(Icons.play_arrow, size: 16), - label: Text(l.flowsRunButton), - onPressed: canRun - ? () => Navigator.pop(context, _collect(defs)) - : null, - ); - }, - ), - ], + }, ); } } - -class _InputField extends StatelessWidget { - final FlowInputDef def; - final bool isBytes; - final TextEditingController? textController; - final _PickedFile? pickedFile; - final VoidCallback onPick; - - const _InputField({ - required this.def, - required this.isBytes, - required this.textController, - required this.pickedFile, - required this.onPick, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l = AppLocalizations.of(context)!; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - def.name, - style: FaiTheme.mono( - size: 12, - weight: FontWeight.w600, - color: theme.colorScheme.onSurface, - ), - ), - const SizedBox(width: FaiSpace.sm), - FaiPill( - label: def.type, - tone: isBytes ? FaiPillTone.warning : FaiPillTone.neutral, - monospace: true, - ), - ], - ), - const SizedBox(height: 6), - if (isBytes) - Row( - children: [ - OutlinedButton.icon( - onPressed: onPick, - icon: const Icon(Icons.attach_file, size: 14), - label: Text( - pickedFile == null - ? l.flowsTypeBytesPick - : l.flowsTypeBytesChange, - ), - ), - const SizedBox(width: FaiSpace.md), - Expanded( - child: Text( - pickedFile == null - ? l.flowsTypeBytesNoFile - : '${pickedFile!.name} · ' - '${humanBytes(pickedFile!.bytes.length)}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ) - else - TextField( - controller: textController, - decoration: const InputDecoration( - border: OutlineInputBorder(), - isDense: true, - ), - ), - ], - ); - } -} - -class _FlowRunDialog extends StatefulWidget { - final SavedFlow flow; - final Map textInputs; - final Map fileInputs; - final Map fileMimeTypes; - - const _FlowRunDialog({ - required this.flow, - required this.textInputs, - required this.fileInputs, - required this.fileMimeTypes, - }); - - @override - State<_FlowRunDialog> createState() => _FlowRunDialogState(); -} - -class _FlowRunDialogState extends State<_FlowRunDialog> { - late final Future> _future; - late final StreamSubscription _eventSub; - late final DateTime _startedAt; - // Insertion-ordered map so the rendered list mirrors the - // order steps were actually triggered, matching what the - // operator sees on the CLI side. - final Map _liveSteps = {}; - - @override - void initState() { - super.initState(); - _startedAt = DateTime.now(); - // Subscribe BEFORE submitting so the first `step.started` - // event lands in our handler rather than the broadcast - // channel's dead-letter buffer. - _eventSub = HubService.instance - .streamEvents( - backfill: 0, - types: const [ - 'step.started', - 'step.completed', - 'step.failed', - 'step.awaiting_approval', - 'step.approved', - 'step.rejected', - ], - ) - .listen(_onEvent); - _future = HubService.instance.runSavedFlow( - name: widget.flow.name, - textInputs: widget.textInputs, - fileInputs: widget.fileInputs, - fileMimeTypes: widget.fileMimeTypes, - ); - } - - @override - void dispose() { - _eventSub.cancel(); - super.dispose(); - } - - void _onEvent(AuditEvent e) { - // Two-layer filter: same flow name, AND only events newer - // than our subscribe-time. Without the timestamp gate, - // backfill or stream re-deliveries from an earlier run of - // the same flow would paint stale steps into this dialog. - if (e.flowName != widget.flow.name) return; - if (e.timestamp.isBefore(_startedAt)) return; - final stepId = e.stepId; - if (stepId == null || stepId.isEmpty) return; - - setState(() { - switch (e.type) { - case 'step.started': - case 'step.approved': - _liveSteps[stepId] = _LiveStep( - id: stepId, - state: _StepState.running, - ); - case 'step.completed': - _liveSteps[stepId] = _LiveStep( - id: stepId, - state: _StepState.done, - durationMs: e.durationMs, - ); - case 'step.failed': - _liveSteps[stepId] = _LiveStep( - id: stepId, - state: _StepState.error, - error: e.error, - ); - case 'step.awaiting_approval': - _liveSteps[stepId] = _LiveStep( - id: stepId, - state: _StepState.awaitingApproval, - ); - case 'step.rejected': - _liveSteps[stepId] = _LiveStep( - id: stepId, - state: _StepState.error, - error: 'rejected', - ); - } - }); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l = AppLocalizations.of(context)!; - return AlertDialog( - // Title tracks the future's state so the dialog header - // stops claiming "extract läuft" once the run is over. - // Recomputed on every snapshot change via FutureBuilder. - title: FutureBuilder>( - future: _future, - builder: (context, snapshot) { - final String title; - if (snapshot.connectionState == ConnectionState.waiting) { - title = l.flowsRunningTitle(widget.flow.name); - } else if (snapshot.hasError) { - title = l.flowsErrorTitle(widget.flow.name); - } else { - title = l.flowsResultTitle(widget.flow.name); - } - return Text(title); - }, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(FaiRadius.md), - ), - content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480), - child: FutureBuilder>( - future: _future, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return _LiveStepList( - steps: _liveSteps.values.toList(growable: false), - runningLabel: l.flowsRunning, - ); - } - if (snapshot.hasError) { - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Icon( - Icons.error_outline, - color: theme.colorScheme.error, - size: 20, - ), - const SizedBox(width: FaiSpace.sm), - Text( - l.flowsRunErrorTitle, - style: theme.textTheme.titleSmall?.copyWith( - color: theme.colorScheme.error, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - const SizedBox(height: FaiSpace.md), - FaiErrorBox( - error: snapshot.error, - isError: true, - maxHeight: 280, - ), - ], - ), - ); - } - final outputs = snapshot.data ?? const {}; - if (outputs.isEmpty) { - return Text( - l.flowsNoOutputs, - style: theme.textTheme.bodyMedium, - ); - } - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - for (final entry in outputs.entries) ...[ - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text( - entry.key.toUpperCase(), - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - letterSpacing: 0.6, - ), - ), - ), - FaiFlowOutput(output: entry.value), - const SizedBox(height: FaiSpace.md), - ], - ], - ), - ); - }, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(l.buttonClose), - ), - ], - ); - } -} - -enum _StepState { running, done, error, awaitingApproval } - -class _LiveStep { - final String id; - final _StepState state; - final int? durationMs; - final String? error; - - const _LiveStep({ - required this.id, - required this.state, - this.durationMs, - this.error, - }); -} - -/// Live step list shown while a flow is running. Mirrors the -/// `fai run` CLI rendering: empty box for pending steps the -/// hub has hinted at but not started yet, a small spinner for -/// the currently-running step, a check + duration for done -/// steps, and a cross + first error line on failure. The list -/// grows in insertion order so the operator sees the runtime -/// order of execution, not a re-sorted view. -class _LiveStepList extends StatelessWidget { - final List<_LiveStep> steps; - final String runningLabel; - - const _LiveStepList({ - required this.steps, - required this.runningLabel, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - // Empty state (subscribe arrived first, no step has fired - // yet) — show a small spinner + the localised "running" - // label so the dialog doesn't look blank. - if (steps.isEmpty) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: FaiSpace.md), - const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(height: FaiSpace.md), - Text( - runningLabel, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ); - } - final done = steps - .where((s) => s.state == _StepState.done || s.state == _StepState.error) - .length; - final pct = done * 100 ~/ steps.length; - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - for (final step in steps) _stepRow(context, step), - const SizedBox(height: FaiSpace.md), - LinearProgressIndicator( - value: pct / 100, - minHeight: 4, - backgroundColor: theme.colorScheme.surfaceContainerHigh, - ), - const SizedBox(height: 4), - Text( - '$pct%', - style: FaiTheme.mono( - size: 10, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ); - } - - Widget _stepRow(BuildContext context, _LiveStep step) { - final theme = Theme.of(context); - final Widget glyph; - final String suffix; - Color color = theme.colorScheme.onSurface; - switch (step.state) { - case _StepState.running: - glyph = const SizedBox( - height: 14, - width: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ); - suffix = ''; - case _StepState.done: - glyph = Icon(Icons.check, size: 16, color: theme.colorScheme.primary); - final s = (step.durationMs ?? 0) / 1000.0; - suffix = ' ${s.toStringAsFixed(2)}s'; - case _StepState.error: - glyph = Icon(Icons.close, size: 16, color: theme.colorScheme.error); - suffix = step.error == null || step.error!.isEmpty - ? '' - : ' — ${step.error!.split('\n').first.trim()}'; - color = theme.colorScheme.error; - case _StepState.awaitingApproval: - glyph = Icon( - Icons.pause_circle_outline, - size: 16, - color: theme.colorScheme.tertiary, - ); - suffix = ' awaiting approval'; - color = theme.colorScheme.tertiary; - } - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - SizedBox(width: 16, child: Center(child: glyph)), - const SizedBox(width: FaiSpace.sm), - Flexible( - child: Text( - '${step.id}$suffix', - style: theme.textTheme.bodyMedium?.copyWith(color: color), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } -} - -/// Per-spec install state for the dependencies dialog. The -/// dialog walks the list sequentially, advancing each row from -/// `pending` → `installing` → `done` | `failed` so the operator -/// sees one row light up at a time. -enum _InstallStatus { pending, installing, done, failed } - -class _InstallItemState { - /// Verbatim spec from the flow's `use:` line, e.g. - /// `text.extract@^0`. Shown as-is so the operator recognises - /// the same string they saw on the flow card. - final String spec; - - /// Bare capability name (everything left of `@`). What the - /// hub's `installModule` actually resolves through the store - /// index — the version constraint is dropped here because - /// the index already picks a matching version. - final String bareName; - - _InstallStatus status; - String? installedVersion; - /// Original thrown object so the UI can run it through - /// [friendlyError] later instead of staring at a wall of gRPC - /// trailers. - Object? error; - - _InstallItemState(this.spec) - : bareName = _stripVersion(spec), - status = _InstallStatus.pending; - - static String _stripVersion(String spec) { - final at = spec.indexOf('@'); - return at >= 0 ? spec.substring(0, at) : spec; - } -} - -/// Sequential installer for a list of capability specs. Pops -/// with `true` if at least one install completed successfully -/// (so the parent knows to refresh its installed-modules view), -/// `false` otherwise. -class _InstallDependenciesDialog extends StatefulWidget { - final List specs; - - const _InstallDependenciesDialog({required this.specs}); - - @override - State<_InstallDependenciesDialog> createState() => - _InstallDependenciesDialogState(); -} - -class _InstallDependenciesDialogState - extends State<_InstallDependenciesDialog> { - late final List<_InstallItemState> _items; - bool _running = true; - - @override - void initState() { - super.initState(); - _items = widget.specs.map(_InstallItemState.new).toList(); - // Kick off the install sequence after first frame so any - // initState-time setState calls inside the chain hit a - // mounted widget. - WidgetsBinding.instance.addPostFrameCallback((_) => _runAll()); - } - - Future _runAll() async { - for (final item in _items) { - if (!mounted) return; - setState(() => item.status = _InstallStatus.installing); - try { - final r = await HubService.instance.installModule( - source: item.bareName, - ); - if (!mounted) return; - setState(() { - item.status = _InstallStatus.done; - item.installedVersion = r.version; - }); - } catch (e) { - if (!mounted) return; - setState(() { - item.status = _InstallStatus.failed; - item.error = e; - }); - } - } - if (!mounted) return; - setState(() => _running = false); - } - - bool get _anySucceeded => - _items.any((i) => i.status == _InstallStatus.done); - - int get _doneCount => - _items.where((i) => i.status == _InstallStatus.done).length; - - int get _failedCount => - _items.where((i) => i.status == _InstallStatus.failed).length; - - /// True iff at least one failure carries the v0.10.91+ HTML-instead- - /// of-bundle signature emitted by `download_to_temp` when the - /// registry redirects an unauthenticated request to a signin page. - /// Used to surface a contextual "Open Settings" button so the - /// operator does not have to read the wall of error text first. - bool get _anyAuthWallFailure => _items.any( - (i) => - i.status == _InstallStatus.failed && _isAuthWallError(i.error), - ); - - static bool _isAuthWallError(Object? err) { - if (err == null) return false; - final msg = err.toString(); - return msg.contains('registry returned an HTML page') || - msg.contains('no registry token configured'); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l = AppLocalizations.of(context)!; - return AlertDialog( - title: Text(l.flowsInstallDepsTitle), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(FaiRadius.md), - ), - content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 520, maxHeight: 480), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l.flowsInstallDepsBody, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: FaiSpace.md), - for (final item in _items) ...[ - _InstallRow(item: item), - const SizedBox(height: FaiSpace.sm), - ], - if (!_running) ...[ - const SizedBox(height: FaiSpace.xs), - Text( - _failedCount == 0 - ? l.flowsInstallDepsAllDone - : l.flowsInstallDepsAnyFailed(_doneCount, _failedCount), - style: theme.textTheme.bodySmall?.copyWith( - color: _failedCount == 0 - ? null - : theme.colorScheme.error, - fontWeight: FontWeight.w500, - ), - ), - ], - ], - ), - ), - ), - actions: [ - if (!_running && _anyAuthWallFailure) - FilledButton.icon( - icon: const Icon(Icons.vpn_key_outlined, size: 16), - label: Text(l.flowsInstallAuthWallButton), - onPressed: () async { - // Close install dialog first so the operator returns - // to the flow card on Settings dismiss — then they - // can re-click the pill to retry the install. - Navigator.pop(context, _anySucceeded); - await FaiSettingsDialog.show(context); - }, - ), - TextButton( - onPressed: _running - ? null - : () => Navigator.pop(context, _anySucceeded), - child: Text(l.buttonClose), - ), - ], - ); - } -} - -/// One row in the install-dependencies dialog. Status icon + -/// capability name + per-state detail line (version on success, -/// truncated error on failure). -class _InstallRow extends StatelessWidget { - final _InstallItemState item; - - const _InstallRow({required this.item}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l = AppLocalizations.of(context)!; - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 20, - height: 20, - child: Center(child: _statusIcon(theme)), - ), - const SizedBox(width: FaiSpace.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.spec, - style: FaiTheme.mono( - size: 12, - weight: FontWeight.w600, - color: theme.colorScheme.onSurface, - ), - ), - const SizedBox(height: 2), - Text( - _detailLine(l), - style: theme.textTheme.bodySmall?.copyWith( - color: item.status == _InstallStatus.failed - ? theme.colorScheme.error - : theme.colorScheme.onSurfaceVariant, - ), - ), - if (item.status == _InstallStatus.failed && - item.error != null) ...[ - const SizedBox(height: FaiSpace.xs), - FaiErrorBox( - error: item.error, - isError: true, - maxHeight: 120, - ), - ], - ], - ), - ), - ], - ); - } - - Widget _statusIcon(ThemeData theme) { - switch (item.status) { - case _InstallStatus.pending: - return Icon( - Icons.radio_button_unchecked, - size: 16, - color: theme.colorScheme.onSurfaceVariant, - ); - case _InstallStatus.installing: - return const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ); - case _InstallStatus.done: - return Icon( - Icons.check_circle, - size: 16, - color: theme.colorScheme.primary, - ); - case _InstallStatus.failed: - return Icon( - Icons.error_outline, - size: 16, - color: theme.colorScheme.error, - ); - } - } - - String _detailLine(AppLocalizations l) { - switch (item.status) { - case _InstallStatus.pending: - return l.flowsInstallStatusPending; - case _InstallStatus.installing: - return l.flowsInstallStatusInstalling; - case _InstallStatus.done: - return l.flowsInstallStatusDone(item.installedVersion ?? '?'); - case _InstallStatus.failed: - return l.flowsInstallStatusFailed; - } - } -} diff --git a/pubspec.lock b/pubspec.lock index 95a58a6..9b296de 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -125,10 +125,10 @@ packages: description: path: "." ref: main - resolved-ref: daf6732893315c6841c4f32c69c19cd1dc3caca8 + resolved-ref: "870cbc29f71a7fd9972755203714274ac128897b" url: "https://git.flemming.ai/fai/studio-flow-editor" source: git - version: "0.1.4" + version: "0.2.0" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 17d2605..a869976 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.51.10 +version: 0.52.0 environment: sdk: ^3.11.0-200.1.beta