chain-studio/lib/pages/flows.dart
flemming-it 63e19974c0 feat(studio): @-syntax file inputs in flows, copyable doc errors, services-row overflow (v0.40.0)
Three concrete fixes against today's user feedback.

- Flows that take binary inputs (extract / extract-summarize /
  …) work from the Flows tab. The run-flow input dialog now
  accepts the same `@/path/to/file` syntax `fai run --input`
  uses on the CLI: any value beginning with `@` is read as
  bytes and sent as a binary Payload; plain values still flow
  through as text. The dialog hint copy and the example
  placeholder reflect the new syntax. File-read failures
  surface as a SnackBar before the run dialog opens, so a
  typo in the path doesn't reach the hub. Threaded through
  `_FlowRunDialog` and `HubService.runSavedFlow`, which both
  carry separate `textInputs` and `fileInputs` maps now and
  forward to the SDK's mixed-mode runSavedFlow (v0.14.0).

- The Welcome doc-reader's error path uses `FaiErrorBox` so
  the actual underlying error is selectable + copy-to-
  clipboard via the existing widget. Plus the loader throws
  a richer error string that names *both* attempted asset
  paths (`<slug>_<lang>.md` and the EN fallback) and the
  underlying exception each, so the operator can paste a
  diagnostic into a chat without us having to ship a
  separate "how to read Flutter asset errors" doc.

- Doctor's empty Services panel had a horizontal RenderFlex
  overflow at narrow widths because the long mono-spaced
  hint ("add to ~/.fai/config.yaml under services:") and
  the leading icon+text both demanded full intrinsic width
  in a single Row. Now wraps via a `Wrap` widget so the
  hint flows to a second line on narrow viewports and stays
  in the same row when there's space.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-09 02:07:31 +02:00

420 lines
13 KiB
Dart

import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.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});
@override
State<FlowsPage> createState() => _FlowsPageState();
}
class _FlowsPageState extends State<FlowsPage> {
late Future<List<SavedFlow>> _future;
@override
void initState() {
super.initState();
_future = HubService.instance.listFlows();
}
void _refresh() => setState(() {
_future = HubService.instance.listFlows();
});
Future<void> _runFlow(SavedFlow flow) async {
final inputs = await _FlowInputDialog.show(context, flow);
if (inputs == null) return;
// Split the dialog's text-shaped key=value pairs into text
// vs file payloads. Values starting with `@` mean "treat
// the rest as a path; read the file as bytes" — same
// syntax `fai run --input` uses on the CLI. File-read
// failures bubble up as the run dialog's error state so
// the operator sees a useful message.
final textInputs = <String, String>{};
final fileInputs = <String, Uint8List>{};
String? readError;
for (final entry in inputs.entries) {
final value = entry.value;
if (value.startsWith('@')) {
final path = value.substring(1).trim();
try {
fileInputs[entry.key] = await File(path).readAsBytes();
} catch (e) {
readError = '${entry.key}: $e';
break;
}
} else {
textInputs[entry.key] = value;
}
}
if (!mounted) return;
if (readError != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(readError)),
);
return;
}
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => _FlowRunDialog(
flow: flow,
textInputs: textInputs,
fileInputs: fileInputs,
),
);
}
@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<List<SavedFlow>>(
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 flows = snapshot.data ?? [];
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) =>
_FlowCard(flow: flows[i], onRun: () => _runFlow(flows[i])),
);
},
),
);
}
}
class _FlowCard extends StatelessWidget {
final SavedFlow flow;
final VoidCallback onRun;
const _FlowCard({required this.flow, required this.onRun});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FaiCard(
child: 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: '${flow.sizeBytes} B',
tone: FaiPillTone.neutral,
monospace: true,
),
const SizedBox(width: FaiSpace.md),
FilledButton.icon(
onPressed: onRun,
icon: const Icon(Icons.play_arrow, size: 16),
label: Text(AppLocalizations.of(context)!.flowsRunButton),
),
],
),
);
}
}
class _FlowInputDialog extends StatefulWidget {
final SavedFlow flow;
const _FlowInputDialog({required this.flow});
static Future<Map<String, String>?> show(
BuildContext context,
SavedFlow flow,
) {
return showDialog<Map<String, String>>(
context: context,
builder: (_) => _FlowInputDialog(flow: flow),
);
}
@override
State<_FlowInputDialog> createState() => _FlowInputDialogState();
}
class _FlowInputDialogState extends State<_FlowInputDialog> {
// The hub does not yet expose the flow's input schema over
// gRPC, so this dialog accepts free-form key=value pairs.
// Operator pastes pairs separated by newlines; we split on
// the first `=` per line.
final _ctrl = TextEditingController();
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
Map<String, String> _parsePairs() {
final out = <String, String>{};
for (final raw in _ctrl.text.split('\n')) {
final line = raw.trim();
if (line.isEmpty) continue;
final eq = line.indexOf('=');
if (eq < 0) continue;
out[line.substring(0, eq).trim()] = line.substring(eq + 1).trim();
}
return out;
}
@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: 480, maxHeight: 360),
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),
Expanded(
child: TextField(
controller: _ctrl,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: FaiTheme.mono(size: 12),
decoration: InputDecoration(
hintText: l.flowsInputsHint,
border: const OutlineInputBorder(),
isDense: true,
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, null),
child: Text(l.buttonCancel),
),
FilledButton.icon(
icon: const Icon(Icons.play_arrow, size: 16),
label: Text(l.flowsRunButton),
onPressed: () => Navigator.pop(context, _parsePairs()),
),
],
);
}
}
class _FlowRunDialog extends StatefulWidget {
final SavedFlow flow;
final Map<String, String> textInputs;
final Map<String, Uint8List> fileInputs;
const _FlowRunDialog({
required this.flow,
required this.textInputs,
required this.fileInputs,
});
@override
State<_FlowRunDialog> createState() => _FlowRunDialogState();
}
class _FlowRunDialogState extends State<_FlowRunDialog> {
late final Future<Map<String, String>> _future;
@override
void initState() {
super.initState();
_future = HubService.instance.runSavedFlow(
name: widget.flow.name,
textInputs: widget.textInputs,
fileInputs: widget.fileInputs,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(l.flowsRunningTitle(widget.flow.name)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480),
child: FutureBuilder<Map<String, String>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: FaiSpace.md),
const CircularProgressIndicator(),
const SizedBox(height: FaiSpace.md),
Text(
l.flowsRunning,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
if (snapshot.hasError) {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
color: theme.colorScheme.error,
size: 32,
),
const SizedBox(height: FaiSpace.md),
SelectableText(
snapshot.error.toString(),
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.error,
),
),
],
),
);
}
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,
),
),
),
Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(
color: theme.colorScheme.outlineVariant,
),
),
child: SelectableText(
entry.value,
style: FaiTheme.mono(size: 11),
),
),
const SizedBox(height: FaiSpace.md),
],
],
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l.buttonClose),
),
],
);
}
}