feat(studio): typed flow outputs + on-disk docs + UX polish

Documentation system (the original "whole docs system is broken"
complaint):
- Studio reads inline docs from disk via the new
  getInstalledModuleDocs RPC. No network, no auth, no provider
  lock-in.
- store.dart probes for MODULE.md eagerly when a module-detail
  sheet opens (a single file stat). The Documentation section
  only renders when the bundle actually shipped docs.
- Shared FaiTheme.markdownStyle helper used by Welcome's
  DocReaderSheet and Store's DocsPanel so every inline doc
  reads in the same typography. The shared style forces
  code.backgroundColor = transparent to suppress the per-span
  bands flutter_markdown's default code style draws on dark
  themes.

Flow run dialog:
- Run-button gating now strips the @version suffix from
  installed capabilities before the contains() check. Without
  this fix the button stayed disabled for every flow with
  dependencies, even after a successful install.
- Studio derives a MIME type from the picked file's extension
  (small per-suffix map; .pdf, .docx, .txt, .json, ...) and
  forwards it to the hub. Fixes "unsupported MIME type:
  application/octet-stream" from text.extract.
- Dialog title tracks the future's state: running -> "extract
  laeuft", success -> "extract -- Ergebnis", failure ->
  "extract -- fehlgeschlagen".
- Output rendering moved from String stringification to a
  sealed FlowOutput type (Text / Json / Bytes / File / Unknown).
  A new FaiFlowOutput widget dispatches per variant: markdown
  for text/markdown (heuristic), pretty JSON for proto Struct,
  inline image preview + Save-As for bytes, Open for file URIs.

Byte sizes:
- New humanBytes() helper renders 23.3 kB / 1.04 MB style values
  with three significant digits, matching Finder / GNOME Files.
  Wired into flow-card pills, picked-file readouts, and the
  bytes-payload preview line.

Signed-off-by: flemming-it <sf@flemming.it>
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-25 11:17:58 +02:00
parent 081ffd7233
commit 34f2b7b313
14 changed files with 849 additions and 296 deletions

View file

@ -5,6 +5,7 @@ import 'package:fai_dart_sdk/fai_dart_sdk.dart' show FlowInputDef;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import '../data/format.dart';
import '../data/hub.dart';
import '../data/system_actions.dart';
import '../l10n/app_localizations.dart';
@ -40,6 +41,18 @@ class _FlowsPageState extends State<FlowsPage> {
flows: results[0] as List<SavedFlow>,
installedCapabilities: (results[1] as List<ModuleSummary>)
.expand((m) => m.capabilities)
// `listModules()` emits capability strings with a
// `@version` suffix (e.g. `text.extract@0.1.0`). The
// flow's `requiredCapabilities` carry their own
// user-typed version constraint, so we compare on the
// bare capability name and let the hub do the strict
// version match at run time. Without this strip, the
// contains-check would never hit and the Run button
// would stay disabled even after a successful install.
.map((c) {
final at = c.indexOf('@');
return at >= 0 ? c.substring(0, at) : c;
})
.toSet(),
);
}
@ -74,6 +87,7 @@ class _FlowsPageState extends State<FlowsPage> {
flow: flow,
textInputs: inputs.textInputs,
fileInputs: inputs.fileInputs,
fileMimeTypes: inputs.fileMimeTypes,
),
);
}
@ -254,7 +268,7 @@ class _FlowCard extends StatelessWidget {
),
),
FaiPill(
label: '${flow.sizeBytes} B',
label: humanBytes(flow.sizeBytes),
tone: FaiPillTone.neutral,
monospace: true,
),
@ -369,16 +383,75 @@ class _ClickablePill extends StatelessWidget {
class _FlowRunInputs {
final Map<String, String> textInputs;
final Map<String, Uint8List> 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<String, String> fileMimeTypes;
const _FlowRunInputs({
required this.textInputs,
required this.fileInputs,
required this.fileMimeTypes,
});
}
class _PickedFile {
final String name;
final Uint8List bytes;
const _PickedFile({required this.name, required this.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
@ -457,16 +530,26 @@ class _FlowInputDialogState extends State<_FlowInputDialog> {
_FlowRunInputs _collect(List<FlowInputDef> defs) {
final textInputs = <String, String>{};
final fileInputs = <String, Uint8List>{};
final fileMimeTypes = <String, String>{};
for (final d in defs) {
if (_isBytesType(d.type)) {
final picked = _pickedFiles[d.name];
if (picked != null) fileInputs[d.name] = picked.bytes;
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);
return _FlowRunInputs(
textInputs: textInputs,
fileInputs: fileInputs,
fileMimeTypes: fileMimeTypes,
);
}
Future<void> _pickFile(String inputName) async {
@ -490,7 +573,11 @@ class _FlowInputDialogState extends State<_FlowInputDialog> {
}
if (bytes == null || !mounted) return;
setState(() {
_pickedFiles[inputName] = _PickedFile(name: f.name, bytes: bytes!);
_pickedFiles[inputName] = _PickedFile(
name: f.name,
bytes: bytes!,
mimeType: _mimeForFilename(f.name),
);
});
}
@ -676,7 +763,7 @@ class _InputField extends StatelessWidget {
pickedFile == null
? l.flowsTypeBytesNoFile
: '${pickedFile!.name} · '
'${l.flowsTypeBytesSize(pickedFile!.bytes.length)}',
'${humanBytes(pickedFile!.bytes.length)}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@ -702,11 +789,13 @@ class _FlowRunDialog extends StatefulWidget {
final SavedFlow flow;
final Map<String, String> textInputs;
final Map<String, Uint8List> fileInputs;
final Map<String, String> fileMimeTypes;
const _FlowRunDialog({
required this.flow,
required this.textInputs,
required this.fileInputs,
required this.fileMimeTypes,
});
@override
@ -714,7 +803,7 @@ class _FlowRunDialog extends StatefulWidget {
}
class _FlowRunDialogState extends State<_FlowRunDialog> {
late final Future<Map<String, String>> _future;
late final Future<Map<String, FlowOutput>> _future;
@override
void initState() {
@ -723,6 +812,7 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
name: widget.flow.name,
textInputs: widget.textInputs,
fileInputs: widget.fileInputs,
fileMimeTypes: widget.fileMimeTypes,
);
}
@ -731,13 +821,29 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(l.flowsRunningTitle(widget.flow.name)),
// 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<Map<String, FlowOutput>>(
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<Map<String, String>>(
child: FutureBuilder<Map<String, FlowOutput>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
@ -812,21 +918,7 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
),
),
),
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),
),
),
FaiFlowOutput(output: entry.value),
const SizedBox(height: FaiSpace.md),
],
],