Compare commits
No commits in common. "91706cea2b0c8d5aabfdeb8d3fb7cb90603e5f1f" and "081ffd723364469862628d0313a5bd7d4235650b" have entirely different histories.
91706cea2b
...
081ffd7233
33 changed files with 472 additions and 3122 deletions
|
|
@ -83,12 +83,9 @@ The prompt template (`tools/today/prompt.template.md`) hard-encodes:
|
|||
`feedback_no_marketing_speak.md` is included in the prompt: ban
|
||||
"viral", "killer", "powerful", "just works", "revolutionary",
|
||||
"game-changing".
|
||||
4. **No private references.** Generic terms only — no pilot
|
||||
customer names, internal codenames, or specific
|
||||
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`).
|
||||
4. **No private references.** Generic terms only; no
|
||||
ITDZ / Kammergericht / HTW / J∆I per
|
||||
`feedback_confidentiality.md`.
|
||||
5. **Concrete CTA.** Every story names exactly one action the operator
|
||||
can take in Studio (which page, which button).
|
||||
6. **Body fits in ~3 sentences.** Hero card height is fixed; longer
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
// 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();
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
// 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]}';
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
// 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,9 +11,6 @@ import 'package:fai_dart_sdk/fai_dart_sdk.dart';
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'flow_output.dart';
|
||||
export 'flow_output.dart';
|
||||
|
||||
class HubService {
|
||||
HubService._();
|
||||
static final HubService instance = HubService._();
|
||||
|
|
@ -93,18 +90,13 @@ class HubService {
|
|||
|
||||
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 {
|
||||
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>>{};
|
||||
for (final c in wasm) {
|
||||
for (final c in caps) {
|
||||
byModule.putIfAbsent(c.moduleName, () => []).add(c);
|
||||
}
|
||||
return byModule.entries.map((e) {
|
||||
|
|
@ -120,25 +112,6 @@ class HubService {
|
|||
..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.
|
||||
Future<ModuleDetail> moduleInfo(String name) async {
|
||||
final r = await _client.moduleInfo(name);
|
||||
|
|
@ -150,7 +123,6 @@ class HubService {
|
|||
.toList(),
|
||||
permissions: r.permissions,
|
||||
directory: r.directory,
|
||||
acceptsMime: r.acceptsMime,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -374,71 +346,17 @@ class HubService {
|
|||
return (name: r.name, version: r.version);
|
||||
}
|
||||
|
||||
/// Read the installed module's `MODULE.md` (or
|
||||
/// `MODULE.<locale>.md`) from disk under
|
||||
/// `~/.fai/modules/<module>/`. Air-gap friendly, no network.
|
||||
///
|
||||
/// `state` is one of:
|
||||
/// - `'found'` — `text` holds the markdown
|
||||
/// - `'no_docs'` — module installed but bundle had no docs
|
||||
/// - `'not_installed'` — module isn't installed at all
|
||||
Future<({String state, String text, String sourcePath})>
|
||||
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,
|
||||
/// Fetch a module's README markdown via the hub. Errors come
|
||||
/// back as `errorKind` strings (not exceptions) so callers can
|
||||
/// render fallback UI without try/catch ceremony.
|
||||
Future<({String errorKind, String text, String sourceUrl})>
|
||||
fetchModuleDocs(String name, {String locale = ''}) async {
|
||||
final r = await _client.fetchModuleDocs(name, locale: locale);
|
||||
return (
|
||||
errorKind: r.errorKind,
|
||||
text: r.text,
|
||||
sourceUrl: r.sourceUrl,
|
||||
);
|
||||
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.
|
||||
|
|
@ -642,31 +560,35 @@ class HubService {
|
|||
/// land as text Payloads, byte values as bytes Payloads —
|
||||
/// flows that mix both (e.g. extract taking a `document:
|
||||
/// bytes` plus a text-shaped option) flow through one RPC.
|
||||
/// [fileMimeTypes] is keyed the same as [fileInputs]; missing
|
||||
/// 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({
|
||||
/// Returns the named outputs as a map of UI-friendly strings.
|
||||
Future<Map<String, String>> runSavedFlow({
|
||||
required String name,
|
||||
Map<String, String> textInputs = const {},
|
||||
Map<String, Uint8List> fileInputs = const {},
|
||||
Map<String, String> fileMimeTypes = const {},
|
||||
}) async {
|
||||
final r = await _client.runSavedFlow(
|
||||
name: name,
|
||||
textInputs: textInputs,
|
||||
fileInputs: fileInputs,
|
||||
fileMimeTypes: fileMimeTypes,
|
||||
);
|
||||
return {
|
||||
for (final entry in r.outputs.entries)
|
||||
entry.key: FlowOutput.fromPayload(entry.value),
|
||||
entry.key: _payloadToText(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({
|
||||
int limit = 50,
|
||||
List<String> types = const [],
|
||||
|
|
@ -904,26 +826,6 @@ 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
|
||||
/// don't import protobuf packages.
|
||||
class ModuleSummary {
|
||||
|
|
@ -962,10 +864,6 @@ class ModuleDetail {
|
|||
final List<String> capabilities;
|
||||
final List<String> permissions;
|
||||
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({
|
||||
required this.name,
|
||||
|
|
@ -973,7 +871,6 @@ class ModuleDetail {
|
|||
required this.capabilities,
|
||||
required this.permissions,
|
||||
required this.directory,
|
||||
this.acceptsMime = const [],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,152 +0,0 @@
|
|||
// 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,30 +75,6 @@
|
|||
"buttonReadDocs": "Doku öffnen",
|
||||
"buttonCopy": "In Zwischenablage kopieren",
|
||||
"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",
|
||||
"settingsHost": "Host",
|
||||
|
|
@ -530,43 +506,9 @@
|
|||
"flowsRunButton": "Starten",
|
||||
"flowsRunningTitle": "{name} läuft",
|
||||
"@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…",
|
||||
"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",
|
||||
"approvalsTabPending": "Offen",
|
||||
"approvalsTabHistory": "Verlauf",
|
||||
|
|
|
|||
|
|
@ -76,30 +76,6 @@
|
|||
"buttonReadDocs": "Read docs",
|
||||
"buttonCopy": "Copy to clipboard",
|
||||
"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",
|
||||
"settingsHost": "Host",
|
||||
|
|
@ -531,43 +507,9 @@
|
|||
"flowsRunButton": "Run",
|
||||
"flowsRunningTitle": "Running {name}",
|
||||
"@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } },
|
||||
"flowsResultTitle": "{name} — result",
|
||||
"@flowsResultTitle": { "placeholders": { "name": { "type": "String" } } },
|
||||
"flowsErrorTitle": "{name} — failed",
|
||||
"@flowsErrorTitle": { "placeholders": { "name": { "type": "String" } } },
|
||||
"flowsRunning": "Flow running…",
|
||||
"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",
|
||||
"approvalsTabPending": "Pending",
|
||||
"approvalsTabHistory": "History",
|
||||
|
|
|
|||
|
|
@ -518,132 +518,6 @@ abstract class AppLocalizations {
|
|||
/// **'Copied'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -2408,18 +2282,6 @@ abstract class AppLocalizations {
|
|||
/// **'Running {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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -2432,162 +2294,6 @@ abstract class AppLocalizations {
|
|||
/// **'Flow completed with no declared outputs.'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -237,85 +237,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
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
|
||||
String get settingsTitle => 'Hub-Endpunkt';
|
||||
|
||||
|
|
@ -1387,113 +1308,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
return '$name läuft';
|
||||
}
|
||||
|
||||
@override
|
||||
String flowsResultTitle(String name) {
|
||||
return '$name — Ergebnis';
|
||||
}
|
||||
|
||||
@override
|
||||
String flowsErrorTitle(String name) {
|
||||
return '$name — fehlgeschlagen';
|
||||
}
|
||||
|
||||
@override
|
||||
String get flowsRunning => 'Flow läuft…';
|
||||
|
||||
@override
|
||||
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
|
||||
String get approvalsTitle => 'Freigaben';
|
||||
|
||||
|
|
|
|||
|
|
@ -237,85 +237,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
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
|
||||
String get settingsTitle => 'Hub endpoint';
|
||||
|
||||
|
|
@ -1400,112 +1321,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
return 'Running $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String flowsResultTitle(String name) {
|
||||
return '$name — result';
|
||||
}
|
||||
|
||||
@override
|
||||
String flowsErrorTitle(String name) {
|
||||
return '$name — failed';
|
||||
}
|
||||
|
||||
@override
|
||||
String get flowsRunning => 'Flow running…';
|
||||
|
||||
@override
|
||||
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
|
||||
String get approvalsTitle => 'Approvals';
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import 'package:flutter/services.dart';
|
|||
|
||||
import 'data/hub.dart';
|
||||
import 'data/system_actions.dart';
|
||||
import 'data/theme_plugin.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
import 'pages/approvals.dart';
|
||||
import 'pages/audit.dart';
|
||||
|
|
@ -31,33 +30,22 @@ const String kStudioVersion = '0.42.0';
|
|||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// Restore the persisted endpoint, theme mode, locale, and
|
||||
// (when installed) the operator's theme-plugin choice
|
||||
// before the first frame so nothing flickers on startup.
|
||||
// Restore the persisted endpoint, theme mode, and locale
|
||||
// before the first frame so none of them flicker on startup.
|
||||
await HubService.instance.loadPersistedEndpoint();
|
||||
final themeMode = await HubService.instance.loadThemeMode();
|
||||
final locale = await HubService.instance.loadLocale();
|
||||
final themePlugin = await loadActiveThemePlugin();
|
||||
runApp(StudioApp(
|
||||
initialThemeMode: themeMode,
|
||||
initialLocale: locale,
|
||||
initialThemePlugin: themePlugin,
|
||||
));
|
||||
runApp(StudioApp(initialThemeMode: themeMode, initialLocale: locale));
|
||||
}
|
||||
|
||||
class StudioApp extends StatefulWidget {
|
||||
final ThemeModeValue initialThemeMode;
|
||||
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({
|
||||
super.key,
|
||||
required this.initialThemeMode,
|
||||
required this.initialLocale,
|
||||
this.initialThemePlugin,
|
||||
});
|
||||
|
||||
/// Lookup helper so descendants can flip the theme without
|
||||
|
|
@ -80,12 +68,6 @@ class StudioAppState extends State<StudioApp> {
|
|||
/// this, MaterialApp re-renders with the new
|
||||
/// AppLocalizations, and every translated string updates.
|
||||
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 {
|
||||
modeNotifier.value = m;
|
||||
|
|
@ -97,24 +79,17 @@ class StudioAppState extends State<StudioApp> {
|
|||
await HubService.instance.saveLocale(l);
|
||||
}
|
||||
|
||||
Future<void> setThemePlugin(String? capability) async {
|
||||
themePluginNotifier.value = capability;
|
||||
await saveActiveThemePlugin(capability);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
modeNotifier = ValueNotifier(widget.initialThemeMode);
|
||||
localeNotifier = ValueNotifier(widget.initialLocale);
|
||||
themePluginNotifier = ValueNotifier(widget.initialThemePlugin);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
modeNotifier.dispose();
|
||||
localeNotifier.dispose();
|
||||
themePluginNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -129,59 +104,22 @@ 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
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<ThemeModeValue>(
|
||||
valueListenable: modeNotifier,
|
||||
builder: (_, mode, _) => ValueListenableBuilder<Locale>(
|
||||
valueListenable: localeNotifier,
|
||||
builder: (_, locale, _) => ValueListenableBuilder<String?>(
|
||||
valueListenable: themePluginNotifier,
|
||||
builder: (_, pluginCap, _) => FutureBuilder<({ThemeData? light, ThemeData? dark})>(
|
||||
// Re-key on the capability so a plugin change
|
||||
// discards the in-flight future for the previous
|
||||
// plugin and starts fresh.
|
||||
key: ValueKey('plugin_themes:$pluginCap'),
|
||||
future: _pluginThemes(pluginCap),
|
||||
builder: (context, snap) {
|
||||
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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
builder: (_, locale, _) => MaterialApp(
|
||||
title: 'F∆I Studio',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: FaiTheme.light(),
|
||||
darkTheme: FaiTheme.dark(),
|
||||
themeMode: _flutterMode(mode),
|
||||
locale: locale,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
home: const StudioShell(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import 'package:fai_dart_sdk/fai_dart_sdk.dart' show FlowInputDef;
|
|||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/format.dart';
|
||||
import '../data/hub.dart';
|
||||
import '../data/system_actions.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
|
@ -29,27 +28,18 @@ class _FlowsPageState extends State<FlowsPage> {
|
|||
_future = _load();
|
||||
}
|
||||
|
||||
/// Pulls the saved flows AND every capability the hub can
|
||||
/// execute (WASM modules + built-ins like `system.approval` +
|
||||
/// federated MCP/n8n tools) in parallel so the page can
|
||||
/// compute "is this flow runnable" per row.
|
||||
///
|
||||
/// Using `allCapabilities()` instead of `listModules()` is
|
||||
/// load-bearing: built-ins are not WASM modules, so they would
|
||||
/// never appear in a module-grouped list — and a flow that
|
||||
/// uses `system.approval@^0` would stay stuck on "missing
|
||||
/// dependency" forever.
|
||||
/// Pulls the saved flows AND the installed-modules list in
|
||||
/// parallel so the page can compute "is this flow runnable"
|
||||
/// per row from the same data the Run button gates on.
|
||||
Future<_FlowsBundle> _load() async {
|
||||
final results = await Future.wait([
|
||||
HubService.instance.listFlows(),
|
||||
HubService.instance
|
||||
.allCapabilities()
|
||||
.catchError((_) => <CapabilityInfo>[]),
|
||||
HubService.instance.listModules().catchError((_) => <ModuleSummary>[]),
|
||||
]);
|
||||
return _FlowsBundle(
|
||||
flows: results[0] as List<SavedFlow>,
|
||||
installedCapabilities: (results[1] as List<CapabilityInfo>)
|
||||
.map((c) => c.capability)
|
||||
installedCapabilities: (results[1] as List<ModuleSummary>)
|
||||
.expand((m) => m.capabilities)
|
||||
.toSet(),
|
||||
);
|
||||
}
|
||||
|
|
@ -84,7 +74,6 @@ class _FlowsPageState extends State<FlowsPage> {
|
|||
flow: flow,
|
||||
textInputs: inputs.textInputs,
|
||||
fileInputs: inputs.fileInputs,
|
||||
fileMimeTypes: inputs.fileMimeTypes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -265,7 +254,7 @@ class _FlowCard extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
FaiPill(
|
||||
label: humanBytes(flow.sizeBytes),
|
||||
label: '${flow.sizeBytes} B',
|
||||
tone: FaiPillTone.neutral,
|
||||
monospace: true,
|
||||
),
|
||||
|
|
@ -380,75 +369,16 @@ class _ClickablePill extends StatelessWidget {
|
|||
class _FlowRunInputs {
|
||||
final Map<String, String> textInputs;
|
||||
final Map<String, Uint8List> fileInputs;
|
||||
/// Parallel to [fileInputs]; same keys. Modules like
|
||||
/// `text.extract` reject `application/octet-stream`, so we
|
||||
/// derive a real MIME type from the picked file's extension
|
||||
/// and forward it to the hub.
|
||||
final Map<String, String> fileMimeTypes;
|
||||
const _FlowRunInputs({
|
||||
required this.textInputs,
|
||||
required this.fileInputs,
|
||||
required this.fileMimeTypes,
|
||||
});
|
||||
}
|
||||
|
||||
class _PickedFile {
|
||||
final String name;
|
||||
final Uint8List bytes;
|
||||
/// 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 '';
|
||||
}
|
||||
const _PickedFile({required this.name, required this.bytes});
|
||||
}
|
||||
|
||||
/// Run-flow input dialog. Fetches the flow's declared input
|
||||
|
|
@ -527,26 +457,16 @@ class _FlowInputDialogState extends State<_FlowInputDialog> {
|
|||
_FlowRunInputs _collect(List<FlowInputDef> defs) {
|
||||
final textInputs = <String, String>{};
|
||||
final fileInputs = <String, Uint8List>{};
|
||||
final fileMimeTypes = <String, String>{};
|
||||
for (final d in defs) {
|
||||
if (_isBytesType(d.type)) {
|
||||
final picked = _pickedFiles[d.name];
|
||||
if (picked != null) {
|
||||
fileInputs[d.name] = picked.bytes;
|
||||
if (picked.mimeType.isNotEmpty) {
|
||||
fileMimeTypes[d.name] = picked.mimeType;
|
||||
}
|
||||
}
|
||||
if (picked != null) fileInputs[d.name] = picked.bytes;
|
||||
} else {
|
||||
final c = _textControllers[d.name];
|
||||
if (c != null) textInputs[d.name] = c.text;
|
||||
}
|
||||
}
|
||||
return _FlowRunInputs(
|
||||
textInputs: textInputs,
|
||||
fileInputs: fileInputs,
|
||||
fileMimeTypes: fileMimeTypes,
|
||||
);
|
||||
return _FlowRunInputs(textInputs: textInputs, fileInputs: fileInputs);
|
||||
}
|
||||
|
||||
Future<void> _pickFile(String inputName) async {
|
||||
|
|
@ -570,11 +490,7 @@ class _FlowInputDialogState extends State<_FlowInputDialog> {
|
|||
}
|
||||
if (bytes == null || !mounted) return;
|
||||
setState(() {
|
||||
_pickedFiles[inputName] = _PickedFile(
|
||||
name: f.name,
|
||||
bytes: bytes!,
|
||||
mimeType: _mimeForFilename(f.name),
|
||||
);
|
||||
_pickedFiles[inputName] = _PickedFile(name: f.name, bytes: bytes!);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -635,7 +551,7 @@ class _FlowInputDialogState extends State<_FlowInputDialog> {
|
|||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
FaiErrorBox(
|
||||
error: snap.error,
|
||||
text: snap.error.toString(),
|
||||
isError: true,
|
||||
maxHeight: 200,
|
||||
),
|
||||
|
|
@ -760,7 +676,7 @@ class _InputField extends StatelessWidget {
|
|||
pickedFile == null
|
||||
? l.flowsTypeBytesNoFile
|
||||
: '${pickedFile!.name} · '
|
||||
'${humanBytes(pickedFile!.bytes.length)}',
|
||||
'${l.flowsTypeBytesSize(pickedFile!.bytes.length)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -786,13 +702,11 @@ class _FlowRunDialog extends StatefulWidget {
|
|||
final SavedFlow flow;
|
||||
final Map<String, String> textInputs;
|
||||
final Map<String, Uint8List> fileInputs;
|
||||
final Map<String, String> fileMimeTypes;
|
||||
|
||||
const _FlowRunDialog({
|
||||
required this.flow,
|
||||
required this.textInputs,
|
||||
required this.fileInputs,
|
||||
required this.fileMimeTypes,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -800,7 +714,7 @@ class _FlowRunDialog extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _FlowRunDialogState extends State<_FlowRunDialog> {
|
||||
late final Future<Map<String, FlowOutput>> _future;
|
||||
late final Future<Map<String, String>> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -809,7 +723,6 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
|
|||
name: widget.flow.name,
|
||||
textInputs: widget.textInputs,
|
||||
fileInputs: widget.fileInputs,
|
||||
fileMimeTypes: widget.fileMimeTypes,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -818,29 +731,13 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
|
|||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return AlertDialog(
|
||||
// Title tracks the future's state so the dialog header
|
||||
// stops claiming "extract läuft" once the run is over.
|
||||
// Recomputed on every snapshot change via FutureBuilder.
|
||||
title: FutureBuilder<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);
|
||||
},
|
||||
),
|
||||
title: Text(l.flowsRunningTitle(widget.flow.name)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||
),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480),
|
||||
child: FutureBuilder<Map<String, FlowOutput>>(
|
||||
child: FutureBuilder<Map<String, String>>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
|
|
@ -884,7 +781,7 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
|
|||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
FaiErrorBox(
|
||||
error: snapshot.error,
|
||||
text: snapshot.error.toString(),
|
||||
isError: true,
|
||||
maxHeight: 280,
|
||||
),
|
||||
|
|
@ -915,7 +812,21 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
|
|||
),
|
||||
),
|
||||
),
|
||||
FaiFlowOutput(output: entry.value),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(FaiSpace.md),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: SelectableText(
|
||||
entry.value,
|
||||
style: FaiTheme.mono(size: 11),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
],
|
||||
],
|
||||
|
|
@ -954,10 +865,7 @@ class _InstallItemState {
|
|||
|
||||
_InstallStatus status;
|
||||
String? installedVersion;
|
||||
/// Original thrown object so the UI can run it through
|
||||
/// [friendlyError] later instead of staring at a wall of gRPC
|
||||
/// trailers.
|
||||
Object? error;
|
||||
String? error;
|
||||
|
||||
_InstallItemState(this.spec)
|
||||
: bareName = _stripVersion(spec),
|
||||
|
|
@ -1015,7 +923,7 @@ class _InstallDependenciesDialogState
|
|||
if (!mounted) return;
|
||||
setState(() {
|
||||
item.status = _InstallStatus.failed;
|
||||
item.error = e;
|
||||
item.error = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1042,9 +950,8 @@ class _InstallDependenciesDialogState
|
|||
i.status == _InstallStatus.failed && _isAuthWallError(i.error),
|
||||
);
|
||||
|
||||
static bool _isAuthWallError(Object? err) {
|
||||
if (err == null) return false;
|
||||
final msg = err.toString();
|
||||
static bool _isAuthWallError(String? msg) {
|
||||
if (msg == null) return false;
|
||||
return msg.contains('registry returned an HTML page') ||
|
||||
msg.contains('no registry token configured');
|
||||
}
|
||||
|
|
@ -1164,7 +1071,7 @@ class _InstallRow extends StatelessWidget {
|
|||
item.error != null) ...[
|
||||
const SizedBox(height: FaiSpace.xs),
|
||||
FaiErrorBox(
|
||||
error: item.error,
|
||||
text: item.error!,
|
||||
isError: true,
|
||||
maxHeight: 120,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import 'dart:convert';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
|
||||
import '../data/hub.dart';
|
||||
import '../data/system_actions.dart';
|
||||
|
|
@ -36,13 +36,6 @@ class _StorePageState extends State<StorePage> {
|
|||
/// has no source field.
|
||||
String _source = '';
|
||||
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
|
||||
/// while the request is in flight.
|
||||
final Set<String> _addingSources = <String>{};
|
||||
|
|
@ -143,19 +136,7 @@ class _StorePageState extends State<StorePage> {
|
|||
final all = results[0] as List<StoreItem>;
|
||||
final mods = results[1] as List<ModuleSummary>;
|
||||
_installedVersions = {for (final m in mods) m.name: m.version};
|
||||
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;
|
||||
return _installedOnly ? all.where((e) => e.installed).toList() : all;
|
||||
}
|
||||
|
||||
void _runSearch() {
|
||||
|
|
@ -280,7 +261,6 @@ class _StorePageState extends State<StorePage> {
|
|||
_status = '';
|
||||
_source = '';
|
||||
_installedOnly = false;
|
||||
_installableOnly = false;
|
||||
_queryCtrl.clear();
|
||||
});
|
||||
_runSearch();
|
||||
|
|
@ -460,10 +440,6 @@ class _StorePageState extends State<StorePage> {
|
|||
if (_status.isNotEmpty) n++;
|
||||
if (_source.isNotEmpty) 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;
|
||||
}
|
||||
|
||||
|
|
@ -581,7 +557,6 @@ class _StorePageState extends State<StorePage> {
|
|||
status: _status,
|
||||
source: _source,
|
||||
installedOnly: _installedOnly,
|
||||
installableOnly: _installableOnly,
|
||||
),
|
||||
);
|
||||
if (outcome == null) return;
|
||||
|
|
@ -589,7 +564,6 @@ class _StorePageState extends State<StorePage> {
|
|||
_status = outcome.status;
|
||||
_source = outcome.source;
|
||||
_installedOnly = outcome.installedOnly;
|
||||
_installableOnly = outcome.installableOnly;
|
||||
});
|
||||
_runSearch();
|
||||
}
|
||||
|
|
@ -918,12 +892,10 @@ class _FilterOutcome {
|
|||
final String status;
|
||||
final String source;
|
||||
final bool installedOnly;
|
||||
final bool installableOnly;
|
||||
const _FilterOutcome({
|
||||
required this.status,
|
||||
required this.source,
|
||||
required this.installedOnly,
|
||||
required this.installableOnly,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -936,13 +908,11 @@ class _FilterDialog extends StatefulWidget {
|
|||
final String status;
|
||||
final String source;
|
||||
final bool installedOnly;
|
||||
final bool installableOnly;
|
||||
|
||||
const _FilterDialog({
|
||||
required this.status,
|
||||
required this.source,
|
||||
required this.installedOnly,
|
||||
required this.installableOnly,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -953,7 +923,6 @@ class _FilterDialogState extends State<_FilterDialog> {
|
|||
late String _status = widget.status;
|
||||
late String _source = widget.source;
|
||||
late bool _installedOnly = widget.installedOnly;
|
||||
late bool _installableOnly = widget.installableOnly;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -1020,23 +989,11 @@ class _FilterDialogState extends State<_FilterDialog> {
|
|||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
header(l.storeFilterInstalledGroup),
|
||||
Wrap(
|
||||
spacing: FaiSpace.xs,
|
||||
runSpacing: FaiSpace.xs,
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
],
|
||||
FilterChip(
|
||||
label: Text(l.storeInstalledOnly),
|
||||
selected: _installedOnly,
|
||||
onSelected: (v) => setState(() => _installedOnly = v),
|
||||
showCheckmark: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -1048,11 +1005,6 @@ class _FilterDialogState extends State<_FilterDialog> {
|
|||
_status = '';
|
||||
_source = '';
|
||||
_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),
|
||||
|
|
@ -1069,7 +1021,6 @@ class _FilterDialogState extends State<_FilterDialog> {
|
|||
status: _status,
|
||||
source: _source,
|
||||
installedOnly: _installedOnly,
|
||||
installableOnly: _installableOnly,
|
||||
),
|
||||
),
|
||||
child: Text(l.storeFilterApply),
|
||||
|
|
@ -1615,29 +1566,11 @@ class _FeaturedTile extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (item.isFederated)
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Text(
|
||||
tagline.isEmpty ? l.storeNoTagline : tagline,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
|
|
@ -1667,16 +1600,6 @@ class _FeaturedTile extends StatelessWidget {
|
|||
onPressed: onInstall,
|
||||
icon: const Icon(Icons.download, size: 16),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -1724,14 +1647,7 @@ class _StoreCard extends StatelessWidget {
|
|||
? item.taglineDe
|
||||
: item.taglineEn;
|
||||
final installable = item.status == 'published' || item.status == 'alpha';
|
||||
// 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(
|
||||
return Material(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||
child: InkWell(
|
||||
|
|
@ -1866,7 +1782,6 @@ class _StoreCard extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1908,13 +1823,8 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
String get _locale => Localizations.localeOf(context).languageCode;
|
||||
bool _busy = false;
|
||||
String? _toast;
|
||||
/// `null` while the initial probe is in flight; non-null once
|
||||
/// `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;
|
||||
Future<({String errorKind, String text, String sourceUrl})>? _docsFuture;
|
||||
bool _docsLoaded = false;
|
||||
/// Live module-info for installed entries: declared
|
||||
/// permissions and on-disk directory. Pulled lazily so
|
||||
/// not-installed entries don't run an unnecessary RPC. Stays
|
||||
|
|
@ -1930,32 +1840,6 @@ 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 {
|
||||
try {
|
||||
final detail =
|
||||
|
|
@ -2050,6 +1934,19 @@ 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
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
|
@ -2166,15 +2063,6 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
const SizedBox(height: FaiSpace.lg),
|
||||
],
|
||||
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(
|
||||
description,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
|
|
@ -2303,13 +2191,15 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
],
|
||||
if (_docsResult?.state == 'found') ...[
|
||||
if (item.repository.isNotEmpty || item.docsUrl.isNotEmpty) ...[
|
||||
_SectionHeader(l.storeSectionDocumentation),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
_DocsPanel(text: _docsResult!.text),
|
||||
_DocsPanel(
|
||||
future: _docsFuture,
|
||||
onLoad: _loadDocs,
|
||||
onOpenRepo: _openRepository,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
],
|
||||
if (item.repository.isNotEmpty) ...[
|
||||
_SectionHeader(l.storeSectionSource),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
InkWell(
|
||||
|
|
@ -2352,6 +2242,12 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
],
|
||||
Row(
|
||||
children: [
|
||||
if (item.repository.isNotEmpty)
|
||||
OutlinedButton.icon(
|
||||
onPressed: _openRepository,
|
||||
icon: const Icon(Icons.menu_book_outlined, size: 16),
|
||||
label: Text(l.buttonReadDocs),
|
||||
),
|
||||
const Spacer(),
|
||||
if (item.installed) ...[
|
||||
OutlinedButton.icon(
|
||||
|
|
@ -2388,46 +2284,14 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
_busy ? l.storeInstallingButton : l.buttonInstall,
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
// Inline hint replaces the previous
|
||||
// tooltip-only affordance: the operator
|
||||
// sees the reason without having to hover.
|
||||
Expanded(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
else
|
||||
Tooltip(
|
||||
message: l.storeNotInstallableTooltip(item.status),
|
||||
child: FilledButton(
|
||||
onPressed: null,
|
||||
child: Text(l.storeNotInstallable),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
@ -2521,10 +2385,12 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> {
|
|||
size: 32,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
FaiErrorBox(
|
||||
error: snap.error,
|
||||
isError: true,
|
||||
maxHeight: 200,
|
||||
SelectableText(
|
||||
snap.error.toString(),
|
||||
style: FaiTheme.mono(
|
||||
size: 11,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -2596,40 +2462,210 @@ IconData _iconForCategory(String category) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Renders the module's inline `MODULE.md` (or its localized
|
||||
/// sibling) in the same theme as the rest of Studio. The markdown
|
||||
/// body is supplied by the parent — `_DocsPanel` itself stays a
|
||||
/// pure renderer with no async / no error handling.
|
||||
///
|
||||
/// Blockquote + table-cell decoration is overridden because
|
||||
/// flutter_markdown's default styles hardcode
|
||||
/// `Colors.blue.shade100`, which is unreadable on a dark theme.
|
||||
/// Lazy-loaded inline README renderer. Shows a "Load
|
||||
/// documentation" button until the operator clicks it (so we
|
||||
/// don't blow the network unprompted), then a markdown view of
|
||||
/// the result. Auth / not-found errors come back with friendly
|
||||
/// fix-hint copy and a fallback "View on repository" button.
|
||||
class _DocsPanel extends StatelessWidget {
|
||||
final String text;
|
||||
final Future<({String errorKind, String text, String sourceUrl})>? future;
|
||||
final VoidCallback onLoad;
|
||||
final VoidCallback onOpenRepo;
|
||||
|
||||
const _DocsPanel({required this.text});
|
||||
const _DocsPanel({
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
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: text,
|
||||
shrinkWrap: true,
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) async {
|
||||
if (href == null || href.isEmpty) return;
|
||||
await SystemActions.openInOs(href);
|
||||
},
|
||||
styleSheet: FaiTheme.markdownStyle(theme),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -2661,6 +2697,24 @@ int _versionCmp(String a, String b) {
|
|||
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
|
||||
/// back to the category-derived `Icons.X` glyph in a colored
|
||||
/// circle when the URL is empty or fails to load. Wraps the
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../data/hub.dart';
|
||||
|
|
@ -1041,7 +1041,7 @@ class _DocReaderSheetState extends State<_DocReaderSheet> {
|
|||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
FaiErrorBox(
|
||||
error: snap.error,
|
||||
text: snap.error.toString(),
|
||||
isError: true,
|
||||
maxHeight: 200,
|
||||
),
|
||||
|
|
@ -1058,7 +1058,17 @@ class _DocReaderSheetState extends State<_DocReaderSheet> {
|
|||
await SystemActions.openInOs(href);
|
||||
}
|
||||
},
|
||||
styleSheet: FaiTheme.markdownStyle(theme),
|
||||
styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith(
|
||||
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,7 +1,6 @@
|
|||
// Theme assembly. ColorScheme + typography + component themes.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import 'tokens.dart';
|
||||
|
|
@ -78,69 +77,6 @@ class FaiTheme {
|
|||
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() {
|
||||
final scheme = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
// 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,15 +10,13 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../data/friendly_error.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/theme.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
class FaiErrorBox extends StatefulWidget {
|
||||
/// The text the operator wants to read (and copy). Rendered
|
||||
/// monospace, selectable, multi-line. Ignored when [error] is
|
||||
/// supplied.
|
||||
/// monospace, selectable, multi-line.
|
||||
final String text;
|
||||
/// When true, the box border + foreground colour come from the
|
||||
/// error palette. False renders neutral chrome — useful for
|
||||
|
|
@ -29,25 +27,13 @@ class FaiErrorBox extends StatefulWidget {
|
|||
/// box. Falls back to no constraint when null so short
|
||||
/// messages don't get a useless scrollbar.
|
||||
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({
|
||||
super.key,
|
||||
this.text = '',
|
||||
required this.text,
|
||||
this.isError = false,
|
||||
this.maxHeight,
|
||||
this.error,
|
||||
}) : assert(
|
||||
text != '' || error != null,
|
||||
'FaiErrorBox needs either text or error',
|
||||
);
|
||||
});
|
||||
|
||||
@override
|
||||
State<FaiErrorBox> createState() => _FaiErrorBoxState();
|
||||
|
|
@ -55,10 +41,9 @@ class FaiErrorBox extends StatefulWidget {
|
|||
|
||||
class _FaiErrorBoxState extends State<FaiErrorBox> {
|
||||
bool _justCopied = false;
|
||||
bool _detailExpanded = false;
|
||||
|
||||
Future<void> _copy(String what) async {
|
||||
await Clipboard.setData(ClipboardData(text: what));
|
||||
Future<void> _copy() async {
|
||||
await Clipboard.setData(ClipboardData(text: widget.text));
|
||||
if (!mounted) return;
|
||||
setState(() => _justCopied = true);
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
|
|
@ -76,10 +61,6 @@ class _FaiErrorBoxState extends State<FaiErrorBox> {
|
|||
final fg = widget.isError
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.onSurface;
|
||||
final friendly = widget.error == null
|
||||
? null
|
||||
: friendlyError(widget.error!, l);
|
||||
final copyText = friendly?.detail ?? widget.text;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
|
|
@ -94,101 +75,37 @@ class _FaiErrorBoxState extends State<FaiErrorBox> {
|
|||
border: Border.all(color: accent.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Tooltip(
|
||||
message: _justCopied ? l.buttonCopied : l.buttonCopy,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
_justCopied ? Icons.check : Icons.content_copy,
|
||||
size: 14,
|
||||
// Copy button sits above the text on its own row so
|
||||
// long messages never collide with it. Compact enough
|
||||
// not to dominate short single-line messages.
|
||||
Tooltip(
|
||||
message: _justCopied ? l.buttonCopied : l.buttonCopy,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
_justCopied ? Icons.check : Icons.content_copy,
|
||||
size: 14,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: _copy,
|
||||
),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: widget.maxHeight ?? double.infinity,
|
||||
),
|
||||
child: Scrollbar(
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
widget.text,
|
||||
style: FaiTheme.mono(size: 11, color: fg),
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () => _copy(copyText),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (friendly != null) ...[
|
||||
SelectableText(
|
||||
friendly.headline,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: fg,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (friendly.hint != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
friendly.hint!,
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,288 +0,0 @@
|
|||
// 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,8 +8,6 @@ import 'package:flutter/material.dart';
|
|||
import '../data/hub.dart';
|
||||
import '../data/registry_token.dart';
|
||||
import '../data/system_actions.dart';
|
||||
import '../data/theme_plugin.dart';
|
||||
import '../main.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/theme.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
|
@ -512,8 +510,6 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
onClear: _clearRegistryToken,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
const _ThemePluginPanel(),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_MaintenancePanel(
|
||||
onResetDone: () async {
|
||||
// Daemon just restarted under the same channel +
|
||||
|
|
@ -1027,12 +1023,11 @@ class _AddMcpClientDialogState extends State<_AddMcpClientDialog> {
|
|||
/// Pick a suggestion → fill the form. Operators still hit
|
||||
/// "Add + discover" so nothing happens unprompted.
|
||||
void _applySuggestion(_McpSuggestion s) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
setState(() {
|
||||
_name.text = s.name;
|
||||
_endpoint.text = s.endpoint;
|
||||
_apiKey.text = s.apiKeyEnv;
|
||||
_desc.text = s.resolveDescription(l);
|
||||
_desc.text = s.description;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1460,122 +1455,6 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
|
|||
/// metadata; nothing is auto-spawned. Trust model:
|
||||
/// referencing public projects with their canonical install
|
||||
/// 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
|
||||
/// state via `fai reset --yes`. Lives at the bottom of Settings
|
||||
/// so the everyday config controls aren't crowded by a destructive
|
||||
|
|
@ -1891,73 +1770,36 @@ class _McpSuggestion {
|
|||
final IconData icon;
|
||||
final String endpoint;
|
||||
final String apiKeyEnv;
|
||||
/// 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);
|
||||
final String description;
|
||||
const _McpSuggestion({
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.endpoint,
|
||||
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>[
|
||||
// ── HTTPS / streamable-HTTP servers (no Node, no API key) ──
|
||||
// Descriptions are resolved via [_McpSuggestion.resolveDescription]
|
||||
// against the active locale — see `mcpSuggestion*Desc` keys in
|
||||
// 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.
|
||||
// Same set Studio promotes as one-click cards in the Today
|
||||
// hero — listed here so the operator finds them even after
|
||||
// the hero gets dismissed.
|
||||
_McpSuggestion(
|
||||
name: 'deepwiki',
|
||||
icon: Icons.menu_book_outlined,
|
||||
endpoint: 'https://mcp.deepwiki.com/mcp',
|
||||
apiKeyEnv: '',
|
||||
description:
|
||||
'Public HTTPS — GitHub repo documentation search (AI-powered). No Node, no API key.',
|
||||
),
|
||||
_McpSuggestion(
|
||||
name: 'semgrep',
|
||||
icon: Icons.security,
|
||||
endpoint: 'https://mcp.semgrep.ai/mcp',
|
||||
apiKeyEnv: '',
|
||||
description:
|
||||
'Public HTTPS — security scanning for code vulnerabilities. No Node, no API key.',
|
||||
),
|
||||
// ── stdio servers (require Node + npx) ───────────────────
|
||||
_McpSuggestion(
|
||||
|
|
@ -1965,24 +1807,30 @@ const _kMcpSuggestions = <_McpSuggestion>[
|
|||
icon: Icons.folder_outlined,
|
||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-filesystem /tmp',
|
||||
apiKeyEnv: '',
|
||||
description:
|
||||
'Anthropic — read/write files in /tmp. Edit the path before saving.',
|
||||
),
|
||||
_McpSuggestion(
|
||||
name: 'fetch',
|
||||
icon: Icons.cloud_download_outlined,
|
||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-fetch',
|
||||
apiKeyEnv: '',
|
||||
description: 'Anthropic — fetch arbitrary HTTP(S) URLs as markdown.',
|
||||
),
|
||||
_McpSuggestion(
|
||||
name: 'github',
|
||||
icon: Icons.code,
|
||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-github',
|
||||
apiKeyEnv: 'GITHUB_PERSONAL_ACCESS_TOKEN',
|
||||
description:
|
||||
'Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.',
|
||||
),
|
||||
_McpSuggestion(
|
||||
name: 'puppeteer',
|
||||
icon: Icons.web,
|
||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-puppeteer',
|
||||
apiKeyEnv: '',
|
||||
description: 'Anthropic — headless browser automation for screenshots / scraping.',
|
||||
),
|
||||
_McpSuggestion(
|
||||
name: 'postgres',
|
||||
|
|
@ -1990,6 +1838,8 @@ const _kMcpSuggestions = <_McpSuggestion>[
|
|||
endpoint:
|
||||
'stdio://npx -y @modelcontextprotocol/server-postgres postgresql://user:pw@host/db',
|
||||
apiKeyEnv: '',
|
||||
description:
|
||||
'Anthropic — read-only SQL queries against a Postgres database. Edit the URL.',
|
||||
),
|
||||
_McpSuggestion(
|
||||
name: 'sqlite',
|
||||
|
|
@ -1997,23 +1847,27 @@ const _kMcpSuggestions = <_McpSuggestion>[
|
|||
endpoint:
|
||||
'stdio://npx -y @modelcontextprotocol/server-sqlite --db-path /tmp/db.sqlite',
|
||||
apiKeyEnv: '',
|
||||
description: 'Anthropic — query a local SQLite file. Edit the path.',
|
||||
),
|
||||
_McpSuggestion(
|
||||
name: 'brave-search',
|
||||
icon: Icons.search,
|
||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-brave-search',
|
||||
apiKeyEnv: 'BRAVE_API_KEY',
|
||||
description: 'Anthropic — web search via Brave. API key required.',
|
||||
),
|
||||
_McpSuggestion(
|
||||
name: 'memory',
|
||||
icon: Icons.psychology_outlined,
|
||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-memory',
|
||||
apiKeyEnv: '',
|
||||
description: 'Anthropic — persistent knowledge graph for an agent\'s memory.',
|
||||
),
|
||||
_McpSuggestion(
|
||||
name: 'time',
|
||||
icon: Icons.schedule,
|
||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-time',
|
||||
apiKeyEnv: '',
|
||||
description: 'Anthropic — current time + timezone conversion.',
|
||||
),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -553,60 +553,53 @@ class _PrivacyModeChips extends StatelessWidget {
|
|||
l.systemAiPrivacyFullDesc,
|
||||
),
|
||||
];
|
||||
// Material 3 deprecated per-Radio `groupValue` / `onChanged`
|
||||
// after Flutter 3.32. The modern pattern wraps the radios in
|
||||
// a RadioGroup<T> ancestor that owns the selection + change
|
||||
// callback; the individual Radio widgets only declare their
|
||||
// value. Same UX, no deprecation warnings.
|
||||
return RadioGroup<String>(
|
||||
groupValue: value,
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l.systemAiPrivacyHeader,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
fontSize: 10,
|
||||
),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l.systemAiPrivacyHeader,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
fontSize: 10,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
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,
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@ export 'fai_card.dart';
|
|||
export 'fai_data_row.dart';
|
||||
export 'fai_delta_mark.dart';
|
||||
export 'fai_empty_state.dart';
|
||||
export 'fai_en_badge.dart';
|
||||
export 'fai_error_box.dart';
|
||||
export 'fai_flow_output.dart';
|
||||
export 'fai_module_sheet.dart';
|
||||
export 'fai_pill.dart';
|
||||
export 'fai_settings_dialog.dart';
|
||||
|
|
|
|||
|
|
@ -146,14 +146,14 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_markdown_plus:
|
||||
flutter_markdown:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_markdown_plus
|
||||
sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de"
|
||||
name: flutter_markdown
|
||||
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.7"
|
||||
version: "0.7.7+1"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -20,10 +20,7 @@ dependencies:
|
|||
google_fonts: ^8.1.0
|
||||
shared_preferences: ^2.5.5
|
||||
# Inline README rendering inside the Store detail sheet.
|
||||
# `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
|
||||
flutter_markdown: ^0.7.7
|
||||
# Today-Hero pipeline reads accepted stories from
|
||||
# ~/.fai/today/active.yaml — see docs/today-pipeline.md.
|
||||
yaml: ^3.1.2
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
// 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)));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
// 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
// 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 _) {}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
// 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
// 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,49 +63,16 @@ FILE_ALLOWLIST=(
|
|||
'fixtures?/.*\.(pem|key)$'
|
||||
)
|
||||
|
||||
# Confidential terms are loaded at runtime from a file outside
|
||||
# the repo (~/.fai-security/banned-terms.txt by default; override
|
||||
# with $FAI_BANNED_TERMS_FILE). The list itself is private — the
|
||||
# whole point of the gate is to keep certain strings out of any
|
||||
# committed artefact, so the list of strings to gate on must not
|
||||
# live in a committed artefact either.
|
||||
#
|
||||
# File format: one regex per line. Lines starting with `#` and
|
||||
# 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: confidentiality.md — these are PRIVATE. Never in public
|
||||
# code / docs / commits / examples. Word-boundary matched to avoid
|
||||
# false-positives on substrings.
|
||||
CONFIDENTIAL_TERMS=(
|
||||
'\bITDZ\b'
|
||||
'\bKammergericht\b'
|
||||
'\bHTW\b'
|
||||
'J∆I'
|
||||
'\bJAI\b'
|
||||
)
|
||||
|
||||
# Memory: feedback_no_marketing_speak.md.
|
||||
BANNED_PHRASES=(
|
||||
|
|
@ -175,10 +142,7 @@ scan_diff() {
|
|||
local diff="$1"
|
||||
[ -z "$diff" ] && return
|
||||
scan_secrets "$diff"
|
||||
# `${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 "confidential term" "$diff" "${CONFIDENTIAL_TERMS[@]}"
|
||||
scan_terms_ci "banned phrase" "$diff" "${BANNED_PHRASES[@]}"
|
||||
}
|
||||
|
||||
|
|
@ -244,7 +208,7 @@ scan_message_mode() {
|
|||
fail "Co-Authored-By: Claude trailer is forbidden (memory: feedback_coding_style.md)"
|
||||
fi
|
||||
|
||||
scan_terms_ci "confidential term in commit message" "$msg" ${CONFIDENTIAL_TERMS[@]+"${CONFIDENTIAL_TERMS[@]}"}
|
||||
scan_terms_ci "confidential term in commit message" "$msg" "${CONFIDENTIAL_TERMS[@]}"
|
||||
scan_terms_ci "banned phrase in commit message" "$msg" "${BANNED_PHRASES[@]}"
|
||||
}
|
||||
|
||||
|
|
@ -253,16 +217,6 @@ scan_ci_mode() {
|
|||
if ! git rev-parse --quiet --verify "$base" >/dev/null 2>&1; then
|
||||
base=$(git rev-list --max-parents=0 HEAD | tail -1)
|
||||
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
|
||||
files=$(git diff --name-only --diff-filter=ACMR "$base"..HEAD)
|
||||
diff=$(git diff --no-color -U0 "$base"..HEAD -- . "${EXCLUDES[@]}" \
|
||||
|
|
|
|||
|
|
@ -153,19 +153,9 @@ git add tests/server.pem
|
|||
assert_content_passes "tests/*.pem allowlisted"
|
||||
|
||||
d=$(setup_repo); cd "$d"
|
||||
# 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
|
||||
echo "Notes from the ITDZ Berlin meeting" > confidential.md
|
||||
git add confidential.md
|
||||
assert_content_fails "confidential term (synthetic test term)" "SYNTHETIC_BANNED_TERM_XYZZY"
|
||||
rm -f "$TEST_BANNED_TERMS"
|
||||
unset FAI_BANNED_TERMS_FILE
|
||||
assert_content_fails "confidential term ITDZ" "ITDZ"
|
||||
|
||||
d=$(setup_repo); cd "$d"
|
||||
echo "Our viral release of a powerful tool that just works" > marketing.md
|
||||
|
|
|
|||
|
|
@ -32,14 +32,9 @@ feel like they're reading a translation.
|
|||
- Banned words: "viral", "killer", "powerful", "just works",
|
||||
"revolutionary", "game-changing", "seamless", "next-generation",
|
||||
"cutting-edge", "world-class", "unprecedented".
|
||||
- No private organisation, person, or location names. Pilot
|
||||
customers, internal codenames, specific institutions, and
|
||||
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 private organisation names: never "ITDZ", "Kammergericht",
|
||||
"HTW", "J∆I", or any specific institution. Generic terms only —
|
||||
"a regulated organisation", "a public administration".
|
||||
- No emoji.
|
||||
- No "Co-authored-by Claude" or other AI attribution.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue