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:
parent
081ffd7233
commit
34f2b7b313
14 changed files with 849 additions and 296 deletions
|
|
@ -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),
|
||||
],
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue