Compare commits
7 commits
081ffd7233
...
91706cea2b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91706cea2b | ||
|
|
a5bcde6446 | ||
|
|
efa9871a75 | ||
|
|
21c2a0f411 | ||
|
|
69b54629d3 | ||
|
|
c11461b0f9 | ||
|
|
34f2b7b313 |
33 changed files with 3122 additions and 472 deletions
|
|
@ -83,9 +83,12 @@ The prompt template (`tools/today/prompt.template.md`) hard-encodes:
|
||||||
`feedback_no_marketing_speak.md` is included in the prompt: ban
|
`feedback_no_marketing_speak.md` is included in the prompt: ban
|
||||||
"viral", "killer", "powerful", "just works", "revolutionary",
|
"viral", "killer", "powerful", "just works", "revolutionary",
|
||||||
"game-changing".
|
"game-changing".
|
||||||
4. **No private references.** Generic terms only; no
|
4. **No private references.** Generic terms only — no pilot
|
||||||
ITDZ / Kammergericht / HTW / J∆I per
|
customer names, internal codenames, or specific
|
||||||
`feedback_confidentiality.md`.
|
institutions. The operator's banned-terms list lives at
|
||||||
|
`~/.fai-security/banned-terms.txt` (outside the repo on
|
||||||
|
purpose; gated by the pre-commit hook in
|
||||||
|
`tools/security/check-staged.sh`).
|
||||||
5. **Concrete CTA.** Every story names exactly one action the operator
|
5. **Concrete CTA.** Every story names exactly one action the operator
|
||||||
can take in Studio (which page, which button).
|
can take in Studio (which page, which button).
|
||||||
6. **Body fits in ~3 sentences.** Hero card height is fixed; longer
|
6. **Body fits in ~3 sentences.** Hero card height is fixed; longer
|
||||||
|
|
|
||||||
85
lib/data/flow_output.dart
Normal file
85
lib/data/flow_output.dart
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
// FlowOutput — typed representation of one entry in
|
||||||
|
// `SubmitResponse.outputs`. Lets Studio render text vs. JSON vs.
|
||||||
|
// bytes vs. file with the right widget instead of stringifying
|
||||||
|
// everything into `<bytes: …>` placeholders.
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:fai_dart_sdk/fai_dart_sdk.dart' show Payload;
|
||||||
|
|
||||||
|
/// One named output of a flow run. Sealed via factory constructors
|
||||||
|
/// — callers pattern-match with `switch` on the runtime type to
|
||||||
|
/// pick a renderer. Variants:
|
||||||
|
///
|
||||||
|
/// - [FlowOutputText] : plain text. `mimeType` may be set
|
||||||
|
/// (e.g. `text/markdown`) so the
|
||||||
|
/// renderer can pick a Markdown widget.
|
||||||
|
/// - [FlowOutputJson] : a proto Struct, already pretty-
|
||||||
|
/// printed as JSON so the UI can render
|
||||||
|
/// it directly.
|
||||||
|
/// - [FlowOutputBytes] : raw bytes + MIME. The renderer
|
||||||
|
/// decides whether to inline a preview
|
||||||
|
/// (e.g. images) and exposes a
|
||||||
|
/// Save-As affordance otherwise.
|
||||||
|
/// - [FlowOutputFile] : a URI the host already wrote to. The
|
||||||
|
/// renderer offers an Open action.
|
||||||
|
/// - [FlowOutputUnknown] : oneof was empty / unset. Never
|
||||||
|
/// expected in practice but kept so
|
||||||
|
/// the UI never panics on a future
|
||||||
|
/// schema bump.
|
||||||
|
sealed class FlowOutput {
|
||||||
|
const FlowOutput();
|
||||||
|
|
||||||
|
factory FlowOutput.fromPayload(Payload p) {
|
||||||
|
if (p.hasText()) {
|
||||||
|
return FlowOutputText(text: p.text);
|
||||||
|
}
|
||||||
|
if (p.hasJson()) {
|
||||||
|
// proto Struct → toProto3Json gives a Dart-side
|
||||||
|
// Map/List/primitive tree. We re-encode with an indent so
|
||||||
|
// the UI can show the operator-friendly form directly.
|
||||||
|
final tree = p.json.toProto3Json();
|
||||||
|
const encoder = JsonEncoder.withIndent(' ');
|
||||||
|
return FlowOutputJson(pretty: encoder.convert(tree));
|
||||||
|
}
|
||||||
|
if (p.hasBytes()) {
|
||||||
|
return FlowOutputBytes(
|
||||||
|
bytes: Uint8List.fromList(p.bytes.data),
|
||||||
|
mimeType: p.bytes.mimeType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (p.hasFile()) {
|
||||||
|
return FlowOutputFile(uri: p.file.uri, mimeType: p.file.mimeType);
|
||||||
|
}
|
||||||
|
return const FlowOutputUnknown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FlowOutputText extends FlowOutput {
|
||||||
|
final String text;
|
||||||
|
const FlowOutputText({required this.text});
|
||||||
|
}
|
||||||
|
|
||||||
|
class FlowOutputJson extends FlowOutput {
|
||||||
|
/// Already indented JSON, ready for SelectableText / Markdown
|
||||||
|
/// code-block rendering.
|
||||||
|
final String pretty;
|
||||||
|
const FlowOutputJson({required this.pretty});
|
||||||
|
}
|
||||||
|
|
||||||
|
class FlowOutputBytes extends FlowOutput {
|
||||||
|
final Uint8List bytes;
|
||||||
|
final String mimeType;
|
||||||
|
const FlowOutputBytes({required this.bytes, required this.mimeType});
|
||||||
|
}
|
||||||
|
|
||||||
|
class FlowOutputFile extends FlowOutput {
|
||||||
|
final String uri;
|
||||||
|
final String mimeType;
|
||||||
|
const FlowOutputFile({required this.uri, required this.mimeType});
|
||||||
|
}
|
||||||
|
|
||||||
|
class FlowOutputUnknown extends FlowOutput {
|
||||||
|
const FlowOutputUnknown();
|
||||||
|
}
|
||||||
33
lib/data/format.dart
Normal file
33
lib/data/format.dart
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Display-only formatters shared across pages. Pure functions,
|
||||||
|
// no I/O, no locale fall-through — locale-specific labels (the
|
||||||
|
// unit word itself, e.g. "Bytes" vs. "bytes") stay in `.arb`;
|
||||||
|
// these helpers only handle the numeric formatting.
|
||||||
|
|
||||||
|
/// Render a byte count with an SI suffix that stays at three
|
||||||
|
/// significant digits ("23.3 kB", "1.04 MB", "847 B"). Uses 1000
|
||||||
|
/// as the base, not 1024 — matches macOS Finder / GNOME Files
|
||||||
|
/// and is what most operators expect when they read "kB".
|
||||||
|
///
|
||||||
|
/// Returns plain `n B` for values under 1000 so small flows
|
||||||
|
/// and tiny inputs stay precise instead of becoming "0.5 kB".
|
||||||
|
String humanBytes(int bytes) {
|
||||||
|
if (bytes < 1000) return '$bytes B';
|
||||||
|
const units = ['kB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
var v = bytes.toDouble() / 1000.0;
|
||||||
|
var unitIndex = 0;
|
||||||
|
while (v >= 1000 && unitIndex < units.length - 1) {
|
||||||
|
v /= 1000.0;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
// Pick decimals so the rendered number stays at 3 significant
|
||||||
|
// figures: 9.99 / 99.9 / 999 — same approach Finder uses.
|
||||||
|
final String formatted;
|
||||||
|
if (v >= 100) {
|
||||||
|
formatted = v.toStringAsFixed(0);
|
||||||
|
} else if (v >= 10) {
|
||||||
|
formatted = v.toStringAsFixed(1);
|
||||||
|
} else {
|
||||||
|
formatted = v.toStringAsFixed(2);
|
||||||
|
}
|
||||||
|
return '$formatted ${units[unitIndex]}';
|
||||||
|
}
|
||||||
148
lib/data/friendly_error.dart
Normal file
148
lib/data/friendly_error.dart
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
// Friendly-error mapper. Turns a thrown object (typically a
|
||||||
|
// `GrpcError` from package:grpc) into a short human sentence
|
||||||
|
// plus an optional recovery hint, ready to drop into the UI.
|
||||||
|
//
|
||||||
|
// Why this exists: Studio used to render `GrpcError.toString()`
|
||||||
|
// verbatim — operators saw walls of text like
|
||||||
|
// "gRPC Error (code: 13, codeName: INTERNAL, message: ..., details: [], rawResponse: null, trailers: {...})".
|
||||||
|
// The friendly mapper folds every gRPC code into a one-line
|
||||||
|
// explanation we can localise, plus a recovery action when one
|
||||||
|
// is obvious (open settings, restart hub, retry).
|
||||||
|
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Result of running an error through [friendlyError]. The
|
||||||
|
/// `headline` is the short user-facing sentence; `detail` is
|
||||||
|
/// the verbatim original error string (kept around in case the
|
||||||
|
/// operator wants to copy-paste it into a bug report). `hint`
|
||||||
|
/// is a one-sentence recovery suggestion or `null`.
|
||||||
|
class FriendlyError {
|
||||||
|
final String headline;
|
||||||
|
final String detail;
|
||||||
|
final String? hint;
|
||||||
|
|
||||||
|
const FriendlyError({
|
||||||
|
required this.headline,
|
||||||
|
required this.detail,
|
||||||
|
this.hint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map an arbitrary thrown object to a [FriendlyError]. Always
|
||||||
|
/// returns a value — never throws — so callers can drop the
|
||||||
|
/// result straight into UI without try/catch ceremony.
|
||||||
|
FriendlyError friendlyError(Object error, AppLocalizations l) {
|
||||||
|
// We deliberately don't import package:grpc here so Studio
|
||||||
|
// doesn't have to add it to its own pubspec — the dependency
|
||||||
|
// lives one layer down in fai_dart_sdk. `GrpcError` has a
|
||||||
|
// stable `.code` (int) and `.message` (String?) shape; we
|
||||||
|
// duck-type on those instead of an `is` check.
|
||||||
|
final code = _intField(error, 'code');
|
||||||
|
final detail = _stringField(error, 'message') ?? '';
|
||||||
|
if (code != null) {
|
||||||
|
switch (code) {
|
||||||
|
case 3: // INVALID_ARGUMENT
|
||||||
|
return FriendlyError(
|
||||||
|
headline: l.errInvalidArgument,
|
||||||
|
detail: detail,
|
||||||
|
hint: l.errInvalidArgumentHint,
|
||||||
|
);
|
||||||
|
case 5: // NOT_FOUND
|
||||||
|
return FriendlyError(
|
||||||
|
headline: l.errNotFound,
|
||||||
|
detail: detail,
|
||||||
|
hint: l.errNotFoundHint,
|
||||||
|
);
|
||||||
|
case 6: // ALREADY_EXISTS
|
||||||
|
return FriendlyError(
|
||||||
|
headline: l.errAlreadyExists,
|
||||||
|
detail: detail,
|
||||||
|
hint: null,
|
||||||
|
);
|
||||||
|
case 7: // PERMISSION_DENIED
|
||||||
|
return FriendlyError(
|
||||||
|
headline: l.errPermissionDenied,
|
||||||
|
detail: detail,
|
||||||
|
hint: l.errPermissionDeniedHint,
|
||||||
|
);
|
||||||
|
case 9: // FAILED_PRECONDITION
|
||||||
|
return FriendlyError(
|
||||||
|
headline: l.errFailedPrecondition,
|
||||||
|
detail: detail,
|
||||||
|
hint: l.errFailedPreconditionHint,
|
||||||
|
);
|
||||||
|
case 13: // INTERNAL
|
||||||
|
return FriendlyError(
|
||||||
|
headline: l.errInternal,
|
||||||
|
detail: detail,
|
||||||
|
hint: l.errInternalHint,
|
||||||
|
);
|
||||||
|
case 14: // UNAVAILABLE
|
||||||
|
return FriendlyError(
|
||||||
|
headline: l.errUnavailable,
|
||||||
|
detail: detail,
|
||||||
|
hint: l.errUnavailableHint,
|
||||||
|
);
|
||||||
|
case 16: // UNAUTHENTICATED
|
||||||
|
return FriendlyError(
|
||||||
|
headline: l.errUnauthenticated,
|
||||||
|
detail: detail,
|
||||||
|
hint: l.errUnauthenticatedHint,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
final codeName = _stringField(error, 'codeName') ?? 'gRPC $code';
|
||||||
|
return FriendlyError(
|
||||||
|
headline: '$codeName: ${detail.isEmpty ? l.errGeneric : detail}',
|
||||||
|
detail: detail,
|
||||||
|
hint: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Non-gRPC error (e.g. FormatException from a bad URL). Show
|
||||||
|
// its toString — but as the headline, not buried in a wall.
|
||||||
|
return FriendlyError(
|
||||||
|
headline: error.toString(),
|
||||||
|
detail: error.toString(),
|
||||||
|
hint: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to read an `int` field by name off an arbitrary object.
|
||||||
|
/// Returns `null` when the field doesn't exist or has another
|
||||||
|
/// runtime type. Used to duck-type `GrpcError.code` without
|
||||||
|
/// pulling package:grpc as a Studio dependency.
|
||||||
|
int? _intField(Object obj, String field) {
|
||||||
|
try {
|
||||||
|
final dyn = obj as dynamic;
|
||||||
|
// ignore: avoid_dynamic_calls
|
||||||
|
final v = (() {
|
||||||
|
switch (field) {
|
||||||
|
case 'code':
|
||||||
|
return dyn.code;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
return v is int ? v : null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _stringField(Object obj, String field) {
|
||||||
|
try {
|
||||||
|
final dyn = obj as dynamic;
|
||||||
|
// ignore: avoid_dynamic_calls
|
||||||
|
final v = (() {
|
||||||
|
switch (field) {
|
||||||
|
case 'message':
|
||||||
|
return dyn.message;
|
||||||
|
case 'codeName':
|
||||||
|
return dyn.codeName;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
return v is String ? v : null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,9 @@ import 'package:fai_dart_sdk/fai_dart_sdk.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'flow_output.dart';
|
||||||
|
export 'flow_output.dart';
|
||||||
|
|
||||||
class HubService {
|
class HubService {
|
||||||
HubService._();
|
HubService._();
|
||||||
static final HubService instance = HubService._();
|
static final HubService instance = HubService._();
|
||||||
|
|
@ -90,13 +93,18 @@ class HubService {
|
||||||
|
|
||||||
Future<bool> healthy() => _client.healthy();
|
Future<bool> healthy() => _client.healthy();
|
||||||
|
|
||||||
|
/// List of installed WASM modules, grouped by `module_name`.
|
||||||
|
/// Built-in and federated capabilities are excluded — they
|
||||||
|
/// don't correspond to a bundle on disk and would confuse the
|
||||||
|
/// Modules page (a "system" or "via:filesystem" pseudo-module
|
||||||
|
/// has nothing to uninstall). Callers that need every callable
|
||||||
|
/// capability — e.g. the flow's missing-dependencies check —
|
||||||
|
/// use [allCapabilities] instead.
|
||||||
Future<List<ModuleSummary>> listModules() async {
|
Future<List<ModuleSummary>> listModules() async {
|
||||||
final caps = await _client.listCapabilities();
|
final caps = await _client.listCapabilities();
|
||||||
|
final wasm = caps.where((c) => c.kind.isEmpty || c.kind == 'wasm');
|
||||||
// Group capabilities by module so Studio's UI maps a card
|
|
||||||
// to a module rather than a capability.
|
|
||||||
final byModule = <String, List<CapabilityEntry>>{};
|
final byModule = <String, List<CapabilityEntry>>{};
|
||||||
for (final c in caps) {
|
for (final c in wasm) {
|
||||||
byModule.putIfAbsent(c.moduleName, () => []).add(c);
|
byModule.putIfAbsent(c.moduleName, () => []).add(c);
|
||||||
}
|
}
|
||||||
return byModule.entries.map((e) {
|
return byModule.entries.map((e) {
|
||||||
|
|
@ -112,6 +120,25 @@ class HubService {
|
||||||
..sort((a, b) => a.name.compareTo(b.name));
|
..sort((a, b) => a.name.compareTo(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every capability the hub can execute, with the `kind` tag
|
||||||
|
/// telling apart wasm / builtin / federated. The flow page
|
||||||
|
/// uses this to decide which capabilities are "already
|
||||||
|
/// available" — including built-ins like `system.approval`
|
||||||
|
/// and federated MCP / n8n tools — so the Run button enables
|
||||||
|
/// when the dependency is reachable, not only when a bundle
|
||||||
|
/// happens to be installed.
|
||||||
|
Future<List<CapabilityInfo>> allCapabilities() async {
|
||||||
|
final caps = await _client.listCapabilities();
|
||||||
|
return caps
|
||||||
|
.map((c) => CapabilityInfo(
|
||||||
|
capability: c.capability,
|
||||||
|
version: c.version,
|
||||||
|
moduleName: c.moduleName,
|
||||||
|
kind: c.kind.isEmpty ? 'wasm' : c.kind,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
/// Fully-detailed manifest for one installed module.
|
/// Fully-detailed manifest for one installed module.
|
||||||
Future<ModuleDetail> moduleInfo(String name) async {
|
Future<ModuleDetail> moduleInfo(String name) async {
|
||||||
final r = await _client.moduleInfo(name);
|
final r = await _client.moduleInfo(name);
|
||||||
|
|
@ -123,6 +150,7 @@ class HubService {
|
||||||
.toList(),
|
.toList(),
|
||||||
permissions: r.permissions,
|
permissions: r.permissions,
|
||||||
directory: r.directory,
|
directory: r.directory,
|
||||||
|
acceptsMime: r.acceptsMime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -346,17 +374,71 @@ class HubService {
|
||||||
return (name: r.name, version: r.version);
|
return (name: r.name, version: r.version);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch a module's README markdown via the hub. Errors come
|
/// Read the installed module's `MODULE.md` (or
|
||||||
/// back as `errorKind` strings (not exceptions) so callers can
|
/// `MODULE.<locale>.md`) from disk under
|
||||||
/// render fallback UI without try/catch ceremony.
|
/// `~/.fai/modules/<module>/`. Air-gap friendly, no network.
|
||||||
Future<({String errorKind, String text, String sourceUrl})>
|
///
|
||||||
fetchModuleDocs(String name, {String locale = ''}) async {
|
/// `state` is one of:
|
||||||
final r = await _client.fetchModuleDocs(name, locale: locale);
|
/// - `'found'` — `text` holds the markdown
|
||||||
return (
|
/// - `'no_docs'` — module installed but bundle had no docs
|
||||||
errorKind: r.errorKind,
|
/// - `'not_installed'` — module isn't installed at all
|
||||||
text: r.text,
|
Future<({String state, String text, String sourcePath})>
|
||||||
sourceUrl: r.sourceUrl,
|
readInstalledModuleDocs(String name, {String locale = ''}) async {
|
||||||
|
final r = await _client.getInstalledModuleDocs(name, locale: locale);
|
||||||
|
final String state;
|
||||||
|
if (r.notInstalled) {
|
||||||
|
state = 'not_installed';
|
||||||
|
} else if (r.text.isEmpty) {
|
||||||
|
state = 'no_docs';
|
||||||
|
} else {
|
||||||
|
state = 'found';
|
||||||
|
}
|
||||||
|
return (state: state, text: r.text, sourcePath: r.sourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invoke a Studio theme plugin's `theme` hook and unwrap
|
||||||
|
/// the result into Flutter-friendly `ColorScheme` slots.
|
||||||
|
///
|
||||||
|
/// Returns a record:
|
||||||
|
/// - `brightness`: echo of the input.
|
||||||
|
/// - `tokens`: 14 32-bit ARGB values in Material 3
|
||||||
|
/// order. Caller passes them to
|
||||||
|
/// `ColorScheme(...)` after deciding the
|
||||||
|
/// `brightness` enum.
|
||||||
|
///
|
||||||
|
/// Throws on hub-side errors (plugin not installed, plugin
|
||||||
|
/// declined the brightness, etc.) — caller routes the
|
||||||
|
/// exception through `friendlyError` like every other RPC.
|
||||||
|
Future<({String brightness, List<int> tokens})> invokePluginTheme({
|
||||||
|
required String capability,
|
||||||
|
required String brightness,
|
||||||
|
}) async {
|
||||||
|
final r = await _client.invokePluginTheme(
|
||||||
|
capability: capability,
|
||||||
|
brightness: brightness,
|
||||||
);
|
);
|
||||||
|
return (brightness: r.brightness, tokens: r.tokens.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invoke an installed Studio translate plugin and return
|
||||||
|
/// the translated text. Empty `fromLocale` lets the plugin
|
||||||
|
/// auto-detect; `toLocale` is the active Studio locale.
|
||||||
|
///
|
||||||
|
/// Caller catches the gRPC exception and routes it through
|
||||||
|
/// `friendlyError` like every other RPC.
|
||||||
|
Future<String> invokePluginTranslate({
|
||||||
|
required String capability,
|
||||||
|
required String text,
|
||||||
|
required String toLocale,
|
||||||
|
String fromLocale = '',
|
||||||
|
}) async {
|
||||||
|
final r = await _client.invokePluginTranslate(
|
||||||
|
capability: capability,
|
||||||
|
text: text,
|
||||||
|
toLocale: toLocale,
|
||||||
|
fromLocale: fromLocale,
|
||||||
|
);
|
||||||
|
return r.translated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot of every configured MCP server.
|
/// Snapshot of every configured MCP server.
|
||||||
|
|
@ -560,35 +642,31 @@ class HubService {
|
||||||
/// land as text Payloads, byte values as bytes Payloads —
|
/// land as text Payloads, byte values as bytes Payloads —
|
||||||
/// flows that mix both (e.g. extract taking a `document:
|
/// flows that mix both (e.g. extract taking a `document:
|
||||||
/// bytes` plus a text-shaped option) flow through one RPC.
|
/// bytes` plus a text-shaped option) flow through one RPC.
|
||||||
/// Returns the named outputs as a map of UI-friendly strings.
|
/// [fileMimeTypes] is keyed the same as [fileInputs]; missing
|
||||||
Future<Map<String, String>> runSavedFlow({
|
/// entries fall back to `application/octet-stream`. Modules
|
||||||
|
/// often gate on MIME (the `text.extract` module refuses
|
||||||
|
/// octet-stream), so callers should provide a real type.
|
||||||
|
/// Returns the named outputs as a map of typed [FlowOutput]
|
||||||
|
/// values so each page can render text / JSON / bytes / file
|
||||||
|
/// with the right widget.
|
||||||
|
Future<Map<String, FlowOutput>> runSavedFlow({
|
||||||
required String name,
|
required String name,
|
||||||
Map<String, String> textInputs = const {},
|
Map<String, String> textInputs = const {},
|
||||||
Map<String, Uint8List> fileInputs = const {},
|
Map<String, Uint8List> fileInputs = const {},
|
||||||
|
Map<String, String> fileMimeTypes = const {},
|
||||||
}) async {
|
}) async {
|
||||||
final r = await _client.runSavedFlow(
|
final r = await _client.runSavedFlow(
|
||||||
name: name,
|
name: name,
|
||||||
textInputs: textInputs,
|
textInputs: textInputs,
|
||||||
fileInputs: fileInputs,
|
fileInputs: fileInputs,
|
||||||
|
fileMimeTypes: fileMimeTypes,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
for (final entry in r.outputs.entries)
|
for (final entry in r.outputs.entries)
|
||||||
entry.key: _payloadToText(entry.value),
|
entry.key: FlowOutput.fromPayload(entry.value),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
String _payloadToText(Payload p) {
|
|
||||||
if (p.hasText()) return p.text;
|
|
||||||
if (p.hasJson()) return p.json.toString();
|
|
||||||
if (p.hasBytes()) {
|
|
||||||
return '<bytes: ${p.bytes.data.length} bytes, ${p.bytes.mimeType}>';
|
|
||||||
}
|
|
||||||
if (p.hasFile()) {
|
|
||||||
return '<file: ${p.file.uri}>';
|
|
||||||
}
|
|
||||||
return '<unknown payload variant>';
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<AuditEvent>> recentEvents({
|
Future<List<AuditEvent>> recentEvents({
|
||||||
int limit = 50,
|
int limit = 50,
|
||||||
List<String> types = const [],
|
List<String> types = const [],
|
||||||
|
|
@ -826,6 +904,26 @@ enum ThemeModeValue {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Single capability with its provenance. `kind` is one of
|
||||||
|
/// `wasm` (installed module), `builtin` (hub-internal, e.g.
|
||||||
|
/// `system.approval`), or `federated` (MCP / n8n tool reachable
|
||||||
|
/// through the bridge). UI uses `kind` to decide what actions
|
||||||
|
/// to offer — only `wasm` capabilities have an install /
|
||||||
|
/// uninstall affordance.
|
||||||
|
class CapabilityInfo {
|
||||||
|
final String capability;
|
||||||
|
final String version;
|
||||||
|
final String moduleName;
|
||||||
|
final String kind;
|
||||||
|
|
||||||
|
const CapabilityInfo({
|
||||||
|
required this.capability,
|
||||||
|
required this.version,
|
||||||
|
required this.moduleName,
|
||||||
|
required this.kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// UI-side type, decoupled from the proto wire type so pages
|
/// UI-side type, decoupled from the proto wire type so pages
|
||||||
/// don't import protobuf packages.
|
/// don't import protobuf packages.
|
||||||
class ModuleSummary {
|
class ModuleSummary {
|
||||||
|
|
@ -864,6 +962,10 @@ class ModuleDetail {
|
||||||
final List<String> capabilities;
|
final List<String> capabilities;
|
||||||
final List<String> permissions;
|
final List<String> permissions;
|
||||||
final String directory;
|
final String directory;
|
||||||
|
/// MIME allow-list declared in the module's `module.yaml`.
|
||||||
|
/// Empty when the module didn't declare any — caller falls
|
||||||
|
/// back to its built-in extension heuristic.
|
||||||
|
final List<String> acceptsMime;
|
||||||
|
|
||||||
const ModuleDetail({
|
const ModuleDetail({
|
||||||
required this.name,
|
required this.name,
|
||||||
|
|
@ -871,6 +973,7 @@ class ModuleDetail {
|
||||||
required this.capabilities,
|
required this.capabilities,
|
||||||
required this.permissions,
|
required this.permissions,
|
||||||
required this.directory,
|
required this.directory,
|
||||||
|
this.acceptsMime = const [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
152
lib/data/theme_plugin.dart
Normal file
152
lib/data/theme_plugin.dart
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
// Studio theme-plugin helpers: discovery, loading, persistence.
|
||||||
|
//
|
||||||
|
// `studio.theme.*` capabilities returned by the hub's
|
||||||
|
// `list_capabilities` are eligible theme plugins. Each one
|
||||||
|
// exposes a `theme` hook that returns a ColorScheme for a
|
||||||
|
// given brightness. This module wraps that into the shape
|
||||||
|
// Flutter's MaterialApp expects: a pair of `ThemeData`
|
||||||
|
// (light + dark) built from the plugin's tokens, ready to
|
||||||
|
// drop into the running app.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'hub.dart';
|
||||||
|
|
||||||
|
/// SharedPreferences key for the operator's preferred theme
|
||||||
|
/// plugin. `null` (or absent) means "use Studio's built-in
|
||||||
|
/// default" (FaiTheme.light / .dark).
|
||||||
|
const _kThemePluginKey = 'theme_plugin_capability';
|
||||||
|
|
||||||
|
/// One ColorScheme pair as built from a theme-plugin's
|
||||||
|
/// `theme_for("light")` + `theme_for("dark")` round-trips.
|
||||||
|
/// `null` slots are intentional — when the plugin declines
|
||||||
|
/// one brightness the caller falls back to its built-in
|
||||||
|
/// scheme for that side and uses the plugin's only for the
|
||||||
|
/// other.
|
||||||
|
class ThemePluginSchemes {
|
||||||
|
final ColorScheme? light;
|
||||||
|
final ColorScheme? dark;
|
||||||
|
const ThemePluginSchemes({this.light, this.dark});
|
||||||
|
|
||||||
|
bool get isEmpty => light == null && dark == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List every installed `studio.theme.*` capability the hub
|
||||||
|
/// is currently able to dispatch. Returns an empty list when
|
||||||
|
/// no theme plugin is installed (typical fresh hub).
|
||||||
|
Future<List<String>> listThemePluginCapabilities() async {
|
||||||
|
final caps = await HubService.instance.allCapabilities();
|
||||||
|
return caps
|
||||||
|
.where((c) =>
|
||||||
|
c.kind == 'wasm' && c.capability.startsWith('studio.theme.'))
|
||||||
|
.map((c) => c.capability)
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the operator's preferred theme-plugin capability from
|
||||||
|
/// SharedPreferences. Returns `null` when none chosen.
|
||||||
|
Future<String?> loadActiveThemePlugin() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final v = prefs.getString(_kThemePluginKey);
|
||||||
|
if (v == null || v.isEmpty) return null;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the operator's theme-plugin choice. Pass `null` to
|
||||||
|
/// clear it (back to the built-in default).
|
||||||
|
Future<void> saveActiveThemePlugin(String? capability) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (capability == null || capability.isEmpty) {
|
||||||
|
await prefs.remove(_kThemePluginKey);
|
||||||
|
} else {
|
||||||
|
await prefs.setString(_kThemePluginKey, capability);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invoke the plugin twice (light + dark) and translate its
|
||||||
|
/// 14 ARGB tokens into a `ColorScheme` per brightness. Plugin
|
||||||
|
/// errors are swallowed per brightness — a plugin that
|
||||||
|
/// declines `dark` but accepts `light` still contributes the
|
||||||
|
/// light scheme; the caller falls back to the built-in dark.
|
||||||
|
Future<ThemePluginSchemes> loadThemePluginSchemes(String capability) async {
|
||||||
|
ColorScheme? light;
|
||||||
|
ColorScheme? dark;
|
||||||
|
try {
|
||||||
|
final r = await HubService.instance.invokePluginTheme(
|
||||||
|
capability: capability,
|
||||||
|
brightness: 'light',
|
||||||
|
);
|
||||||
|
light = _tokensToColorScheme(r.tokens, Brightness.light);
|
||||||
|
} catch (_) {
|
||||||
|
// Declined / misconfigured / unreachable. Fall through —
|
||||||
|
// built-in light scheme will fill the slot.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final r = await HubService.instance.invokePluginTheme(
|
||||||
|
capability: capability,
|
||||||
|
brightness: 'dark',
|
||||||
|
);
|
||||||
|
dark = _tokensToColorScheme(r.tokens, Brightness.dark);
|
||||||
|
} catch (_) {}
|
||||||
|
return ThemePluginSchemes(light: light, dark: dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `ThemeData` from a plugin-supplied `ColorScheme`,
|
||||||
|
/// keeping Studio's typography (Inter for UI, JetBrains Mono
|
||||||
|
/// for code) so a theme swap doesn't reflow text metrics.
|
||||||
|
/// Mirrors what `FaiTheme.light()` / `.dark()` do internally
|
||||||
|
/// but with the operator-chosen palette.
|
||||||
|
ThemeData themeDataFromScheme(ColorScheme scheme) {
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: scheme,
|
||||||
|
scaffoldBackgroundColor: scheme.surface,
|
||||||
|
canvasColor: scheme.surface,
|
||||||
|
cardColor: scheme.surfaceContainer,
|
||||||
|
dividerColor: scheme.outlineVariant,
|
||||||
|
textTheme: GoogleFonts.interTextTheme(
|
||||||
|
scheme.brightness == Brightness.dark
|
||||||
|
? Typography.whiteCupertino
|
||||||
|
: Typography.blackCupertino,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate the plugin's 14 ARGB tokens into a `ColorScheme`.
|
||||||
|
/// Order matches the plugin contract:
|
||||||
|
/// primary, on-primary, secondary, on-secondary,
|
||||||
|
/// tertiary, on-tertiary, error, on-error,
|
||||||
|
/// surface, on-surface, surface-variant, on-surface-variant,
|
||||||
|
/// outline, outline-variant.
|
||||||
|
/// Length mismatches are tolerated — missing slots fall back
|
||||||
|
/// to a sensible Material 3 baseline so a half-built plugin
|
||||||
|
/// doesn't crash the picker.
|
||||||
|
ColorScheme _tokensToColorScheme(List<int> tokens, Brightness brightness) {
|
||||||
|
Color slot(int i) {
|
||||||
|
if (i < tokens.length) return Color(tokens[i]);
|
||||||
|
// Reasonable fallback when the plugin underdelivered.
|
||||||
|
return brightness == Brightness.dark
|
||||||
|
? const Color(0xFF1A1A1A)
|
||||||
|
: const Color(0xFFEEEEEE);
|
||||||
|
}
|
||||||
|
return ColorScheme(
|
||||||
|
brightness: brightness,
|
||||||
|
primary: slot(0),
|
||||||
|
onPrimary: slot(1),
|
||||||
|
secondary: slot(2),
|
||||||
|
onSecondary: slot(3),
|
||||||
|
tertiary: slot(4),
|
||||||
|
onTertiary: slot(5),
|
||||||
|
error: slot(6),
|
||||||
|
onError: slot(7),
|
||||||
|
surface: slot(8),
|
||||||
|
onSurface: slot(9),
|
||||||
|
surfaceContainer: slot(10),
|
||||||
|
onSurfaceVariant: slot(11),
|
||||||
|
outline: slot(12),
|
||||||
|
outlineVariant: slot(13),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -75,6 +75,30 @@
|
||||||
"buttonReadDocs": "Doku öffnen",
|
"buttonReadDocs": "Doku öffnen",
|
||||||
"buttonCopy": "In Zwischenablage kopieren",
|
"buttonCopy": "In Zwischenablage kopieren",
|
||||||
"buttonCopied": "Kopiert",
|
"buttonCopied": "Kopiert",
|
||||||
|
"buttonShowDetails": "Details anzeigen",
|
||||||
|
"buttonHideDetails": "Details ausblenden",
|
||||||
|
"storeInstallableOnly": "Nur installierbare",
|
||||||
|
"storePillComingSoon": "Bald verfügbar",
|
||||||
|
"storeNotInstallableInline": "Dieses Modul ist auf der Roadmap (Status: {status}). Es gibt noch kein installierbares Bundle — Repository verfolgen für Updates.",
|
||||||
|
"@storeNotInstallableInline": { "placeholders": { "status": { "type": "String" } } },
|
||||||
|
|
||||||
|
"mcpSuggestionDeepwikiDesc": "Öffentliches HTTPS — KI-gestützte Doku-Suche in GitHub-Repos. Kein Node, kein API-Key.",
|
||||||
|
"mcpSuggestionSemgrepDesc": "Öffentliches HTTPS — Security-Scanning von Code-Schwachstellen. Kein Node, kein API-Key.",
|
||||||
|
"mcpSuggestionFilesystemDesc": "Anthropic — Dateien in /tmp lesen / schreiben. Pfad vor dem Speichern anpassen.",
|
||||||
|
"mcpSuggestionFetchDesc": "Anthropic — beliebige HTTP(S)-URLs als Markdown abrufen.",
|
||||||
|
"mcpSuggestionGithubDesc": "Anthropic — Issues, PRs, Repo-Dateien. Braucht einen GitHub-PAT in der Env-Variable.",
|
||||||
|
"mcpSuggestionPuppeteerDesc": "Anthropic — Headless-Browser-Automation für Screenshots / Scraping.",
|
||||||
|
"mcpSuggestionPostgresDesc": "Anthropic — Read-only-SQL-Queries gegen eine Postgres-Datenbank. URL anpassen.",
|
||||||
|
"mcpSuggestionSqliteDesc": "Anthropic — Abfragen gegen eine lokale SQLite-Datei. Pfad anpassen.",
|
||||||
|
"mcpSuggestionBraveSearchDesc": "Anthropic — Websuche via Brave. API-Key erforderlich.",
|
||||||
|
"mcpSuggestionMemoryDesc": "Anthropic — persistenter Knowledge-Graph als Agenten-Gedächtnis.",
|
||||||
|
"mcpSuggestionTimeDesc": "Anthropic — aktuelle Uhrzeit + Zeitzonen-Konvertierung.",
|
||||||
|
"mcpServerEnLanguageBadge": "Server-Text in Englisch — Studio zeigt ihn wie geliefert.",
|
||||||
|
|
||||||
|
"themePluginHeader": "Theme-Plugin",
|
||||||
|
"themePluginNone": "Standard (kein Plugin)",
|
||||||
|
"themePluginHint": "Installiertes studio.theme.*-Plugin auswählen, um das ganze Studio umzustylen. Erst eines aus dem Store installieren.",
|
||||||
|
"themePluginEmpty": "Kein Theme-Plugin installiert. Im Store eines aus der Kategorie studio-plugin auswählen.",
|
||||||
|
|
||||||
"settingsTitle": "Hub-Endpunkt",
|
"settingsTitle": "Hub-Endpunkt",
|
||||||
"settingsHost": "Host",
|
"settingsHost": "Host",
|
||||||
|
|
@ -506,9 +530,43 @@
|
||||||
"flowsRunButton": "Starten",
|
"flowsRunButton": "Starten",
|
||||||
"flowsRunningTitle": "{name} läuft",
|
"flowsRunningTitle": "{name} läuft",
|
||||||
"@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } },
|
"@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } },
|
||||||
|
"flowsResultTitle": "{name} — Ergebnis",
|
||||||
|
"@flowsResultTitle": { "placeholders": { "name": { "type": "String" } } },
|
||||||
|
"flowsErrorTitle": "{name} — fehlgeschlagen",
|
||||||
|
"@flowsErrorTitle": { "placeholders": { "name": { "type": "String" } } },
|
||||||
"flowsRunning": "Flow läuft…",
|
"flowsRunning": "Flow läuft…",
|
||||||
"flowsNoOutputs": "Flow abgeschlossen ohne deklarierte Outputs.",
|
"flowsNoOutputs": "Flow abgeschlossen ohne deklarierte Outputs.",
|
||||||
|
|
||||||
|
"errGeneric": "Da ist etwas schiefgegangen.",
|
||||||
|
"errInvalidArgument": "Ungültige Eingabe.",
|
||||||
|
"errInvalidArgumentHint": "Werte im Formular prüfen und erneut versuchen.",
|
||||||
|
"errNotFound": "Nicht gefunden.",
|
||||||
|
"errNotFoundHint": "Das Angefragte ist nicht hier — entweder entfernt oder nie existiert.",
|
||||||
|
"errAlreadyExists": "Existiert bereits.",
|
||||||
|
"errPermissionDenied": "Berechtigung verweigert.",
|
||||||
|
"errPermissionDeniedHint": "Die Operator-Policy des Hubs blockiert diese Aktion. `~/.fai/config.yaml` prüfen.",
|
||||||
|
"errFailedPrecondition": "Setup-Problem hat das blockiert.",
|
||||||
|
"errFailedPreconditionHint": "Der Hub konnte nicht weiter, weil etwas Vorausgesetztes fehlt. Details unten erklären was.",
|
||||||
|
"errInternal": "Unerwarteter interner Fehler.",
|
||||||
|
"errInternalHint": "Hub-Logs unter `~/.fai/logs/` prüfen. Wenn das öfter passiert, bitte melden.",
|
||||||
|
"errUnavailable": "Hub nicht erreichbar.",
|
||||||
|
"errUnavailableHint": "Sicherstellen, dass `fai serve` (oder `fai daemon start`) läuft und der Daemon-Endpunkt zu Studios Einstellungen passt.",
|
||||||
|
"errUnauthenticated": "Authentifizierung erforderlich.",
|
||||||
|
"errUnauthenticatedHint": "`FAI_REGISTRY_TOKEN` setzen oder in den Einstellungen anmelden, dann erneut versuchen.",
|
||||||
|
"errCopyDetail": "Detail kopieren",
|
||||||
|
"errDetailCopied": "Detail in die Zwischenablage kopiert.",
|
||||||
|
"flowsOutputUnknown": "Unbekannter Output-Payload-Typ.",
|
||||||
|
"flowsOutputBytesUnknownMime": "Binärdaten",
|
||||||
|
"flowsOutputSaveAs": "Speichern unter…",
|
||||||
|
"flowsOutputSaveAsTitle": "Flow-Output speichern",
|
||||||
|
"flowsOutputSavedAt": "Gespeichert: {path}",
|
||||||
|
"@flowsOutputSavedAt": { "placeholders": { "path": { "type": "String" } } },
|
||||||
|
"flowsOutputSaveFailed": "Speichern fehlgeschlagen: {error}",
|
||||||
|
"@flowsOutputSaveFailed": { "placeholders": { "error": { "type": "String" } } },
|
||||||
|
"flowsOutputOpen": "Öffnen",
|
||||||
|
"flowsOutputOpenFailed": "Öffnen fehlgeschlagen: {error}",
|
||||||
|
"@flowsOutputOpenFailed": { "placeholders": { "error": { "type": "String" } } },
|
||||||
|
|
||||||
"approvalsTitle": "Freigaben",
|
"approvalsTitle": "Freigaben",
|
||||||
"approvalsTabPending": "Offen",
|
"approvalsTabPending": "Offen",
|
||||||
"approvalsTabHistory": "Verlauf",
|
"approvalsTabHistory": "Verlauf",
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,30 @@
|
||||||
"buttonReadDocs": "Read docs",
|
"buttonReadDocs": "Read docs",
|
||||||
"buttonCopy": "Copy to clipboard",
|
"buttonCopy": "Copy to clipboard",
|
||||||
"buttonCopied": "Copied",
|
"buttonCopied": "Copied",
|
||||||
|
"buttonShowDetails": "Show details",
|
||||||
|
"buttonHideDetails": "Hide details",
|
||||||
|
"storeInstallableOnly": "Installable only",
|
||||||
|
"storePillComingSoon": "Coming soon",
|
||||||
|
"storeNotInstallableInline": "This module is on the roadmap (status: {status}). It has no installable bundle yet — follow the repository for updates.",
|
||||||
|
"@storeNotInstallableInline": { "placeholders": { "status": { "type": "String" } } },
|
||||||
|
|
||||||
|
"mcpSuggestionDeepwikiDesc": "Public HTTPS — GitHub repo documentation search (AI-powered). No Node, no API key.",
|
||||||
|
"mcpSuggestionSemgrepDesc": "Public HTTPS — security scanning for code vulnerabilities. No Node, no API key.",
|
||||||
|
"mcpSuggestionFilesystemDesc": "Anthropic — read / write files in /tmp. Edit the path before saving.",
|
||||||
|
"mcpSuggestionFetchDesc": "Anthropic — fetch arbitrary HTTP(S) URLs as markdown.",
|
||||||
|
"mcpSuggestionGithubDesc": "Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.",
|
||||||
|
"mcpSuggestionPuppeteerDesc": "Anthropic — headless browser automation for screenshots / scraping.",
|
||||||
|
"mcpSuggestionPostgresDesc": "Anthropic — read-only SQL queries against a Postgres database. Edit the URL.",
|
||||||
|
"mcpSuggestionSqliteDesc": "Anthropic — query a local SQLite file. Edit the path.",
|
||||||
|
"mcpSuggestionBraveSearchDesc": "Anthropic — web search via Brave. API key required.",
|
||||||
|
"mcpSuggestionMemoryDesc": "Anthropic — persistent knowledge graph for an agent's memory.",
|
||||||
|
"mcpSuggestionTimeDesc": "Anthropic — current time + timezone conversion.",
|
||||||
|
"mcpServerEnLanguageBadge": "Server text in English — Studio shows it as supplied.",
|
||||||
|
|
||||||
|
"themePluginHeader": "Theme plugin",
|
||||||
|
"themePluginNone": "Built-in (no plugin)",
|
||||||
|
"themePluginHint": "Pick an installed studio.theme.* plugin to re-skin the whole app. Install one from the Store first.",
|
||||||
|
"themePluginEmpty": "No theme plugins installed. Open the Store and pick one in the studio-plugin category.",
|
||||||
|
|
||||||
"settingsTitle": "Hub endpoint",
|
"settingsTitle": "Hub endpoint",
|
||||||
"settingsHost": "Host",
|
"settingsHost": "Host",
|
||||||
|
|
@ -507,9 +531,43 @@
|
||||||
"flowsRunButton": "Run",
|
"flowsRunButton": "Run",
|
||||||
"flowsRunningTitle": "Running {name}",
|
"flowsRunningTitle": "Running {name}",
|
||||||
"@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } },
|
"@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } },
|
||||||
|
"flowsResultTitle": "{name} — result",
|
||||||
|
"@flowsResultTitle": { "placeholders": { "name": { "type": "String" } } },
|
||||||
|
"flowsErrorTitle": "{name} — failed",
|
||||||
|
"@flowsErrorTitle": { "placeholders": { "name": { "type": "String" } } },
|
||||||
"flowsRunning": "Flow running…",
|
"flowsRunning": "Flow running…",
|
||||||
"flowsNoOutputs": "Flow completed with no declared outputs.",
|
"flowsNoOutputs": "Flow completed with no declared outputs.",
|
||||||
|
|
||||||
|
"errGeneric": "Something went wrong.",
|
||||||
|
"errInvalidArgument": "Invalid input.",
|
||||||
|
"errInvalidArgumentHint": "Check the form values and try again.",
|
||||||
|
"errNotFound": "Not found.",
|
||||||
|
"errNotFoundHint": "The item you asked for isn't here — it may have been removed or never existed.",
|
||||||
|
"errAlreadyExists": "Already exists.",
|
||||||
|
"errPermissionDenied": "Permission denied.",
|
||||||
|
"errPermissionDeniedHint": "The hub's operator policy blocks this action. Check `~/.fai/config.yaml`.",
|
||||||
|
"errFailedPrecondition": "Setup issue blocked this.",
|
||||||
|
"errFailedPreconditionHint": "The hub couldn't proceed because something it needs isn't ready. The detail below explains what.",
|
||||||
|
"errInternal": "Unexpected internal error.",
|
||||||
|
"errInternalHint": "Check the hub logs at `~/.fai/logs/`. If this repeats, please report it.",
|
||||||
|
"errUnavailable": "Hub not reachable.",
|
||||||
|
"errUnavailableHint": "Make sure `fai serve` (or `fai daemon start`) is running and the daemon endpoint matches Studio's settings.",
|
||||||
|
"errUnauthenticated": "Authentication required.",
|
||||||
|
"errUnauthenticatedHint": "Set `FAI_REGISTRY_TOKEN` or sign in through Settings before retrying.",
|
||||||
|
"errCopyDetail": "Copy detail",
|
||||||
|
"errDetailCopied": "Detail copied to clipboard.",
|
||||||
|
"flowsOutputUnknown": "Output payload variant not recognised.",
|
||||||
|
"flowsOutputBytesUnknownMime": "binary",
|
||||||
|
"flowsOutputSaveAs": "Save as…",
|
||||||
|
"flowsOutputSaveAsTitle": "Save flow output",
|
||||||
|
"flowsOutputSavedAt": "Saved to {path}",
|
||||||
|
"@flowsOutputSavedAt": { "placeholders": { "path": { "type": "String" } } },
|
||||||
|
"flowsOutputSaveFailed": "Save failed: {error}",
|
||||||
|
"@flowsOutputSaveFailed": { "placeholders": { "error": { "type": "String" } } },
|
||||||
|
"flowsOutputOpen": "Open",
|
||||||
|
"flowsOutputOpenFailed": "Open failed: {error}",
|
||||||
|
"@flowsOutputOpenFailed": { "placeholders": { "error": { "type": "String" } } },
|
||||||
|
|
||||||
"approvalsTitle": "Approvals",
|
"approvalsTitle": "Approvals",
|
||||||
"approvalsTabPending": "Pending",
|
"approvalsTabPending": "Pending",
|
||||||
"approvalsTabHistory": "History",
|
"approvalsTabHistory": "History",
|
||||||
|
|
|
||||||
|
|
@ -518,6 +518,132 @@ abstract class AppLocalizations {
|
||||||
/// **'Copied'**
|
/// **'Copied'**
|
||||||
String get buttonCopied;
|
String get buttonCopied;
|
||||||
|
|
||||||
|
/// No description provided for @buttonShowDetails.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Show details'**
|
||||||
|
String get buttonShowDetails;
|
||||||
|
|
||||||
|
/// No description provided for @buttonHideDetails.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Hide details'**
|
||||||
|
String get buttonHideDetails;
|
||||||
|
|
||||||
|
/// No description provided for @storeInstallableOnly.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Installable only'**
|
||||||
|
String get storeInstallableOnly;
|
||||||
|
|
||||||
|
/// No description provided for @storePillComingSoon.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Coming soon'**
|
||||||
|
String get storePillComingSoon;
|
||||||
|
|
||||||
|
/// No description provided for @storeNotInstallableInline.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This module is on the roadmap (status: {status}). It has no installable bundle yet — follow the repository for updates.'**
|
||||||
|
String storeNotInstallableInline(String status);
|
||||||
|
|
||||||
|
/// No description provided for @mcpSuggestionDeepwikiDesc.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Public HTTPS — GitHub repo documentation search (AI-powered). No Node, no API key.'**
|
||||||
|
String get mcpSuggestionDeepwikiDesc;
|
||||||
|
|
||||||
|
/// No description provided for @mcpSuggestionSemgrepDesc.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Public HTTPS — security scanning for code vulnerabilities. No Node, no API key.'**
|
||||||
|
String get mcpSuggestionSemgrepDesc;
|
||||||
|
|
||||||
|
/// No description provided for @mcpSuggestionFilesystemDesc.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Anthropic — read / write files in /tmp. Edit the path before saving.'**
|
||||||
|
String get mcpSuggestionFilesystemDesc;
|
||||||
|
|
||||||
|
/// No description provided for @mcpSuggestionFetchDesc.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Anthropic — fetch arbitrary HTTP(S) URLs as markdown.'**
|
||||||
|
String get mcpSuggestionFetchDesc;
|
||||||
|
|
||||||
|
/// No description provided for @mcpSuggestionGithubDesc.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.'**
|
||||||
|
String get mcpSuggestionGithubDesc;
|
||||||
|
|
||||||
|
/// No description provided for @mcpSuggestionPuppeteerDesc.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Anthropic — headless browser automation for screenshots / scraping.'**
|
||||||
|
String get mcpSuggestionPuppeteerDesc;
|
||||||
|
|
||||||
|
/// No description provided for @mcpSuggestionPostgresDesc.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Anthropic — read-only SQL queries against a Postgres database. Edit the URL.'**
|
||||||
|
String get mcpSuggestionPostgresDesc;
|
||||||
|
|
||||||
|
/// No description provided for @mcpSuggestionSqliteDesc.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Anthropic — query a local SQLite file. Edit the path.'**
|
||||||
|
String get mcpSuggestionSqliteDesc;
|
||||||
|
|
||||||
|
/// No description provided for @mcpSuggestionBraveSearchDesc.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Anthropic — web search via Brave. API key required.'**
|
||||||
|
String get mcpSuggestionBraveSearchDesc;
|
||||||
|
|
||||||
|
/// No description provided for @mcpSuggestionMemoryDesc.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Anthropic — persistent knowledge graph for an agent\'s memory.'**
|
||||||
|
String get mcpSuggestionMemoryDesc;
|
||||||
|
|
||||||
|
/// No description provided for @mcpSuggestionTimeDesc.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Anthropic — current time + timezone conversion.'**
|
||||||
|
String get mcpSuggestionTimeDesc;
|
||||||
|
|
||||||
|
/// No description provided for @mcpServerEnLanguageBadge.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Server text in English — Studio shows it as supplied.'**
|
||||||
|
String get mcpServerEnLanguageBadge;
|
||||||
|
|
||||||
|
/// No description provided for @themePluginHeader.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Theme plugin'**
|
||||||
|
String get themePluginHeader;
|
||||||
|
|
||||||
|
/// No description provided for @themePluginNone.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Built-in (no plugin)'**
|
||||||
|
String get themePluginNone;
|
||||||
|
|
||||||
|
/// No description provided for @themePluginHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pick an installed studio.theme.* plugin to re-skin the whole app. Install one from the Store first.'**
|
||||||
|
String get themePluginHint;
|
||||||
|
|
||||||
|
/// No description provided for @themePluginEmpty.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No theme plugins installed. Open the Store and pick one in the studio-plugin category.'**
|
||||||
|
String get themePluginEmpty;
|
||||||
|
|
||||||
/// No description provided for @settingsTitle.
|
/// No description provided for @settingsTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -2282,6 +2408,18 @@ abstract class AppLocalizations {
|
||||||
/// **'Running {name}'**
|
/// **'Running {name}'**
|
||||||
String flowsRunningTitle(String name);
|
String flowsRunningTitle(String name);
|
||||||
|
|
||||||
|
/// No description provided for @flowsResultTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{name} — result'**
|
||||||
|
String flowsResultTitle(String name);
|
||||||
|
|
||||||
|
/// No description provided for @flowsErrorTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{name} — failed'**
|
||||||
|
String flowsErrorTitle(String name);
|
||||||
|
|
||||||
/// No description provided for @flowsRunning.
|
/// No description provided for @flowsRunning.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -2294,6 +2432,162 @@ abstract class AppLocalizations {
|
||||||
/// **'Flow completed with no declared outputs.'**
|
/// **'Flow completed with no declared outputs.'**
|
||||||
String get flowsNoOutputs;
|
String get flowsNoOutputs;
|
||||||
|
|
||||||
|
/// No description provided for @errGeneric.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Something went wrong.'**
|
||||||
|
String get errGeneric;
|
||||||
|
|
||||||
|
/// No description provided for @errInvalidArgument.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Invalid input.'**
|
||||||
|
String get errInvalidArgument;
|
||||||
|
|
||||||
|
/// No description provided for @errInvalidArgumentHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Check the form values and try again.'**
|
||||||
|
String get errInvalidArgumentHint;
|
||||||
|
|
||||||
|
/// No description provided for @errNotFound.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Not found.'**
|
||||||
|
String get errNotFound;
|
||||||
|
|
||||||
|
/// No description provided for @errNotFoundHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The item you asked for isn\'t here — it may have been removed or never existed.'**
|
||||||
|
String get errNotFoundHint;
|
||||||
|
|
||||||
|
/// No description provided for @errAlreadyExists.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Already exists.'**
|
||||||
|
String get errAlreadyExists;
|
||||||
|
|
||||||
|
/// No description provided for @errPermissionDenied.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Permission denied.'**
|
||||||
|
String get errPermissionDenied;
|
||||||
|
|
||||||
|
/// No description provided for @errPermissionDeniedHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The hub\'s operator policy blocks this action. Check `~/.fai/config.yaml`.'**
|
||||||
|
String get errPermissionDeniedHint;
|
||||||
|
|
||||||
|
/// No description provided for @errFailedPrecondition.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Setup issue blocked this.'**
|
||||||
|
String get errFailedPrecondition;
|
||||||
|
|
||||||
|
/// No description provided for @errFailedPreconditionHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The hub couldn\'t proceed because something it needs isn\'t ready. The detail below explains what.'**
|
||||||
|
String get errFailedPreconditionHint;
|
||||||
|
|
||||||
|
/// No description provided for @errInternal.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Unexpected internal error.'**
|
||||||
|
String get errInternal;
|
||||||
|
|
||||||
|
/// No description provided for @errInternalHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Check the hub logs at `~/.fai/logs/`. If this repeats, please report it.'**
|
||||||
|
String get errInternalHint;
|
||||||
|
|
||||||
|
/// No description provided for @errUnavailable.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Hub not reachable.'**
|
||||||
|
String get errUnavailable;
|
||||||
|
|
||||||
|
/// No description provided for @errUnavailableHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Make sure `fai serve` (or `fai daemon start`) is running and the daemon endpoint matches Studio\'s settings.'**
|
||||||
|
String get errUnavailableHint;
|
||||||
|
|
||||||
|
/// No description provided for @errUnauthenticated.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Authentication required.'**
|
||||||
|
String get errUnauthenticated;
|
||||||
|
|
||||||
|
/// No description provided for @errUnauthenticatedHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Set `FAI_REGISTRY_TOKEN` or sign in through Settings before retrying.'**
|
||||||
|
String get errUnauthenticatedHint;
|
||||||
|
|
||||||
|
/// No description provided for @errCopyDetail.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Copy detail'**
|
||||||
|
String get errCopyDetail;
|
||||||
|
|
||||||
|
/// No description provided for @errDetailCopied.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Detail copied to clipboard.'**
|
||||||
|
String get errDetailCopied;
|
||||||
|
|
||||||
|
/// No description provided for @flowsOutputUnknown.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Output payload variant not recognised.'**
|
||||||
|
String get flowsOutputUnknown;
|
||||||
|
|
||||||
|
/// No description provided for @flowsOutputBytesUnknownMime.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'binary'**
|
||||||
|
String get flowsOutputBytesUnknownMime;
|
||||||
|
|
||||||
|
/// No description provided for @flowsOutputSaveAs.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save as…'**
|
||||||
|
String get flowsOutputSaveAs;
|
||||||
|
|
||||||
|
/// No description provided for @flowsOutputSaveAsTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save flow output'**
|
||||||
|
String get flowsOutputSaveAsTitle;
|
||||||
|
|
||||||
|
/// No description provided for @flowsOutputSavedAt.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Saved to {path}'**
|
||||||
|
String flowsOutputSavedAt(String path);
|
||||||
|
|
||||||
|
/// No description provided for @flowsOutputSaveFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save failed: {error}'**
|
||||||
|
String flowsOutputSaveFailed(String error);
|
||||||
|
|
||||||
|
/// No description provided for @flowsOutputOpen.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Open'**
|
||||||
|
String get flowsOutputOpen;
|
||||||
|
|
||||||
|
/// No description provided for @flowsOutputOpenFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Open failed: {error}'**
|
||||||
|
String flowsOutputOpenFailed(String error);
|
||||||
|
|
||||||
/// No description provided for @approvalsTitle.
|
/// No description provided for @approvalsTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,85 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get buttonCopied => 'Kopiert';
|
String get buttonCopied => 'Kopiert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get buttonShowDetails => 'Details anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get buttonHideDetails => 'Details ausblenden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeInstallableOnly => 'Nur installierbare';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storePillComingSoon => 'Bald verfügbar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storeNotInstallableInline(String status) {
|
||||||
|
return 'Dieses Modul ist auf der Roadmap (Status: $status). Es gibt noch kein installierbares Bundle — Repository verfolgen für Updates.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionDeepwikiDesc =>
|
||||||
|
'Öffentliches HTTPS — KI-gestützte Doku-Suche in GitHub-Repos. Kein Node, kein API-Key.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionSemgrepDesc =>
|
||||||
|
'Öffentliches HTTPS — Security-Scanning von Code-Schwachstellen. Kein Node, kein API-Key.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionFilesystemDesc =>
|
||||||
|
'Anthropic — Dateien in /tmp lesen / schreiben. Pfad vor dem Speichern anpassen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionFetchDesc =>
|
||||||
|
'Anthropic — beliebige HTTP(S)-URLs als Markdown abrufen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionGithubDesc =>
|
||||||
|
'Anthropic — Issues, PRs, Repo-Dateien. Braucht einen GitHub-PAT in der Env-Variable.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionPuppeteerDesc =>
|
||||||
|
'Anthropic — Headless-Browser-Automation für Screenshots / Scraping.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionPostgresDesc =>
|
||||||
|
'Anthropic — Read-only-SQL-Queries gegen eine Postgres-Datenbank. URL anpassen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionSqliteDesc =>
|
||||||
|
'Anthropic — Abfragen gegen eine lokale SQLite-Datei. Pfad anpassen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionBraveSearchDesc =>
|
||||||
|
'Anthropic — Websuche via Brave. API-Key erforderlich.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionMemoryDesc =>
|
||||||
|
'Anthropic — persistenter Knowledge-Graph als Agenten-Gedächtnis.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionTimeDesc =>
|
||||||
|
'Anthropic — aktuelle Uhrzeit + Zeitzonen-Konvertierung.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpServerEnLanguageBadge =>
|
||||||
|
'Server-Text in Englisch — Studio zeigt ihn wie geliefert.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themePluginHeader => 'Theme-Plugin';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themePluginNone => 'Standard (kein Plugin)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themePluginHint =>
|
||||||
|
'Installiertes studio.theme.*-Plugin auswählen, um das ganze Studio umzustylen. Erst eines aus dem Store installieren.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themePluginEmpty =>
|
||||||
|
'Kein Theme-Plugin installiert. Im Store eines aus der Kategorie studio-plugin auswählen.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Hub-Endpunkt';
|
String get settingsTitle => 'Hub-Endpunkt';
|
||||||
|
|
||||||
|
|
@ -1308,12 +1387,113 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return '$name läuft';
|
return '$name läuft';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String flowsResultTitle(String name) {
|
||||||
|
return '$name — Ergebnis';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String flowsErrorTitle(String name) {
|
||||||
|
return '$name — fehlgeschlagen';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get flowsRunning => 'Flow läuft…';
|
String get flowsRunning => 'Flow läuft…';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get flowsNoOutputs => 'Flow abgeschlossen ohne deklarierte Outputs.';
|
String get flowsNoOutputs => 'Flow abgeschlossen ohne deklarierte Outputs.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errGeneric => 'Da ist etwas schiefgegangen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errInvalidArgument => 'Ungültige Eingabe.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errInvalidArgumentHint =>
|
||||||
|
'Werte im Formular prüfen und erneut versuchen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errNotFound => 'Nicht gefunden.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errNotFoundHint =>
|
||||||
|
'Das Angefragte ist nicht hier — entweder entfernt oder nie existiert.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errAlreadyExists => 'Existiert bereits.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errPermissionDenied => 'Berechtigung verweigert.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errPermissionDeniedHint =>
|
||||||
|
'Die Operator-Policy des Hubs blockiert diese Aktion. `~/.fai/config.yaml` prüfen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errFailedPrecondition => 'Setup-Problem hat das blockiert.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errFailedPreconditionHint =>
|
||||||
|
'Der Hub konnte nicht weiter, weil etwas Vorausgesetztes fehlt. Details unten erklären was.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errInternal => 'Unerwarteter interner Fehler.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errInternalHint =>
|
||||||
|
'Hub-Logs unter `~/.fai/logs/` prüfen. Wenn das öfter passiert, bitte melden.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errUnavailable => 'Hub nicht erreichbar.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errUnavailableHint =>
|
||||||
|
'Sicherstellen, dass `fai serve` (oder `fai daemon start`) läuft und der Daemon-Endpunkt zu Studios Einstellungen passt.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errUnauthenticated => 'Authentifizierung erforderlich.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errUnauthenticatedHint =>
|
||||||
|
'`FAI_REGISTRY_TOKEN` setzen oder in den Einstellungen anmelden, dann erneut versuchen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errCopyDetail => 'Detail kopieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errDetailCopied => 'Detail in die Zwischenablage kopiert.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get flowsOutputUnknown => 'Unbekannter Output-Payload-Typ.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get flowsOutputBytesUnknownMime => 'Binärdaten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get flowsOutputSaveAs => 'Speichern unter…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get flowsOutputSaveAsTitle => 'Flow-Output speichern';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String flowsOutputSavedAt(String path) {
|
||||||
|
return 'Gespeichert: $path';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String flowsOutputSaveFailed(String error) {
|
||||||
|
return 'Speichern fehlgeschlagen: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get flowsOutputOpen => 'Öffnen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String flowsOutputOpenFailed(String error) {
|
||||||
|
return 'Öffnen fehlgeschlagen: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsTitle => 'Freigaben';
|
String get approvalsTitle => 'Freigaben';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,85 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get buttonCopied => 'Copied';
|
String get buttonCopied => 'Copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get buttonShowDetails => 'Show details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get buttonHideDetails => 'Hide details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeInstallableOnly => 'Installable only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storePillComingSoon => 'Coming soon';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storeNotInstallableInline(String status) {
|
||||||
|
return 'This module is on the roadmap (status: $status). It has no installable bundle yet — follow the repository for updates.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionDeepwikiDesc =>
|
||||||
|
'Public HTTPS — GitHub repo documentation search (AI-powered). No Node, no API key.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionSemgrepDesc =>
|
||||||
|
'Public HTTPS — security scanning for code vulnerabilities. No Node, no API key.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionFilesystemDesc =>
|
||||||
|
'Anthropic — read / write files in /tmp. Edit the path before saving.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionFetchDesc =>
|
||||||
|
'Anthropic — fetch arbitrary HTTP(S) URLs as markdown.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionGithubDesc =>
|
||||||
|
'Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionPuppeteerDesc =>
|
||||||
|
'Anthropic — headless browser automation for screenshots / scraping.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionPostgresDesc =>
|
||||||
|
'Anthropic — read-only SQL queries against a Postgres database. Edit the URL.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionSqliteDesc =>
|
||||||
|
'Anthropic — query a local SQLite file. Edit the path.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionBraveSearchDesc =>
|
||||||
|
'Anthropic — web search via Brave. API key required.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionMemoryDesc =>
|
||||||
|
'Anthropic — persistent knowledge graph for an agent\'s memory.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpSuggestionTimeDesc =>
|
||||||
|
'Anthropic — current time + timezone conversion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mcpServerEnLanguageBadge =>
|
||||||
|
'Server text in English — Studio shows it as supplied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themePluginHeader => 'Theme plugin';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themePluginNone => 'Built-in (no plugin)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themePluginHint =>
|
||||||
|
'Pick an installed studio.theme.* plugin to re-skin the whole app. Install one from the Store first.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themePluginEmpty =>
|
||||||
|
'No theme plugins installed. Open the Store and pick one in the studio-plugin category.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Hub endpoint';
|
String get settingsTitle => 'Hub endpoint';
|
||||||
|
|
||||||
|
|
@ -1321,12 +1400,112 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return 'Running $name';
|
return 'Running $name';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String flowsResultTitle(String name) {
|
||||||
|
return '$name — result';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String flowsErrorTitle(String name) {
|
||||||
|
return '$name — failed';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get flowsRunning => 'Flow running…';
|
String get flowsRunning => 'Flow running…';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get flowsNoOutputs => 'Flow completed with no declared outputs.';
|
String get flowsNoOutputs => 'Flow completed with no declared outputs.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errGeneric => 'Something went wrong.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errInvalidArgument => 'Invalid input.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errInvalidArgumentHint => 'Check the form values and try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errNotFound => 'Not found.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errNotFoundHint =>
|
||||||
|
'The item you asked for isn\'t here — it may have been removed or never existed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errAlreadyExists => 'Already exists.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errPermissionDenied => 'Permission denied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errPermissionDeniedHint =>
|
||||||
|
'The hub\'s operator policy blocks this action. Check `~/.fai/config.yaml`.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errFailedPrecondition => 'Setup issue blocked this.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errFailedPreconditionHint =>
|
||||||
|
'The hub couldn\'t proceed because something it needs isn\'t ready. The detail below explains what.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errInternal => 'Unexpected internal error.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errInternalHint =>
|
||||||
|
'Check the hub logs at `~/.fai/logs/`. If this repeats, please report it.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errUnavailable => 'Hub not reachable.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errUnavailableHint =>
|
||||||
|
'Make sure `fai serve` (or `fai daemon start`) is running and the daemon endpoint matches Studio\'s settings.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errUnauthenticated => 'Authentication required.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errUnauthenticatedHint =>
|
||||||
|
'Set `FAI_REGISTRY_TOKEN` or sign in through Settings before retrying.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errCopyDetail => 'Copy detail';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errDetailCopied => 'Detail copied to clipboard.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get flowsOutputUnknown => 'Output payload variant not recognised.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get flowsOutputBytesUnknownMime => 'binary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get flowsOutputSaveAs => 'Save as…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get flowsOutputSaveAsTitle => 'Save flow output';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String flowsOutputSavedAt(String path) {
|
||||||
|
return 'Saved to $path';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String flowsOutputSaveFailed(String error) {
|
||||||
|
return 'Save failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get flowsOutputOpen => 'Open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String flowsOutputOpenFailed(String error) {
|
||||||
|
return 'Open failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsTitle => 'Approvals';
|
String get approvalsTitle => 'Approvals';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'data/hub.dart';
|
import 'data/hub.dart';
|
||||||
import 'data/system_actions.dart';
|
import 'data/system_actions.dart';
|
||||||
|
import 'data/theme_plugin.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
import 'pages/approvals.dart';
|
import 'pages/approvals.dart';
|
||||||
import 'pages/audit.dart';
|
import 'pages/audit.dart';
|
||||||
|
|
@ -30,22 +31,33 @@ const String kStudioVersion = '0.42.0';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
// Restore the persisted endpoint, theme mode, and locale
|
// Restore the persisted endpoint, theme mode, locale, and
|
||||||
// before the first frame so none of them flicker on startup.
|
// (when installed) the operator's theme-plugin choice
|
||||||
|
// before the first frame so nothing flickers on startup.
|
||||||
await HubService.instance.loadPersistedEndpoint();
|
await HubService.instance.loadPersistedEndpoint();
|
||||||
final themeMode = await HubService.instance.loadThemeMode();
|
final themeMode = await HubService.instance.loadThemeMode();
|
||||||
final locale = await HubService.instance.loadLocale();
|
final locale = await HubService.instance.loadLocale();
|
||||||
runApp(StudioApp(initialThemeMode: themeMode, initialLocale: locale));
|
final themePlugin = await loadActiveThemePlugin();
|
||||||
|
runApp(StudioApp(
|
||||||
|
initialThemeMode: themeMode,
|
||||||
|
initialLocale: locale,
|
||||||
|
initialThemePlugin: themePlugin,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
class StudioApp extends StatefulWidget {
|
class StudioApp extends StatefulWidget {
|
||||||
final ThemeModeValue initialThemeMode;
|
final ThemeModeValue initialThemeMode;
|
||||||
final Locale initialLocale;
|
final Locale initialLocale;
|
||||||
|
/// Capability name of the active theme plugin (e.g.
|
||||||
|
/// `studio.theme.solarized`) or `null` for the built-in
|
||||||
|
/// FaiTheme.
|
||||||
|
final String? initialThemePlugin;
|
||||||
|
|
||||||
const StudioApp({
|
const StudioApp({
|
||||||
super.key,
|
super.key,
|
||||||
required this.initialThemeMode,
|
required this.initialThemeMode,
|
||||||
required this.initialLocale,
|
required this.initialLocale,
|
||||||
|
this.initialThemePlugin,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Lookup helper so descendants can flip the theme without
|
/// Lookup helper so descendants can flip the theme without
|
||||||
|
|
@ -68,6 +80,12 @@ class StudioAppState extends State<StudioApp> {
|
||||||
/// this, MaterialApp re-renders with the new
|
/// this, MaterialApp re-renders with the new
|
||||||
/// AppLocalizations, and every translated string updates.
|
/// AppLocalizations, and every translated string updates.
|
||||||
late final ValueNotifier<Locale> localeNotifier;
|
late final ValueNotifier<Locale> localeNotifier;
|
||||||
|
/// Capability name of the active `studio.theme.*` plugin
|
||||||
|
/// (e.g. `studio.theme.solarized`) or `null` for
|
||||||
|
/// FaiTheme's built-in palette. Flipping this triggers a
|
||||||
|
/// re-fetch of the plugin's ColorSchemes; the MaterialApp
|
||||||
|
/// rebuilds with the new themes.
|
||||||
|
late final ValueNotifier<String?> themePluginNotifier;
|
||||||
|
|
||||||
Future<void> setMode(ThemeModeValue m) async {
|
Future<void> setMode(ThemeModeValue m) async {
|
||||||
modeNotifier.value = m;
|
modeNotifier.value = m;
|
||||||
|
|
@ -79,17 +97,24 @@ class StudioAppState extends State<StudioApp> {
|
||||||
await HubService.instance.saveLocale(l);
|
await HubService.instance.saveLocale(l);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setThemePlugin(String? capability) async {
|
||||||
|
themePluginNotifier.value = capability;
|
||||||
|
await saveActiveThemePlugin(capability);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
modeNotifier = ValueNotifier(widget.initialThemeMode);
|
modeNotifier = ValueNotifier(widget.initialThemeMode);
|
||||||
localeNotifier = ValueNotifier(widget.initialLocale);
|
localeNotifier = ValueNotifier(widget.initialLocale);
|
||||||
|
themePluginNotifier = ValueNotifier(widget.initialThemePlugin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
modeNotifier.dispose();
|
modeNotifier.dispose();
|
||||||
localeNotifier.dispose();
|
localeNotifier.dispose();
|
||||||
|
themePluginNotifier.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,22 +129,59 @@ class StudioAppState extends State<StudioApp> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build (or return null) ThemeData pair for the active
|
||||||
|
/// theme plugin. Returns immediately with `null` when no
|
||||||
|
/// plugin is selected; otherwise awaits the plugin's
|
||||||
|
/// `theme_for` round-trip for both brightnesses.
|
||||||
|
Future<({ThemeData? light, ThemeData? dark})> _pluginThemes(
|
||||||
|
String? capability,
|
||||||
|
) async {
|
||||||
|
if (capability == null || capability.isEmpty) {
|
||||||
|
return (light: null, dark: null);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final schemes = await loadThemePluginSchemes(capability);
|
||||||
|
return (
|
||||||
|
light: schemes.light == null ? null : themeDataFromScheme(schemes.light!),
|
||||||
|
dark: schemes.dark == null ? null : themeDataFromScheme(schemes.dark!),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Plugin unreachable, hub down, etc. Fall back to
|
||||||
|
// FaiTheme defaults silently — the Settings dialog is
|
||||||
|
// where the operator surfaces / fixes the problem.
|
||||||
|
return (light: null, dark: null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ValueListenableBuilder<ThemeModeValue>(
|
return ValueListenableBuilder<ThemeModeValue>(
|
||||||
valueListenable: modeNotifier,
|
valueListenable: modeNotifier,
|
||||||
builder: (_, mode, _) => ValueListenableBuilder<Locale>(
|
builder: (_, mode, _) => ValueListenableBuilder<Locale>(
|
||||||
valueListenable: localeNotifier,
|
valueListenable: localeNotifier,
|
||||||
builder: (_, locale, _) => MaterialApp(
|
builder: (_, locale, _) => ValueListenableBuilder<String?>(
|
||||||
title: 'F∆I Studio',
|
valueListenable: themePluginNotifier,
|
||||||
debugShowCheckedModeBanner: false,
|
builder: (_, pluginCap, _) => FutureBuilder<({ThemeData? light, ThemeData? dark})>(
|
||||||
theme: FaiTheme.light(),
|
// Re-key on the capability so a plugin change
|
||||||
darkTheme: FaiTheme.dark(),
|
// discards the in-flight future for the previous
|
||||||
themeMode: _flutterMode(mode),
|
// plugin and starts fresh.
|
||||||
locale: locale,
|
key: ValueKey('plugin_themes:$pluginCap'),
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
future: _pluginThemes(pluginCap),
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
builder: (context, snap) {
|
||||||
home: const StudioShell(),
|
final p = snap.data ?? (light: null, dark: null);
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'F∆I Studio',
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: p.light ?? FaiTheme.light(),
|
||||||
|
darkTheme: p.dark ?? FaiTheme.dark(),
|
||||||
|
themeMode: _flutterMode(mode),
|
||||||
|
locale: locale,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
home: const StudioShell(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:fai_dart_sdk/fai_dart_sdk.dart' show FlowInputDef;
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../data/format.dart';
|
||||||
import '../data/hub.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';
|
||||||
|
|
@ -28,18 +29,27 @@ class _FlowsPageState extends State<FlowsPage> {
|
||||||
_future = _load();
|
_future = _load();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pulls the saved flows AND the installed-modules list in
|
/// Pulls the saved flows AND every capability the hub can
|
||||||
/// parallel so the page can compute "is this flow runnable"
|
/// execute (WASM modules + built-ins like `system.approval` +
|
||||||
/// per row from the same data the Run button gates on.
|
/// 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 {
|
Future<_FlowsBundle> _load() async {
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
HubService.instance.listFlows(),
|
HubService.instance.listFlows(),
|
||||||
HubService.instance.listModules().catchError((_) => <ModuleSummary>[]),
|
HubService.instance
|
||||||
|
.allCapabilities()
|
||||||
|
.catchError((_) => <CapabilityInfo>[]),
|
||||||
]);
|
]);
|
||||||
return _FlowsBundle(
|
return _FlowsBundle(
|
||||||
flows: results[0] as List<SavedFlow>,
|
flows: results[0] as List<SavedFlow>,
|
||||||
installedCapabilities: (results[1] as List<ModuleSummary>)
|
installedCapabilities: (results[1] as List<CapabilityInfo>)
|
||||||
.expand((m) => m.capabilities)
|
.map((c) => c.capability)
|
||||||
.toSet(),
|
.toSet(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -74,6 +84,7 @@ class _FlowsPageState extends State<FlowsPage> {
|
||||||
flow: flow,
|
flow: flow,
|
||||||
textInputs: inputs.textInputs,
|
textInputs: inputs.textInputs,
|
||||||
fileInputs: inputs.fileInputs,
|
fileInputs: inputs.fileInputs,
|
||||||
|
fileMimeTypes: inputs.fileMimeTypes,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -254,7 +265,7 @@ class _FlowCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
FaiPill(
|
FaiPill(
|
||||||
label: '${flow.sizeBytes} B',
|
label: humanBytes(flow.sizeBytes),
|
||||||
tone: FaiPillTone.neutral,
|
tone: FaiPillTone.neutral,
|
||||||
monospace: true,
|
monospace: true,
|
||||||
),
|
),
|
||||||
|
|
@ -369,16 +380,75 @@ class _ClickablePill extends StatelessWidget {
|
||||||
class _FlowRunInputs {
|
class _FlowRunInputs {
|
||||||
final Map<String, String> textInputs;
|
final Map<String, String> textInputs;
|
||||||
final Map<String, Uint8List> fileInputs;
|
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({
|
const _FlowRunInputs({
|
||||||
required this.textInputs,
|
required this.textInputs,
|
||||||
required this.fileInputs,
|
required this.fileInputs,
|
||||||
|
required this.fileMimeTypes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PickedFile {
|
class _PickedFile {
|
||||||
final String name;
|
final String name;
|
||||||
final Uint8List bytes;
|
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
|
/// Run-flow input dialog. Fetches the flow's declared input
|
||||||
|
|
@ -457,16 +527,26 @@ class _FlowInputDialogState extends State<_FlowInputDialog> {
|
||||||
_FlowRunInputs _collect(List<FlowInputDef> defs) {
|
_FlowRunInputs _collect(List<FlowInputDef> defs) {
|
||||||
final textInputs = <String, String>{};
|
final textInputs = <String, String>{};
|
||||||
final fileInputs = <String, Uint8List>{};
|
final fileInputs = <String, Uint8List>{};
|
||||||
|
final fileMimeTypes = <String, String>{};
|
||||||
for (final d in defs) {
|
for (final d in defs) {
|
||||||
if (_isBytesType(d.type)) {
|
if (_isBytesType(d.type)) {
|
||||||
final picked = _pickedFiles[d.name];
|
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 {
|
} else {
|
||||||
final c = _textControllers[d.name];
|
final c = _textControllers[d.name];
|
||||||
if (c != null) textInputs[d.name] = c.text;
|
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 {
|
Future<void> _pickFile(String inputName) async {
|
||||||
|
|
@ -490,7 +570,11 @@ class _FlowInputDialogState extends State<_FlowInputDialog> {
|
||||||
}
|
}
|
||||||
if (bytes == null || !mounted) return;
|
if (bytes == null || !mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_pickedFiles[inputName] = _PickedFile(name: f.name, bytes: bytes!);
|
_pickedFiles[inputName] = _PickedFile(
|
||||||
|
name: f.name,
|
||||||
|
bytes: bytes!,
|
||||||
|
mimeType: _mimeForFilename(f.name),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -551,7 +635,7 @@ class _FlowInputDialogState extends State<_FlowInputDialog> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.sm),
|
const SizedBox(height: FaiSpace.sm),
|
||||||
FaiErrorBox(
|
FaiErrorBox(
|
||||||
text: snap.error.toString(),
|
error: snap.error,
|
||||||
isError: true,
|
isError: true,
|
||||||
maxHeight: 200,
|
maxHeight: 200,
|
||||||
),
|
),
|
||||||
|
|
@ -676,7 +760,7 @@ class _InputField extends StatelessWidget {
|
||||||
pickedFile == null
|
pickedFile == null
|
||||||
? l.flowsTypeBytesNoFile
|
? l.flowsTypeBytesNoFile
|
||||||
: '${pickedFile!.name} · '
|
: '${pickedFile!.name} · '
|
||||||
'${l.flowsTypeBytesSize(pickedFile!.bytes.length)}',
|
'${humanBytes(pickedFile!.bytes.length)}',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|
@ -702,11 +786,13 @@ class _FlowRunDialog extends StatefulWidget {
|
||||||
final SavedFlow flow;
|
final SavedFlow flow;
|
||||||
final Map<String, String> textInputs;
|
final Map<String, String> textInputs;
|
||||||
final Map<String, Uint8List> fileInputs;
|
final Map<String, Uint8List> fileInputs;
|
||||||
|
final Map<String, String> fileMimeTypes;
|
||||||
|
|
||||||
const _FlowRunDialog({
|
const _FlowRunDialog({
|
||||||
required this.flow,
|
required this.flow,
|
||||||
required this.textInputs,
|
required this.textInputs,
|
||||||
required this.fileInputs,
|
required this.fileInputs,
|
||||||
|
required this.fileMimeTypes,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -714,7 +800,7 @@ class _FlowRunDialog extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FlowRunDialogState extends State<_FlowRunDialog> {
|
class _FlowRunDialogState extends State<_FlowRunDialog> {
|
||||||
late final Future<Map<String, String>> _future;
|
late final Future<Map<String, FlowOutput>> _future;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -723,6 +809,7 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
|
||||||
name: widget.flow.name,
|
name: widget.flow.name,
|
||||||
textInputs: widget.textInputs,
|
textInputs: widget.textInputs,
|
||||||
fileInputs: widget.fileInputs,
|
fileInputs: widget.fileInputs,
|
||||||
|
fileMimeTypes: widget.fileMimeTypes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -731,13 +818,29 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
return AlertDialog(
|
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(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||||
),
|
),
|
||||||
content: ConstrainedBox(
|
content: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480),
|
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480),
|
||||||
child: FutureBuilder<Map<String, String>>(
|
child: FutureBuilder<Map<String, FlowOutput>>(
|
||||||
future: _future,
|
future: _future,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
|
@ -781,7 +884,7 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.md),
|
const SizedBox(height: FaiSpace.md),
|
||||||
FaiErrorBox(
|
FaiErrorBox(
|
||||||
text: snapshot.error.toString(),
|
error: snapshot.error,
|
||||||
isError: true,
|
isError: true,
|
||||||
maxHeight: 280,
|
maxHeight: 280,
|
||||||
),
|
),
|
||||||
|
|
@ -812,21 +915,7 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
FaiFlowOutput(output: entry.value),
|
||||||
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),
|
const SizedBox(height: FaiSpace.md),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
@ -865,7 +954,10 @@ class _InstallItemState {
|
||||||
|
|
||||||
_InstallStatus status;
|
_InstallStatus status;
|
||||||
String? installedVersion;
|
String? installedVersion;
|
||||||
String? error;
|
/// 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)
|
_InstallItemState(this.spec)
|
||||||
: bareName = _stripVersion(spec),
|
: bareName = _stripVersion(spec),
|
||||||
|
|
@ -923,7 +1015,7 @@ class _InstallDependenciesDialogState
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
item.status = _InstallStatus.failed;
|
item.status = _InstallStatus.failed;
|
||||||
item.error = e.toString();
|
item.error = e;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -950,8 +1042,9 @@ class _InstallDependenciesDialogState
|
||||||
i.status == _InstallStatus.failed && _isAuthWallError(i.error),
|
i.status == _InstallStatus.failed && _isAuthWallError(i.error),
|
||||||
);
|
);
|
||||||
|
|
||||||
static bool _isAuthWallError(String? msg) {
|
static bool _isAuthWallError(Object? err) {
|
||||||
if (msg == null) return false;
|
if (err == null) return false;
|
||||||
|
final msg = err.toString();
|
||||||
return msg.contains('registry returned an HTML page') ||
|
return msg.contains('registry returned an HTML page') ||
|
||||||
msg.contains('no registry token configured');
|
msg.contains('no registry token configured');
|
||||||
}
|
}
|
||||||
|
|
@ -1071,7 +1164,7 @@ class _InstallRow extends StatelessWidget {
|
||||||
item.error != null) ...[
|
item.error != null) ...[
|
||||||
const SizedBox(height: FaiSpace.xs),
|
const SizedBox(height: FaiSpace.xs),
|
||||||
FaiErrorBox(
|
FaiErrorBox(
|
||||||
text: item.error!,
|
error: item.error,
|
||||||
isError: true,
|
isError: true,
|
||||||
maxHeight: 120,
|
maxHeight: 120,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
import '../data/system_actions.dart';
|
import '../data/system_actions.dart';
|
||||||
|
|
@ -36,6 +36,13 @@ class _StorePageState extends State<StorePage> {
|
||||||
/// has no source field.
|
/// has no source field.
|
||||||
String _source = '';
|
String _source = '';
|
||||||
bool _installedOnly = false;
|
bool _installedOnly = false;
|
||||||
|
/// "Show installable modules only". On by default: roughly
|
||||||
|
/// 2/3 of the seed-index entries currently carry status
|
||||||
|
/// `planned` (no bundle to install), and pre-filter polish
|
||||||
|
/// found that operators couldn't tell why they couldn't
|
||||||
|
/// install half the cards. Planned modules are one chip-flip
|
||||||
|
/// away in the filter dialog.
|
||||||
|
bool _installableOnly = true;
|
||||||
/// Tracks per-recommended-source add buttons to disable them
|
/// Tracks per-recommended-source add buttons to disable them
|
||||||
/// while the request is in flight.
|
/// while the request is in flight.
|
||||||
final Set<String> _addingSources = <String>{};
|
final Set<String> _addingSources = <String>{};
|
||||||
|
|
@ -136,7 +143,19 @@ class _StorePageState extends State<StorePage> {
|
||||||
final all = results[0] as List<StoreItem>;
|
final all = results[0] as List<StoreItem>;
|
||||||
final mods = results[1] as List<ModuleSummary>;
|
final mods = results[1] as List<ModuleSummary>;
|
||||||
_installedVersions = {for (final m in mods) m.name: m.version};
|
_installedVersions = {for (final m in mods) m.name: m.version};
|
||||||
return _installedOnly ? all.where((e) => e.installed).toList() : all;
|
var out = all;
|
||||||
|
if (_installedOnly) {
|
||||||
|
out = out.where((e) => e.installed).toList();
|
||||||
|
}
|
||||||
|
if (_installableOnly) {
|
||||||
|
// An already-installed item counts as "installable" for
|
||||||
|
// this filter — the user can still update / uninstall it.
|
||||||
|
out = out
|
||||||
|
.where((e) =>
|
||||||
|
e.installed || e.status == 'published' || e.status == 'alpha')
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _runSearch() {
|
void _runSearch() {
|
||||||
|
|
@ -261,6 +280,7 @@ class _StorePageState extends State<StorePage> {
|
||||||
_status = '';
|
_status = '';
|
||||||
_source = '';
|
_source = '';
|
||||||
_installedOnly = false;
|
_installedOnly = false;
|
||||||
|
_installableOnly = false;
|
||||||
_queryCtrl.clear();
|
_queryCtrl.clear();
|
||||||
});
|
});
|
||||||
_runSearch();
|
_runSearch();
|
||||||
|
|
@ -440,6 +460,10 @@ class _StorePageState extends State<StorePage> {
|
||||||
if (_status.isNotEmpty) n++;
|
if (_status.isNotEmpty) n++;
|
||||||
if (_source.isNotEmpty) n++;
|
if (_source.isNotEmpty) n++;
|
||||||
if (_installedOnly) n++;
|
if (_installedOnly) n++;
|
||||||
|
// `installableOnly` defaults to ON, so we only highlight it
|
||||||
|
// as an "active filter" when the operator turned it OFF —
|
||||||
|
// the surprising state is the one worth flagging.
|
||||||
|
if (!_installableOnly) n++;
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -557,6 +581,7 @@ class _StorePageState extends State<StorePage> {
|
||||||
status: _status,
|
status: _status,
|
||||||
source: _source,
|
source: _source,
|
||||||
installedOnly: _installedOnly,
|
installedOnly: _installedOnly,
|
||||||
|
installableOnly: _installableOnly,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (outcome == null) return;
|
if (outcome == null) return;
|
||||||
|
|
@ -564,6 +589,7 @@ class _StorePageState extends State<StorePage> {
|
||||||
_status = outcome.status;
|
_status = outcome.status;
|
||||||
_source = outcome.source;
|
_source = outcome.source;
|
||||||
_installedOnly = outcome.installedOnly;
|
_installedOnly = outcome.installedOnly;
|
||||||
|
_installableOnly = outcome.installableOnly;
|
||||||
});
|
});
|
||||||
_runSearch();
|
_runSearch();
|
||||||
}
|
}
|
||||||
|
|
@ -892,10 +918,12 @@ class _FilterOutcome {
|
||||||
final String status;
|
final String status;
|
||||||
final String source;
|
final String source;
|
||||||
final bool installedOnly;
|
final bool installedOnly;
|
||||||
|
final bool installableOnly;
|
||||||
const _FilterOutcome({
|
const _FilterOutcome({
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.source,
|
required this.source,
|
||||||
required this.installedOnly,
|
required this.installedOnly,
|
||||||
|
required this.installableOnly,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -908,11 +936,13 @@ class _FilterDialog extends StatefulWidget {
|
||||||
final String status;
|
final String status;
|
||||||
final String source;
|
final String source;
|
||||||
final bool installedOnly;
|
final bool installedOnly;
|
||||||
|
final bool installableOnly;
|
||||||
|
|
||||||
const _FilterDialog({
|
const _FilterDialog({
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.source,
|
required this.source,
|
||||||
required this.installedOnly,
|
required this.installedOnly,
|
||||||
|
required this.installableOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -923,6 +953,7 @@ class _FilterDialogState extends State<_FilterDialog> {
|
||||||
late String _status = widget.status;
|
late String _status = widget.status;
|
||||||
late String _source = widget.source;
|
late String _source = widget.source;
|
||||||
late bool _installedOnly = widget.installedOnly;
|
late bool _installedOnly = widget.installedOnly;
|
||||||
|
late bool _installableOnly = widget.installableOnly;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -989,11 +1020,23 @@ class _FilterDialogState extends State<_FilterDialog> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.lg),
|
const SizedBox(height: FaiSpace.lg),
|
||||||
header(l.storeFilterInstalledGroup),
|
header(l.storeFilterInstalledGroup),
|
||||||
FilterChip(
|
Wrap(
|
||||||
label: Text(l.storeInstalledOnly),
|
spacing: FaiSpace.xs,
|
||||||
selected: _installedOnly,
|
runSpacing: FaiSpace.xs,
|
||||||
onSelected: (v) => setState(() => _installedOnly = v),
|
children: [
|
||||||
showCheckmark: true,
|
FilterChip(
|
||||||
|
label: Text(l.storeInstallableOnly),
|
||||||
|
selected: _installableOnly,
|
||||||
|
onSelected: (v) => setState(() => _installableOnly = v),
|
||||||
|
showCheckmark: true,
|
||||||
|
),
|
||||||
|
FilterChip(
|
||||||
|
label: Text(l.storeInstalledOnly),
|
||||||
|
selected: _installedOnly,
|
||||||
|
onSelected: (v) => setState(() => _installedOnly = v),
|
||||||
|
showCheckmark: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -1005,6 +1048,11 @@ class _FilterDialogState extends State<_FilterDialog> {
|
||||||
_status = '';
|
_status = '';
|
||||||
_source = '';
|
_source = '';
|
||||||
_installedOnly = false;
|
_installedOnly = false;
|
||||||
|
// Reset puts `installableOnly` back to its default
|
||||||
|
// (on) — the operator hits Reset to escape a
|
||||||
|
// custom-filter state and expects the default view
|
||||||
|
// back, not an everything-including-planned view.
|
||||||
|
_installableOnly = true;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: Text(l.storeFilterReset),
|
child: Text(l.storeFilterReset),
|
||||||
|
|
@ -1021,6 +1069,7 @@ class _FilterDialogState extends State<_FilterDialog> {
|
||||||
status: _status,
|
status: _status,
|
||||||
source: _source,
|
source: _source,
|
||||||
installedOnly: _installedOnly,
|
installedOnly: _installedOnly,
|
||||||
|
installableOnly: _installableOnly,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(l.storeFilterApply),
|
child: Text(l.storeFilterApply),
|
||||||
|
|
@ -1566,11 +1615,29 @@ class _FeaturedTile extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.md),
|
const SizedBox(height: FaiSpace.md),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
tagline.isEmpty ? l.storeNoTagline : tagline,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: theme.textTheme.bodyLarge,
|
children: [
|
||||||
maxLines: 3,
|
if (item.isFederated)
|
||||||
overflow: TextOverflow.ellipsis,
|
// Federated items get tagline copy straight
|
||||||
|
// from the upstream MCP / n8n server, which
|
||||||
|
// is English regardless of Studio's locale.
|
||||||
|
// The [EN] pill is the honest signal until
|
||||||
|
// the planned `studio.translate` plugin
|
||||||
|
// rewrites it on the fly.
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: FaiEnBadge.forContext(context),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
tagline.isEmpty ? l.storeNoTagline : tagline,
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.sm),
|
const SizedBox(height: FaiSpace.sm),
|
||||||
|
|
@ -1600,6 +1667,16 @@ class _FeaturedTile extends StatelessWidget {
|
||||||
onPressed: onInstall,
|
onPressed: onInstall,
|
||||||
icon: const Icon(Icons.download, size: 16),
|
icon: const Icon(Icons.download, size: 16),
|
||||||
label: Text(l.buttonInstall),
|
label: Text(l.buttonInstall),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
// Status is `planned` (or another non-installable
|
||||||
|
// wire value). Without an explicit pill here the
|
||||||
|
// featured tile would just go silent, leaving the
|
||||||
|
// operator wondering why the card has no action.
|
||||||
|
FaiPill(
|
||||||
|
label: l.storePillComingSoon,
|
||||||
|
tone: FaiPillTone.neutral,
|
||||||
|
icon: Icons.schedule,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -1647,7 +1724,14 @@ class _StoreCard extends StatelessWidget {
|
||||||
? item.taglineDe
|
? item.taglineDe
|
||||||
: item.taglineEn;
|
: item.taglineEn;
|
||||||
final installable = item.status == 'published' || item.status == 'alpha';
|
final installable = item.status == 'published' || item.status == 'alpha';
|
||||||
return Material(
|
// Dim planned/non-installable cards so the eye lands on
|
||||||
|
// actionable cards first. We don't disable interaction — the
|
||||||
|
// detail sheet still opens — but the visual hierarchy is
|
||||||
|
// honest about what the operator can act on.
|
||||||
|
final notInstallable = !installable && !item.installed;
|
||||||
|
return Opacity(
|
||||||
|
opacity: notInstallable ? 0.6 : 1.0,
|
||||||
|
child: Material(
|
||||||
color: theme.colorScheme.surfaceContainer,
|
color: theme.colorScheme.surfaceContainer,
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
|
|
@ -1782,6 +1866,7 @@ class _StoreCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1823,8 +1908,13 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
String get _locale => Localizations.localeOf(context).languageCode;
|
String get _locale => Localizations.localeOf(context).languageCode;
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
String? _toast;
|
String? _toast;
|
||||||
Future<({String errorKind, String text, String sourceUrl})>? _docsFuture;
|
/// `null` while the initial probe is in flight; non-null once
|
||||||
bool _docsLoaded = false;
|
/// `readInstalledModuleDocs` has returned. The probe is cheap —
|
||||||
|
/// a single file stat on disk — so we run it eagerly when the
|
||||||
|
/// sheet opens for an installed module instead of gating it
|
||||||
|
/// behind a "Load docs" click.
|
||||||
|
({String state, String text, String sourcePath})? _docsResult;
|
||||||
|
bool _docsProbed = false;
|
||||||
/// Live module-info for installed entries: declared
|
/// Live module-info for installed entries: declared
|
||||||
/// permissions and on-disk directory. Pulled lazily so
|
/// permissions and on-disk directory. Pulled lazily so
|
||||||
/// not-installed entries don't run an unnecessary RPC. Stays
|
/// not-installed entries don't run an unnecessary RPC. Stays
|
||||||
|
|
@ -1840,6 +1930,32 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
// Locale is read off `Localizations`, which is only ready in
|
||||||
|
// didChangeDependencies, not initState.
|
||||||
|
if (widget.item.installed && !_docsProbed) {
|
||||||
|
_docsProbed = true;
|
||||||
|
_probeInstalledDocs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _probeInstalledDocs() async {
|
||||||
|
final locale = Localizations.localeOf(context).languageCode;
|
||||||
|
try {
|
||||||
|
final r = await HubService.instance
|
||||||
|
.readInstalledModuleDocs(widget.item.name, locale: locale);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _docsResult = r);
|
||||||
|
} catch (_) {
|
||||||
|
// Treat probe failures as "no docs available" — the section
|
||||||
|
// simply hides, the rest of the sheet still works.
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _docsResult = (state: 'no_docs', text: '', sourcePath: ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadModuleDetail() async {
|
Future<void> _loadModuleDetail() async {
|
||||||
try {
|
try {
|
||||||
final detail =
|
final detail =
|
||||||
|
|
@ -1934,19 +2050,6 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadDocs() {
|
|
||||||
if (_docsLoaded) return;
|
|
||||||
// Sprache vom aktuellen Studio-Locale ans Hub weitergeben,
|
|
||||||
// damit `README.de.md` zuerst probiert wird wenn Studio auf
|
|
||||||
// Deutsch steht.
|
|
||||||
final locale = Localizations.localeOf(context).languageCode;
|
|
||||||
setState(() {
|
|
||||||
_docsLoaded = true;
|
|
||||||
_docsFuture =
|
|
||||||
HubService.instance.fetchModuleDocs(widget.item.name, locale: locale);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -2063,6 +2166,15 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
const SizedBox(height: FaiSpace.lg),
|
const SizedBox(height: FaiSpace.lg),
|
||||||
],
|
],
|
||||||
if (description.isNotEmpty) ...[
|
if (description.isNotEmpty) ...[
|
||||||
|
if (item.isFederated) ...[
|
||||||
|
// Description copy was supplied by the
|
||||||
|
// upstream server, not by Studio's
|
||||||
|
// localisation pipeline — flag it.
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
|
child: FaiEnBadge.forContext(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
SelectableText(
|
SelectableText(
|
||||||
description,
|
description,
|
||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
|
|
@ -2191,15 +2303,13 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.lg),
|
const SizedBox(height: FaiSpace.lg),
|
||||||
],
|
],
|
||||||
if (item.repository.isNotEmpty || item.docsUrl.isNotEmpty) ...[
|
if (_docsResult?.state == 'found') ...[
|
||||||
_SectionHeader(l.storeSectionDocumentation),
|
_SectionHeader(l.storeSectionDocumentation),
|
||||||
const SizedBox(height: FaiSpace.sm),
|
const SizedBox(height: FaiSpace.sm),
|
||||||
_DocsPanel(
|
_DocsPanel(text: _docsResult!.text),
|
||||||
future: _docsFuture,
|
|
||||||
onLoad: _loadDocs,
|
|
||||||
onOpenRepo: _openRepository,
|
|
||||||
),
|
|
||||||
const SizedBox(height: FaiSpace.lg),
|
const SizedBox(height: FaiSpace.lg),
|
||||||
|
],
|
||||||
|
if (item.repository.isNotEmpty) ...[
|
||||||
_SectionHeader(l.storeSectionSource),
|
_SectionHeader(l.storeSectionSource),
|
||||||
const SizedBox(height: FaiSpace.sm),
|
const SizedBox(height: FaiSpace.sm),
|
||||||
InkWell(
|
InkWell(
|
||||||
|
|
@ -2242,12 +2352,6 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
],
|
],
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (item.repository.isNotEmpty)
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _openRepository,
|
|
||||||
icon: const Icon(Icons.menu_book_outlined, size: 16),
|
|
||||||
label: Text(l.buttonReadDocs),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (item.installed) ...[
|
if (item.installed) ...[
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
|
|
@ -2284,14 +2388,46 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
_busy ? l.storeInstallingButton : l.buttonInstall,
|
_busy ? l.storeInstallingButton : l.buttonInstall,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else ...[
|
||||||
Tooltip(
|
// Inline hint replaces the previous
|
||||||
message: l.storeNotInstallableTooltip(item.status),
|
// tooltip-only affordance: the operator
|
||||||
child: FilledButton(
|
// sees the reason without having to hover.
|
||||||
onPressed: null,
|
Expanded(
|
||||||
child: Text(l.storeNotInstallable),
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: FaiSpace.md,
|
||||||
|
vertical: FaiSpace.sm,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.schedule,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: FaiSpace.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l.storeNotInstallableInline(item.status),
|
||||||
|
style:
|
||||||
|
theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color:
|
||||||
|
theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -2385,12 +2521,10 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> {
|
||||||
size: 32,
|
size: 32,
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.md),
|
const SizedBox(height: FaiSpace.md),
|
||||||
SelectableText(
|
FaiErrorBox(
|
||||||
snap.error.toString(),
|
error: snap.error,
|
||||||
style: FaiTheme.mono(
|
isError: true,
|
||||||
size: 11,
|
maxHeight: 200,
|
||||||
color: theme.colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -2462,210 +2596,40 @@ IconData _iconForCategory(String category) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lazy-loaded inline README renderer. Shows a "Load
|
/// Renders the module's inline `MODULE.md` (or its localized
|
||||||
/// documentation" button until the operator clicks it (so we
|
/// sibling) in the same theme as the rest of Studio. The markdown
|
||||||
/// don't blow the network unprompted), then a markdown view of
|
/// body is supplied by the parent — `_DocsPanel` itself stays a
|
||||||
/// the result. Auth / not-found errors come back with friendly
|
/// pure renderer with no async / no error handling.
|
||||||
/// fix-hint copy and a fallback "View on repository" button.
|
///
|
||||||
|
/// Blockquote + table-cell decoration is overridden because
|
||||||
|
/// flutter_markdown's default styles hardcode
|
||||||
|
/// `Colors.blue.shade100`, which is unreadable on a dark theme.
|
||||||
class _DocsPanel extends StatelessWidget {
|
class _DocsPanel extends StatelessWidget {
|
||||||
final Future<({String errorKind, String text, String sourceUrl})>? future;
|
final String text;
|
||||||
final VoidCallback onLoad;
|
|
||||||
final VoidCallback onOpenRepo;
|
|
||||||
|
|
||||||
const _DocsPanel({
|
const _DocsPanel({required this.text});
|
||||||
required this.future,
|
|
||||||
required this.onLoad,
|
|
||||||
required this.onOpenRepo,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final l = AppLocalizations.of(context)!;
|
|
||||||
if (future == null) {
|
|
||||||
// Row im Modul-Detail-Sheet kann eng werden (~340 dp).
|
|
||||||
// Hint-Text wird in Expanded gewrappt damit er bricht
|
|
||||||
// statt zu overflowen (CrossAxisAlignment.center hält die
|
|
||||||
// Optik mit dem Button auf einer Höhe).
|
|
||||||
return Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: onLoad,
|
|
||||||
icon: const Icon(Icons.menu_book_outlined, size: 16),
|
|
||||||
label: Text(l.storeLoadDocs),
|
|
||||||
),
|
|
||||||
const SizedBox(width: FaiSpace.sm),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
l.storeDocsHint,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return FutureBuilder<({String errorKind, String text, String sourceUrl})>(
|
|
||||||
future: future,
|
|
||||||
builder: (context, snap) {
|
|
||||||
if (snap.connectionState == ConnectionState.waiting) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.md),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const SizedBox(
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
const SizedBox(width: FaiSpace.sm),
|
|
||||||
Text(l.storeDocsFetching),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (snap.hasError) {
|
|
||||||
return _DocsErrorPanel(
|
|
||||||
message: snap.error.toString(),
|
|
||||||
onOpenRepo: onOpenRepo,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final r = snap.data!;
|
|
||||||
if (r.errorKind.isNotEmpty) {
|
|
||||||
return _DocsErrorPanel(
|
|
||||||
message: _friendlyDocsError(context, r.errorKind, r.text),
|
|
||||||
onOpenRepo: onOpenRepo,
|
|
||||||
sourceUrl: r.sourceUrl,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
constraints: const BoxConstraints(maxHeight: 480),
|
|
||||||
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: Markdown(
|
|
||||||
data: r.text,
|
|
||||||
shrinkWrap: true,
|
|
||||||
selectable: true,
|
|
||||||
onTapLink: (text, href, title) async {
|
|
||||||
if (href == null || href.isEmpty) return;
|
|
||||||
await SystemActions.openInOs(href);
|
|
||||||
},
|
|
||||||
styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith(
|
|
||||||
p: theme.textTheme.bodyMedium,
|
|
||||||
code: FaiTheme.mono(
|
|
||||||
size: 12,
|
|
||||||
color: theme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
codeblockDecoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surfaceContainer,
|
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
||||||
),
|
|
||||||
// flutter_markdown default-blockquote nutzt
|
|
||||||
// hardcodiertes `Colors.blue.shade100` als
|
|
||||||
// Background, was im Dark-Theme mit weißem Text
|
|
||||||
// unlesbar wird. Wir setzen Theme-konsistente
|
|
||||||
// Werte: surfaceContainer-Background + linke
|
|
||||||
// Akzent-Border, Text in onSurface.
|
|
||||||
blockquote: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
blockquoteDecoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surfaceContainer,
|
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
||||||
border: Border(
|
|
||||||
left: BorderSide(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
width: 3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
blockquotePadding: const EdgeInsets.fromLTRB(
|
|
||||||
FaiSpace.md,
|
|
||||||
FaiSpace.sm,
|
|
||||||
FaiSpace.sm,
|
|
||||||
FaiSpace.sm,
|
|
||||||
),
|
|
||||||
// Tabellen-Cells haben dasselbe Default-Problem
|
|
||||||
// (Material-2-Light-Annahme). Theme-Werte erzwingen.
|
|
||||||
tableCellsDecoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surfaceContainerHigh,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DocsErrorPanel extends StatelessWidget {
|
|
||||||
final String message;
|
|
||||||
final VoidCallback onOpenRepo;
|
|
||||||
final String? sourceUrl;
|
|
||||||
|
|
||||||
const _DocsErrorPanel({
|
|
||||||
required this.message,
|
|
||||||
required this.onOpenRepo,
|
|
||||||
this.sourceUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
constraints: const BoxConstraints(maxHeight: 480),
|
||||||
padding: const EdgeInsets.all(FaiSpace.md),
|
padding: const EdgeInsets.all(FaiSpace.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.surfaceContainerHigh,
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Markdown(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
data: text,
|
||||||
children: [
|
shrinkWrap: true,
|
||||||
Row(
|
selectable: true,
|
||||||
children: [
|
onTapLink: (text, href, title) async {
|
||||||
Icon(
|
if (href == null || href.isEmpty) return;
|
||||||
Icons.info_outline,
|
await SystemActions.openInOs(href);
|
||||||
size: 16,
|
},
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
styleSheet: FaiTheme.markdownStyle(theme),
|
||||||
),
|
|
||||||
const SizedBox(width: FaiSpace.sm),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
message,
|
|
||||||
style: theme.textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (sourceUrl != null && sourceUrl!.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
SelectableText(
|
|
||||||
sourceUrl!,
|
|
||||||
style: FaiTheme.mono(
|
|
||||||
size: 10,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: FaiSpace.sm),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: onOpenRepo,
|
|
||||||
icon: const Icon(Icons.open_in_new, size: 14),
|
|
||||||
label: Text(AppLocalizations.of(context)!.storeOpenRepoButton),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2697,24 +2661,6 @@ int _versionCmp(String a, String b) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
String _friendlyDocsError(BuildContext context, String kind, String detail) {
|
|
||||||
final l = AppLocalizations.of(context)!;
|
|
||||||
switch (kind) {
|
|
||||||
case 'not_found':
|
|
||||||
return l.storeDocsErrorNotFound;
|
|
||||||
case 'no_docs':
|
|
||||||
return l.storeDocsErrorNoDocs;
|
|
||||||
case 'auth':
|
|
||||||
return l.storeDocsErrorAuth;
|
|
||||||
case 'network':
|
|
||||||
return l.storeDocsErrorNetwork(detail);
|
|
||||||
case 'parse':
|
|
||||||
return l.storeDocsErrorParse;
|
|
||||||
default:
|
|
||||||
return detail.isEmpty ? l.storeDocsErrorGeneric : detail;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Module icon: renders the explicit icon URL when set, falls
|
/// Module icon: renders the explicit icon URL when set, falls
|
||||||
/// back to the category-derived `Icons.X` glyph in a colored
|
/// back to the category-derived `Icons.X` glyph in a colored
|
||||||
/// circle when the URL is empty or fails to load. Wraps the
|
/// circle when the URL is empty or fails to load. Wraps the
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart' show rootBundle;
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
|
|
@ -1041,7 +1041,7 @@ class _DocReaderSheetState extends State<_DocReaderSheet> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.sm),
|
const SizedBox(height: FaiSpace.sm),
|
||||||
FaiErrorBox(
|
FaiErrorBox(
|
||||||
text: snap.error.toString(),
|
error: snap.error,
|
||||||
isError: true,
|
isError: true,
|
||||||
maxHeight: 200,
|
maxHeight: 200,
|
||||||
),
|
),
|
||||||
|
|
@ -1058,17 +1058,7 @@ class _DocReaderSheetState extends State<_DocReaderSheet> {
|
||||||
await SystemActions.openInOs(href);
|
await SystemActions.openInOs(href);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith(
|
styleSheet: FaiTheme.markdownStyle(theme),
|
||||||
p: theme.textTheme.bodyMedium?.copyWith(height: 1.5),
|
|
||||||
code: FaiTheme.mono(
|
|
||||||
size: 12,
|
|
||||||
color: theme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
codeblockDecoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surfaceContainerHigh,
|
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Theme assembly. ColorScheme + typography + component themes.
|
// Theme assembly. ColorScheme + typography + component themes.
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
import 'tokens.dart';
|
import 'tokens.dart';
|
||||||
|
|
@ -77,6 +78,69 @@ class FaiTheme {
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Shared markdown style used by every inline doc renderer
|
||||||
|
/// (Welcome DocReaderSheet, Store module-detail DocsPanel,
|
||||||
|
/// future help surfaces). Centralising it means every doc
|
||||||
|
/// reads in the same typography, with the same dark-theme
|
||||||
|
/// safe colors.
|
||||||
|
///
|
||||||
|
/// Why every property is set explicitly even when it just
|
||||||
|
/// re-states the default:
|
||||||
|
///
|
||||||
|
/// - `flutter_markdown`'s `MarkdownStyleSheet.fromTheme(theme)`
|
||||||
|
/// sets `code` with `backgroundColor: theme.cardTheme.color`.
|
||||||
|
/// That per-span backgroundColor is rendered behind every
|
||||||
|
/// character inside a fenced code block, which on a dark
|
||||||
|
/// theme shows up as horizontal bands behind ASCII diagrams.
|
||||||
|
/// We force `backgroundColor: Colors.transparent` to suppress
|
||||||
|
/// it — the codeblock's own decoration provides the panel
|
||||||
|
/// background.
|
||||||
|
/// - `blockquote` defaults to a hardcoded `Colors.blue.shade100`
|
||||||
|
/// background that is unreadable in dark mode. We replace it
|
||||||
|
/// with theme-aware tokens (surfaceContainer + left accent
|
||||||
|
/// border).
|
||||||
|
/// - `tableCellsDecoration` has the same dark-mode bug, fixed
|
||||||
|
/// the same way.
|
||||||
|
static MarkdownStyleSheet markdownStyle(ThemeData theme) {
|
||||||
|
return MarkdownStyleSheet.fromTheme(theme).copyWith(
|
||||||
|
p: theme.textTheme.bodyMedium?.copyWith(height: 1.5),
|
||||||
|
code: mono(
|
||||||
|
size: 12,
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
).copyWith(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
codeblockDecoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||||
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
codeblockPadding: const EdgeInsets.all(FaiSpace.md),
|
||||||
|
blockquote: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
blockquoteDecoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||||
|
border: Border(
|
||||||
|
left: BorderSide(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
width: 3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
blockquotePadding: const EdgeInsets.fromLTRB(
|
||||||
|
FaiSpace.md,
|
||||||
|
FaiSpace.sm,
|
||||||
|
FaiSpace.sm,
|
||||||
|
FaiSpace.sm,
|
||||||
|
),
|
||||||
|
tableCellsDecoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static ThemeData dark() {
|
static ThemeData dark() {
|
||||||
final scheme = ColorScheme(
|
final scheme = ColorScheme(
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
|
|
|
||||||
61
lib/widgets/fai_en_badge.dart
Normal file
61
lib/widgets/fai_en_badge.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// FaiEnBadge — small "[EN]" pill shown next to text that came
|
||||||
|
// from outside Studio's localization pipeline (MCP server tool
|
||||||
|
// names, n8n endpoint descriptions, native LLM responses).
|
||||||
|
//
|
||||||
|
// The honest move: when the active locale isn't English and we
|
||||||
|
// show a piece of text that we know is English, mark it so the
|
||||||
|
// operator doesn't blame Studio for a half-translated page.
|
||||||
|
// `studio.translate` plugin will eventually rewrite these in
|
||||||
|
// place — until then, the badge is the right amount of
|
||||||
|
// transparency.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../theme/tokens.dart';
|
||||||
|
|
||||||
|
class FaiEnBadge extends StatelessWidget {
|
||||||
|
/// When true, the badge renders. When false (e.g. the active
|
||||||
|
/// locale already is English), it returns
|
||||||
|
/// `SizedBox.shrink()` so callers can drop it inline without
|
||||||
|
/// guarding visibility themselves.
|
||||||
|
final bool visible;
|
||||||
|
|
||||||
|
const FaiEnBadge({super.key, required this.visible});
|
||||||
|
|
||||||
|
/// Convenience constructor: derives `visible` from the active
|
||||||
|
/// Localizations locale. Use this from inside a Build method
|
||||||
|
/// where you already have a BuildContext.
|
||||||
|
factory FaiEnBadge.forContext(BuildContext context) {
|
||||||
|
final lang = Localizations.localeOf(context).languageCode;
|
||||||
|
return FaiEnBadge(visible: lang != 'en');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!visible) return const SizedBox.shrink();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
return Tooltip(
|
||||||
|
message: l.mcpServerEnLanguageBadge,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'EN',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
fontSize: 9,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,13 +10,15 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../data/friendly_error.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/theme.dart';
|
import '../theme/theme.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
|
||||||
class FaiErrorBox extends StatefulWidget {
|
class FaiErrorBox extends StatefulWidget {
|
||||||
/// The text the operator wants to read (and copy). Rendered
|
/// The text the operator wants to read (and copy). Rendered
|
||||||
/// monospace, selectable, multi-line.
|
/// monospace, selectable, multi-line. Ignored when [error] is
|
||||||
|
/// supplied.
|
||||||
final String text;
|
final String text;
|
||||||
/// When true, the box border + foreground colour come from the
|
/// When true, the box border + foreground colour come from the
|
||||||
/// error palette. False renders neutral chrome — useful for
|
/// error palette. False renders neutral chrome — useful for
|
||||||
|
|
@ -27,13 +29,25 @@ class FaiErrorBox extends StatefulWidget {
|
||||||
/// box. Falls back to no constraint when null so short
|
/// box. Falls back to no constraint when null so short
|
||||||
/// messages don't get a useless scrollbar.
|
/// messages don't get a useless scrollbar.
|
||||||
final double? maxHeight;
|
final double? maxHeight;
|
||||||
|
/// When non-null, the box renders the [friendlyError] mapping
|
||||||
|
/// of this error: a one-line headline, a recovery-hint line
|
||||||
|
/// (when one applies), and the verbatim original message
|
||||||
|
/// collapsed behind a "Details" expander. Use this instead of
|
||||||
|
/// [text] anywhere we'd otherwise show a raw
|
||||||
|
/// `e.toString()` — gRPC-Errors come out as walls of code +
|
||||||
|
/// trailers otherwise.
|
||||||
|
final Object? error;
|
||||||
|
|
||||||
const FaiErrorBox({
|
const FaiErrorBox({
|
||||||
super.key,
|
super.key,
|
||||||
required this.text,
|
this.text = '',
|
||||||
this.isError = false,
|
this.isError = false,
|
||||||
this.maxHeight,
|
this.maxHeight,
|
||||||
});
|
this.error,
|
||||||
|
}) : assert(
|
||||||
|
text != '' || error != null,
|
||||||
|
'FaiErrorBox needs either text or error',
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FaiErrorBox> createState() => _FaiErrorBoxState();
|
State<FaiErrorBox> createState() => _FaiErrorBoxState();
|
||||||
|
|
@ -41,9 +55,10 @@ class FaiErrorBox extends StatefulWidget {
|
||||||
|
|
||||||
class _FaiErrorBoxState extends State<FaiErrorBox> {
|
class _FaiErrorBoxState extends State<FaiErrorBox> {
|
||||||
bool _justCopied = false;
|
bool _justCopied = false;
|
||||||
|
bool _detailExpanded = false;
|
||||||
|
|
||||||
Future<void> _copy() async {
|
Future<void> _copy(String what) async {
|
||||||
await Clipboard.setData(ClipboardData(text: widget.text));
|
await Clipboard.setData(ClipboardData(text: what));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _justCopied = true);
|
setState(() => _justCopied = true);
|
||||||
Future.delayed(const Duration(seconds: 2), () {
|
Future.delayed(const Duration(seconds: 2), () {
|
||||||
|
|
@ -61,6 +76,10 @@ class _FaiErrorBoxState extends State<FaiErrorBox> {
|
||||||
final fg = widget.isError
|
final fg = widget.isError
|
||||||
? theme.colorScheme.error
|
? theme.colorScheme.error
|
||||||
: theme.colorScheme.onSurface;
|
: theme.colorScheme.onSurface;
|
||||||
|
final friendly = widget.error == null
|
||||||
|
? null
|
||||||
|
: friendlyError(widget.error!, l);
|
||||||
|
final copyText = friendly?.detail ?? widget.text;
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
|
@ -75,37 +94,101 @@ class _FaiErrorBoxState extends State<FaiErrorBox> {
|
||||||
border: Border.all(color: accent.withValues(alpha: 0.4)),
|
border: Border.all(color: accent.withValues(alpha: 0.4)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Copy button sits above the text on its own row so
|
Align(
|
||||||
// long messages never collide with it. Compact enough
|
alignment: Alignment.centerRight,
|
||||||
// not to dominate short single-line messages.
|
child: Tooltip(
|
||||||
Tooltip(
|
message: _justCopied ? l.buttonCopied : l.buttonCopy,
|
||||||
message: _justCopied ? l.buttonCopied : l.buttonCopy,
|
child: IconButton(
|
||||||
child: IconButton(
|
icon: Icon(
|
||||||
icon: Icon(
|
_justCopied ? Icons.check : Icons.content_copy,
|
||||||
_justCopied ? Icons.check : Icons.content_copy,
|
size: 14,
|
||||||
size: 14,
|
),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
onPressed: () => _copy(copyText),
|
||||||
),
|
),
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
constraints: const BoxConstraints(),
|
|
||||||
onPressed: _copy,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ConstrainedBox(
|
if (friendly != null) ...[
|
||||||
constraints: BoxConstraints(
|
SelectableText(
|
||||||
maxHeight: widget.maxHeight ?? double.infinity,
|
friendly.headline,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: fg,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Scrollbar(
|
if (friendly.hint != null) ...[
|
||||||
child: SingleChildScrollView(
|
const SizedBox(height: 4),
|
||||||
child: SelectableText(
|
Text(
|
||||||
widget.text,
|
friendly.hint!,
|
||||||
style: FaiTheme.mono(size: 11, color: fg),
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (friendly.detail.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: FaiSpace.sm),
|
||||||
|
InkWell(
|
||||||
|
onTap: () => setState(() {
|
||||||
|
_detailExpanded = !_detailExpanded;
|
||||||
|
}),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_detailExpanded
|
||||||
|
? Icons.expand_less
|
||||||
|
: Icons.expand_more,
|
||||||
|
size: 14,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_detailExpanded ? l.buttonHideDetails : l.buttonShowDetails,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_detailExpanded) ...[
|
||||||
|
const SizedBox(height: FaiSpace.xs),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: widget.maxHeight ?? double.infinity,
|
||||||
|
),
|
||||||
|
child: Scrollbar(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: SelectableText(
|
||||||
|
friendly.detail,
|
||||||
|
style: FaiTheme.mono(
|
||||||
|
size: 11,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
] else
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: widget.maxHeight ?? double.infinity,
|
||||||
|
),
|
||||||
|
child: Scrollbar(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: SelectableText(
|
||||||
|
widget.text,
|
||||||
|
style: FaiTheme.mono(size: 11, color: fg),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
288
lib/widgets/fai_flow_output.dart
Normal file
288
lib/widgets/fai_flow_output.dart
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
// FaiFlowOutput — type-aware renderer for one entry in
|
||||||
|
// `runSavedFlow`'s output map. Reads `FlowOutput` and dispatches
|
||||||
|
// to the right widget:
|
||||||
|
//
|
||||||
|
// - text: SelectableText for plain text; Markdown widget
|
||||||
|
// when the (hinted) MIME is text/markdown.
|
||||||
|
// - json: indented JSON in a code-block container.
|
||||||
|
// - bytes: inline image thumbnail when image/*, otherwise a
|
||||||
|
// "{mime}, {size}" summary. Always offers Save-as.
|
||||||
|
// - file: "Open" button → SystemActions.openInOs(uri).
|
||||||
|
//
|
||||||
|
// Stateless on purpose: the dialog wraps it; we never own a
|
||||||
|
// future or a controller here.
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
|
||||||
|
import '../data/flow_output.dart';
|
||||||
|
import '../data/format.dart';
|
||||||
|
import '../data/system_actions.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../theme/theme.dart';
|
||||||
|
import '../theme/tokens.dart';
|
||||||
|
|
||||||
|
class FaiFlowOutput extends StatelessWidget {
|
||||||
|
final FlowOutput output;
|
||||||
|
|
||||||
|
const FaiFlowOutput({super.key, required this.output});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
switch (output) {
|
||||||
|
case FlowOutputText(:final text):
|
||||||
|
return _TextView(text: text);
|
||||||
|
case FlowOutputJson(:final pretty):
|
||||||
|
return _CodeBlock(text: pretty, theme: theme);
|
||||||
|
case FlowOutputBytes(:final bytes, :final mimeType):
|
||||||
|
return _BytesView(bytes: bytes, mimeType: mimeType);
|
||||||
|
case FlowOutputFile(:final uri, :final mimeType):
|
||||||
|
return _FileView(uri: uri, mimeType: mimeType);
|
||||||
|
case FlowOutputUnknown():
|
||||||
|
return _CodeBlock(
|
||||||
|
text: AppLocalizations.of(context)!.flowsOutputUnknown,
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain-text renderer. Auto-detects markdown via a cheap
|
||||||
|
/// heuristic (leading `#` heading, fenced code block, or
|
||||||
|
/// numbered/bulleted list) so flows that emit text/markdown
|
||||||
|
/// without setting an explicit MIME still render formatted.
|
||||||
|
class _TextView extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
const _TextView({required this.text});
|
||||||
|
|
||||||
|
static final RegExp _markdownHeuristic = RegExp(
|
||||||
|
r'(^|\n)(#{1,6}\s|```|\*\s|-\s|\d+\.\s)',
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get _looksLikeMarkdown =>
|
||||||
|
text.length < 64 * 1024 && _markdownHeuristic.hasMatch(text);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
if (_looksLikeMarkdown) {
|
||||||
|
return 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: MarkdownBody(
|
||||||
|
data: text,
|
||||||
|
selectable: true,
|
||||||
|
onTapLink: (_, href, _) async {
|
||||||
|
if (href != null && href.isNotEmpty) {
|
||||||
|
await SystemActions.openInOs(href);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
styleSheet: FaiTheme.markdownStyle(theme),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return 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(
|
||||||
|
text,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(height: 1.5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CodeBlock extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final ThemeData theme;
|
||||||
|
|
||||||
|
const _CodeBlock({required this.text, required this.theme});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return 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(
|
||||||
|
text,
|
||||||
|
style: FaiTheme.mono(
|
||||||
|
size: 12,
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BytesView extends StatelessWidget {
|
||||||
|
final Uint8List bytes;
|
||||||
|
final String mimeType;
|
||||||
|
|
||||||
|
const _BytesView({required this.bytes, required this.mimeType});
|
||||||
|
|
||||||
|
bool get _isImage => mimeType.startsWith('image/');
|
||||||
|
|
||||||
|
/// Reasonable default filename for the Save dialog. Strips the
|
||||||
|
/// MIME up to the slash and uses the suffix when known
|
||||||
|
/// (image/png → output.png), otherwise a generic `.bin`.
|
||||||
|
String _defaultFilename() {
|
||||||
|
final slash = mimeType.indexOf('/');
|
||||||
|
if (slash < 0 || slash == mimeType.length - 1) return 'output.bin';
|
||||||
|
final sub = mimeType.substring(slash + 1);
|
||||||
|
// Strip common parameter suffixes (`png; charset=...`).
|
||||||
|
final semi = sub.indexOf(';');
|
||||||
|
final ext = semi < 0 ? sub : sub.substring(0, semi);
|
||||||
|
// `vnd.openxmlformats-…wordprocessingml.document` → docx.
|
||||||
|
if (ext.contains('wordprocessingml')) return 'output.docx';
|
||||||
|
if (ext.contains('spreadsheetml')) return 'output.xlsx';
|
||||||
|
if (ext.contains('presentationml')) return 'output.pptx';
|
||||||
|
return 'output.$ext';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save(BuildContext context) async {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final path = await FilePicker.platform.saveFile(
|
||||||
|
dialogTitle: l.flowsOutputSaveAsTitle,
|
||||||
|
fileName: _defaultFilename(),
|
||||||
|
);
|
||||||
|
if (path == null) return;
|
||||||
|
try {
|
||||||
|
await File(path).writeAsBytes(bytes, flush: true);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(l.flowsOutputSavedAt(path))),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(l.flowsOutputSaveFailed('$e'))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
return 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: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_isImage) ...[
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 240),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||||
|
child: Image.memory(bytes, fit: BoxFit.contain),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: FaiSpace.sm),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
'${mimeType.isEmpty ? l.flowsOutputBytesUnknownMime : mimeType}'
|
||||||
|
' · ${humanBytes(bytes.length)}',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: FaiSpace.sm),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.save_alt, size: 16),
|
||||||
|
label: Text(l.flowsOutputSaveAs),
|
||||||
|
onPressed: () => _save(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FileView extends StatelessWidget {
|
||||||
|
final String uri;
|
||||||
|
final String mimeType;
|
||||||
|
|
||||||
|
const _FileView({required this.uri, required this.mimeType});
|
||||||
|
|
||||||
|
Future<void> _open(BuildContext context) async {
|
||||||
|
// Most file-URIs come from modules that wrote a temp/output
|
||||||
|
// path on the host. SystemActions normalises `file://` for
|
||||||
|
// open-in-OS on all three platforms.
|
||||||
|
final target = uri.startsWith('file://') ? uri.substring(7) : uri;
|
||||||
|
final r = await SystemActions.openInOs(target);
|
||||||
|
if (!context.mounted || r.ok) return;
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(l.flowsOutputOpenFailed(r.stderr))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
return 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: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SelectableText(
|
||||||
|
uri,
|
||||||
|
style: FaiTheme.mono(
|
||||||
|
size: 11,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (mimeType.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
mimeType,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: FaiSpace.sm),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.open_in_new, size: 16),
|
||||||
|
label: Text(l.flowsOutputOpen),
|
||||||
|
onPressed: () => _open(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,8 @@ import 'package:flutter/material.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
import '../data/registry_token.dart';
|
import '../data/registry_token.dart';
|
||||||
import '../data/system_actions.dart';
|
import '../data/system_actions.dart';
|
||||||
|
import '../data/theme_plugin.dart';
|
||||||
|
import '../main.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/theme.dart';
|
import '../theme/theme.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
|
@ -510,6 +512,8 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
onClear: _clearRegistryToken,
|
onClear: _clearRegistryToken,
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.lg),
|
const SizedBox(height: FaiSpace.lg),
|
||||||
|
const _ThemePluginPanel(),
|
||||||
|
const SizedBox(height: FaiSpace.lg),
|
||||||
_MaintenancePanel(
|
_MaintenancePanel(
|
||||||
onResetDone: () async {
|
onResetDone: () async {
|
||||||
// Daemon just restarted under the same channel +
|
// Daemon just restarted under the same channel +
|
||||||
|
|
@ -1023,11 +1027,12 @@ class _AddMcpClientDialogState extends State<_AddMcpClientDialog> {
|
||||||
/// Pick a suggestion → fill the form. Operators still hit
|
/// Pick a suggestion → fill the form. Operators still hit
|
||||||
/// "Add + discover" so nothing happens unprompted.
|
/// "Add + discover" so nothing happens unprompted.
|
||||||
void _applySuggestion(_McpSuggestion s) {
|
void _applySuggestion(_McpSuggestion s) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
setState(() {
|
setState(() {
|
||||||
_name.text = s.name;
|
_name.text = s.name;
|
||||||
_endpoint.text = s.endpoint;
|
_endpoint.text = s.endpoint;
|
||||||
_apiKey.text = s.apiKeyEnv;
|
_apiKey.text = s.apiKeyEnv;
|
||||||
_desc.text = s.description;
|
_desc.text = s.resolveDescription(l);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1455,6 +1460,122 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
|
||||||
/// metadata; nothing is auto-spawned. Trust model:
|
/// metadata; nothing is auto-spawned. Trust model:
|
||||||
/// referencing public projects with their canonical install
|
/// referencing public projects with their canonical install
|
||||||
/// command is the same legal / security shape as a package
|
/// command is the same legal / security shape as a package
|
||||||
|
/// Theme-plugin picker. Lists installed `studio.theme.*`
|
||||||
|
/// capabilities (via list_capabilities) and lets the operator
|
||||||
|
/// pick one or fall back to Studio's built-in palette. The
|
||||||
|
/// choice is persisted in SharedPreferences and applied at
|
||||||
|
/// the MaterialApp level via `StudioApp.of(context).setThemePlugin`.
|
||||||
|
///
|
||||||
|
/// When no theme plugin is installed the panel renders a
|
||||||
|
/// short hint pointing at the Store rather than a useless
|
||||||
|
/// empty dropdown.
|
||||||
|
class _ThemePluginPanel extends StatefulWidget {
|
||||||
|
const _ThemePluginPanel();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ThemePluginPanel> createState() => _ThemePluginPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ThemePluginPanelState extends State<_ThemePluginPanel> {
|
||||||
|
Future<List<String>>? _capsFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_capsFuture = listThemePluginCapabilities();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final app = StudioApp.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l.themePluginHeader,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
FutureBuilder<List<String>>(
|
||||||
|
future: _capsFuture,
|
||||||
|
builder: (context, snap) {
|
||||||
|
if (snap.connectionState != ConnectionState.done) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final caps = snap.data ?? const <String>[];
|
||||||
|
if (caps.isEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Text(
|
||||||
|
l.themePluginEmpty,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Dropdown shows "Built-in" + every installed
|
||||||
|
// studio.theme.* cap. Selection writes through
|
||||||
|
// StudioApp's notifier so the MaterialApp rebuilds
|
||||||
|
// with the new ColorScheme without a restart.
|
||||||
|
final active = app?.themePluginNotifier.value;
|
||||||
|
final items = <DropdownMenuItem<String?>>[
|
||||||
|
DropdownMenuItem<String?>(
|
||||||
|
value: null,
|
||||||
|
child: Text(l.themePluginNone),
|
||||||
|
),
|
||||||
|
for (final cap in caps)
|
||||||
|
DropdownMenuItem<String?>(
|
||||||
|
value: cap,
|
||||||
|
child: Text(cap, style: FaiTheme.mono(size: 12)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
DropdownButton<String?>(
|
||||||
|
value: active,
|
||||||
|
items: items,
|
||||||
|
isDense: true,
|
||||||
|
onChanged: app == null
|
||||||
|
? null
|
||||||
|
: (v) {
|
||||||
|
app.setThemePlugin(v);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
l.themePluginHint,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Hub maintenance panel — currently one action: reset operator
|
/// Hub maintenance panel — currently one action: reset operator
|
||||||
/// state via `fai reset --yes`. Lives at the bottom of Settings
|
/// state via `fai reset --yes`. Lives at the bottom of Settings
|
||||||
/// so the everyday config controls aren't crowded by a destructive
|
/// so the everyday config controls aren't crowded by a destructive
|
||||||
|
|
@ -1770,36 +1891,73 @@ class _McpSuggestion {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String endpoint;
|
final String endpoint;
|
||||||
final String apiKeyEnv;
|
final String apiKeyEnv;
|
||||||
final String description;
|
/// Localizable description. Use [resolveDescription] to fetch
|
||||||
|
/// the actual text in the active locale — the const list
|
||||||
|
/// can't hold a closure that takes [AppLocalizations], and
|
||||||
|
/// hardcoding English here is what got us into the
|
||||||
|
/// untranslated-helper-text bug.
|
||||||
|
String resolveDescription(AppLocalizations l) =>
|
||||||
|
_suggestionDescription(l, name);
|
||||||
const _McpSuggestion({
|
const _McpSuggestion({
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.endpoint,
|
required this.endpoint,
|
||||||
required this.apiKeyEnv,
|
required this.apiKeyEnv,
|
||||||
required this.description,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Localizable lookup of an MCP-suggestion description. Keyed
|
||||||
|
/// on the same suggestion `name` field as the list below. New
|
||||||
|
/// suggestions need a matching .arb entry; the default-case
|
||||||
|
/// returns an empty string so an unconfigured suggestion fails
|
||||||
|
/// silently instead of leaking a key into the UI.
|
||||||
|
String _suggestionDescription(AppLocalizations l, String name) {
|
||||||
|
switch (name) {
|
||||||
|
case 'deepwiki':
|
||||||
|
return l.mcpSuggestionDeepwikiDesc;
|
||||||
|
case 'semgrep':
|
||||||
|
return l.mcpSuggestionSemgrepDesc;
|
||||||
|
case 'filesystem':
|
||||||
|
return l.mcpSuggestionFilesystemDesc;
|
||||||
|
case 'fetch':
|
||||||
|
return l.mcpSuggestionFetchDesc;
|
||||||
|
case 'github':
|
||||||
|
return l.mcpSuggestionGithubDesc;
|
||||||
|
case 'puppeteer':
|
||||||
|
return l.mcpSuggestionPuppeteerDesc;
|
||||||
|
case 'postgres':
|
||||||
|
return l.mcpSuggestionPostgresDesc;
|
||||||
|
case 'sqlite':
|
||||||
|
return l.mcpSuggestionSqliteDesc;
|
||||||
|
case 'brave-search':
|
||||||
|
return l.mcpSuggestionBraveSearchDesc;
|
||||||
|
case 'memory':
|
||||||
|
return l.mcpSuggestionMemoryDesc;
|
||||||
|
case 'time':
|
||||||
|
return l.mcpSuggestionTimeDesc;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const _kMcpSuggestions = <_McpSuggestion>[
|
const _kMcpSuggestions = <_McpSuggestion>[
|
||||||
// ── HTTPS / streamable-HTTP servers (no Node, no API key) ──
|
// ── HTTPS / streamable-HTTP servers (no Node, no API key) ──
|
||||||
// Same set Studio promotes as one-click cards in the Today
|
// Descriptions are resolved via [_McpSuggestion.resolveDescription]
|
||||||
// hero — listed here so the operator finds them even after
|
// against the active locale — see `mcpSuggestion*Desc` keys in
|
||||||
// the hero gets dismissed.
|
// app_en.arb / app_de.arb. Adding a suggestion here means
|
||||||
|
// adding a matching key in both .arb files plus a case to
|
||||||
|
// `_suggestionDescription` above.
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'deepwiki',
|
name: 'deepwiki',
|
||||||
icon: Icons.menu_book_outlined,
|
icon: Icons.menu_book_outlined,
|
||||||
endpoint: 'https://mcp.deepwiki.com/mcp',
|
endpoint: 'https://mcp.deepwiki.com/mcp',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
description:
|
|
||||||
'Public HTTPS — GitHub repo documentation search (AI-powered). No Node, no API key.',
|
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'semgrep',
|
name: 'semgrep',
|
||||||
icon: Icons.security,
|
icon: Icons.security,
|
||||||
endpoint: 'https://mcp.semgrep.ai/mcp',
|
endpoint: 'https://mcp.semgrep.ai/mcp',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
description:
|
|
||||||
'Public HTTPS — security scanning for code vulnerabilities. No Node, no API key.',
|
|
||||||
),
|
),
|
||||||
// ── stdio servers (require Node + npx) ───────────────────
|
// ── stdio servers (require Node + npx) ───────────────────
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
|
|
@ -1807,30 +1965,24 @@ const _kMcpSuggestions = <_McpSuggestion>[
|
||||||
icon: Icons.folder_outlined,
|
icon: Icons.folder_outlined,
|
||||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-filesystem /tmp',
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-filesystem /tmp',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
description:
|
|
||||||
'Anthropic — read/write files in /tmp. Edit the path before saving.',
|
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'fetch',
|
name: 'fetch',
|
||||||
icon: Icons.cloud_download_outlined,
|
icon: Icons.cloud_download_outlined,
|
||||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-fetch',
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-fetch',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
description: 'Anthropic — fetch arbitrary HTTP(S) URLs as markdown.',
|
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'github',
|
name: 'github',
|
||||||
icon: Icons.code,
|
icon: Icons.code,
|
||||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-github',
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-github',
|
||||||
apiKeyEnv: 'GITHUB_PERSONAL_ACCESS_TOKEN',
|
apiKeyEnv: 'GITHUB_PERSONAL_ACCESS_TOKEN',
|
||||||
description:
|
|
||||||
'Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.',
|
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'puppeteer',
|
name: 'puppeteer',
|
||||||
icon: Icons.web,
|
icon: Icons.web,
|
||||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-puppeteer',
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-puppeteer',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
description: 'Anthropic — headless browser automation for screenshots / scraping.',
|
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'postgres',
|
name: 'postgres',
|
||||||
|
|
@ -1838,8 +1990,6 @@ const _kMcpSuggestions = <_McpSuggestion>[
|
||||||
endpoint:
|
endpoint:
|
||||||
'stdio://npx -y @modelcontextprotocol/server-postgres postgresql://user:pw@host/db',
|
'stdio://npx -y @modelcontextprotocol/server-postgres postgresql://user:pw@host/db',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
description:
|
|
||||||
'Anthropic — read-only SQL queries against a Postgres database. Edit the URL.',
|
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'sqlite',
|
name: 'sqlite',
|
||||||
|
|
@ -1847,27 +1997,23 @@ const _kMcpSuggestions = <_McpSuggestion>[
|
||||||
endpoint:
|
endpoint:
|
||||||
'stdio://npx -y @modelcontextprotocol/server-sqlite --db-path /tmp/db.sqlite',
|
'stdio://npx -y @modelcontextprotocol/server-sqlite --db-path /tmp/db.sqlite',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
description: 'Anthropic — query a local SQLite file. Edit the path.',
|
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'brave-search',
|
name: 'brave-search',
|
||||||
icon: Icons.search,
|
icon: Icons.search,
|
||||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-brave-search',
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-brave-search',
|
||||||
apiKeyEnv: 'BRAVE_API_KEY',
|
apiKeyEnv: 'BRAVE_API_KEY',
|
||||||
description: 'Anthropic — web search via Brave. API key required.',
|
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'memory',
|
name: 'memory',
|
||||||
icon: Icons.psychology_outlined,
|
icon: Icons.psychology_outlined,
|
||||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-memory',
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-memory',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
description: 'Anthropic — persistent knowledge graph for an agent\'s memory.',
|
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'time',
|
name: 'time',
|
||||||
icon: Icons.schedule,
|
icon: Icons.schedule,
|
||||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-time',
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-time',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
description: 'Anthropic — current time + timezone conversion.',
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -553,53 +553,60 @@ class _PrivacyModeChips extends StatelessWidget {
|
||||||
l.systemAiPrivacyFullDesc,
|
l.systemAiPrivacyFullDesc,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
return Column(
|
// Material 3 deprecated per-Radio `groupValue` / `onChanged`
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
// after Flutter 3.32. The modern pattern wraps the radios in
|
||||||
children: [
|
// a RadioGroup<T> ancestor that owns the selection + change
|
||||||
Text(
|
// callback; the individual Radio widgets only declare their
|
||||||
l.systemAiPrivacyHeader,
|
// value. Same UX, no deprecation warnings.
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
return RadioGroup<String>(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
groupValue: value,
|
||||||
letterSpacing: 0.6,
|
onChanged: (v) {
|
||||||
fontSize: 10,
|
if (v != null) onChanged(v);
|
||||||
),
|
},
|
||||||
),
|
child: Column(
|
||||||
const SizedBox(height: 4),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
for (final (wire, label, desc) in modes)
|
children: [
|
||||||
InkWell(
|
Text(
|
||||||
onTap: () => onChanged(wire),
|
l.systemAiPrivacyHeader,
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
child: Padding(
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
padding: const EdgeInsets.symmetric(
|
letterSpacing: 0.6,
|
||||||
vertical: FaiSpace.sm,
|
fontSize: 10,
|
||||||
horizontal: FaiSpace.sm,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Radio<String>(
|
|
||||||
value: wire,
|
|
||||||
groupValue: value,
|
|
||||||
onChanged: (v) => onChanged(v ?? wire),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(label, style: theme.textTheme.bodyMedium),
|
|
||||||
Text(
|
|
||||||
desc,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 4),
|
||||||
|
for (final (wire, label, desc) in modes)
|
||||||
|
InkWell(
|
||||||
|
onTap: () => onChanged(wire),
|
||||||
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: FaiSpace.sm,
|
||||||
|
horizontal: FaiSpace.sm,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Radio<String>(value: wire),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: theme.textTheme.bodyMedium),
|
||||||
|
Text(
|
||||||
|
desc,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ export 'fai_card.dart';
|
||||||
export 'fai_data_row.dart';
|
export 'fai_data_row.dart';
|
||||||
export 'fai_delta_mark.dart';
|
export 'fai_delta_mark.dart';
|
||||||
export 'fai_empty_state.dart';
|
export 'fai_empty_state.dart';
|
||||||
|
export 'fai_en_badge.dart';
|
||||||
export 'fai_error_box.dart';
|
export 'fai_error_box.dart';
|
||||||
|
export 'fai_flow_output.dart';
|
||||||
export 'fai_module_sheet.dart';
|
export 'fai_module_sheet.dart';
|
||||||
export 'fai_pill.dart';
|
export 'fai_pill.dart';
|
||||||
export 'fai_settings_dialog.dart';
|
export 'fai_settings_dialog.dart';
|
||||||
|
|
|
||||||
|
|
@ -146,14 +146,14 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
flutter_markdown:
|
flutter_markdown_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_markdown
|
name: flutter_markdown_plus
|
||||||
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
|
sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7+1"
|
version: "1.0.7"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,10 @@ dependencies:
|
||||||
google_fonts: ^8.1.0
|
google_fonts: ^8.1.0
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
# Inline README rendering inside the Store detail sheet.
|
# Inline README rendering inside the Store detail sheet.
|
||||||
flutter_markdown: ^0.7.7
|
# `flutter_markdown` is discontinued; `flutter_markdown_plus`
|
||||||
|
# is the actively-maintained drop-in fork (same
|
||||||
|
# `MarkdownStyleSheet` / `Markdown` / `MarkdownBody` API).
|
||||||
|
flutter_markdown_plus: ^1.0.3
|
||||||
# Today-Hero pipeline reads accepted stories from
|
# Today-Hero pipeline reads accepted stories from
|
||||||
# ~/.fai/today/active.yaml — see docs/today-pipeline.md.
|
# ~/.fai/today/active.yaml — see docs/today-pipeline.md.
|
||||||
yaml: ^3.1.2
|
yaml: ^3.1.2
|
||||||
|
|
|
||||||
84
test/friendly_error_test.dart
Normal file
84
test/friendly_error_test.dart
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
// Unit tests for the `friendlyError` mapper. These check the
|
||||||
|
// duck-typed shape contract — `GrpcError` from package:grpc is
|
||||||
|
// expected to expose `.code` (int), `.message` (String?), and
|
||||||
|
// `.codeName` (String) — without taking grpc as a Studio
|
||||||
|
// dependency. The fakes here mimic that shape.
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fai_studio/data/friendly_error.dart';
|
||||||
|
import 'package:fai_studio/l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class _FakeGrpcError {
|
||||||
|
final int code;
|
||||||
|
final String? message;
|
||||||
|
final String codeName;
|
||||||
|
_FakeGrpcError(this.code, this.codeName, [this.message]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AppLocalizations> _loadL10n(Locale locale) async {
|
||||||
|
return await AppLocalizations.delegate.load(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
setUpAll(() async {
|
||||||
|
// Force loading the gen-l10n delegate before tests so each
|
||||||
|
// call to `_loadL10n` is fast.
|
||||||
|
await _loadL10n(const Locale('en'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UNAVAILABLE maps to hub-not-reachable copy', () async {
|
||||||
|
final l = await _loadL10n(const Locale('en'));
|
||||||
|
final r = friendlyError(_FakeGrpcError(14, 'UNAVAILABLE', 'connection refused'), l);
|
||||||
|
expect(r.headline, l.errUnavailable);
|
||||||
|
expect(r.hint, l.errUnavailableHint);
|
||||||
|
expect(r.detail, 'connection refused');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FAILED_PRECONDITION carries module detail in body', () async {
|
||||||
|
final l = await _loadL10n(const Locale('en'));
|
||||||
|
final r = friendlyError(
|
||||||
|
_FakeGrpcError(9, 'FAILED_PRECONDITION',
|
||||||
|
'install error: no store entry for system.approval'),
|
||||||
|
l,
|
||||||
|
);
|
||||||
|
expect(r.headline, l.errFailedPrecondition);
|
||||||
|
expect(r.hint, l.errFailedPreconditionHint);
|
||||||
|
expect(r.detail, contains('system.approval'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NOT_FOUND has a recovery hint', () async {
|
||||||
|
final l = await _loadL10n(const Locale('en'));
|
||||||
|
final r = friendlyError(_FakeGrpcError(5, 'NOT_FOUND', 'no such module'), l);
|
||||||
|
expect(r.headline, l.errNotFound);
|
||||||
|
expect(r.hint, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Unknown code falls back to codeName: message', () async {
|
||||||
|
final l = await _loadL10n(const Locale('en'));
|
||||||
|
final r = friendlyError(_FakeGrpcError(99, 'CUSTOM_CODE', 'something'), l);
|
||||||
|
expect(r.headline, contains('CUSTOM_CODE'));
|
||||||
|
expect(r.headline, contains('something'));
|
||||||
|
expect(r.hint, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Non-gRPC error renders toString as headline', () async {
|
||||||
|
final l = await _loadL10n(const Locale('en'));
|
||||||
|
final r = friendlyError(FormatException('bad url'), l);
|
||||||
|
expect(r.headline, contains('bad url'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Locale switches the headline language', () async {
|
||||||
|
final lDe = await _loadL10n(const Locale('de'));
|
||||||
|
final r =
|
||||||
|
friendlyError(_FakeGrpcError(14, 'UNAVAILABLE', 'connection refused'), lDe);
|
||||||
|
expect(r.headline, lDe.errUnavailable);
|
||||||
|
// Sanity: the DE headline really is different from the EN
|
||||||
|
// one — guards against the test silently passing if l10n
|
||||||
|
// regen returned EN for both.
|
||||||
|
final lEn = await _loadL10n(const Locale('en'));
|
||||||
|
expect(lDe.errUnavailable, isNot(equals(lEn.errUnavailable)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
72
test/integration/README.md
Normal file
72
test/integration/README.md
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
# Integration tests
|
||||||
|
|
||||||
|
These tests spin up a real `fai serve` subprocess against a
|
||||||
|
fresh temp dir and exercise Studio's Hub-facing data layer
|
||||||
|
against it. They cover the "kind of regression that survives
|
||||||
|
unit tests because the system-shape only manifests across the
|
||||||
|
gRPC boundary".
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Scaffold.** One canonical test (capabilities). The harness is
|
||||||
|
production-quality (proper teardown, idempotent, port-clean) so
|
||||||
|
new tests slot in by importing `hub_fixture.dart` and calling
|
||||||
|
`HubFixture.start()` / `dispose()` in `setUp` / `tearDown`.
|
||||||
|
|
||||||
|
What's NOT here yet:
|
||||||
|
|
||||||
|
- A full "install dep → run echo → assert title" scenario. The
|
||||||
|
harness can do it (install_module is a real RPC), but
|
||||||
|
building the fixture for module-bundle downloads against a
|
||||||
|
hermetic local registry is its own scaffold. Tracked as
|
||||||
|
follow-up to S-21.
|
||||||
|
- Studio-side widget testing against the live hub. The
|
||||||
|
flutter `integration_test` package supports it
|
||||||
|
(`testWidgets` + `IntegrationTestWidgetsFlutterBinding`),
|
||||||
|
but pumping a full `StudioApp` against a real gRPC channel
|
||||||
|
needs `binding.enableSurfaceBindingHack()` ceremony we
|
||||||
|
haven't designed yet.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd fai_studio
|
||||||
|
flutter test test/integration/
|
||||||
|
```
|
||||||
|
|
||||||
|
Prereq: a `fai` binary on PATH or at
|
||||||
|
`../fai_platform/target/release/fai`. The harness skips with a
|
||||||
|
clear message when neither exists, so this command does not
|
||||||
|
fail on a fresh checkout — it just reports skipped tests.
|
||||||
|
|
||||||
|
To get the binary:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ../fai_platform
|
||||||
|
cargo build --release --bin fai
|
||||||
|
```
|
||||||
|
|
||||||
|
### First-time-start gotcha
|
||||||
|
|
||||||
|
A cold `fai serve` spends its first ~30s building the
|
||||||
|
curated-model database and initialising SQLite migrations.
|
||||||
|
`HubFixture.start()` waits up to 60s by default; if your local
|
||||||
|
hub takes longer the first time, run `fai serve` once by hand
|
||||||
|
against any temp `FAI_DATA_DIR` to warm the per-user cargo /
|
||||||
|
SBOM caches:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FAI_DATA_DIR=/tmp/fai_warmup fai serve --bind 127.0.0.1:0
|
||||||
|
# wait for "hub started", Ctrl-C
|
||||||
|
```
|
||||||
|
|
||||||
|
Subsequent integration-test runs are quick.
|
||||||
|
|
||||||
|
## Not yet wired into CI
|
||||||
|
|
||||||
|
`.forgejo/workflows/ci.yml` doesn't run these yet. Adding them
|
||||||
|
needs an artifact-passing pattern: the platform build job
|
||||||
|
publishes `target/release/fai` as a CI artifact; the studio
|
||||||
|
test job consumes it. Pattern is straightforward once we want
|
||||||
|
it; it's deferred because we don't yet have enough integration
|
||||||
|
tests to justify the CI runtime.
|
||||||
73
test/integration/capabilities_test.dart
Normal file
73
test/integration/capabilities_test.dart
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
// First integration test against a real `fai serve` subprocess.
|
||||||
|
//
|
||||||
|
// What this test covers (the regression bait):
|
||||||
|
//
|
||||||
|
// When the operator opens a flow that references `system.approval`
|
||||||
|
// or any other built-in / federated capability, the missing-deps
|
||||||
|
// check in Studio's Flows page must consider those caps already
|
||||||
|
// available — otherwise the Run button stays disabled forever
|
||||||
|
// and the operator's only recourse is to install a module that
|
||||||
|
// doesn't exist.
|
||||||
|
//
|
||||||
|
// Why this test as the canonical first scaffold:
|
||||||
|
//
|
||||||
|
// It's the exact bug class that survived three commits of the
|
||||||
|
// May-2026 trust pass before someone noticed. A real-Hub test
|
||||||
|
// for it makes regressions impossible to ship.
|
||||||
|
//
|
||||||
|
// Cost: ~2 seconds (Hub start) + ~0.1 seconds (gRPC). Acceptable.
|
||||||
|
//
|
||||||
|
// Run manually with:
|
||||||
|
//
|
||||||
|
// flutter test test/integration/
|
||||||
|
//
|
||||||
|
// CI does not yet run these by default — they need a built `fai`
|
||||||
|
// binary in PATH or in ../fai_platform/target/release/. Wire that
|
||||||
|
// in via .forgejo/workflows/ci.yml once the platform CI publishes
|
||||||
|
// a Linux binary as an artifact for downstream jobs to consume.
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'hub_fixture.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Hub capability listing', () {
|
||||||
|
late HubFixture? fixture;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
fixture = await HubFixture.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await fixture?.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('system.approval shows up as a builtin capability', () async {
|
||||||
|
final f = fixture;
|
||||||
|
if (f == null) return; // skipped — no binary
|
||||||
|
final caps = await f.client.listCapabilities();
|
||||||
|
final approval = caps.where(
|
||||||
|
(c) => c.capability == 'system.approval',
|
||||||
|
).toList();
|
||||||
|
expect(approval, isNotEmpty,
|
||||||
|
reason: 'system.approval must appear in list_capabilities '
|
||||||
|
'so Studio does not try to install it through the store');
|
||||||
|
expect(approval.first.kind, 'builtin',
|
||||||
|
reason: 'kind=builtin lets Studio gate the Install button correctly');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every capability carries a kind tag', () async {
|
||||||
|
final f = fixture;
|
||||||
|
if (f == null) return;
|
||||||
|
final caps = await f.client.listCapabilities();
|
||||||
|
expect(caps, isNotEmpty,
|
||||||
|
reason: 'a fresh hub has at least the system.approval builtin');
|
||||||
|
for (final c in caps) {
|
||||||
|
expect(['wasm', 'builtin', 'federated'], contains(c.kind),
|
||||||
|
reason: 'cap ${c.capability} has unrecognised kind="${c.kind}" — '
|
||||||
|
'Studio gates the Install button on this string, so the wire '
|
||||||
|
'value must stay in the known set');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
192
test/integration/hub_fixture.dart
Normal file
192
test/integration/hub_fixture.dart
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
// Hub fixture for integration-level tests.
|
||||||
|
//
|
||||||
|
// Spawns a real `fai serve` subprocess on a free port in a
|
||||||
|
// temporary directory, waits for its gRPC endpoint to answer
|
||||||
|
// `Healthy`, and exposes the port + a teardown so test files
|
||||||
|
// can talk to a clean Hub without setting up an isolate-side
|
||||||
|
// gRPC server in-process.
|
||||||
|
//
|
||||||
|
// Cost / scope notes:
|
||||||
|
//
|
||||||
|
// * The `fai` binary must be on the operator's PATH or at
|
||||||
|
// `target/release/fai` relative to the platform repo. We
|
||||||
|
// resolve those two paths in order; if neither exists, the
|
||||||
|
// fixture skips the suite with a clear message rather than
|
||||||
|
// failing — these tests are opt-in.
|
||||||
|
// * The hub is started with `--quiet` (no log spam) against a
|
||||||
|
// temp dir, so the operator's real `~/.fai/` stays untouched.
|
||||||
|
// * Cleanup runs `process.kill(SIGTERM)` then `process.kill()`
|
||||||
|
// after a grace period. The temp dir is removed.
|
||||||
|
// * Each fixture is independent — call `HubFixture.start()` in
|
||||||
|
// `setUp`, `fixture.dispose()` in `tearDown`. Don't share
|
||||||
|
// between tests; the few seconds of startup beat debugging
|
||||||
|
// state-bleed.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:fai_dart_sdk/fai_dart_sdk.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
class HubFixture {
|
||||||
|
/// The hub subprocess. Kept private so callers go through
|
||||||
|
/// [dispose] for shutdown.
|
||||||
|
final Process _process;
|
||||||
|
|
||||||
|
/// gRPC port the hub is listening on. Free port chosen by
|
||||||
|
/// the OS at startup time.
|
||||||
|
final int port;
|
||||||
|
|
||||||
|
/// Temp directory the hub was started against. Wiped in
|
||||||
|
/// [dispose] so tests leave no state behind on disk.
|
||||||
|
final Directory tempDir;
|
||||||
|
|
||||||
|
/// Pre-connected SDK client. Reuse across the test's
|
||||||
|
/// assertions — gRPC channel creation is cheap but
|
||||||
|
/// non-zero, and the tests don't benefit from churning it.
|
||||||
|
final HubClient client;
|
||||||
|
|
||||||
|
HubFixture._({
|
||||||
|
required Process process,
|
||||||
|
required this.port,
|
||||||
|
required this.tempDir,
|
||||||
|
required this.client,
|
||||||
|
}) : _process = process;
|
||||||
|
|
||||||
|
/// Boots a fresh hub against a temp dir and waits up to
|
||||||
|
/// `readyTimeout` for it to answer Healthy. Use
|
||||||
|
/// [skipIfBinaryMissing] = true (default) to let tests
|
||||||
|
/// `markSkipped` instead of failing when the binary isn't
|
||||||
|
/// present — that's the desired behaviour in CI environments
|
||||||
|
/// that don't build the binary first.
|
||||||
|
static Future<HubFixture?> start({
|
||||||
|
// A cold hub spends its first 30s building the curated-
|
||||||
|
// model database + initialising SQLite. 60s gives that
|
||||||
|
// honest headroom without making a hung hub look like
|
||||||
|
// a slow one.
|
||||||
|
Duration readyTimeout = const Duration(seconds: 60),
|
||||||
|
bool skipIfBinaryMissing = true,
|
||||||
|
}) async {
|
||||||
|
final binary = await _resolveBinary();
|
||||||
|
if (binary == null) {
|
||||||
|
if (skipIfBinaryMissing) {
|
||||||
|
markTestSkipped(
|
||||||
|
'Hub fixture skipped: no `fai` binary found on PATH or in '
|
||||||
|
'../fai_platform/target/release/fai. Build it with '
|
||||||
|
'`cargo build --release` and re-run.',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw StateError('fai binary not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp('fai_studio_test_');
|
||||||
|
final port = await _pickFreePort();
|
||||||
|
final addr = '127.0.0.1:$port';
|
||||||
|
|
||||||
|
// Run as a child process. `serve --bind` lets us avoid the
|
||||||
|
// default 50051 (and any local daemon the developer happens
|
||||||
|
// to be running). FAI_DATA_DIR keeps the temp footprint
|
||||||
|
// contained — no state leaks into ~/.fai.
|
||||||
|
final process = await Process.start(
|
||||||
|
binary,
|
||||||
|
['serve', '--bind', addr],
|
||||||
|
environment: {
|
||||||
|
...Platform.environment,
|
||||||
|
'FAI_DATA_DIR': tempDir.path,
|
||||||
|
'FAI_MODULES_DIR': '${tempDir.path}/modules',
|
||||||
|
// Suppress info logs without a CLI flag — the binary
|
||||||
|
// respects RUST_LOG for tracing-subscriber.
|
||||||
|
'RUST_LOG': 'warn',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drain stdout/stderr to keep the OS pipe buffers from
|
||||||
|
// blocking the hub. Test failures still surface the last
|
||||||
|
// ~64 KiB of output via the dispose path.
|
||||||
|
process.stdout.transform(SystemEncoding().decoder).listen(_noop);
|
||||||
|
process.stderr.transform(SystemEncoding().decoder).listen(_noop);
|
||||||
|
|
||||||
|
// Poll the hub until it's Healthy. Bound by readyTimeout —
|
||||||
|
// a hung hub is a test failure, not a hang.
|
||||||
|
final client = HubClient(
|
||||||
|
endpoint: HubEndpoint(host: '127.0.0.1', port: port),
|
||||||
|
);
|
||||||
|
final deadline = DateTime.now().add(readyTimeout);
|
||||||
|
while (DateTime.now().isBefore(deadline)) {
|
||||||
|
try {
|
||||||
|
final ok = await client.healthy();
|
||||||
|
if (ok) {
|
||||||
|
return HubFixture._(
|
||||||
|
process: process,
|
||||||
|
port: port,
|
||||||
|
tempDir: tempDir,
|
||||||
|
client: client,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Hub still booting; try again.
|
||||||
|
}
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boot didn't complete in time. Tear down + fail with a
|
||||||
|
// useful message.
|
||||||
|
process.kill(ProcessSignal.sigterm);
|
||||||
|
await tempDir.delete(recursive: true);
|
||||||
|
throw StateError(
|
||||||
|
'Hub did not become healthy within $readyTimeout. '
|
||||||
|
'Check `fai serve` works manually with FAI_DATA_DIR=$tempDir.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shuts down the hub and removes the temp dir. Idempotent
|
||||||
|
/// so a teardown that runs after a test-failure exit still
|
||||||
|
/// cleans up.
|
||||||
|
Future<void> dispose() async {
|
||||||
|
await client.close();
|
||||||
|
_process.kill(ProcessSignal.sigterm);
|
||||||
|
// Give the hub up to 3 seconds to flush its event log.
|
||||||
|
try {
|
||||||
|
await _process.exitCode.timeout(const Duration(seconds: 3));
|
||||||
|
} on TimeoutException {
|
||||||
|
_process.kill(ProcessSignal.sigkill);
|
||||||
|
}
|
||||||
|
if (await tempDir.exists()) {
|
||||||
|
await tempDir.delete(recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look for `fai` first on PATH (production-ish), then in
|
||||||
|
/// the standard `target/release/` build output relative to
|
||||||
|
/// the platform checkout (developer flow). Returns null when
|
||||||
|
/// neither path resolves.
|
||||||
|
static Future<String?> _resolveBinary() async {
|
||||||
|
final pathResult = await Process.run(
|
||||||
|
Platform.isWindows ? 'where' : 'which',
|
||||||
|
['fai'],
|
||||||
|
);
|
||||||
|
if (pathResult.exitCode == 0) {
|
||||||
|
final s = (pathResult.stdout as String).trim();
|
||||||
|
if (s.isNotEmpty) return s.split('\n').first.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
final candidate = Platform.isWindows
|
||||||
|
? '../fai_platform/target/release/fai.exe'
|
||||||
|
: '../fai_platform/target/release/fai';
|
||||||
|
final f = File(candidate);
|
||||||
|
if (await f.exists()) {
|
||||||
|
return f.absolute.path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<int> _pickFreePort() async {
|
||||||
|
final s = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
final port = s.port;
|
||||||
|
await s.close();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _noop(Object _) {}
|
||||||
42
test/integration/install_install_planned_test.dart
Normal file
42
test/integration/install_install_planned_test.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
// Integration test: install_module against a `planned` seed
|
||||||
|
// entry must fail with a clear, human-readable error class —
|
||||||
|
// not a panic, not a silent success.
|
||||||
|
//
|
||||||
|
// Why this matters: planned modules are listed in the Store
|
||||||
|
// but have no wasm_url. The old behaviour was a server-side
|
||||||
|
// panic on resolve_install_source(); the new contract emits a
|
||||||
|
// FAILED_PRECONDITION with "no installable version" so
|
||||||
|
// Studio's friendly-error mapper can show a usable message.
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'hub_fixture.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('install_module on a planned entry surfaces a clear error',
|
||||||
|
() async {
|
||||||
|
final fixture = await HubFixture.start();
|
||||||
|
if (fixture == null) return;
|
||||||
|
try {
|
||||||
|
// web.scrape is `planned` in the seed and has no
|
||||||
|
// wasm_url. The hub resolves the name through the store
|
||||||
|
// index, finds the entry, then fails at the
|
||||||
|
// "no installable version" check.
|
||||||
|
try {
|
||||||
|
await fixture.client.installModule(source: 'web.scrape');
|
||||||
|
fail('install_module should not succeed for a planned entry');
|
||||||
|
} catch (e) {
|
||||||
|
final msg = e.toString();
|
||||||
|
expect(
|
||||||
|
msg.contains('no installable version') ||
|
||||||
|
msg.contains('not yet published'),
|
||||||
|
isTrue,
|
||||||
|
reason:
|
||||||
|
'Expected a clear planned-status error, got: $msg',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await fixture.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
88
test/integration/plugin_theme_test.dart
Normal file
88
test/integration/plugin_theme_test.dart
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
// End-to-end integration test for the Studio-plugin theme
|
||||||
|
// hook: spins a real `fai serve`, installs the in-tree
|
||||||
|
// `studio-theme-solarized` plugin via direct module-dir
|
||||||
|
// staging (mirror-install would also work but takes longer
|
||||||
|
// and pulls a network egress), then calls `invokePluginTheme`
|
||||||
|
// and asserts the round-trip.
|
||||||
|
//
|
||||||
|
// Run manually with:
|
||||||
|
//
|
||||||
|
// flutter test test/integration/plugin_theme_test.dart
|
||||||
|
//
|
||||||
|
// Pre-reqs (handled by HubFixture's skip-when-missing path):
|
||||||
|
// - fai binary on PATH or at ../fai_platform/target/release/fai
|
||||||
|
// - studio-theme-solarized plugin built to module.wasm in its
|
||||||
|
// fai_modules dir.
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'hub_fixture.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test(
|
||||||
|
'invokePluginTheme returns Solarized Light + Dark schemes',
|
||||||
|
() async {
|
||||||
|
final fixture = await HubFixture.start();
|
||||||
|
if (fixture == null) return;
|
||||||
|
try {
|
||||||
|
// Stage the plugin into the fixture's modules dir
|
||||||
|
// ourselves. Going through `installModule` would
|
||||||
|
// require the plugin to be on the mirror, which would
|
||||||
|
// add network + bundle-fetch latency that doesn't
|
||||||
|
// belong in an in-process round-trip test.
|
||||||
|
final pluginSrc = Directory(
|
||||||
|
'../fai_modules/studio-theme-solarized',
|
||||||
|
);
|
||||||
|
if (!await pluginSrc.exists()) {
|
||||||
|
markTestSkipped('studio-theme-solarized source not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final wasm = File('${pluginSrc.path}/module.wasm');
|
||||||
|
if (!await wasm.exists()) {
|
||||||
|
markTestSkipped(
|
||||||
|
'studio-theme-solarized has no built module.wasm — '
|
||||||
|
'run `cargo build --release --target wasm32-wasip2` first',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final dest = Directory(
|
||||||
|
'${fixture.tempDir.path}/modules/studio-theme-solarized',
|
||||||
|
);
|
||||||
|
await dest.create(recursive: true);
|
||||||
|
await File('${pluginSrc.path}/module.yaml')
|
||||||
|
.copy('${dest.path}/module.yaml');
|
||||||
|
await wasm.copy('${dest.path}/module.wasm');
|
||||||
|
|
||||||
|
// Call the hook for both brightnesses.
|
||||||
|
final light = await fixture.client.invokePluginTheme(
|
||||||
|
capability: 'studio.theme.solarized',
|
||||||
|
brightness: 'light',
|
||||||
|
);
|
||||||
|
expect(light.brightness, 'light');
|
||||||
|
expect(light.tokens.length, 14);
|
||||||
|
// Solarized blue is the pinned primary in both schemes.
|
||||||
|
expect(light.tokens[0], 0xFF268BD2);
|
||||||
|
|
||||||
|
final dark = await fixture.client.invokePluginTheme(
|
||||||
|
capability: 'studio.theme.solarized',
|
||||||
|
brightness: 'dark',
|
||||||
|
);
|
||||||
|
expect(dark.brightness, 'dark');
|
||||||
|
expect(dark.tokens.length, 14);
|
||||||
|
expect(dark.tokens[0], 0xFF268BD2);
|
||||||
|
|
||||||
|
// Different surface tokens between Light and Dark —
|
||||||
|
// regression guard against a plugin that returns the
|
||||||
|
// same scheme for both.
|
||||||
|
expect(light.tokens[8], isNot(equals(dark.tokens[8])));
|
||||||
|
} finally {
|
||||||
|
await fixture.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Component-Model instantiation can take a few seconds on
|
||||||
|
// first run as wasmtime caches the compiled artefact.
|
||||||
|
timeout: const Timeout(Duration(minutes: 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -63,16 +63,49 @@ FILE_ALLOWLIST=(
|
||||||
'fixtures?/.*\.(pem|key)$'
|
'fixtures?/.*\.(pem|key)$'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Memory: confidentiality.md — these are PRIVATE. Never in public
|
# Confidential terms are loaded at runtime from a file outside
|
||||||
# code / docs / commits / examples. Word-boundary matched to avoid
|
# the repo (~/.fai-security/banned-terms.txt by default; override
|
||||||
# false-positives on substrings.
|
# with $FAI_BANNED_TERMS_FILE). The list itself is private — the
|
||||||
CONFIDENTIAL_TERMS=(
|
# whole point of the gate is to keep certain strings out of any
|
||||||
'\bITDZ\b'
|
# committed artefact, so the list of strings to gate on must not
|
||||||
'\bKammergericht\b'
|
# live in a committed artefact either.
|
||||||
'\bHTW\b'
|
#
|
||||||
'J∆I'
|
# File format: one regex per line. Lines starting with `#` and
|
||||||
'\bJAI\b'
|
# blank lines are ignored. Each line is matched as a
|
||||||
)
|
# case-insensitive grep -E pattern with `-i`; use `\b…\b` for
|
||||||
|
# word boundaries.
|
||||||
|
#
|
||||||
|
# Bootstrap on a fresh machine:
|
||||||
|
# mkdir -p ~/.fai-security
|
||||||
|
# chmod 700 ~/.fai-security
|
||||||
|
# printf '\\b%s\\b\\n' TERM_A TERM_B > ~/.fai-security/banned-terms.txt
|
||||||
|
# chmod 600 ~/.fai-security/banned-terms.txt
|
||||||
|
#
|
||||||
|
# When the file is missing the gate logs a warning but does NOT
|
||||||
|
# fail — running on a fresh checkout where the operator hasn't
|
||||||
|
# yet bootstrapped the list shouldn't block them. Real CI runs
|
||||||
|
# explicitly verify the file is present and refuse otherwise
|
||||||
|
# (see scan_ci_mode below).
|
||||||
|
CONFIDENTIAL_TERMS=()
|
||||||
|
_load_confidential_terms() {
|
||||||
|
local file="${FAI_BANNED_TERMS_FILE:-$HOME/.fai-security/banned-terms.txt}"
|
||||||
|
if [ ! -f "$file" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
while IFS= read -r line; do
|
||||||
|
# Strip trailing comments + leading/trailing whitespace.
|
||||||
|
line="${line%%#*}"
|
||||||
|
line="${line#"${line%%[![:space:]]*}"}"
|
||||||
|
line="${line%"${line##*[![:space:]]}"}"
|
||||||
|
[ -z "$line" ] && continue
|
||||||
|
CONFIDENTIAL_TERMS+=("$line")
|
||||||
|
done < "$file"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if ! _load_confidential_terms; then
|
||||||
|
printf "%s⚠ confidentiality list not found at ${FAI_BANNED_TERMS_FILE:-~/.fai-security/banned-terms.txt}; gate will skip the confidential-terms scan%s\n" \
|
||||||
|
"${YEL:-}" "${RST:-}" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
# Memory: feedback_no_marketing_speak.md.
|
# Memory: feedback_no_marketing_speak.md.
|
||||||
BANNED_PHRASES=(
|
BANNED_PHRASES=(
|
||||||
|
|
@ -142,7 +175,10 @@ scan_diff() {
|
||||||
local diff="$1"
|
local diff="$1"
|
||||||
[ -z "$diff" ] && return
|
[ -z "$diff" ] && return
|
||||||
scan_secrets "$diff"
|
scan_secrets "$diff"
|
||||||
scan_terms_ci "confidential term" "$diff" "${CONFIDENTIAL_TERMS[@]}"
|
# `${ARR[@]+"${ARR[@]}"}` is the safe-under-set-u expansion
|
||||||
|
# for arrays that may be empty (CONFIDENTIAL_TERMS is empty
|
||||||
|
# when the operator hasn't bootstrapped ~/.fai-security/).
|
||||||
|
scan_terms_ci "confidential term" "$diff" ${CONFIDENTIAL_TERMS[@]+"${CONFIDENTIAL_TERMS[@]}"}
|
||||||
scan_terms_ci "banned phrase" "$diff" "${BANNED_PHRASES[@]}"
|
scan_terms_ci "banned phrase" "$diff" "${BANNED_PHRASES[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,7 +244,7 @@ scan_message_mode() {
|
||||||
fail "Co-Authored-By: Claude trailer is forbidden (memory: feedback_coding_style.md)"
|
fail "Co-Authored-By: Claude trailer is forbidden (memory: feedback_coding_style.md)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
scan_terms_ci "confidential term in commit message" "$msg" "${CONFIDENTIAL_TERMS[@]}"
|
scan_terms_ci "confidential term in commit message" "$msg" ${CONFIDENTIAL_TERMS[@]+"${CONFIDENTIAL_TERMS[@]}"}
|
||||||
scan_terms_ci "banned phrase in commit message" "$msg" "${BANNED_PHRASES[@]}"
|
scan_terms_ci "banned phrase in commit message" "$msg" "${BANNED_PHRASES[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,6 +253,16 @@ scan_ci_mode() {
|
||||||
if ! git rev-parse --quiet --verify "$base" >/dev/null 2>&1; then
|
if ! git rev-parse --quiet --verify "$base" >/dev/null 2>&1; then
|
||||||
base=$(git rev-list --max-parents=0 HEAD | tail -1)
|
base=$(git rev-list --max-parents=0 HEAD | tail -1)
|
||||||
fi
|
fi
|
||||||
|
# In CI the confidentiality list is non-optional: a missing
|
||||||
|
# file means an unbootstrapped runner is reviewing changes,
|
||||||
|
# and we'd be silently letting confidential terms through.
|
||||||
|
# The deploy step that wires up the runner is responsible for
|
||||||
|
# placing the file at $FAI_BANNED_TERMS_FILE.
|
||||||
|
if [ "${#CONFIDENTIAL_TERMS[@]}" -eq 0 ]; then
|
||||||
|
fail "CI mode requires \$FAI_BANNED_TERMS_FILE (or ~/.fai-security/banned-terms.txt) to exist"
|
||||||
|
fail " bootstrap the runner with a private confidentiality list before re-running"
|
||||||
|
return
|
||||||
|
fi
|
||||||
local files diff
|
local files diff
|
||||||
files=$(git diff --name-only --diff-filter=ACMR "$base"..HEAD)
|
files=$(git diff --name-only --diff-filter=ACMR "$base"..HEAD)
|
||||||
diff=$(git diff --no-color -U0 "$base"..HEAD -- . "${EXCLUDES[@]}" \
|
diff=$(git diff --no-color -U0 "$base"..HEAD -- . "${EXCLUDES[@]}" \
|
||||||
|
|
|
||||||
|
|
@ -153,9 +153,19 @@ git add tests/server.pem
|
||||||
assert_content_passes "tests/*.pem allowlisted"
|
assert_content_passes "tests/*.pem allowlisted"
|
||||||
|
|
||||||
d=$(setup_repo); cd "$d"
|
d=$(setup_repo); cd "$d"
|
||||||
echo "Notes from the ITDZ Berlin meeting" > confidential.md
|
# The confidentiality list lives outside the repo at
|
||||||
|
# $FAI_BANNED_TERMS_FILE (default ~/.fai-security/banned-terms.txt).
|
||||||
|
# For the test we point at a temp file holding a single
|
||||||
|
# generic test term — the actual production terms stay in the
|
||||||
|
# operator's untracked file and never enter this test suite.
|
||||||
|
TEST_BANNED_TERMS=$(mktemp -t fai_banned.XXXXXX)
|
||||||
|
printf "\\\\bSYNTHETIC_BANNED_TERM_XYZZY\\\\b\n" > "$TEST_BANNED_TERMS"
|
||||||
|
export FAI_BANNED_TERMS_FILE="$TEST_BANNED_TERMS"
|
||||||
|
echo "Document contains SYNTHETIC_BANNED_TERM_XYZZY in passing" > confidential.md
|
||||||
git add confidential.md
|
git add confidential.md
|
||||||
assert_content_fails "confidential term ITDZ" "ITDZ"
|
assert_content_fails "confidential term (synthetic test term)" "SYNTHETIC_BANNED_TERM_XYZZY"
|
||||||
|
rm -f "$TEST_BANNED_TERMS"
|
||||||
|
unset FAI_BANNED_TERMS_FILE
|
||||||
|
|
||||||
d=$(setup_repo); cd "$d"
|
d=$(setup_repo); cd "$d"
|
||||||
echo "Our viral release of a powerful tool that just works" > marketing.md
|
echo "Our viral release of a powerful tool that just works" > marketing.md
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,14 @@ feel like they're reading a translation.
|
||||||
- Banned words: "viral", "killer", "powerful", "just works",
|
- Banned words: "viral", "killer", "powerful", "just works",
|
||||||
"revolutionary", "game-changing", "seamless", "next-generation",
|
"revolutionary", "game-changing", "seamless", "next-generation",
|
||||||
"cutting-edge", "world-class", "unprecedented".
|
"cutting-edge", "world-class", "unprecedented".
|
||||||
- No private organisation names: never "ITDZ", "Kammergericht",
|
- No private organisation, person, or location names. Pilot
|
||||||
"HTW", "J∆I", or any specific institution. Generic terms only —
|
customers, internal codenames, specific institutions, and
|
||||||
"a regulated organisation", "a public administration".
|
specific cities never appear in Today copy. Generic terms
|
||||||
|
only — "a regulated organisation", "a public administration",
|
||||||
|
"a critical-infrastructure operator". The operator's
|
||||||
|
banned-terms list at ~/.fai-security/banned-terms.txt is the
|
||||||
|
authoritative source; this prompt deliberately doesn't
|
||||||
|
enumerate the strings.
|
||||||
- No emoji.
|
- No emoji.
|
||||||
- No "Co-authored-by Claude" or other AI attribution.
|
- No "Co-authored-by Claude" or other AI attribution.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue