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
|
`feedback_no_marketing_speak.md` is included in the prompt: ban
|
||||||
"viral", "killer", "powerful", "just works", "revolutionary",
|
"viral", "killer", "powerful", "just works", "revolutionary",
|
||||||
"game-changing".
|
"game-changing".
|
||||||
4. **No private references.** Generic terms only — no pilot
|
4. **No private references.** Generic terms only; no
|
||||||
customer names, internal codenames, or specific
|
ITDZ / Kammergericht / HTW / J∆I per
|
||||||
institutions. The operator's banned-terms list lives at
|
`feedback_confidentiality.md`.
|
||||||
`~/.fai-security/banned-terms.txt` (outside the repo on
|
|
||||||
purpose; gated by the pre-commit hook in
|
|
||||||
`tools/security/check-staged.sh`).
|
|
||||||
5. **Concrete CTA.** Every story names exactly one action the operator
|
5. **Concrete CTA.** Every story names exactly one action the operator
|
||||||
can take in Studio (which page, which button).
|
can take in Studio (which page, which button).
|
||||||
6. **Body fits in ~3 sentences.** Hero card height is fixed; longer
|
6. **Body fits in ~3 sentences.** Hero card height is fixed; longer
|
||||||
|
|
|
||||||
|
|
@ -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:flutter/widgets.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'flow_output.dart';
|
|
||||||
export 'flow_output.dart';
|
|
||||||
|
|
||||||
class HubService {
|
class HubService {
|
||||||
HubService._();
|
HubService._();
|
||||||
static final HubService instance = HubService._();
|
static final HubService instance = HubService._();
|
||||||
|
|
@ -93,18 +90,13 @@ class HubService {
|
||||||
|
|
||||||
Future<bool> healthy() => _client.healthy();
|
Future<bool> healthy() => _client.healthy();
|
||||||
|
|
||||||
/// List of installed WASM modules, grouped by `module_name`.
|
|
||||||
/// Built-in and federated capabilities are excluded — they
|
|
||||||
/// don't correspond to a bundle on disk and would confuse the
|
|
||||||
/// Modules page (a "system" or "via:filesystem" pseudo-module
|
|
||||||
/// has nothing to uninstall). Callers that need every callable
|
|
||||||
/// capability — e.g. the flow's missing-dependencies check —
|
|
||||||
/// use [allCapabilities] instead.
|
|
||||||
Future<List<ModuleSummary>> listModules() async {
|
Future<List<ModuleSummary>> listModules() async {
|
||||||
final caps = await _client.listCapabilities();
|
final caps = await _client.listCapabilities();
|
||||||
final wasm = caps.where((c) => c.kind.isEmpty || c.kind == 'wasm');
|
|
||||||
|
// Group capabilities by module so Studio's UI maps a card
|
||||||
|
// to a module rather than a capability.
|
||||||
final byModule = <String, List<CapabilityEntry>>{};
|
final byModule = <String, List<CapabilityEntry>>{};
|
||||||
for (final c in wasm) {
|
for (final c in caps) {
|
||||||
byModule.putIfAbsent(c.moduleName, () => []).add(c);
|
byModule.putIfAbsent(c.moduleName, () => []).add(c);
|
||||||
}
|
}
|
||||||
return byModule.entries.map((e) {
|
return byModule.entries.map((e) {
|
||||||
|
|
@ -120,25 +112,6 @@ class HubService {
|
||||||
..sort((a, b) => a.name.compareTo(b.name));
|
..sort((a, b) => a.name.compareTo(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every capability the hub can execute, with the `kind` tag
|
|
||||||
/// telling apart wasm / builtin / federated. The flow page
|
|
||||||
/// uses this to decide which capabilities are "already
|
|
||||||
/// available" — including built-ins like `system.approval`
|
|
||||||
/// and federated MCP / n8n tools — so the Run button enables
|
|
||||||
/// when the dependency is reachable, not only when a bundle
|
|
||||||
/// happens to be installed.
|
|
||||||
Future<List<CapabilityInfo>> allCapabilities() async {
|
|
||||||
final caps = await _client.listCapabilities();
|
|
||||||
return caps
|
|
||||||
.map((c) => CapabilityInfo(
|
|
||||||
capability: c.capability,
|
|
||||||
version: c.version,
|
|
||||||
moduleName: c.moduleName,
|
|
||||||
kind: c.kind.isEmpty ? 'wasm' : c.kind,
|
|
||||||
))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fully-detailed manifest for one installed module.
|
/// Fully-detailed manifest for one installed module.
|
||||||
Future<ModuleDetail> moduleInfo(String name) async {
|
Future<ModuleDetail> moduleInfo(String name) async {
|
||||||
final r = await _client.moduleInfo(name);
|
final r = await _client.moduleInfo(name);
|
||||||
|
|
@ -150,7 +123,6 @@ class HubService {
|
||||||
.toList(),
|
.toList(),
|
||||||
permissions: r.permissions,
|
permissions: r.permissions,
|
||||||
directory: r.directory,
|
directory: r.directory,
|
||||||
acceptsMime: r.acceptsMime,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -374,71 +346,17 @@ class HubService {
|
||||||
return (name: r.name, version: r.version);
|
return (name: r.name, version: r.version);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the installed module's `MODULE.md` (or
|
/// Fetch a module's README markdown via the hub. Errors come
|
||||||
/// `MODULE.<locale>.md`) from disk under
|
/// back as `errorKind` strings (not exceptions) so callers can
|
||||||
/// `~/.fai/modules/<module>/`. Air-gap friendly, no network.
|
/// render fallback UI without try/catch ceremony.
|
||||||
///
|
Future<({String errorKind, String text, String sourceUrl})>
|
||||||
/// `state` is one of:
|
fetchModuleDocs(String name, {String locale = ''}) async {
|
||||||
/// - `'found'` — `text` holds the markdown
|
final r = await _client.fetchModuleDocs(name, locale: locale);
|
||||||
/// - `'no_docs'` — module installed but bundle had no docs
|
return (
|
||||||
/// - `'not_installed'` — module isn't installed at all
|
errorKind: r.errorKind,
|
||||||
Future<({String state, String text, String sourcePath})>
|
text: r.text,
|
||||||
readInstalledModuleDocs(String name, {String locale = ''}) async {
|
sourceUrl: r.sourceUrl,
|
||||||
final r = await _client.getInstalledModuleDocs(name, locale: locale);
|
|
||||||
final String state;
|
|
||||||
if (r.notInstalled) {
|
|
||||||
state = 'not_installed';
|
|
||||||
} else if (r.text.isEmpty) {
|
|
||||||
state = 'no_docs';
|
|
||||||
} else {
|
|
||||||
state = 'found';
|
|
||||||
}
|
|
||||||
return (state: state, text: r.text, sourcePath: r.sourcePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Invoke a Studio theme plugin's `theme` hook and unwrap
|
|
||||||
/// the result into Flutter-friendly `ColorScheme` slots.
|
|
||||||
///
|
|
||||||
/// Returns a record:
|
|
||||||
/// - `brightness`: echo of the input.
|
|
||||||
/// - `tokens`: 14 32-bit ARGB values in Material 3
|
|
||||||
/// order. Caller passes them to
|
|
||||||
/// `ColorScheme(...)` after deciding the
|
|
||||||
/// `brightness` enum.
|
|
||||||
///
|
|
||||||
/// Throws on hub-side errors (plugin not installed, plugin
|
|
||||||
/// declined the brightness, etc.) — caller routes the
|
|
||||||
/// exception through `friendlyError` like every other RPC.
|
|
||||||
Future<({String brightness, List<int> tokens})> invokePluginTheme({
|
|
||||||
required String capability,
|
|
||||||
required String brightness,
|
|
||||||
}) async {
|
|
||||||
final r = await _client.invokePluginTheme(
|
|
||||||
capability: capability,
|
|
||||||
brightness: brightness,
|
|
||||||
);
|
);
|
||||||
return (brightness: r.brightness, tokens: r.tokens.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Invoke an installed Studio translate plugin and return
|
|
||||||
/// the translated text. Empty `fromLocale` lets the plugin
|
|
||||||
/// auto-detect; `toLocale` is the active Studio locale.
|
|
||||||
///
|
|
||||||
/// Caller catches the gRPC exception and routes it through
|
|
||||||
/// `friendlyError` like every other RPC.
|
|
||||||
Future<String> invokePluginTranslate({
|
|
||||||
required String capability,
|
|
||||||
required String text,
|
|
||||||
required String toLocale,
|
|
||||||
String fromLocale = '',
|
|
||||||
}) async {
|
|
||||||
final r = await _client.invokePluginTranslate(
|
|
||||||
capability: capability,
|
|
||||||
text: text,
|
|
||||||
toLocale: toLocale,
|
|
||||||
fromLocale: fromLocale,
|
|
||||||
);
|
|
||||||
return r.translated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot of every configured MCP server.
|
/// Snapshot of every configured MCP server.
|
||||||
|
|
@ -642,31 +560,35 @@ class HubService {
|
||||||
/// land as text Payloads, byte values as bytes Payloads —
|
/// land as text Payloads, byte values as bytes Payloads —
|
||||||
/// flows that mix both (e.g. extract taking a `document:
|
/// flows that mix both (e.g. extract taking a `document:
|
||||||
/// bytes` plus a text-shaped option) flow through one RPC.
|
/// bytes` plus a text-shaped option) flow through one RPC.
|
||||||
/// [fileMimeTypes] is keyed the same as [fileInputs]; missing
|
/// Returns the named outputs as a map of UI-friendly strings.
|
||||||
/// entries fall back to `application/octet-stream`. Modules
|
Future<Map<String, String>> runSavedFlow({
|
||||||
/// often gate on MIME (the `text.extract` module refuses
|
|
||||||
/// octet-stream), so callers should provide a real type.
|
|
||||||
/// Returns the named outputs as a map of typed [FlowOutput]
|
|
||||||
/// values so each page can render text / JSON / bytes / file
|
|
||||||
/// with the right widget.
|
|
||||||
Future<Map<String, FlowOutput>> runSavedFlow({
|
|
||||||
required String name,
|
required String name,
|
||||||
Map<String, String> textInputs = const {},
|
Map<String, String> textInputs = const {},
|
||||||
Map<String, Uint8List> fileInputs = const {},
|
Map<String, Uint8List> fileInputs = const {},
|
||||||
Map<String, String> fileMimeTypes = const {},
|
|
||||||
}) async {
|
}) async {
|
||||||
final r = await _client.runSavedFlow(
|
final r = await _client.runSavedFlow(
|
||||||
name: name,
|
name: name,
|
||||||
textInputs: textInputs,
|
textInputs: textInputs,
|
||||||
fileInputs: fileInputs,
|
fileInputs: fileInputs,
|
||||||
fileMimeTypes: fileMimeTypes,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
for (final entry in r.outputs.entries)
|
for (final entry in r.outputs.entries)
|
||||||
entry.key: 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({
|
Future<List<AuditEvent>> recentEvents({
|
||||||
int limit = 50,
|
int limit = 50,
|
||||||
List<String> types = const [],
|
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
|
/// UI-side type, decoupled from the proto wire type so pages
|
||||||
/// don't import protobuf packages.
|
/// don't import protobuf packages.
|
||||||
class ModuleSummary {
|
class ModuleSummary {
|
||||||
|
|
@ -962,10 +864,6 @@ class ModuleDetail {
|
||||||
final List<String> capabilities;
|
final List<String> capabilities;
|
||||||
final List<String> permissions;
|
final List<String> permissions;
|
||||||
final String directory;
|
final String directory;
|
||||||
/// MIME allow-list declared in the module's `module.yaml`.
|
|
||||||
/// Empty when the module didn't declare any — caller falls
|
|
||||||
/// back to its built-in extension heuristic.
|
|
||||||
final List<String> acceptsMime;
|
|
||||||
|
|
||||||
const ModuleDetail({
|
const ModuleDetail({
|
||||||
required this.name,
|
required this.name,
|
||||||
|
|
@ -973,7 +871,6 @@ class ModuleDetail {
|
||||||
required this.capabilities,
|
required this.capabilities,
|
||||||
required this.permissions,
|
required this.permissions,
|
||||||
required this.directory,
|
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",
|
"buttonReadDocs": "Doku öffnen",
|
||||||
"buttonCopy": "In Zwischenablage kopieren",
|
"buttonCopy": "In Zwischenablage kopieren",
|
||||||
"buttonCopied": "Kopiert",
|
"buttonCopied": "Kopiert",
|
||||||
"buttonShowDetails": "Details anzeigen",
|
|
||||||
"buttonHideDetails": "Details ausblenden",
|
|
||||||
"storeInstallableOnly": "Nur installierbare",
|
|
||||||
"storePillComingSoon": "Bald verfügbar",
|
|
||||||
"storeNotInstallableInline": "Dieses Modul ist auf der Roadmap (Status: {status}). Es gibt noch kein installierbares Bundle — Repository verfolgen für Updates.",
|
|
||||||
"@storeNotInstallableInline": { "placeholders": { "status": { "type": "String" } } },
|
|
||||||
|
|
||||||
"mcpSuggestionDeepwikiDesc": "Öffentliches HTTPS — KI-gestützte Doku-Suche in GitHub-Repos. Kein Node, kein API-Key.",
|
|
||||||
"mcpSuggestionSemgrepDesc": "Öffentliches HTTPS — Security-Scanning von Code-Schwachstellen. Kein Node, kein API-Key.",
|
|
||||||
"mcpSuggestionFilesystemDesc": "Anthropic — Dateien in /tmp lesen / schreiben. Pfad vor dem Speichern anpassen.",
|
|
||||||
"mcpSuggestionFetchDesc": "Anthropic — beliebige HTTP(S)-URLs als Markdown abrufen.",
|
|
||||||
"mcpSuggestionGithubDesc": "Anthropic — Issues, PRs, Repo-Dateien. Braucht einen GitHub-PAT in der Env-Variable.",
|
|
||||||
"mcpSuggestionPuppeteerDesc": "Anthropic — Headless-Browser-Automation für Screenshots / Scraping.",
|
|
||||||
"mcpSuggestionPostgresDesc": "Anthropic — Read-only-SQL-Queries gegen eine Postgres-Datenbank. URL anpassen.",
|
|
||||||
"mcpSuggestionSqliteDesc": "Anthropic — Abfragen gegen eine lokale SQLite-Datei. Pfad anpassen.",
|
|
||||||
"mcpSuggestionBraveSearchDesc": "Anthropic — Websuche via Brave. API-Key erforderlich.",
|
|
||||||
"mcpSuggestionMemoryDesc": "Anthropic — persistenter Knowledge-Graph als Agenten-Gedächtnis.",
|
|
||||||
"mcpSuggestionTimeDesc": "Anthropic — aktuelle Uhrzeit + Zeitzonen-Konvertierung.",
|
|
||||||
"mcpServerEnLanguageBadge": "Server-Text in Englisch — Studio zeigt ihn wie geliefert.",
|
|
||||||
|
|
||||||
"themePluginHeader": "Theme-Plugin",
|
|
||||||
"themePluginNone": "Standard (kein Plugin)",
|
|
||||||
"themePluginHint": "Installiertes studio.theme.*-Plugin auswählen, um das ganze Studio umzustylen. Erst eines aus dem Store installieren.",
|
|
||||||
"themePluginEmpty": "Kein Theme-Plugin installiert. Im Store eines aus der Kategorie studio-plugin auswählen.",
|
|
||||||
|
|
||||||
"settingsTitle": "Hub-Endpunkt",
|
"settingsTitle": "Hub-Endpunkt",
|
||||||
"settingsHost": "Host",
|
"settingsHost": "Host",
|
||||||
|
|
@ -530,43 +506,9 @@
|
||||||
"flowsRunButton": "Starten",
|
"flowsRunButton": "Starten",
|
||||||
"flowsRunningTitle": "{name} läuft",
|
"flowsRunningTitle": "{name} läuft",
|
||||||
"@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } },
|
"@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } },
|
||||||
"flowsResultTitle": "{name} — Ergebnis",
|
|
||||||
"@flowsResultTitle": { "placeholders": { "name": { "type": "String" } } },
|
|
||||||
"flowsErrorTitle": "{name} — fehlgeschlagen",
|
|
||||||
"@flowsErrorTitle": { "placeholders": { "name": { "type": "String" } } },
|
|
||||||
"flowsRunning": "Flow läuft…",
|
"flowsRunning": "Flow läuft…",
|
||||||
"flowsNoOutputs": "Flow abgeschlossen ohne deklarierte Outputs.",
|
"flowsNoOutputs": "Flow abgeschlossen ohne deklarierte Outputs.",
|
||||||
|
|
||||||
"errGeneric": "Da ist etwas schiefgegangen.",
|
|
||||||
"errInvalidArgument": "Ungültige Eingabe.",
|
|
||||||
"errInvalidArgumentHint": "Werte im Formular prüfen und erneut versuchen.",
|
|
||||||
"errNotFound": "Nicht gefunden.",
|
|
||||||
"errNotFoundHint": "Das Angefragte ist nicht hier — entweder entfernt oder nie existiert.",
|
|
||||||
"errAlreadyExists": "Existiert bereits.",
|
|
||||||
"errPermissionDenied": "Berechtigung verweigert.",
|
|
||||||
"errPermissionDeniedHint": "Die Operator-Policy des Hubs blockiert diese Aktion. `~/.fai/config.yaml` prüfen.",
|
|
||||||
"errFailedPrecondition": "Setup-Problem hat das blockiert.",
|
|
||||||
"errFailedPreconditionHint": "Der Hub konnte nicht weiter, weil etwas Vorausgesetztes fehlt. Details unten erklären was.",
|
|
||||||
"errInternal": "Unerwarteter interner Fehler.",
|
|
||||||
"errInternalHint": "Hub-Logs unter `~/.fai/logs/` prüfen. Wenn das öfter passiert, bitte melden.",
|
|
||||||
"errUnavailable": "Hub nicht erreichbar.",
|
|
||||||
"errUnavailableHint": "Sicherstellen, dass `fai serve` (oder `fai daemon start`) läuft und der Daemon-Endpunkt zu Studios Einstellungen passt.",
|
|
||||||
"errUnauthenticated": "Authentifizierung erforderlich.",
|
|
||||||
"errUnauthenticatedHint": "`FAI_REGISTRY_TOKEN` setzen oder in den Einstellungen anmelden, dann erneut versuchen.",
|
|
||||||
"errCopyDetail": "Detail kopieren",
|
|
||||||
"errDetailCopied": "Detail in die Zwischenablage kopiert.",
|
|
||||||
"flowsOutputUnknown": "Unbekannter Output-Payload-Typ.",
|
|
||||||
"flowsOutputBytesUnknownMime": "Binärdaten",
|
|
||||||
"flowsOutputSaveAs": "Speichern unter…",
|
|
||||||
"flowsOutputSaveAsTitle": "Flow-Output speichern",
|
|
||||||
"flowsOutputSavedAt": "Gespeichert: {path}",
|
|
||||||
"@flowsOutputSavedAt": { "placeholders": { "path": { "type": "String" } } },
|
|
||||||
"flowsOutputSaveFailed": "Speichern fehlgeschlagen: {error}",
|
|
||||||
"@flowsOutputSaveFailed": { "placeholders": { "error": { "type": "String" } } },
|
|
||||||
"flowsOutputOpen": "Öffnen",
|
|
||||||
"flowsOutputOpenFailed": "Öffnen fehlgeschlagen: {error}",
|
|
||||||
"@flowsOutputOpenFailed": { "placeholders": { "error": { "type": "String" } } },
|
|
||||||
|
|
||||||
"approvalsTitle": "Freigaben",
|
"approvalsTitle": "Freigaben",
|
||||||
"approvalsTabPending": "Offen",
|
"approvalsTabPending": "Offen",
|
||||||
"approvalsTabHistory": "Verlauf",
|
"approvalsTabHistory": "Verlauf",
|
||||||
|
|
|
||||||
|
|
@ -76,30 +76,6 @@
|
||||||
"buttonReadDocs": "Read docs",
|
"buttonReadDocs": "Read docs",
|
||||||
"buttonCopy": "Copy to clipboard",
|
"buttonCopy": "Copy to clipboard",
|
||||||
"buttonCopied": "Copied",
|
"buttonCopied": "Copied",
|
||||||
"buttonShowDetails": "Show details",
|
|
||||||
"buttonHideDetails": "Hide details",
|
|
||||||
"storeInstallableOnly": "Installable only",
|
|
||||||
"storePillComingSoon": "Coming soon",
|
|
||||||
"storeNotInstallableInline": "This module is on the roadmap (status: {status}). It has no installable bundle yet — follow the repository for updates.",
|
|
||||||
"@storeNotInstallableInline": { "placeholders": { "status": { "type": "String" } } },
|
|
||||||
|
|
||||||
"mcpSuggestionDeepwikiDesc": "Public HTTPS — GitHub repo documentation search (AI-powered). No Node, no API key.",
|
|
||||||
"mcpSuggestionSemgrepDesc": "Public HTTPS — security scanning for code vulnerabilities. No Node, no API key.",
|
|
||||||
"mcpSuggestionFilesystemDesc": "Anthropic — read / write files in /tmp. Edit the path before saving.",
|
|
||||||
"mcpSuggestionFetchDesc": "Anthropic — fetch arbitrary HTTP(S) URLs as markdown.",
|
|
||||||
"mcpSuggestionGithubDesc": "Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.",
|
|
||||||
"mcpSuggestionPuppeteerDesc": "Anthropic — headless browser automation for screenshots / scraping.",
|
|
||||||
"mcpSuggestionPostgresDesc": "Anthropic — read-only SQL queries against a Postgres database. Edit the URL.",
|
|
||||||
"mcpSuggestionSqliteDesc": "Anthropic — query a local SQLite file. Edit the path.",
|
|
||||||
"mcpSuggestionBraveSearchDesc": "Anthropic — web search via Brave. API key required.",
|
|
||||||
"mcpSuggestionMemoryDesc": "Anthropic — persistent knowledge graph for an agent's memory.",
|
|
||||||
"mcpSuggestionTimeDesc": "Anthropic — current time + timezone conversion.",
|
|
||||||
"mcpServerEnLanguageBadge": "Server text in English — Studio shows it as supplied.",
|
|
||||||
|
|
||||||
"themePluginHeader": "Theme plugin",
|
|
||||||
"themePluginNone": "Built-in (no plugin)",
|
|
||||||
"themePluginHint": "Pick an installed studio.theme.* plugin to re-skin the whole app. Install one from the Store first.",
|
|
||||||
"themePluginEmpty": "No theme plugins installed. Open the Store and pick one in the studio-plugin category.",
|
|
||||||
|
|
||||||
"settingsTitle": "Hub endpoint",
|
"settingsTitle": "Hub endpoint",
|
||||||
"settingsHost": "Host",
|
"settingsHost": "Host",
|
||||||
|
|
@ -531,43 +507,9 @@
|
||||||
"flowsRunButton": "Run",
|
"flowsRunButton": "Run",
|
||||||
"flowsRunningTitle": "Running {name}",
|
"flowsRunningTitle": "Running {name}",
|
||||||
"@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } },
|
"@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } },
|
||||||
"flowsResultTitle": "{name} — result",
|
|
||||||
"@flowsResultTitle": { "placeholders": { "name": { "type": "String" } } },
|
|
||||||
"flowsErrorTitle": "{name} — failed",
|
|
||||||
"@flowsErrorTitle": { "placeholders": { "name": { "type": "String" } } },
|
|
||||||
"flowsRunning": "Flow running…",
|
"flowsRunning": "Flow running…",
|
||||||
"flowsNoOutputs": "Flow completed with no declared outputs.",
|
"flowsNoOutputs": "Flow completed with no declared outputs.",
|
||||||
|
|
||||||
"errGeneric": "Something went wrong.",
|
|
||||||
"errInvalidArgument": "Invalid input.",
|
|
||||||
"errInvalidArgumentHint": "Check the form values and try again.",
|
|
||||||
"errNotFound": "Not found.",
|
|
||||||
"errNotFoundHint": "The item you asked for isn't here — it may have been removed or never existed.",
|
|
||||||
"errAlreadyExists": "Already exists.",
|
|
||||||
"errPermissionDenied": "Permission denied.",
|
|
||||||
"errPermissionDeniedHint": "The hub's operator policy blocks this action. Check `~/.fai/config.yaml`.",
|
|
||||||
"errFailedPrecondition": "Setup issue blocked this.",
|
|
||||||
"errFailedPreconditionHint": "The hub couldn't proceed because something it needs isn't ready. The detail below explains what.",
|
|
||||||
"errInternal": "Unexpected internal error.",
|
|
||||||
"errInternalHint": "Check the hub logs at `~/.fai/logs/`. If this repeats, please report it.",
|
|
||||||
"errUnavailable": "Hub not reachable.",
|
|
||||||
"errUnavailableHint": "Make sure `fai serve` (or `fai daemon start`) is running and the daemon endpoint matches Studio's settings.",
|
|
||||||
"errUnauthenticated": "Authentication required.",
|
|
||||||
"errUnauthenticatedHint": "Set `FAI_REGISTRY_TOKEN` or sign in through Settings before retrying.",
|
|
||||||
"errCopyDetail": "Copy detail",
|
|
||||||
"errDetailCopied": "Detail copied to clipboard.",
|
|
||||||
"flowsOutputUnknown": "Output payload variant not recognised.",
|
|
||||||
"flowsOutputBytesUnknownMime": "binary",
|
|
||||||
"flowsOutputSaveAs": "Save as…",
|
|
||||||
"flowsOutputSaveAsTitle": "Save flow output",
|
|
||||||
"flowsOutputSavedAt": "Saved to {path}",
|
|
||||||
"@flowsOutputSavedAt": { "placeholders": { "path": { "type": "String" } } },
|
|
||||||
"flowsOutputSaveFailed": "Save failed: {error}",
|
|
||||||
"@flowsOutputSaveFailed": { "placeholders": { "error": { "type": "String" } } },
|
|
||||||
"flowsOutputOpen": "Open",
|
|
||||||
"flowsOutputOpenFailed": "Open failed: {error}",
|
|
||||||
"@flowsOutputOpenFailed": { "placeholders": { "error": { "type": "String" } } },
|
|
||||||
|
|
||||||
"approvalsTitle": "Approvals",
|
"approvalsTitle": "Approvals",
|
||||||
"approvalsTabPending": "Pending",
|
"approvalsTabPending": "Pending",
|
||||||
"approvalsTabHistory": "History",
|
"approvalsTabHistory": "History",
|
||||||
|
|
|
||||||
|
|
@ -518,132 +518,6 @@ abstract class AppLocalizations {
|
||||||
/// **'Copied'**
|
/// **'Copied'**
|
||||||
String get buttonCopied;
|
String get buttonCopied;
|
||||||
|
|
||||||
/// No description provided for @buttonShowDetails.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Show details'**
|
|
||||||
String get buttonShowDetails;
|
|
||||||
|
|
||||||
/// No description provided for @buttonHideDetails.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Hide details'**
|
|
||||||
String get buttonHideDetails;
|
|
||||||
|
|
||||||
/// No description provided for @storeInstallableOnly.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Installable only'**
|
|
||||||
String get storeInstallableOnly;
|
|
||||||
|
|
||||||
/// No description provided for @storePillComingSoon.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Coming soon'**
|
|
||||||
String get storePillComingSoon;
|
|
||||||
|
|
||||||
/// No description provided for @storeNotInstallableInline.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'This module is on the roadmap (status: {status}). It has no installable bundle yet — follow the repository for updates.'**
|
|
||||||
String storeNotInstallableInline(String status);
|
|
||||||
|
|
||||||
/// No description provided for @mcpSuggestionDeepwikiDesc.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Public HTTPS — GitHub repo documentation search (AI-powered). No Node, no API key.'**
|
|
||||||
String get mcpSuggestionDeepwikiDesc;
|
|
||||||
|
|
||||||
/// No description provided for @mcpSuggestionSemgrepDesc.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Public HTTPS — security scanning for code vulnerabilities. No Node, no API key.'**
|
|
||||||
String get mcpSuggestionSemgrepDesc;
|
|
||||||
|
|
||||||
/// No description provided for @mcpSuggestionFilesystemDesc.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Anthropic — read / write files in /tmp. Edit the path before saving.'**
|
|
||||||
String get mcpSuggestionFilesystemDesc;
|
|
||||||
|
|
||||||
/// No description provided for @mcpSuggestionFetchDesc.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Anthropic — fetch arbitrary HTTP(S) URLs as markdown.'**
|
|
||||||
String get mcpSuggestionFetchDesc;
|
|
||||||
|
|
||||||
/// No description provided for @mcpSuggestionGithubDesc.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.'**
|
|
||||||
String get mcpSuggestionGithubDesc;
|
|
||||||
|
|
||||||
/// No description provided for @mcpSuggestionPuppeteerDesc.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Anthropic — headless browser automation for screenshots / scraping.'**
|
|
||||||
String get mcpSuggestionPuppeteerDesc;
|
|
||||||
|
|
||||||
/// No description provided for @mcpSuggestionPostgresDesc.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Anthropic — read-only SQL queries against a Postgres database. Edit the URL.'**
|
|
||||||
String get mcpSuggestionPostgresDesc;
|
|
||||||
|
|
||||||
/// No description provided for @mcpSuggestionSqliteDesc.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Anthropic — query a local SQLite file. Edit the path.'**
|
|
||||||
String get mcpSuggestionSqliteDesc;
|
|
||||||
|
|
||||||
/// No description provided for @mcpSuggestionBraveSearchDesc.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Anthropic — web search via Brave. API key required.'**
|
|
||||||
String get mcpSuggestionBraveSearchDesc;
|
|
||||||
|
|
||||||
/// No description provided for @mcpSuggestionMemoryDesc.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Anthropic — persistent knowledge graph for an agent\'s memory.'**
|
|
||||||
String get mcpSuggestionMemoryDesc;
|
|
||||||
|
|
||||||
/// No description provided for @mcpSuggestionTimeDesc.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Anthropic — current time + timezone conversion.'**
|
|
||||||
String get mcpSuggestionTimeDesc;
|
|
||||||
|
|
||||||
/// No description provided for @mcpServerEnLanguageBadge.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Server text in English — Studio shows it as supplied.'**
|
|
||||||
String get mcpServerEnLanguageBadge;
|
|
||||||
|
|
||||||
/// No description provided for @themePluginHeader.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Theme plugin'**
|
|
||||||
String get themePluginHeader;
|
|
||||||
|
|
||||||
/// No description provided for @themePluginNone.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Built-in (no plugin)'**
|
|
||||||
String get themePluginNone;
|
|
||||||
|
|
||||||
/// No description provided for @themePluginHint.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Pick an installed studio.theme.* plugin to re-skin the whole app. Install one from the Store first.'**
|
|
||||||
String get themePluginHint;
|
|
||||||
|
|
||||||
/// No description provided for @themePluginEmpty.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'No theme plugins installed. Open the Store and pick one in the studio-plugin category.'**
|
|
||||||
String get themePluginEmpty;
|
|
||||||
|
|
||||||
/// No description provided for @settingsTitle.
|
/// No description provided for @settingsTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -2408,18 +2282,6 @@ abstract class AppLocalizations {
|
||||||
/// **'Running {name}'**
|
/// **'Running {name}'**
|
||||||
String flowsRunningTitle(String name);
|
String flowsRunningTitle(String name);
|
||||||
|
|
||||||
/// No description provided for @flowsResultTitle.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'{name} — result'**
|
|
||||||
String flowsResultTitle(String name);
|
|
||||||
|
|
||||||
/// No description provided for @flowsErrorTitle.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'{name} — failed'**
|
|
||||||
String flowsErrorTitle(String name);
|
|
||||||
|
|
||||||
/// No description provided for @flowsRunning.
|
/// No description provided for @flowsRunning.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -2432,162 +2294,6 @@ abstract class AppLocalizations {
|
||||||
/// **'Flow completed with no declared outputs.'**
|
/// **'Flow completed with no declared outputs.'**
|
||||||
String get flowsNoOutputs;
|
String get flowsNoOutputs;
|
||||||
|
|
||||||
/// No description provided for @errGeneric.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Something went wrong.'**
|
|
||||||
String get errGeneric;
|
|
||||||
|
|
||||||
/// No description provided for @errInvalidArgument.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Invalid input.'**
|
|
||||||
String get errInvalidArgument;
|
|
||||||
|
|
||||||
/// No description provided for @errInvalidArgumentHint.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Check the form values and try again.'**
|
|
||||||
String get errInvalidArgumentHint;
|
|
||||||
|
|
||||||
/// No description provided for @errNotFound.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Not found.'**
|
|
||||||
String get errNotFound;
|
|
||||||
|
|
||||||
/// No description provided for @errNotFoundHint.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'The item you asked for isn\'t here — it may have been removed or never existed.'**
|
|
||||||
String get errNotFoundHint;
|
|
||||||
|
|
||||||
/// No description provided for @errAlreadyExists.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Already exists.'**
|
|
||||||
String get errAlreadyExists;
|
|
||||||
|
|
||||||
/// No description provided for @errPermissionDenied.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Permission denied.'**
|
|
||||||
String get errPermissionDenied;
|
|
||||||
|
|
||||||
/// No description provided for @errPermissionDeniedHint.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'The hub\'s operator policy blocks this action. Check `~/.fai/config.yaml`.'**
|
|
||||||
String get errPermissionDeniedHint;
|
|
||||||
|
|
||||||
/// No description provided for @errFailedPrecondition.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Setup issue blocked this.'**
|
|
||||||
String get errFailedPrecondition;
|
|
||||||
|
|
||||||
/// No description provided for @errFailedPreconditionHint.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'The hub couldn\'t proceed because something it needs isn\'t ready. The detail below explains what.'**
|
|
||||||
String get errFailedPreconditionHint;
|
|
||||||
|
|
||||||
/// No description provided for @errInternal.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Unexpected internal error.'**
|
|
||||||
String get errInternal;
|
|
||||||
|
|
||||||
/// No description provided for @errInternalHint.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Check the hub logs at `~/.fai/logs/`. If this repeats, please report it.'**
|
|
||||||
String get errInternalHint;
|
|
||||||
|
|
||||||
/// No description provided for @errUnavailable.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Hub not reachable.'**
|
|
||||||
String get errUnavailable;
|
|
||||||
|
|
||||||
/// No description provided for @errUnavailableHint.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Make sure `fai serve` (or `fai daemon start`) is running and the daemon endpoint matches Studio\'s settings.'**
|
|
||||||
String get errUnavailableHint;
|
|
||||||
|
|
||||||
/// No description provided for @errUnauthenticated.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Authentication required.'**
|
|
||||||
String get errUnauthenticated;
|
|
||||||
|
|
||||||
/// No description provided for @errUnauthenticatedHint.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Set `FAI_REGISTRY_TOKEN` or sign in through Settings before retrying.'**
|
|
||||||
String get errUnauthenticatedHint;
|
|
||||||
|
|
||||||
/// No description provided for @errCopyDetail.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Copy detail'**
|
|
||||||
String get errCopyDetail;
|
|
||||||
|
|
||||||
/// No description provided for @errDetailCopied.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Detail copied to clipboard.'**
|
|
||||||
String get errDetailCopied;
|
|
||||||
|
|
||||||
/// No description provided for @flowsOutputUnknown.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Output payload variant not recognised.'**
|
|
||||||
String get flowsOutputUnknown;
|
|
||||||
|
|
||||||
/// No description provided for @flowsOutputBytesUnknownMime.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'binary'**
|
|
||||||
String get flowsOutputBytesUnknownMime;
|
|
||||||
|
|
||||||
/// No description provided for @flowsOutputSaveAs.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Save as…'**
|
|
||||||
String get flowsOutputSaveAs;
|
|
||||||
|
|
||||||
/// No description provided for @flowsOutputSaveAsTitle.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Save flow output'**
|
|
||||||
String get flowsOutputSaveAsTitle;
|
|
||||||
|
|
||||||
/// No description provided for @flowsOutputSavedAt.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Saved to {path}'**
|
|
||||||
String flowsOutputSavedAt(String path);
|
|
||||||
|
|
||||||
/// No description provided for @flowsOutputSaveFailed.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Save failed: {error}'**
|
|
||||||
String flowsOutputSaveFailed(String error);
|
|
||||||
|
|
||||||
/// No description provided for @flowsOutputOpen.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Open'**
|
|
||||||
String get flowsOutputOpen;
|
|
||||||
|
|
||||||
/// No description provided for @flowsOutputOpenFailed.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Open failed: {error}'**
|
|
||||||
String flowsOutputOpenFailed(String error);
|
|
||||||
|
|
||||||
/// No description provided for @approvalsTitle.
|
/// No description provided for @approvalsTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -237,85 +237,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get buttonCopied => 'Kopiert';
|
String get buttonCopied => 'Kopiert';
|
||||||
|
|
||||||
@override
|
|
||||||
String get buttonShowDetails => 'Details anzeigen';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get buttonHideDetails => 'Details ausblenden';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get storeInstallableOnly => 'Nur installierbare';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get storePillComingSoon => 'Bald verfügbar';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String storeNotInstallableInline(String status) {
|
|
||||||
return 'Dieses Modul ist auf der Roadmap (Status: $status). Es gibt noch kein installierbares Bundle — Repository verfolgen für Updates.';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionDeepwikiDesc =>
|
|
||||||
'Öffentliches HTTPS — KI-gestützte Doku-Suche in GitHub-Repos. Kein Node, kein API-Key.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionSemgrepDesc =>
|
|
||||||
'Öffentliches HTTPS — Security-Scanning von Code-Schwachstellen. Kein Node, kein API-Key.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionFilesystemDesc =>
|
|
||||||
'Anthropic — Dateien in /tmp lesen / schreiben. Pfad vor dem Speichern anpassen.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionFetchDesc =>
|
|
||||||
'Anthropic — beliebige HTTP(S)-URLs als Markdown abrufen.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionGithubDesc =>
|
|
||||||
'Anthropic — Issues, PRs, Repo-Dateien. Braucht einen GitHub-PAT in der Env-Variable.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionPuppeteerDesc =>
|
|
||||||
'Anthropic — Headless-Browser-Automation für Screenshots / Scraping.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionPostgresDesc =>
|
|
||||||
'Anthropic — Read-only-SQL-Queries gegen eine Postgres-Datenbank. URL anpassen.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionSqliteDesc =>
|
|
||||||
'Anthropic — Abfragen gegen eine lokale SQLite-Datei. Pfad anpassen.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionBraveSearchDesc =>
|
|
||||||
'Anthropic — Websuche via Brave. API-Key erforderlich.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionMemoryDesc =>
|
|
||||||
'Anthropic — persistenter Knowledge-Graph als Agenten-Gedächtnis.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionTimeDesc =>
|
|
||||||
'Anthropic — aktuelle Uhrzeit + Zeitzonen-Konvertierung.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpServerEnLanguageBadge =>
|
|
||||||
'Server-Text in Englisch — Studio zeigt ihn wie geliefert.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get themePluginHeader => 'Theme-Plugin';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get themePluginNone => 'Standard (kein Plugin)';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get themePluginHint =>
|
|
||||||
'Installiertes studio.theme.*-Plugin auswählen, um das ganze Studio umzustylen. Erst eines aus dem Store installieren.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get themePluginEmpty =>
|
|
||||||
'Kein Theme-Plugin installiert. Im Store eines aus der Kategorie studio-plugin auswählen.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Hub-Endpunkt';
|
String get settingsTitle => 'Hub-Endpunkt';
|
||||||
|
|
||||||
|
|
@ -1387,113 +1308,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return '$name läuft';
|
return '$name läuft';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String flowsResultTitle(String name) {
|
|
||||||
return '$name — Ergebnis';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String flowsErrorTitle(String name) {
|
|
||||||
return '$name — fehlgeschlagen';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get flowsRunning => 'Flow läuft…';
|
String get flowsRunning => 'Flow läuft…';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get flowsNoOutputs => 'Flow abgeschlossen ohne deklarierte Outputs.';
|
String get flowsNoOutputs => 'Flow abgeschlossen ohne deklarierte Outputs.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get errGeneric => 'Da ist etwas schiefgegangen.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errInvalidArgument => 'Ungültige Eingabe.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errInvalidArgumentHint =>
|
|
||||||
'Werte im Formular prüfen und erneut versuchen.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errNotFound => 'Nicht gefunden.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errNotFoundHint =>
|
|
||||||
'Das Angefragte ist nicht hier — entweder entfernt oder nie existiert.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errAlreadyExists => 'Existiert bereits.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errPermissionDenied => 'Berechtigung verweigert.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errPermissionDeniedHint =>
|
|
||||||
'Die Operator-Policy des Hubs blockiert diese Aktion. `~/.fai/config.yaml` prüfen.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errFailedPrecondition => 'Setup-Problem hat das blockiert.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errFailedPreconditionHint =>
|
|
||||||
'Der Hub konnte nicht weiter, weil etwas Vorausgesetztes fehlt. Details unten erklären was.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errInternal => 'Unerwarteter interner Fehler.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errInternalHint =>
|
|
||||||
'Hub-Logs unter `~/.fai/logs/` prüfen. Wenn das öfter passiert, bitte melden.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errUnavailable => 'Hub nicht erreichbar.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errUnavailableHint =>
|
|
||||||
'Sicherstellen, dass `fai serve` (oder `fai daemon start`) läuft und der Daemon-Endpunkt zu Studios Einstellungen passt.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errUnauthenticated => 'Authentifizierung erforderlich.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errUnauthenticatedHint =>
|
|
||||||
'`FAI_REGISTRY_TOKEN` setzen oder in den Einstellungen anmelden, dann erneut versuchen.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errCopyDetail => 'Detail kopieren';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errDetailCopied => 'Detail in die Zwischenablage kopiert.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get flowsOutputUnknown => 'Unbekannter Output-Payload-Typ.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get flowsOutputBytesUnknownMime => 'Binärdaten';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get flowsOutputSaveAs => 'Speichern unter…';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get flowsOutputSaveAsTitle => 'Flow-Output speichern';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String flowsOutputSavedAt(String path) {
|
|
||||||
return 'Gespeichert: $path';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String flowsOutputSaveFailed(String error) {
|
|
||||||
return 'Speichern fehlgeschlagen: $error';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get flowsOutputOpen => 'Öffnen';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String flowsOutputOpenFailed(String error) {
|
|
||||||
return 'Öffnen fehlgeschlagen: $error';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsTitle => 'Freigaben';
|
String get approvalsTitle => 'Freigaben';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -237,85 +237,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get buttonCopied => 'Copied';
|
String get buttonCopied => 'Copied';
|
||||||
|
|
||||||
@override
|
|
||||||
String get buttonShowDetails => 'Show details';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get buttonHideDetails => 'Hide details';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get storeInstallableOnly => 'Installable only';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get storePillComingSoon => 'Coming soon';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String storeNotInstallableInline(String status) {
|
|
||||||
return 'This module is on the roadmap (status: $status). It has no installable bundle yet — follow the repository for updates.';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionDeepwikiDesc =>
|
|
||||||
'Public HTTPS — GitHub repo documentation search (AI-powered). No Node, no API key.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionSemgrepDesc =>
|
|
||||||
'Public HTTPS — security scanning for code vulnerabilities. No Node, no API key.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionFilesystemDesc =>
|
|
||||||
'Anthropic — read / write files in /tmp. Edit the path before saving.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionFetchDesc =>
|
|
||||||
'Anthropic — fetch arbitrary HTTP(S) URLs as markdown.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionGithubDesc =>
|
|
||||||
'Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionPuppeteerDesc =>
|
|
||||||
'Anthropic — headless browser automation for screenshots / scraping.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionPostgresDesc =>
|
|
||||||
'Anthropic — read-only SQL queries against a Postgres database. Edit the URL.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionSqliteDesc =>
|
|
||||||
'Anthropic — query a local SQLite file. Edit the path.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionBraveSearchDesc =>
|
|
||||||
'Anthropic — web search via Brave. API key required.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionMemoryDesc =>
|
|
||||||
'Anthropic — persistent knowledge graph for an agent\'s memory.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpSuggestionTimeDesc =>
|
|
||||||
'Anthropic — current time + timezone conversion.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mcpServerEnLanguageBadge =>
|
|
||||||
'Server text in English — Studio shows it as supplied.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get themePluginHeader => 'Theme plugin';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get themePluginNone => 'Built-in (no plugin)';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get themePluginHint =>
|
|
||||||
'Pick an installed studio.theme.* plugin to re-skin the whole app. Install one from the Store first.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get themePluginEmpty =>
|
|
||||||
'No theme plugins installed. Open the Store and pick one in the studio-plugin category.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Hub endpoint';
|
String get settingsTitle => 'Hub endpoint';
|
||||||
|
|
||||||
|
|
@ -1400,112 +1321,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return 'Running $name';
|
return 'Running $name';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String flowsResultTitle(String name) {
|
|
||||||
return '$name — result';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String flowsErrorTitle(String name) {
|
|
||||||
return '$name — failed';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get flowsRunning => 'Flow running…';
|
String get flowsRunning => 'Flow running…';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get flowsNoOutputs => 'Flow completed with no declared outputs.';
|
String get flowsNoOutputs => 'Flow completed with no declared outputs.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get errGeneric => 'Something went wrong.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errInvalidArgument => 'Invalid input.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errInvalidArgumentHint => 'Check the form values and try again.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errNotFound => 'Not found.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errNotFoundHint =>
|
|
||||||
'The item you asked for isn\'t here — it may have been removed or never existed.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errAlreadyExists => 'Already exists.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errPermissionDenied => 'Permission denied.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errPermissionDeniedHint =>
|
|
||||||
'The hub\'s operator policy blocks this action. Check `~/.fai/config.yaml`.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errFailedPrecondition => 'Setup issue blocked this.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errFailedPreconditionHint =>
|
|
||||||
'The hub couldn\'t proceed because something it needs isn\'t ready. The detail below explains what.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errInternal => 'Unexpected internal error.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errInternalHint =>
|
|
||||||
'Check the hub logs at `~/.fai/logs/`. If this repeats, please report it.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errUnavailable => 'Hub not reachable.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errUnavailableHint =>
|
|
||||||
'Make sure `fai serve` (or `fai daemon start`) is running and the daemon endpoint matches Studio\'s settings.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errUnauthenticated => 'Authentication required.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errUnauthenticatedHint =>
|
|
||||||
'Set `FAI_REGISTRY_TOKEN` or sign in through Settings before retrying.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errCopyDetail => 'Copy detail';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errDetailCopied => 'Detail copied to clipboard.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get flowsOutputUnknown => 'Output payload variant not recognised.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get flowsOutputBytesUnknownMime => 'binary';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get flowsOutputSaveAs => 'Save as…';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get flowsOutputSaveAsTitle => 'Save flow output';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String flowsOutputSavedAt(String path) {
|
|
||||||
return 'Saved to $path';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String flowsOutputSaveFailed(String error) {
|
|
||||||
return 'Save failed: $error';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get flowsOutputOpen => 'Open';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String flowsOutputOpenFailed(String error) {
|
|
||||||
return 'Open failed: $error';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsTitle => 'Approvals';
|
String get approvalsTitle => 'Approvals';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'data/hub.dart';
|
import 'data/hub.dart';
|
||||||
import 'data/system_actions.dart';
|
import 'data/system_actions.dart';
|
||||||
import 'data/theme_plugin.dart';
|
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
import 'pages/approvals.dart';
|
import 'pages/approvals.dart';
|
||||||
import 'pages/audit.dart';
|
import 'pages/audit.dart';
|
||||||
|
|
@ -31,33 +30,22 @@ const String kStudioVersion = '0.42.0';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
// Restore the persisted endpoint, theme mode, locale, and
|
// Restore the persisted endpoint, theme mode, and locale
|
||||||
// (when installed) the operator's theme-plugin choice
|
// before the first frame so none of them flicker on startup.
|
||||||
// before the first frame so nothing flickers on startup.
|
|
||||||
await HubService.instance.loadPersistedEndpoint();
|
await HubService.instance.loadPersistedEndpoint();
|
||||||
final themeMode = await HubService.instance.loadThemeMode();
|
final themeMode = await HubService.instance.loadThemeMode();
|
||||||
final locale = await HubService.instance.loadLocale();
|
final locale = await HubService.instance.loadLocale();
|
||||||
final themePlugin = await loadActiveThemePlugin();
|
runApp(StudioApp(initialThemeMode: themeMode, initialLocale: locale));
|
||||||
runApp(StudioApp(
|
|
||||||
initialThemeMode: themeMode,
|
|
||||||
initialLocale: locale,
|
|
||||||
initialThemePlugin: themePlugin,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class StudioApp extends StatefulWidget {
|
class StudioApp extends StatefulWidget {
|
||||||
final ThemeModeValue initialThemeMode;
|
final ThemeModeValue initialThemeMode;
|
||||||
final Locale initialLocale;
|
final Locale initialLocale;
|
||||||
/// Capability name of the active theme plugin (e.g.
|
|
||||||
/// `studio.theme.solarized`) or `null` for the built-in
|
|
||||||
/// FaiTheme.
|
|
||||||
final String? initialThemePlugin;
|
|
||||||
|
|
||||||
const StudioApp({
|
const StudioApp({
|
||||||
super.key,
|
super.key,
|
||||||
required this.initialThemeMode,
|
required this.initialThemeMode,
|
||||||
required this.initialLocale,
|
required this.initialLocale,
|
||||||
this.initialThemePlugin,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Lookup helper so descendants can flip the theme without
|
/// Lookup helper so descendants can flip the theme without
|
||||||
|
|
@ -80,12 +68,6 @@ class StudioAppState extends State<StudioApp> {
|
||||||
/// this, MaterialApp re-renders with the new
|
/// this, MaterialApp re-renders with the new
|
||||||
/// AppLocalizations, and every translated string updates.
|
/// AppLocalizations, and every translated string updates.
|
||||||
late final ValueNotifier<Locale> localeNotifier;
|
late final ValueNotifier<Locale> localeNotifier;
|
||||||
/// Capability name of the active `studio.theme.*` plugin
|
|
||||||
/// (e.g. `studio.theme.solarized`) or `null` for
|
|
||||||
/// FaiTheme's built-in palette. Flipping this triggers a
|
|
||||||
/// re-fetch of the plugin's ColorSchemes; the MaterialApp
|
|
||||||
/// rebuilds with the new themes.
|
|
||||||
late final ValueNotifier<String?> themePluginNotifier;
|
|
||||||
|
|
||||||
Future<void> setMode(ThemeModeValue m) async {
|
Future<void> setMode(ThemeModeValue m) async {
|
||||||
modeNotifier.value = m;
|
modeNotifier.value = m;
|
||||||
|
|
@ -97,24 +79,17 @@ class StudioAppState extends State<StudioApp> {
|
||||||
await HubService.instance.saveLocale(l);
|
await HubService.instance.saveLocale(l);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setThemePlugin(String? capability) async {
|
|
||||||
themePluginNotifier.value = capability;
|
|
||||||
await saveActiveThemePlugin(capability);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
modeNotifier = ValueNotifier(widget.initialThemeMode);
|
modeNotifier = ValueNotifier(widget.initialThemeMode);
|
||||||
localeNotifier = ValueNotifier(widget.initialLocale);
|
localeNotifier = ValueNotifier(widget.initialLocale);
|
||||||
themePluginNotifier = ValueNotifier(widget.initialThemePlugin);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
modeNotifier.dispose();
|
modeNotifier.dispose();
|
||||||
localeNotifier.dispose();
|
localeNotifier.dispose();
|
||||||
themePluginNotifier.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ValueListenableBuilder<ThemeModeValue>(
|
return ValueListenableBuilder<ThemeModeValue>(
|
||||||
valueListenable: modeNotifier,
|
valueListenable: modeNotifier,
|
||||||
builder: (_, mode, _) => ValueListenableBuilder<Locale>(
|
builder: (_, mode, _) => ValueListenableBuilder<Locale>(
|
||||||
valueListenable: localeNotifier,
|
valueListenable: localeNotifier,
|
||||||
builder: (_, locale, _) => ValueListenableBuilder<String?>(
|
builder: (_, locale, _) => MaterialApp(
|
||||||
valueListenable: themePluginNotifier,
|
title: 'F∆I Studio',
|
||||||
builder: (_, pluginCap, _) => FutureBuilder<({ThemeData? light, ThemeData? dark})>(
|
debugShowCheckedModeBanner: false,
|
||||||
// Re-key on the capability so a plugin change
|
theme: FaiTheme.light(),
|
||||||
// discards the in-flight future for the previous
|
darkTheme: FaiTheme.dark(),
|
||||||
// plugin and starts fresh.
|
themeMode: _flutterMode(mode),
|
||||||
key: ValueKey('plugin_themes:$pluginCap'),
|
locale: locale,
|
||||||
future: _pluginThemes(pluginCap),
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
builder: (context, snap) {
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
final p = snap.data ?? (light: null, dark: null);
|
home: const StudioShell(),
|
||||||
return MaterialApp(
|
|
||||||
title: 'F∆I Studio',
|
|
||||||
debugShowCheckedModeBanner: false,
|
|
||||||
theme: p.light ?? FaiTheme.light(),
|
|
||||||
darkTheme: p.dark ?? FaiTheme.dark(),
|
|
||||||
themeMode: _flutterMode(mode),
|
|
||||||
locale: locale,
|
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
||||||
home: const StudioShell(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import 'package:fai_dart_sdk/fai_dart_sdk.dart' show FlowInputDef;
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../data/format.dart';
|
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
import '../data/system_actions.dart';
|
import '../data/system_actions.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
@ -29,27 +28,18 @@ class _FlowsPageState extends State<FlowsPage> {
|
||||||
_future = _load();
|
_future = _load();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pulls the saved flows AND every capability the hub can
|
/// Pulls the saved flows AND the installed-modules list in
|
||||||
/// execute (WASM modules + built-ins like `system.approval` +
|
/// parallel so the page can compute "is this flow runnable"
|
||||||
/// federated MCP/n8n tools) in parallel so the page can
|
/// per row from the same data the Run button gates on.
|
||||||
/// compute "is this flow runnable" per row.
|
|
||||||
///
|
|
||||||
/// Using `allCapabilities()` instead of `listModules()` is
|
|
||||||
/// load-bearing: built-ins are not WASM modules, so they would
|
|
||||||
/// never appear in a module-grouped list — and a flow that
|
|
||||||
/// uses `system.approval@^0` would stay stuck on "missing
|
|
||||||
/// dependency" forever.
|
|
||||||
Future<_FlowsBundle> _load() async {
|
Future<_FlowsBundle> _load() async {
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
HubService.instance.listFlows(),
|
HubService.instance.listFlows(),
|
||||||
HubService.instance
|
HubService.instance.listModules().catchError((_) => <ModuleSummary>[]),
|
||||||
.allCapabilities()
|
|
||||||
.catchError((_) => <CapabilityInfo>[]),
|
|
||||||
]);
|
]);
|
||||||
return _FlowsBundle(
|
return _FlowsBundle(
|
||||||
flows: results[0] as List<SavedFlow>,
|
flows: results[0] as List<SavedFlow>,
|
||||||
installedCapabilities: (results[1] as List<CapabilityInfo>)
|
installedCapabilities: (results[1] as List<ModuleSummary>)
|
||||||
.map((c) => c.capability)
|
.expand((m) => m.capabilities)
|
||||||
.toSet(),
|
.toSet(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +74,6 @@ class _FlowsPageState extends State<FlowsPage> {
|
||||||
flow: flow,
|
flow: flow,
|
||||||
textInputs: inputs.textInputs,
|
textInputs: inputs.textInputs,
|
||||||
fileInputs: inputs.fileInputs,
|
fileInputs: inputs.fileInputs,
|
||||||
fileMimeTypes: inputs.fileMimeTypes,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -265,7 +254,7 @@ class _FlowCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
FaiPill(
|
FaiPill(
|
||||||
label: humanBytes(flow.sizeBytes),
|
label: '${flow.sizeBytes} B',
|
||||||
tone: FaiPillTone.neutral,
|
tone: FaiPillTone.neutral,
|
||||||
monospace: true,
|
monospace: true,
|
||||||
),
|
),
|
||||||
|
|
@ -380,75 +369,16 @@ class _ClickablePill extends StatelessWidget {
|
||||||
class _FlowRunInputs {
|
class _FlowRunInputs {
|
||||||
final Map<String, String> textInputs;
|
final Map<String, String> textInputs;
|
||||||
final Map<String, Uint8List> fileInputs;
|
final Map<String, Uint8List> fileInputs;
|
||||||
/// Parallel to [fileInputs]; same keys. Modules like
|
|
||||||
/// `text.extract` reject `application/octet-stream`, so we
|
|
||||||
/// derive a real MIME type from the picked file's extension
|
|
||||||
/// and forward it to the hub.
|
|
||||||
final Map<String, String> fileMimeTypes;
|
|
||||||
const _FlowRunInputs({
|
const _FlowRunInputs({
|
||||||
required this.textInputs,
|
required this.textInputs,
|
||||||
required this.fileInputs,
|
required this.fileInputs,
|
||||||
required this.fileMimeTypes,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PickedFile {
|
class _PickedFile {
|
||||||
final String name;
|
final String name;
|
||||||
final Uint8List bytes;
|
final Uint8List bytes;
|
||||||
/// MIME derived from the file extension at pick time. Empty
|
const _PickedFile({required this.name, required this.bytes});
|
||||||
/// when the extension is unknown — the SDK then falls back to
|
|
||||||
/// `application/octet-stream`.
|
|
||||||
final String mimeType;
|
|
||||||
const _PickedFile({
|
|
||||||
required this.name,
|
|
||||||
required this.bytes,
|
|
||||||
required this.mimeType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal extension → MIME map covering the file types that
|
|
||||||
/// shipped modules actually accept (PDF, DOCX for text.extract;
|
|
||||||
/// plain text, json, csv for upcoming modules). Keeping it tiny
|
|
||||||
/// avoids pulling the `mime` package as a new dependency. Returns
|
|
||||||
/// empty string for unknown extensions so the SDK default
|
|
||||||
/// (`application/octet-stream`) still applies.
|
|
||||||
String _mimeForFilename(String filename) {
|
|
||||||
final dot = filename.lastIndexOf('.');
|
|
||||||
if (dot < 0 || dot == filename.length - 1) return '';
|
|
||||||
final ext = filename.substring(dot + 1).toLowerCase();
|
|
||||||
switch (ext) {
|
|
||||||
case 'pdf':
|
|
||||||
return 'application/pdf';
|
|
||||||
case 'docx':
|
|
||||||
return 'application/vnd.openxmlformats-officedocument'
|
|
||||||
'.wordprocessingml.document';
|
|
||||||
case 'doc':
|
|
||||||
return 'application/msword';
|
|
||||||
case 'txt':
|
|
||||||
return 'text/plain';
|
|
||||||
case 'md':
|
|
||||||
case 'markdown':
|
|
||||||
return 'text/markdown';
|
|
||||||
case 'json':
|
|
||||||
return 'application/json';
|
|
||||||
case 'csv':
|
|
||||||
return 'text/csv';
|
|
||||||
case 'xml':
|
|
||||||
return 'application/xml';
|
|
||||||
case 'yaml':
|
|
||||||
case 'yml':
|
|
||||||
return 'application/yaml';
|
|
||||||
case 'html':
|
|
||||||
case 'htm':
|
|
||||||
return 'text/html';
|
|
||||||
case 'png':
|
|
||||||
return 'image/png';
|
|
||||||
case 'jpg':
|
|
||||||
case 'jpeg':
|
|
||||||
return 'image/jpeg';
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run-flow input dialog. Fetches the flow's declared input
|
/// Run-flow input dialog. Fetches the flow's declared input
|
||||||
|
|
@ -527,26 +457,16 @@ class _FlowInputDialogState extends State<_FlowInputDialog> {
|
||||||
_FlowRunInputs _collect(List<FlowInputDef> defs) {
|
_FlowRunInputs _collect(List<FlowInputDef> defs) {
|
||||||
final textInputs = <String, String>{};
|
final textInputs = <String, String>{};
|
||||||
final fileInputs = <String, Uint8List>{};
|
final fileInputs = <String, Uint8List>{};
|
||||||
final fileMimeTypes = <String, String>{};
|
|
||||||
for (final d in defs) {
|
for (final d in defs) {
|
||||||
if (_isBytesType(d.type)) {
|
if (_isBytesType(d.type)) {
|
||||||
final picked = _pickedFiles[d.name];
|
final picked = _pickedFiles[d.name];
|
||||||
if (picked != null) {
|
if (picked != null) fileInputs[d.name] = picked.bytes;
|
||||||
fileInputs[d.name] = picked.bytes;
|
|
||||||
if (picked.mimeType.isNotEmpty) {
|
|
||||||
fileMimeTypes[d.name] = picked.mimeType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
final c = _textControllers[d.name];
|
final c = _textControllers[d.name];
|
||||||
if (c != null) textInputs[d.name] = c.text;
|
if (c != null) textInputs[d.name] = c.text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return _FlowRunInputs(
|
return _FlowRunInputs(textInputs: textInputs, fileInputs: fileInputs);
|
||||||
textInputs: textInputs,
|
|
||||||
fileInputs: fileInputs,
|
|
||||||
fileMimeTypes: fileMimeTypes,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickFile(String inputName) async {
|
Future<void> _pickFile(String inputName) async {
|
||||||
|
|
@ -570,11 +490,7 @@ class _FlowInputDialogState extends State<_FlowInputDialog> {
|
||||||
}
|
}
|
||||||
if (bytes == null || !mounted) return;
|
if (bytes == null || !mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_pickedFiles[inputName] = _PickedFile(
|
_pickedFiles[inputName] = _PickedFile(name: f.name, bytes: bytes!);
|
||||||
name: f.name,
|
|
||||||
bytes: bytes!,
|
|
||||||
mimeType: _mimeForFilename(f.name),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -635,7 +551,7 @@ class _FlowInputDialogState extends State<_FlowInputDialog> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.sm),
|
const SizedBox(height: FaiSpace.sm),
|
||||||
FaiErrorBox(
|
FaiErrorBox(
|
||||||
error: snap.error,
|
text: snap.error.toString(),
|
||||||
isError: true,
|
isError: true,
|
||||||
maxHeight: 200,
|
maxHeight: 200,
|
||||||
),
|
),
|
||||||
|
|
@ -760,7 +676,7 @@ class _InputField extends StatelessWidget {
|
||||||
pickedFile == null
|
pickedFile == null
|
||||||
? l.flowsTypeBytesNoFile
|
? l.flowsTypeBytesNoFile
|
||||||
: '${pickedFile!.name} · '
|
: '${pickedFile!.name} · '
|
||||||
'${humanBytes(pickedFile!.bytes.length)}',
|
'${l.flowsTypeBytesSize(pickedFile!.bytes.length)}',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|
@ -786,13 +702,11 @@ class _FlowRunDialog extends StatefulWidget {
|
||||||
final SavedFlow flow;
|
final SavedFlow flow;
|
||||||
final Map<String, String> textInputs;
|
final Map<String, String> textInputs;
|
||||||
final Map<String, Uint8List> fileInputs;
|
final Map<String, Uint8List> fileInputs;
|
||||||
final Map<String, String> fileMimeTypes;
|
|
||||||
|
|
||||||
const _FlowRunDialog({
|
const _FlowRunDialog({
|
||||||
required this.flow,
|
required this.flow,
|
||||||
required this.textInputs,
|
required this.textInputs,
|
||||||
required this.fileInputs,
|
required this.fileInputs,
|
||||||
required this.fileMimeTypes,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -800,7 +714,7 @@ class _FlowRunDialog extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FlowRunDialogState extends State<_FlowRunDialog> {
|
class _FlowRunDialogState extends State<_FlowRunDialog> {
|
||||||
late final Future<Map<String, FlowOutput>> _future;
|
late final Future<Map<String, String>> _future;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -809,7 +723,6 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
|
||||||
name: widget.flow.name,
|
name: widget.flow.name,
|
||||||
textInputs: widget.textInputs,
|
textInputs: widget.textInputs,
|
||||||
fileInputs: widget.fileInputs,
|
fileInputs: widget.fileInputs,
|
||||||
fileMimeTypes: widget.fileMimeTypes,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -818,29 +731,13 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
// Title tracks the future's state so the dialog header
|
title: Text(l.flowsRunningTitle(widget.flow.name)),
|
||||||
// stops claiming "extract läuft" once the run is over.
|
|
||||||
// Recomputed on every snapshot change via FutureBuilder.
|
|
||||||
title: FutureBuilder<Map<String, FlowOutput>>(
|
|
||||||
future: _future,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
final String title;
|
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
title = l.flowsRunningTitle(widget.flow.name);
|
|
||||||
} else if (snapshot.hasError) {
|
|
||||||
title = l.flowsErrorTitle(widget.flow.name);
|
|
||||||
} else {
|
|
||||||
title = l.flowsResultTitle(widget.flow.name);
|
|
||||||
}
|
|
||||||
return Text(title);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||||
),
|
),
|
||||||
content: ConstrainedBox(
|
content: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480),
|
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480),
|
||||||
child: FutureBuilder<Map<String, FlowOutput>>(
|
child: FutureBuilder<Map<String, String>>(
|
||||||
future: _future,
|
future: _future,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
|
@ -884,7 +781,7 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.md),
|
const SizedBox(height: FaiSpace.md),
|
||||||
FaiErrorBox(
|
FaiErrorBox(
|
||||||
error: snapshot.error,
|
text: snapshot.error.toString(),
|
||||||
isError: true,
|
isError: true,
|
||||||
maxHeight: 280,
|
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),
|
const SizedBox(height: FaiSpace.md),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
@ -954,10 +865,7 @@ class _InstallItemState {
|
||||||
|
|
||||||
_InstallStatus status;
|
_InstallStatus status;
|
||||||
String? installedVersion;
|
String? installedVersion;
|
||||||
/// Original thrown object so the UI can run it through
|
String? error;
|
||||||
/// [friendlyError] later instead of staring at a wall of gRPC
|
|
||||||
/// trailers.
|
|
||||||
Object? error;
|
|
||||||
|
|
||||||
_InstallItemState(this.spec)
|
_InstallItemState(this.spec)
|
||||||
: bareName = _stripVersion(spec),
|
: bareName = _stripVersion(spec),
|
||||||
|
|
@ -1015,7 +923,7 @@ class _InstallDependenciesDialogState
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
item.status = _InstallStatus.failed;
|
item.status = _InstallStatus.failed;
|
||||||
item.error = e;
|
item.error = e.toString();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1042,9 +950,8 @@ class _InstallDependenciesDialogState
|
||||||
i.status == _InstallStatus.failed && _isAuthWallError(i.error),
|
i.status == _InstallStatus.failed && _isAuthWallError(i.error),
|
||||||
);
|
);
|
||||||
|
|
||||||
static bool _isAuthWallError(Object? err) {
|
static bool _isAuthWallError(String? msg) {
|
||||||
if (err == null) return false;
|
if (msg == null) return false;
|
||||||
final msg = err.toString();
|
|
||||||
return msg.contains('registry returned an HTML page') ||
|
return msg.contains('registry returned an HTML page') ||
|
||||||
msg.contains('no registry token configured');
|
msg.contains('no registry token configured');
|
||||||
}
|
}
|
||||||
|
|
@ -1164,7 +1071,7 @@ class _InstallRow extends StatelessWidget {
|
||||||
item.error != null) ...[
|
item.error != null) ...[
|
||||||
const SizedBox(height: FaiSpace.xs),
|
const SizedBox(height: FaiSpace.xs),
|
||||||
FaiErrorBox(
|
FaiErrorBox(
|
||||||
error: item.error,
|
text: item.error!,
|
||||||
isError: true,
|
isError: true,
|
||||||
maxHeight: 120,
|
maxHeight: 120,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
|
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
import '../data/system_actions.dart';
|
import '../data/system_actions.dart';
|
||||||
|
|
@ -36,13 +36,6 @@ class _StorePageState extends State<StorePage> {
|
||||||
/// has no source field.
|
/// has no source field.
|
||||||
String _source = '';
|
String _source = '';
|
||||||
bool _installedOnly = false;
|
bool _installedOnly = false;
|
||||||
/// "Show installable modules only". On by default: roughly
|
|
||||||
/// 2/3 of the seed-index entries currently carry status
|
|
||||||
/// `planned` (no bundle to install), and pre-filter polish
|
|
||||||
/// found that operators couldn't tell why they couldn't
|
|
||||||
/// install half the cards. Planned modules are one chip-flip
|
|
||||||
/// away in the filter dialog.
|
|
||||||
bool _installableOnly = true;
|
|
||||||
/// Tracks per-recommended-source add buttons to disable them
|
/// Tracks per-recommended-source add buttons to disable them
|
||||||
/// while the request is in flight.
|
/// while the request is in flight.
|
||||||
final Set<String> _addingSources = <String>{};
|
final Set<String> _addingSources = <String>{};
|
||||||
|
|
@ -143,19 +136,7 @@ class _StorePageState extends State<StorePage> {
|
||||||
final all = results[0] as List<StoreItem>;
|
final all = results[0] as List<StoreItem>;
|
||||||
final mods = results[1] as List<ModuleSummary>;
|
final mods = results[1] as List<ModuleSummary>;
|
||||||
_installedVersions = {for (final m in mods) m.name: m.version};
|
_installedVersions = {for (final m in mods) m.name: m.version};
|
||||||
var out = all;
|
return _installedOnly ? all.where((e) => e.installed).toList() : all;
|
||||||
if (_installedOnly) {
|
|
||||||
out = out.where((e) => e.installed).toList();
|
|
||||||
}
|
|
||||||
if (_installableOnly) {
|
|
||||||
// An already-installed item counts as "installable" for
|
|
||||||
// this filter — the user can still update / uninstall it.
|
|
||||||
out = out
|
|
||||||
.where((e) =>
|
|
||||||
e.installed || e.status == 'published' || e.status == 'alpha')
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _runSearch() {
|
void _runSearch() {
|
||||||
|
|
@ -280,7 +261,6 @@ class _StorePageState extends State<StorePage> {
|
||||||
_status = '';
|
_status = '';
|
||||||
_source = '';
|
_source = '';
|
||||||
_installedOnly = false;
|
_installedOnly = false;
|
||||||
_installableOnly = false;
|
|
||||||
_queryCtrl.clear();
|
_queryCtrl.clear();
|
||||||
});
|
});
|
||||||
_runSearch();
|
_runSearch();
|
||||||
|
|
@ -460,10 +440,6 @@ class _StorePageState extends State<StorePage> {
|
||||||
if (_status.isNotEmpty) n++;
|
if (_status.isNotEmpty) n++;
|
||||||
if (_source.isNotEmpty) n++;
|
if (_source.isNotEmpty) n++;
|
||||||
if (_installedOnly) n++;
|
if (_installedOnly) n++;
|
||||||
// `installableOnly` defaults to ON, so we only highlight it
|
|
||||||
// as an "active filter" when the operator turned it OFF —
|
|
||||||
// the surprising state is the one worth flagging.
|
|
||||||
if (!_installableOnly) n++;
|
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -581,7 +557,6 @@ class _StorePageState extends State<StorePage> {
|
||||||
status: _status,
|
status: _status,
|
||||||
source: _source,
|
source: _source,
|
||||||
installedOnly: _installedOnly,
|
installedOnly: _installedOnly,
|
||||||
installableOnly: _installableOnly,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (outcome == null) return;
|
if (outcome == null) return;
|
||||||
|
|
@ -589,7 +564,6 @@ class _StorePageState extends State<StorePage> {
|
||||||
_status = outcome.status;
|
_status = outcome.status;
|
||||||
_source = outcome.source;
|
_source = outcome.source;
|
||||||
_installedOnly = outcome.installedOnly;
|
_installedOnly = outcome.installedOnly;
|
||||||
_installableOnly = outcome.installableOnly;
|
|
||||||
});
|
});
|
||||||
_runSearch();
|
_runSearch();
|
||||||
}
|
}
|
||||||
|
|
@ -918,12 +892,10 @@ class _FilterOutcome {
|
||||||
final String status;
|
final String status;
|
||||||
final String source;
|
final String source;
|
||||||
final bool installedOnly;
|
final bool installedOnly;
|
||||||
final bool installableOnly;
|
|
||||||
const _FilterOutcome({
|
const _FilterOutcome({
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.source,
|
required this.source,
|
||||||
required this.installedOnly,
|
required this.installedOnly,
|
||||||
required this.installableOnly,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -936,13 +908,11 @@ class _FilterDialog extends StatefulWidget {
|
||||||
final String status;
|
final String status;
|
||||||
final String source;
|
final String source;
|
||||||
final bool installedOnly;
|
final bool installedOnly;
|
||||||
final bool installableOnly;
|
|
||||||
|
|
||||||
const _FilterDialog({
|
const _FilterDialog({
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.source,
|
required this.source,
|
||||||
required this.installedOnly,
|
required this.installedOnly,
|
||||||
required this.installableOnly,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -953,7 +923,6 @@ class _FilterDialogState extends State<_FilterDialog> {
|
||||||
late String _status = widget.status;
|
late String _status = widget.status;
|
||||||
late String _source = widget.source;
|
late String _source = widget.source;
|
||||||
late bool _installedOnly = widget.installedOnly;
|
late bool _installedOnly = widget.installedOnly;
|
||||||
late bool _installableOnly = widget.installableOnly;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -1020,23 +989,11 @@ class _FilterDialogState extends State<_FilterDialog> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.lg),
|
const SizedBox(height: FaiSpace.lg),
|
||||||
header(l.storeFilterInstalledGroup),
|
header(l.storeFilterInstalledGroup),
|
||||||
Wrap(
|
FilterChip(
|
||||||
spacing: FaiSpace.xs,
|
label: Text(l.storeInstalledOnly),
|
||||||
runSpacing: FaiSpace.xs,
|
selected: _installedOnly,
|
||||||
children: [
|
onSelected: (v) => setState(() => _installedOnly = v),
|
||||||
FilterChip(
|
showCheckmark: true,
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -1048,11 +1005,6 @@ class _FilterDialogState extends State<_FilterDialog> {
|
||||||
_status = '';
|
_status = '';
|
||||||
_source = '';
|
_source = '';
|
||||||
_installedOnly = false;
|
_installedOnly = false;
|
||||||
// Reset puts `installableOnly` back to its default
|
|
||||||
// (on) — the operator hits Reset to escape a
|
|
||||||
// custom-filter state and expects the default view
|
|
||||||
// back, not an everything-including-planned view.
|
|
||||||
_installableOnly = true;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: Text(l.storeFilterReset),
|
child: Text(l.storeFilterReset),
|
||||||
|
|
@ -1069,7 +1021,6 @@ class _FilterDialogState extends State<_FilterDialog> {
|
||||||
status: _status,
|
status: _status,
|
||||||
source: _source,
|
source: _source,
|
||||||
installedOnly: _installedOnly,
|
installedOnly: _installedOnly,
|
||||||
installableOnly: _installableOnly,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(l.storeFilterApply),
|
child: Text(l.storeFilterApply),
|
||||||
|
|
@ -1615,29 +1566,11 @@ class _FeaturedTile extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.md),
|
const SizedBox(height: FaiSpace.md),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Text(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
tagline.isEmpty ? l.storeNoTagline : tagline,
|
||||||
children: [
|
style: theme.textTheme.bodyLarge,
|
||||||
if (item.isFederated)
|
maxLines: 3,
|
||||||
// Federated items get tagline copy straight
|
overflow: TextOverflow.ellipsis,
|
||||||
// from the upstream MCP / n8n server, which
|
|
||||||
// is English regardless of Studio's locale.
|
|
||||||
// The [EN] pill is the honest signal until
|
|
||||||
// the planned `studio.translate` plugin
|
|
||||||
// rewrites it on the fly.
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
|
||||||
child: FaiEnBadge.forContext(context),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
tagline.isEmpty ? l.storeNoTagline : tagline,
|
|
||||||
style: theme.textTheme.bodyLarge,
|
|
||||||
maxLines: 3,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.sm),
|
const SizedBox(height: FaiSpace.sm),
|
||||||
|
|
@ -1667,16 +1600,6 @@ class _FeaturedTile extends StatelessWidget {
|
||||||
onPressed: onInstall,
|
onPressed: onInstall,
|
||||||
icon: const Icon(Icons.download, size: 16),
|
icon: const Icon(Icons.download, size: 16),
|
||||||
label: Text(l.buttonInstall),
|
label: Text(l.buttonInstall),
|
||||||
)
|
|
||||||
else
|
|
||||||
// Status is `planned` (or another non-installable
|
|
||||||
// wire value). Without an explicit pill here the
|
|
||||||
// featured tile would just go silent, leaving the
|
|
||||||
// operator wondering why the card has no action.
|
|
||||||
FaiPill(
|
|
||||||
label: l.storePillComingSoon,
|
|
||||||
tone: FaiPillTone.neutral,
|
|
||||||
icon: Icons.schedule,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -1724,14 +1647,7 @@ class _StoreCard extends StatelessWidget {
|
||||||
? item.taglineDe
|
? item.taglineDe
|
||||||
: item.taglineEn;
|
: item.taglineEn;
|
||||||
final installable = item.status == 'published' || item.status == 'alpha';
|
final installable = item.status == 'published' || item.status == 'alpha';
|
||||||
// Dim planned/non-installable cards so the eye lands on
|
return Material(
|
||||||
// actionable cards first. We don't disable interaction — the
|
|
||||||
// detail sheet still opens — but the visual hierarchy is
|
|
||||||
// honest about what the operator can act on.
|
|
||||||
final notInstallable = !installable && !item.installed;
|
|
||||||
return Opacity(
|
|
||||||
opacity: notInstallable ? 0.6 : 1.0,
|
|
||||||
child: Material(
|
|
||||||
color: theme.colorScheme.surfaceContainer,
|
color: theme.colorScheme.surfaceContainer,
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
|
|
@ -1866,7 +1782,6 @@ class _StoreCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1908,13 +1823,8 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
String get _locale => Localizations.localeOf(context).languageCode;
|
String get _locale => Localizations.localeOf(context).languageCode;
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
String? _toast;
|
String? _toast;
|
||||||
/// `null` while the initial probe is in flight; non-null once
|
Future<({String errorKind, String text, String sourceUrl})>? _docsFuture;
|
||||||
/// `readInstalledModuleDocs` has returned. The probe is cheap —
|
bool _docsLoaded = false;
|
||||||
/// a single file stat on disk — so we run it eagerly when the
|
|
||||||
/// sheet opens for an installed module instead of gating it
|
|
||||||
/// behind a "Load docs" click.
|
|
||||||
({String state, String text, String sourcePath})? _docsResult;
|
|
||||||
bool _docsProbed = false;
|
|
||||||
/// Live module-info for installed entries: declared
|
/// Live module-info for installed entries: declared
|
||||||
/// permissions and on-disk directory. Pulled lazily so
|
/// permissions and on-disk directory. Pulled lazily so
|
||||||
/// not-installed entries don't run an unnecessary RPC. Stays
|
/// not-installed entries don't run an unnecessary RPC. Stays
|
||||||
|
|
@ -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 {
|
Future<void> _loadModuleDetail() async {
|
||||||
try {
|
try {
|
||||||
final detail =
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -2166,15 +2063,6 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
const SizedBox(height: FaiSpace.lg),
|
const SizedBox(height: FaiSpace.lg),
|
||||||
],
|
],
|
||||||
if (description.isNotEmpty) ...[
|
if (description.isNotEmpty) ...[
|
||||||
if (item.isFederated) ...[
|
|
||||||
// Description copy was supplied by the
|
|
||||||
// upstream server, not by Studio's
|
|
||||||
// localisation pipeline — flag it.
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
|
||||||
child: FaiEnBadge.forContext(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
SelectableText(
|
SelectableText(
|
||||||
description,
|
description,
|
||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
|
|
@ -2303,13 +2191,15 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.lg),
|
const SizedBox(height: FaiSpace.lg),
|
||||||
],
|
],
|
||||||
if (_docsResult?.state == 'found') ...[
|
if (item.repository.isNotEmpty || item.docsUrl.isNotEmpty) ...[
|
||||||
_SectionHeader(l.storeSectionDocumentation),
|
_SectionHeader(l.storeSectionDocumentation),
|
||||||
const SizedBox(height: FaiSpace.sm),
|
const SizedBox(height: FaiSpace.sm),
|
||||||
_DocsPanel(text: _docsResult!.text),
|
_DocsPanel(
|
||||||
|
future: _docsFuture,
|
||||||
|
onLoad: _loadDocs,
|
||||||
|
onOpenRepo: _openRepository,
|
||||||
|
),
|
||||||
const SizedBox(height: FaiSpace.lg),
|
const SizedBox(height: FaiSpace.lg),
|
||||||
],
|
|
||||||
if (item.repository.isNotEmpty) ...[
|
|
||||||
_SectionHeader(l.storeSectionSource),
|
_SectionHeader(l.storeSectionSource),
|
||||||
const SizedBox(height: FaiSpace.sm),
|
const SizedBox(height: FaiSpace.sm),
|
||||||
InkWell(
|
InkWell(
|
||||||
|
|
@ -2352,6 +2242,12 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
],
|
],
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
if (item.repository.isNotEmpty)
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _openRepository,
|
||||||
|
icon: const Icon(Icons.menu_book_outlined, size: 16),
|
||||||
|
label: Text(l.buttonReadDocs),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (item.installed) ...[
|
if (item.installed) ...[
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
|
|
@ -2388,46 +2284,14 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
_busy ? l.storeInstallingButton : l.buttonInstall,
|
_busy ? l.storeInstallingButton : l.buttonInstall,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else ...[
|
else
|
||||||
// Inline hint replaces the previous
|
Tooltip(
|
||||||
// tooltip-only affordance: the operator
|
message: l.storeNotInstallableTooltip(item.status),
|
||||||
// sees the reason without having to hover.
|
child: FilledButton(
|
||||||
Expanded(
|
onPressed: null,
|
||||||
child: Container(
|
child: Text(l.storeNotInstallable),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -2521,10 +2385,12 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> {
|
||||||
size: 32,
|
size: 32,
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.md),
|
const SizedBox(height: FaiSpace.md),
|
||||||
FaiErrorBox(
|
SelectableText(
|
||||||
error: snap.error,
|
snap.error.toString(),
|
||||||
isError: true,
|
style: FaiTheme.mono(
|
||||||
maxHeight: 200,
|
size: 11,
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -2596,40 +2462,210 @@ IconData _iconForCategory(String category) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders the module's inline `MODULE.md` (or its localized
|
/// Lazy-loaded inline README renderer. Shows a "Load
|
||||||
/// sibling) in the same theme as the rest of Studio. The markdown
|
/// documentation" button until the operator clicks it (so we
|
||||||
/// body is supplied by the parent — `_DocsPanel` itself stays a
|
/// don't blow the network unprompted), then a markdown view of
|
||||||
/// pure renderer with no async / no error handling.
|
/// the result. Auth / not-found errors come back with friendly
|
||||||
///
|
/// fix-hint copy and a fallback "View on repository" button.
|
||||||
/// Blockquote + table-cell decoration is overridden because
|
|
||||||
/// flutter_markdown's default styles hardcode
|
|
||||||
/// `Colors.blue.shade100`, which is unreadable on a dark theme.
|
|
||||||
class _DocsPanel extends StatelessWidget {
|
class _DocsPanel extends StatelessWidget {
|
||||||
final 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
constraints: const BoxConstraints(maxHeight: 480),
|
|
||||||
padding: const EdgeInsets.all(FaiSpace.md),
|
padding: const EdgeInsets.all(FaiSpace.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.surfaceContainerHigh,
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
child: Markdown(
|
child: Column(
|
||||||
data: text,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
shrinkWrap: true,
|
children: [
|
||||||
selectable: true,
|
Row(
|
||||||
onTapLink: (text, href, title) async {
|
children: [
|
||||||
if (href == null || href.isEmpty) return;
|
Icon(
|
||||||
await SystemActions.openInOs(href);
|
Icons.info_outline,
|
||||||
},
|
size: 16,
|
||||||
styleSheet: FaiTheme.markdownStyle(theme),
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _friendlyDocsError(BuildContext context, String kind, String detail) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
switch (kind) {
|
||||||
|
case 'not_found':
|
||||||
|
return l.storeDocsErrorNotFound;
|
||||||
|
case 'no_docs':
|
||||||
|
return l.storeDocsErrorNoDocs;
|
||||||
|
case 'auth':
|
||||||
|
return l.storeDocsErrorAuth;
|
||||||
|
case 'network':
|
||||||
|
return l.storeDocsErrorNetwork(detail);
|
||||||
|
case 'parse':
|
||||||
|
return l.storeDocsErrorParse;
|
||||||
|
default:
|
||||||
|
return detail.isEmpty ? l.storeDocsErrorGeneric : detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Module icon: renders the explicit icon URL when set, falls
|
/// Module icon: renders the explicit icon URL when set, falls
|
||||||
/// back to the category-derived `Icons.X` glyph in a colored
|
/// back to the category-derived `Icons.X` glyph in a colored
|
||||||
/// circle when the URL is empty or fails to load. Wraps the
|
/// circle when the URL is empty or fails to load. Wraps the
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart' show rootBundle;
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
|
|
@ -1041,7 +1041,7 @@ class _DocReaderSheetState extends State<_DocReaderSheet> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.sm),
|
const SizedBox(height: FaiSpace.sm),
|
||||||
FaiErrorBox(
|
FaiErrorBox(
|
||||||
error: snap.error,
|
text: snap.error.toString(),
|
||||||
isError: true,
|
isError: true,
|
||||||
maxHeight: 200,
|
maxHeight: 200,
|
||||||
),
|
),
|
||||||
|
|
@ -1058,7 +1058,17 @@ class _DocReaderSheetState extends State<_DocReaderSheet> {
|
||||||
await SystemActions.openInOs(href);
|
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.
|
// Theme assembly. ColorScheme + typography + component themes.
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
import 'tokens.dart';
|
import 'tokens.dart';
|
||||||
|
|
@ -78,69 +77,6 @@ class FaiTheme {
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Shared markdown style used by every inline doc renderer
|
|
||||||
/// (Welcome DocReaderSheet, Store module-detail DocsPanel,
|
|
||||||
/// future help surfaces). Centralising it means every doc
|
|
||||||
/// reads in the same typography, with the same dark-theme
|
|
||||||
/// safe colors.
|
|
||||||
///
|
|
||||||
/// Why every property is set explicitly even when it just
|
|
||||||
/// re-states the default:
|
|
||||||
///
|
|
||||||
/// - `flutter_markdown`'s `MarkdownStyleSheet.fromTheme(theme)`
|
|
||||||
/// sets `code` with `backgroundColor: theme.cardTheme.color`.
|
|
||||||
/// That per-span backgroundColor is rendered behind every
|
|
||||||
/// character inside a fenced code block, which on a dark
|
|
||||||
/// theme shows up as horizontal bands behind ASCII diagrams.
|
|
||||||
/// We force `backgroundColor: Colors.transparent` to suppress
|
|
||||||
/// it — the codeblock's own decoration provides the panel
|
|
||||||
/// background.
|
|
||||||
/// - `blockquote` defaults to a hardcoded `Colors.blue.shade100`
|
|
||||||
/// background that is unreadable in dark mode. We replace it
|
|
||||||
/// with theme-aware tokens (surfaceContainer + left accent
|
|
||||||
/// border).
|
|
||||||
/// - `tableCellsDecoration` has the same dark-mode bug, fixed
|
|
||||||
/// the same way.
|
|
||||||
static MarkdownStyleSheet markdownStyle(ThemeData theme) {
|
|
||||||
return MarkdownStyleSheet.fromTheme(theme).copyWith(
|
|
||||||
p: theme.textTheme.bodyMedium?.copyWith(height: 1.5),
|
|
||||||
code: mono(
|
|
||||||
size: 12,
|
|
||||||
color: theme.colorScheme.onSurface,
|
|
||||||
).copyWith(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
),
|
|
||||||
codeblockDecoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surfaceContainerHigh,
|
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
||||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
|
||||||
),
|
|
||||||
codeblockPadding: const EdgeInsets.all(FaiSpace.md),
|
|
||||||
blockquote: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
blockquoteDecoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surfaceContainer,
|
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
||||||
border: Border(
|
|
||||||
left: BorderSide(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
width: 3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
blockquotePadding: const EdgeInsets.fromLTRB(
|
|
||||||
FaiSpace.md,
|
|
||||||
FaiSpace.sm,
|
|
||||||
FaiSpace.sm,
|
|
||||||
FaiSpace.sm,
|
|
||||||
),
|
|
||||||
tableCellsDecoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surfaceContainerHigh,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static ThemeData dark() {
|
static ThemeData dark() {
|
||||||
final scheme = ColorScheme(
|
final scheme = ColorScheme(
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
|
|
|
||||||
|
|
@ -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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../data/friendly_error.dart';
|
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/theme.dart';
|
import '../theme/theme.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
|
||||||
class FaiErrorBox extends StatefulWidget {
|
class FaiErrorBox extends StatefulWidget {
|
||||||
/// The text the operator wants to read (and copy). Rendered
|
/// The text the operator wants to read (and copy). Rendered
|
||||||
/// monospace, selectable, multi-line. Ignored when [error] is
|
/// monospace, selectable, multi-line.
|
||||||
/// supplied.
|
|
||||||
final String text;
|
final String text;
|
||||||
/// When true, the box border + foreground colour come from the
|
/// When true, the box border + foreground colour come from the
|
||||||
/// error palette. False renders neutral chrome — useful for
|
/// error palette. False renders neutral chrome — useful for
|
||||||
|
|
@ -29,25 +27,13 @@ class FaiErrorBox extends StatefulWidget {
|
||||||
/// box. Falls back to no constraint when null so short
|
/// box. Falls back to no constraint when null so short
|
||||||
/// messages don't get a useless scrollbar.
|
/// messages don't get a useless scrollbar.
|
||||||
final double? maxHeight;
|
final double? maxHeight;
|
||||||
/// When non-null, the box renders the [friendlyError] mapping
|
|
||||||
/// of this error: a one-line headline, a recovery-hint line
|
|
||||||
/// (when one applies), and the verbatim original message
|
|
||||||
/// collapsed behind a "Details" expander. Use this instead of
|
|
||||||
/// [text] anywhere we'd otherwise show a raw
|
|
||||||
/// `e.toString()` — gRPC-Errors come out as walls of code +
|
|
||||||
/// trailers otherwise.
|
|
||||||
final Object? error;
|
|
||||||
|
|
||||||
const FaiErrorBox({
|
const FaiErrorBox({
|
||||||
super.key,
|
super.key,
|
||||||
this.text = '',
|
required this.text,
|
||||||
this.isError = false,
|
this.isError = false,
|
||||||
this.maxHeight,
|
this.maxHeight,
|
||||||
this.error,
|
});
|
||||||
}) : assert(
|
|
||||||
text != '' || error != null,
|
|
||||||
'FaiErrorBox needs either text or error',
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FaiErrorBox> createState() => _FaiErrorBoxState();
|
State<FaiErrorBox> createState() => _FaiErrorBoxState();
|
||||||
|
|
@ -55,10 +41,9 @@ class FaiErrorBox extends StatefulWidget {
|
||||||
|
|
||||||
class _FaiErrorBoxState extends State<FaiErrorBox> {
|
class _FaiErrorBoxState extends State<FaiErrorBox> {
|
||||||
bool _justCopied = false;
|
bool _justCopied = false;
|
||||||
bool _detailExpanded = false;
|
|
||||||
|
|
||||||
Future<void> _copy(String what) async {
|
Future<void> _copy() async {
|
||||||
await Clipboard.setData(ClipboardData(text: what));
|
await Clipboard.setData(ClipboardData(text: widget.text));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _justCopied = true);
|
setState(() => _justCopied = true);
|
||||||
Future.delayed(const Duration(seconds: 2), () {
|
Future.delayed(const Duration(seconds: 2), () {
|
||||||
|
|
@ -76,10 +61,6 @@ class _FaiErrorBoxState extends State<FaiErrorBox> {
|
||||||
final fg = widget.isError
|
final fg = widget.isError
|
||||||
? theme.colorScheme.error
|
? theme.colorScheme.error
|
||||||
: theme.colorScheme.onSurface;
|
: theme.colorScheme.onSurface;
|
||||||
final friendly = widget.error == null
|
|
||||||
? null
|
|
||||||
: friendlyError(widget.error!, l);
|
|
||||||
final copyText = friendly?.detail ?? widget.text;
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
|
@ -94,101 +75,37 @@ class _FaiErrorBoxState extends State<FaiErrorBox> {
|
||||||
border: Border.all(color: accent.withValues(alpha: 0.4)),
|
border: Border.all(color: accent.withValues(alpha: 0.4)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Align(
|
// Copy button sits above the text on its own row so
|
||||||
alignment: Alignment.centerRight,
|
// long messages never collide with it. Compact enough
|
||||||
child: Tooltip(
|
// not to dominate short single-line messages.
|
||||||
message: _justCopied ? l.buttonCopied : l.buttonCopy,
|
Tooltip(
|
||||||
child: IconButton(
|
message: _justCopied ? l.buttonCopied : l.buttonCopy,
|
||||||
icon: Icon(
|
child: IconButton(
|
||||||
_justCopied ? Icons.check : Icons.content_copy,
|
icon: Icon(
|
||||||
size: 14,
|
_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/hub.dart';
|
||||||
import '../data/registry_token.dart';
|
import '../data/registry_token.dart';
|
||||||
import '../data/system_actions.dart';
|
import '../data/system_actions.dart';
|
||||||
import '../data/theme_plugin.dart';
|
|
||||||
import '../main.dart';
|
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/theme.dart';
|
import '../theme/theme.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
|
@ -512,8 +510,6 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
onClear: _clearRegistryToken,
|
onClear: _clearRegistryToken,
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.lg),
|
const SizedBox(height: FaiSpace.lg),
|
||||||
const _ThemePluginPanel(),
|
|
||||||
const SizedBox(height: FaiSpace.lg),
|
|
||||||
_MaintenancePanel(
|
_MaintenancePanel(
|
||||||
onResetDone: () async {
|
onResetDone: () async {
|
||||||
// Daemon just restarted under the same channel +
|
// Daemon just restarted under the same channel +
|
||||||
|
|
@ -1027,12 +1023,11 @@ class _AddMcpClientDialogState extends State<_AddMcpClientDialog> {
|
||||||
/// Pick a suggestion → fill the form. Operators still hit
|
/// Pick a suggestion → fill the form. Operators still hit
|
||||||
/// "Add + discover" so nothing happens unprompted.
|
/// "Add + discover" so nothing happens unprompted.
|
||||||
void _applySuggestion(_McpSuggestion s) {
|
void _applySuggestion(_McpSuggestion s) {
|
||||||
final l = AppLocalizations.of(context)!;
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_name.text = s.name;
|
_name.text = s.name;
|
||||||
_endpoint.text = s.endpoint;
|
_endpoint.text = s.endpoint;
|
||||||
_apiKey.text = s.apiKeyEnv;
|
_apiKey.text = s.apiKeyEnv;
|
||||||
_desc.text = s.resolveDescription(l);
|
_desc.text = s.description;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1460,122 +1455,6 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
|
||||||
/// metadata; nothing is auto-spawned. Trust model:
|
/// metadata; nothing is auto-spawned. Trust model:
|
||||||
/// referencing public projects with their canonical install
|
/// referencing public projects with their canonical install
|
||||||
/// command is the same legal / security shape as a package
|
/// command is the same legal / security shape as a package
|
||||||
/// Theme-plugin picker. Lists installed `studio.theme.*`
|
|
||||||
/// capabilities (via list_capabilities) and lets the operator
|
|
||||||
/// pick one or fall back to Studio's built-in palette. The
|
|
||||||
/// choice is persisted in SharedPreferences and applied at
|
|
||||||
/// the MaterialApp level via `StudioApp.of(context).setThemePlugin`.
|
|
||||||
///
|
|
||||||
/// When no theme plugin is installed the panel renders a
|
|
||||||
/// short hint pointing at the Store rather than a useless
|
|
||||||
/// empty dropdown.
|
|
||||||
class _ThemePluginPanel extends StatefulWidget {
|
|
||||||
const _ThemePluginPanel();
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_ThemePluginPanel> createState() => _ThemePluginPanelState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ThemePluginPanelState extends State<_ThemePluginPanel> {
|
|
||||||
Future<List<String>>? _capsFuture;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_capsFuture = listThemePluginCapabilities();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final l = AppLocalizations.of(context)!;
|
|
||||||
final app = StudioApp.of(context);
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
l.themePluginHeader,
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
letterSpacing: 0.6,
|
|
||||||
fontSize: 10,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
FutureBuilder<List<String>>(
|
|
||||||
future: _capsFuture,
|
|
||||||
builder: (context, snap) {
|
|
||||||
if (snap.connectionState != ConnectionState.done) {
|
|
||||||
return const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
|
||||||
child: SizedBox(
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final caps = snap.data ?? const <String>[];
|
|
||||||
if (caps.isEmpty) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Text(
|
|
||||||
l.themePluginEmpty,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Dropdown shows "Built-in" + every installed
|
|
||||||
// studio.theme.* cap. Selection writes through
|
|
||||||
// StudioApp's notifier so the MaterialApp rebuilds
|
|
||||||
// with the new ColorScheme without a restart.
|
|
||||||
final active = app?.themePluginNotifier.value;
|
|
||||||
final items = <DropdownMenuItem<String?>>[
|
|
||||||
DropdownMenuItem<String?>(
|
|
||||||
value: null,
|
|
||||||
child: Text(l.themePluginNone),
|
|
||||||
),
|
|
||||||
for (final cap in caps)
|
|
||||||
DropdownMenuItem<String?>(
|
|
||||||
value: cap,
|
|
||||||
child: Text(cap, style: FaiTheme.mono(size: 12)),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
DropdownButton<String?>(
|
|
||||||
value: active,
|
|
||||||
items: items,
|
|
||||||
isDense: true,
|
|
||||||
onChanged: app == null
|
|
||||||
? null
|
|
||||||
: (v) {
|
|
||||||
app.setThemePlugin(v);
|
|
||||||
setState(() {});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
l.themePluginHint,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hub maintenance panel — currently one action: reset operator
|
/// Hub maintenance panel — currently one action: reset operator
|
||||||
/// state via `fai reset --yes`. Lives at the bottom of Settings
|
/// state via `fai reset --yes`. Lives at the bottom of Settings
|
||||||
/// so the everyday config controls aren't crowded by a destructive
|
/// so the everyday config controls aren't crowded by a destructive
|
||||||
|
|
@ -1891,73 +1770,36 @@ class _McpSuggestion {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String endpoint;
|
final String endpoint;
|
||||||
final String apiKeyEnv;
|
final String apiKeyEnv;
|
||||||
/// Localizable description. Use [resolveDescription] to fetch
|
final String description;
|
||||||
/// the actual text in the active locale — the const list
|
|
||||||
/// can't hold a closure that takes [AppLocalizations], and
|
|
||||||
/// hardcoding English here is what got us into the
|
|
||||||
/// untranslated-helper-text bug.
|
|
||||||
String resolveDescription(AppLocalizations l) =>
|
|
||||||
_suggestionDescription(l, name);
|
|
||||||
const _McpSuggestion({
|
const _McpSuggestion({
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.endpoint,
|
required this.endpoint,
|
||||||
required this.apiKeyEnv,
|
required this.apiKeyEnv,
|
||||||
|
required this.description,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Localizable lookup of an MCP-suggestion description. Keyed
|
|
||||||
/// on the same suggestion `name` field as the list below. New
|
|
||||||
/// suggestions need a matching .arb entry; the default-case
|
|
||||||
/// returns an empty string so an unconfigured suggestion fails
|
|
||||||
/// silently instead of leaking a key into the UI.
|
|
||||||
String _suggestionDescription(AppLocalizations l, String name) {
|
|
||||||
switch (name) {
|
|
||||||
case 'deepwiki':
|
|
||||||
return l.mcpSuggestionDeepwikiDesc;
|
|
||||||
case 'semgrep':
|
|
||||||
return l.mcpSuggestionSemgrepDesc;
|
|
||||||
case 'filesystem':
|
|
||||||
return l.mcpSuggestionFilesystemDesc;
|
|
||||||
case 'fetch':
|
|
||||||
return l.mcpSuggestionFetchDesc;
|
|
||||||
case 'github':
|
|
||||||
return l.mcpSuggestionGithubDesc;
|
|
||||||
case 'puppeteer':
|
|
||||||
return l.mcpSuggestionPuppeteerDesc;
|
|
||||||
case 'postgres':
|
|
||||||
return l.mcpSuggestionPostgresDesc;
|
|
||||||
case 'sqlite':
|
|
||||||
return l.mcpSuggestionSqliteDesc;
|
|
||||||
case 'brave-search':
|
|
||||||
return l.mcpSuggestionBraveSearchDesc;
|
|
||||||
case 'memory':
|
|
||||||
return l.mcpSuggestionMemoryDesc;
|
|
||||||
case 'time':
|
|
||||||
return l.mcpSuggestionTimeDesc;
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const _kMcpSuggestions = <_McpSuggestion>[
|
const _kMcpSuggestions = <_McpSuggestion>[
|
||||||
// ── HTTPS / streamable-HTTP servers (no Node, no API key) ──
|
// ── HTTPS / streamable-HTTP servers (no Node, no API key) ──
|
||||||
// Descriptions are resolved via [_McpSuggestion.resolveDescription]
|
// Same set Studio promotes as one-click cards in the Today
|
||||||
// against the active locale — see `mcpSuggestion*Desc` keys in
|
// hero — listed here so the operator finds them even after
|
||||||
// app_en.arb / app_de.arb. Adding a suggestion here means
|
// the hero gets dismissed.
|
||||||
// adding a matching key in both .arb files plus a case to
|
|
||||||
// `_suggestionDescription` above.
|
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'deepwiki',
|
name: 'deepwiki',
|
||||||
icon: Icons.menu_book_outlined,
|
icon: Icons.menu_book_outlined,
|
||||||
endpoint: 'https://mcp.deepwiki.com/mcp',
|
endpoint: 'https://mcp.deepwiki.com/mcp',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
|
description:
|
||||||
|
'Public HTTPS — GitHub repo documentation search (AI-powered). No Node, no API key.',
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'semgrep',
|
name: 'semgrep',
|
||||||
icon: Icons.security,
|
icon: Icons.security,
|
||||||
endpoint: 'https://mcp.semgrep.ai/mcp',
|
endpoint: 'https://mcp.semgrep.ai/mcp',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
|
description:
|
||||||
|
'Public HTTPS — security scanning for code vulnerabilities. No Node, no API key.',
|
||||||
),
|
),
|
||||||
// ── stdio servers (require Node + npx) ───────────────────
|
// ── stdio servers (require Node + npx) ───────────────────
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
|
|
@ -1965,24 +1807,30 @@ const _kMcpSuggestions = <_McpSuggestion>[
|
||||||
icon: Icons.folder_outlined,
|
icon: Icons.folder_outlined,
|
||||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-filesystem /tmp',
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-filesystem /tmp',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
|
description:
|
||||||
|
'Anthropic — read/write files in /tmp. Edit the path before saving.',
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'fetch',
|
name: 'fetch',
|
||||||
icon: Icons.cloud_download_outlined,
|
icon: Icons.cloud_download_outlined,
|
||||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-fetch',
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-fetch',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
|
description: 'Anthropic — fetch arbitrary HTTP(S) URLs as markdown.',
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'github',
|
name: 'github',
|
||||||
icon: Icons.code,
|
icon: Icons.code,
|
||||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-github',
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-github',
|
||||||
apiKeyEnv: 'GITHUB_PERSONAL_ACCESS_TOKEN',
|
apiKeyEnv: 'GITHUB_PERSONAL_ACCESS_TOKEN',
|
||||||
|
description:
|
||||||
|
'Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.',
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'puppeteer',
|
name: 'puppeteer',
|
||||||
icon: Icons.web,
|
icon: Icons.web,
|
||||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-puppeteer',
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-puppeteer',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
|
description: 'Anthropic — headless browser automation for screenshots / scraping.',
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'postgres',
|
name: 'postgres',
|
||||||
|
|
@ -1990,6 +1838,8 @@ const _kMcpSuggestions = <_McpSuggestion>[
|
||||||
endpoint:
|
endpoint:
|
||||||
'stdio://npx -y @modelcontextprotocol/server-postgres postgresql://user:pw@host/db',
|
'stdio://npx -y @modelcontextprotocol/server-postgres postgresql://user:pw@host/db',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
|
description:
|
||||||
|
'Anthropic — read-only SQL queries against a Postgres database. Edit the URL.',
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'sqlite',
|
name: 'sqlite',
|
||||||
|
|
@ -1997,23 +1847,27 @@ const _kMcpSuggestions = <_McpSuggestion>[
|
||||||
endpoint:
|
endpoint:
|
||||||
'stdio://npx -y @modelcontextprotocol/server-sqlite --db-path /tmp/db.sqlite',
|
'stdio://npx -y @modelcontextprotocol/server-sqlite --db-path /tmp/db.sqlite',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
|
description: 'Anthropic — query a local SQLite file. Edit the path.',
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'brave-search',
|
name: 'brave-search',
|
||||||
icon: Icons.search,
|
icon: Icons.search,
|
||||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-brave-search',
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-brave-search',
|
||||||
apiKeyEnv: 'BRAVE_API_KEY',
|
apiKeyEnv: 'BRAVE_API_KEY',
|
||||||
|
description: 'Anthropic — web search via Brave. API key required.',
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'memory',
|
name: 'memory',
|
||||||
icon: Icons.psychology_outlined,
|
icon: Icons.psychology_outlined,
|
||||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-memory',
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-memory',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
|
description: 'Anthropic — persistent knowledge graph for an agent\'s memory.',
|
||||||
),
|
),
|
||||||
_McpSuggestion(
|
_McpSuggestion(
|
||||||
name: 'time',
|
name: 'time',
|
||||||
icon: Icons.schedule,
|
icon: Icons.schedule,
|
||||||
endpoint: 'stdio://npx -y @modelcontextprotocol/server-time',
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-time',
|
||||||
apiKeyEnv: '',
|
apiKeyEnv: '',
|
||||||
|
description: 'Anthropic — current time + timezone conversion.',
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -553,60 +553,53 @@ class _PrivacyModeChips extends StatelessWidget {
|
||||||
l.systemAiPrivacyFullDesc,
|
l.systemAiPrivacyFullDesc,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
// Material 3 deprecated per-Radio `groupValue` / `onChanged`
|
return Column(
|
||||||
// after Flutter 3.32. The modern pattern wraps the radios in
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
// a RadioGroup<T> ancestor that owns the selection + change
|
children: [
|
||||||
// callback; the individual Radio widgets only declare their
|
Text(
|
||||||
// value. Same UX, no deprecation warnings.
|
l.systemAiPrivacyHeader,
|
||||||
return RadioGroup<String>(
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
groupValue: value,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
onChanged: (v) {
|
letterSpacing: 0.6,
|
||||||
if (v != null) onChanged(v);
|
fontSize: 10,
|
||||||
},
|
|
||||||
child: 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)
|
const SizedBox(height: 4),
|
||||||
InkWell(
|
for (final (wire, label, desc) in modes)
|
||||||
onTap: () => onChanged(wire),
|
InkWell(
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
onTap: () => onChanged(wire),
|
||||||
child: Padding(
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||||
padding: const EdgeInsets.symmetric(
|
child: Padding(
|
||||||
vertical: FaiSpace.sm,
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: FaiSpace.sm,
|
vertical: FaiSpace.sm,
|
||||||
),
|
horizontal: FaiSpace.sm,
|
||||||
child: Row(
|
),
|
||||||
children: [
|
child: Row(
|
||||||
Radio<String>(value: wire),
|
children: [
|
||||||
Expanded(
|
Radio<String>(
|
||||||
child: Column(
|
value: wire,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
groupValue: value,
|
||||||
children: [
|
onChanged: (v) => onChanged(v ?? wire),
|
||||||
Text(label, style: theme.textTheme.bodyMedium),
|
),
|
||||||
Text(
|
Expanded(
|
||||||
desc,
|
child: Column(
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
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_data_row.dart';
|
||||||
export 'fai_delta_mark.dart';
|
export 'fai_delta_mark.dart';
|
||||||
export 'fai_empty_state.dart';
|
export 'fai_empty_state.dart';
|
||||||
export 'fai_en_badge.dart';
|
|
||||||
export 'fai_error_box.dart';
|
export 'fai_error_box.dart';
|
||||||
export 'fai_flow_output.dart';
|
|
||||||
export 'fai_module_sheet.dart';
|
export 'fai_module_sheet.dart';
|
||||||
export 'fai_pill.dart';
|
export 'fai_pill.dart';
|
||||||
export 'fai_settings_dialog.dart';
|
export 'fai_settings_dialog.dart';
|
||||||
|
|
|
||||||
|
|
@ -146,14 +146,14 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
flutter_markdown_plus:
|
flutter_markdown:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_markdown_plus
|
name: flutter_markdown
|
||||||
sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de"
|
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.7"
|
version: "0.7.7+1"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,7 @@ dependencies:
|
||||||
google_fonts: ^8.1.0
|
google_fonts: ^8.1.0
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
# Inline README rendering inside the Store detail sheet.
|
# Inline README rendering inside the Store detail sheet.
|
||||||
# `flutter_markdown` is discontinued; `flutter_markdown_plus`
|
flutter_markdown: ^0.7.7
|
||||||
# is the actively-maintained drop-in fork (same
|
|
||||||
# `MarkdownStyleSheet` / `Markdown` / `MarkdownBody` API).
|
|
||||||
flutter_markdown_plus: ^1.0.3
|
|
||||||
# Today-Hero pipeline reads accepted stories from
|
# Today-Hero pipeline reads accepted stories from
|
||||||
# ~/.fai/today/active.yaml — see docs/today-pipeline.md.
|
# ~/.fai/today/active.yaml — see docs/today-pipeline.md.
|
||||||
yaml: ^3.1.2
|
yaml: ^3.1.2
|
||||||
|
|
|
||||||
|
|
@ -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)$'
|
'fixtures?/.*\.(pem|key)$'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Confidential terms are loaded at runtime from a file outside
|
# Memory: confidentiality.md — these are PRIVATE. Never in public
|
||||||
# the repo (~/.fai-security/banned-terms.txt by default; override
|
# code / docs / commits / examples. Word-boundary matched to avoid
|
||||||
# with $FAI_BANNED_TERMS_FILE). The list itself is private — the
|
# false-positives on substrings.
|
||||||
# whole point of the gate is to keep certain strings out of any
|
CONFIDENTIAL_TERMS=(
|
||||||
# committed artefact, so the list of strings to gate on must not
|
'\bITDZ\b'
|
||||||
# live in a committed artefact either.
|
'\bKammergericht\b'
|
||||||
#
|
'\bHTW\b'
|
||||||
# File format: one regex per line. Lines starting with `#` and
|
'J∆I'
|
||||||
# blank lines are ignored. Each line is matched as a
|
'\bJAI\b'
|
||||||
# case-insensitive grep -E pattern with `-i`; use `\b…\b` for
|
)
|
||||||
# word boundaries.
|
|
||||||
#
|
|
||||||
# Bootstrap on a fresh machine:
|
|
||||||
# mkdir -p ~/.fai-security
|
|
||||||
# chmod 700 ~/.fai-security
|
|
||||||
# printf '\\b%s\\b\\n' TERM_A TERM_B > ~/.fai-security/banned-terms.txt
|
|
||||||
# chmod 600 ~/.fai-security/banned-terms.txt
|
|
||||||
#
|
|
||||||
# When the file is missing the gate logs a warning but does NOT
|
|
||||||
# fail — running on a fresh checkout where the operator hasn't
|
|
||||||
# yet bootstrapped the list shouldn't block them. Real CI runs
|
|
||||||
# explicitly verify the file is present and refuse otherwise
|
|
||||||
# (see scan_ci_mode below).
|
|
||||||
CONFIDENTIAL_TERMS=()
|
|
||||||
_load_confidential_terms() {
|
|
||||||
local file="${FAI_BANNED_TERMS_FILE:-$HOME/.fai-security/banned-terms.txt}"
|
|
||||||
if [ ! -f "$file" ]; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
while IFS= read -r line; do
|
|
||||||
# Strip trailing comments + leading/trailing whitespace.
|
|
||||||
line="${line%%#*}"
|
|
||||||
line="${line#"${line%%[![:space:]]*}"}"
|
|
||||||
line="${line%"${line##*[![:space:]]}"}"
|
|
||||||
[ -z "$line" ] && continue
|
|
||||||
CONFIDENTIAL_TERMS+=("$line")
|
|
||||||
done < "$file"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if ! _load_confidential_terms; then
|
|
||||||
printf "%s⚠ confidentiality list not found at ${FAI_BANNED_TERMS_FILE:-~/.fai-security/banned-terms.txt}; gate will skip the confidential-terms scan%s\n" \
|
|
||||||
"${YEL:-}" "${RST:-}" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Memory: feedback_no_marketing_speak.md.
|
# Memory: feedback_no_marketing_speak.md.
|
||||||
BANNED_PHRASES=(
|
BANNED_PHRASES=(
|
||||||
|
|
@ -175,10 +142,7 @@ scan_diff() {
|
||||||
local diff="$1"
|
local diff="$1"
|
||||||
[ -z "$diff" ] && return
|
[ -z "$diff" ] && return
|
||||||
scan_secrets "$diff"
|
scan_secrets "$diff"
|
||||||
# `${ARR[@]+"${ARR[@]}"}` is the safe-under-set-u expansion
|
scan_terms_ci "confidential term" "$diff" "${CONFIDENTIAL_TERMS[@]}"
|
||||||
# for arrays that may be empty (CONFIDENTIAL_TERMS is empty
|
|
||||||
# when the operator hasn't bootstrapped ~/.fai-security/).
|
|
||||||
scan_terms_ci "confidential term" "$diff" ${CONFIDENTIAL_TERMS[@]+"${CONFIDENTIAL_TERMS[@]}"}
|
|
||||||
scan_terms_ci "banned phrase" "$diff" "${BANNED_PHRASES[@]}"
|
scan_terms_ci "banned phrase" "$diff" "${BANNED_PHRASES[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -244,7 +208,7 @@ scan_message_mode() {
|
||||||
fail "Co-Authored-By: Claude trailer is forbidden (memory: feedback_coding_style.md)"
|
fail "Co-Authored-By: Claude trailer is forbidden (memory: feedback_coding_style.md)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
scan_terms_ci "confidential term in commit message" "$msg" ${CONFIDENTIAL_TERMS[@]+"${CONFIDENTIAL_TERMS[@]}"}
|
scan_terms_ci "confidential term in commit message" "$msg" "${CONFIDENTIAL_TERMS[@]}"
|
||||||
scan_terms_ci "banned phrase in commit message" "$msg" "${BANNED_PHRASES[@]}"
|
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
|
if ! git rev-parse --quiet --verify "$base" >/dev/null 2>&1; then
|
||||||
base=$(git rev-list --max-parents=0 HEAD | tail -1)
|
base=$(git rev-list --max-parents=0 HEAD | tail -1)
|
||||||
fi
|
fi
|
||||||
# In CI the confidentiality list is non-optional: a missing
|
|
||||||
# file means an unbootstrapped runner is reviewing changes,
|
|
||||||
# and we'd be silently letting confidential terms through.
|
|
||||||
# The deploy step that wires up the runner is responsible for
|
|
||||||
# placing the file at $FAI_BANNED_TERMS_FILE.
|
|
||||||
if [ "${#CONFIDENTIAL_TERMS[@]}" -eq 0 ]; then
|
|
||||||
fail "CI mode requires \$FAI_BANNED_TERMS_FILE (or ~/.fai-security/banned-terms.txt) to exist"
|
|
||||||
fail " bootstrap the runner with a private confidentiality list before re-running"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
local files diff
|
local files diff
|
||||||
files=$(git diff --name-only --diff-filter=ACMR "$base"..HEAD)
|
files=$(git diff --name-only --diff-filter=ACMR "$base"..HEAD)
|
||||||
diff=$(git diff --no-color -U0 "$base"..HEAD -- . "${EXCLUDES[@]}" \
|
diff=$(git diff --no-color -U0 "$base"..HEAD -- . "${EXCLUDES[@]}" \
|
||||||
|
|
|
||||||
|
|
@ -153,19 +153,9 @@ git add tests/server.pem
|
||||||
assert_content_passes "tests/*.pem allowlisted"
|
assert_content_passes "tests/*.pem allowlisted"
|
||||||
|
|
||||||
d=$(setup_repo); cd "$d"
|
d=$(setup_repo); cd "$d"
|
||||||
# The confidentiality list lives outside the repo at
|
echo "Notes from the ITDZ Berlin meeting" > confidential.md
|
||||||
# $FAI_BANNED_TERMS_FILE (default ~/.fai-security/banned-terms.txt).
|
|
||||||
# For the test we point at a temp file holding a single
|
|
||||||
# generic test term — the actual production terms stay in the
|
|
||||||
# operator's untracked file and never enter this test suite.
|
|
||||||
TEST_BANNED_TERMS=$(mktemp -t fai_banned.XXXXXX)
|
|
||||||
printf "\\\\bSYNTHETIC_BANNED_TERM_XYZZY\\\\b\n" > "$TEST_BANNED_TERMS"
|
|
||||||
export FAI_BANNED_TERMS_FILE="$TEST_BANNED_TERMS"
|
|
||||||
echo "Document contains SYNTHETIC_BANNED_TERM_XYZZY in passing" > confidential.md
|
|
||||||
git add confidential.md
|
git add confidential.md
|
||||||
assert_content_fails "confidential term (synthetic test term)" "SYNTHETIC_BANNED_TERM_XYZZY"
|
assert_content_fails "confidential term ITDZ" "ITDZ"
|
||||||
rm -f "$TEST_BANNED_TERMS"
|
|
||||||
unset FAI_BANNED_TERMS_FILE
|
|
||||||
|
|
||||||
d=$(setup_repo); cd "$d"
|
d=$(setup_repo); cd "$d"
|
||||||
echo "Our viral release of a powerful tool that just works" > marketing.md
|
echo "Our viral release of a powerful tool that just works" > marketing.md
|
||||||
|
|
|
||||||
|
|
@ -32,14 +32,9 @@ feel like they're reading a translation.
|
||||||
- Banned words: "viral", "killer", "powerful", "just works",
|
- Banned words: "viral", "killer", "powerful", "just works",
|
||||||
"revolutionary", "game-changing", "seamless", "next-generation",
|
"revolutionary", "game-changing", "seamless", "next-generation",
|
||||||
"cutting-edge", "world-class", "unprecedented".
|
"cutting-edge", "world-class", "unprecedented".
|
||||||
- No private organisation, person, or location names. Pilot
|
- No private organisation names: never "ITDZ", "Kammergericht",
|
||||||
customers, internal codenames, specific institutions, and
|
"HTW", "J∆I", or any specific institution. Generic terms only —
|
||||||
specific cities never appear in Today copy. Generic terms
|
"a regulated organisation", "a public administration".
|
||||||
only — "a regulated organisation", "a public administration",
|
|
||||||
"a critical-infrastructure operator". The operator's
|
|
||||||
banned-terms list at ~/.fai-security/banned-terms.txt is the
|
|
||||||
authoritative source; this prompt deliberately doesn't
|
|
||||||
enumerate the strings.
|
|
||||||
- No emoji.
|
- No emoji.
|
||||||
- No "Co-authored-by Claude" or other AI attribution.
|
- No "Co-authored-by Claude" or other AI attribution.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue