Some checks failed
Security / Security check (push) Failing after 2s
Track the platform rename: the hub spawn path is now ~/.chain/bin/chain (was ~/.fai/bin/fai.exe on Windows — both dir and binary were stale, so Studio could not launch the hub after the config-dir rename), the ~/.fai/* help strings become ~/.chain/*, FAI_REGISTRY_TOKEN -> CHAIN_REGISTRY_TOKEN, and the two in-app doc URLs point at the public fai/chain repo (fai/platform was renamed to the private fai/chain-private). The .fai module bundle extension is left unchanged (format phase). flutter analyze: no issues. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
1692 lines
52 KiB
Dart
1692 lines
52 KiB
Dart
// HubService — singleton wrapper around fai_dart_sdk's HubClient.
|
|
//
|
|
// Centralises the gRPC connection so each page just imports
|
|
// `HubService.instance` instead of constructing its own client.
|
|
// Methods return UI-friendly types so pages stay free of
|
|
// protobuf imports.
|
|
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:fai_client_sdk/fai_client_sdk.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../l10n/app_localizations.dart';
|
|
import 'flow_output.dart';
|
|
import 'hub_auth_token.dart';
|
|
export 'flow_output.dart';
|
|
|
|
class HubService {
|
|
HubService._();
|
|
static final HubService instance = HubService._();
|
|
|
|
HubClient _client = HubClient();
|
|
|
|
/// The endpoint string ("http://host:port") the connection
|
|
/// targets. Shown in the navigation rail.
|
|
String get endpointLabel => _client.endpoint.toString();
|
|
|
|
HubEndpoint get currentEndpoint => _client.endpoint;
|
|
|
|
static const _kHostKey = 'hub.host';
|
|
static const _kPortKey = 'hub.port';
|
|
static const _kSecureKey = 'hub.secure';
|
|
|
|
/// Sentinel distinguishing "caller did not pass authToken"
|
|
/// from "caller passed null to drop the token".
|
|
static const Object _unset = Object();
|
|
|
|
/// Read persisted endpoint + auth token, then reconnect.
|
|
/// Called once at app start; safe to call again after the
|
|
/// operator updates the token in Settings.
|
|
Future<void> loadPersistedEndpoint() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final host = prefs.getString(_kHostKey);
|
|
final port = prefs.getInt(_kPortKey);
|
|
final secure = prefs.getBool(_kSecureKey);
|
|
final token = await HubAuthToken.read();
|
|
if (host == null && token == null) return;
|
|
final endpoint = HubEndpoint(
|
|
host: host ?? _client.endpoint.host,
|
|
port: port ?? _client.endpoint.port,
|
|
secure: secure ?? _client.endpoint.secure,
|
|
);
|
|
await reconnect(endpoint, authToken: token);
|
|
}
|
|
|
|
/// Reconnect to a new endpoint and persist for next launch.
|
|
/// [authToken] is read from `~/.chain/hub-auth-token` by
|
|
/// default — pass `null` to drop a previously-loaded token,
|
|
/// or omit the parameter to keep the current value.
|
|
Future<void> reconnect(
|
|
HubEndpoint endpoint, {
|
|
Object? authToken = _unset,
|
|
}) async {
|
|
await _client.close();
|
|
final token = identical(authToken, _unset)
|
|
? await HubAuthToken.read()
|
|
: authToken as String?;
|
|
_client = HubClient(endpoint: endpoint, authToken: token);
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_kHostKey, endpoint.host);
|
|
await prefs.setInt(_kPortKey, endpoint.port);
|
|
await prefs.setBool(_kSecureKey, endpoint.secure);
|
|
}
|
|
|
|
/// Reload the token from disk and reconnect using the current
|
|
/// endpoint. Called by Settings after the operator pastes or
|
|
/// clears a token.
|
|
Future<void> reloadAuthToken() async {
|
|
await reconnect(_client.endpoint);
|
|
}
|
|
|
|
static const _kThemeKey = 'theme.mode';
|
|
static const _kLocaleKey = 'locale.code';
|
|
|
|
/// Read the persisted theme mode (system / light / dark) for
|
|
/// initial app startup. Defaults to system.
|
|
Future<ThemeModeValue> loadThemeMode() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final raw = prefs.getString(_kThemeKey);
|
|
return ThemeModeValue.fromWire(raw) ?? ThemeModeValue.system;
|
|
}
|
|
|
|
/// Persist the theme mode the user chose.
|
|
Future<void> saveThemeMode(ThemeModeValue mode) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_kThemeKey, mode.wire);
|
|
}
|
|
|
|
/// Read the persisted UI locale. Defaults to English when
|
|
/// nothing is stored — operators on a fresh install land in
|
|
/// English; the sidebar toggle flips to German on demand.
|
|
Future<Locale> loadLocale() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final raw = prefs.getString(_kLocaleKey);
|
|
if (raw == 'de') return const Locale('de');
|
|
return const Locale('en');
|
|
}
|
|
|
|
/// Persist the locale code (`en` / `de`).
|
|
Future<void> saveLocale(Locale locale) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_kLocaleKey, locale.languageCode);
|
|
}
|
|
|
|
Future<bool> healthy() => _client.healthy();
|
|
|
|
/// List of installed WASM modules, grouped by `module_name`.
|
|
/// Built-in and federated capabilities are excluded — they
|
|
/// don't correspond to a bundle on disk and would confuse the
|
|
/// Modules page (a "system" or "via:filesystem" pseudo-module
|
|
/// has nothing to uninstall). Callers that need every callable
|
|
/// capability — e.g. the flow's missing-dependencies check —
|
|
/// use [allCapabilities] instead.
|
|
Future<List<ModuleSummary>> listModules() async {
|
|
final caps = await _client.listCapabilities();
|
|
final wasm = caps.where((c) => c.kind.isEmpty || c.kind == 'wasm');
|
|
final byModule = <String, List<CapabilityEntry>>{};
|
|
for (final c in wasm) {
|
|
byModule.putIfAbsent(c.moduleName, () => []).add(c);
|
|
}
|
|
return byModule.entries.map((e) {
|
|
final caps = e.value;
|
|
return ModuleSummary(
|
|
name: e.key,
|
|
version: caps.first.moduleVersion,
|
|
capabilities: caps.map((c) => '${c.capability}@${c.version}').toList(),
|
|
);
|
|
}).toList()..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,
|
|
provider: c.provider,
|
|
sourceKind: c.sourceKind,
|
|
versionAdvisory: c.versionAdvisory,
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
/// Fully-detailed manifest for one installed module.
|
|
Future<ModuleDetail> moduleInfo(String name) async {
|
|
final r = await _client.moduleInfo(name);
|
|
return ModuleDetail(
|
|
name: r.name,
|
|
version: r.version,
|
|
capabilities: r.provides
|
|
.map((c) => '${c.namespace}.${c.name}@${c.versionReq}')
|
|
.toList(),
|
|
permissions: r.permissions,
|
|
directory: r.directory,
|
|
acceptsMime: r.acceptsMime,
|
|
inputs: r.inputs
|
|
.map(
|
|
(f) => ModuleFieldInfo(
|
|
name: f.name,
|
|
type: f.type,
|
|
description: Map<String, String>.from(f.description),
|
|
),
|
|
)
|
|
.toList(),
|
|
outputs: r.outputs
|
|
.map(
|
|
(f) => ModuleFieldInfo(
|
|
name: f.name,
|
|
type: f.type,
|
|
description: Map<String, String>.from(f.description),
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
}
|
|
|
|
/// System-AI status snapshot — used by the Settings dialog
|
|
/// and the audit drill-down to decide whether the "Explain"
|
|
/// affordance is active. Always returns a struct (no
|
|
/// exceptions on disabled state).
|
|
Future<SystemAiStatus> systemAiStatus() async {
|
|
final r = await _client.systemAiStatus();
|
|
return SystemAiStatus(
|
|
enabled: r.enabled,
|
|
provider: r.provider,
|
|
endpoint: r.endpoint,
|
|
model: r.model,
|
|
privacyMode: r.privacyMode,
|
|
apiKeyEnv: r.apiKeyEnv,
|
|
cacheCount: r.cacheCount.toInt(),
|
|
);
|
|
}
|
|
|
|
/// Send a one-shot prompt to the System AI. Returns the
|
|
/// answer + latency on success or an [AskAiResult] with
|
|
/// `errorKind` set on failure (never throws). Studio maps
|
|
/// the error kind to its inline-fix copy.
|
|
Future<AskAiResult> askAi(String prompt, {bool forceFresh = false}) async {
|
|
final r = await _client.askAi(prompt, forceFresh: forceFresh);
|
|
return AskAiResult(
|
|
errorKind: r.errorKind,
|
|
text: r.text,
|
|
latencyMs: r.latencyMs,
|
|
cached: r.cached,
|
|
cachedAt: r.cachedAt,
|
|
cacheHits: r.cacheHits,
|
|
);
|
|
}
|
|
|
|
/// Drop every cached System-AI explanation. Returns the count
|
|
/// of entries that were purged.
|
|
Future<int> clearSystemLlmCache() {
|
|
return _client.clearSystemLlmCache();
|
|
}
|
|
|
|
/// Forget a single cached entry by prompt (for "Regenerate").
|
|
Future<void> forgetCachedExplanation(String prompt) {
|
|
return _client.forgetCachedExplanation(prompt);
|
|
}
|
|
|
|
/// Persist a new System-AI configuration. The hub writes back
|
|
/// to `~/.chain/config.yaml` and hot-reloads the in-memory copy
|
|
/// in one round-trip; the caller gets the resulting status
|
|
/// for an immediate UI refresh.
|
|
Future<SystemAiStatus> updateSystemAi({
|
|
required String provider,
|
|
required String endpoint,
|
|
required String model,
|
|
required String apiKeyEnv,
|
|
required String privacyMode,
|
|
}) async {
|
|
final r = await _client.updateSystemAi(
|
|
provider: provider,
|
|
endpoint: endpoint,
|
|
model: model,
|
|
apiKeyEnv: apiKeyEnv,
|
|
privacyMode: privacyMode,
|
|
);
|
|
return SystemAiStatus(
|
|
enabled: r.enabled,
|
|
provider: r.provider,
|
|
endpoint: r.endpoint,
|
|
model: r.model,
|
|
privacyMode: r.privacyMode,
|
|
apiKeyEnv: r.apiKeyEnv,
|
|
cacheCount: r.cacheCount.toInt(),
|
|
);
|
|
}
|
|
|
|
/// Probe a System-AI configuration with a tiny ping. Pass
|
|
/// the editor's current form values so "Test connection"
|
|
/// works *before* save. Empty fields fall back to the live
|
|
/// in-memory config.
|
|
Future<AskAiResult> testSystemAi({
|
|
String provider = '',
|
|
String endpoint = '',
|
|
String model = '',
|
|
String apiKeyEnv = '',
|
|
String privacyMode = '',
|
|
}) async {
|
|
final r = await _client.testSystemAi(
|
|
provider: provider,
|
|
endpoint: endpoint,
|
|
model: model,
|
|
apiKeyEnv: apiKeyEnv,
|
|
privacyMode: privacyMode,
|
|
);
|
|
return AskAiResult(
|
|
errorKind: r.errorKind,
|
|
text: r.text,
|
|
latencyMs: r.latencyMs,
|
|
);
|
|
}
|
|
|
|
/// List the models the provider exposes via `GET /v1/models`.
|
|
/// Studio's editor uses this to populate a dropdown instead
|
|
/// of forcing the operator to type the id manually.
|
|
Future<({String errorKind, String text, List<String> ids})>
|
|
listSystemAiModels({
|
|
String provider = '',
|
|
String endpoint = '',
|
|
String apiKeyEnv = '',
|
|
}) async {
|
|
final r = await _client.listSystemAiModels(
|
|
provider: provider,
|
|
endpoint: endpoint,
|
|
apiKeyEnv: apiKeyEnv,
|
|
);
|
|
return (
|
|
errorKind: r.errorKind,
|
|
text: r.text,
|
|
ids: r.models.map((m) => m.id).toList(),
|
|
);
|
|
}
|
|
|
|
/// Pull (download) an Ollama model via /api/pull.
|
|
/// Synchronous; can take minutes for large models. Errors
|
|
/// come back as `errorKind`.
|
|
Future<({String errorKind, String text, int elapsedMs})> pullSystemAiModel({
|
|
required String endpoint,
|
|
required String model,
|
|
String apiKeyEnv = '',
|
|
}) async {
|
|
final r = await _client.pullSystemAiModel(
|
|
endpoint: endpoint,
|
|
model: model,
|
|
apiKeyEnv: apiKeyEnv,
|
|
);
|
|
return (errorKind: r.errorKind, text: r.text, elapsedMs: r.elapsedMs);
|
|
}
|
|
|
|
/// Detected host hardware. Used by the System-AI editor to
|
|
/// mark models as "recommended" / "may be slow" against the
|
|
/// operator's actual machine.
|
|
Future<HardwareSnapshot> hardwareInfo() async {
|
|
final r = await _client.hardwareInfo();
|
|
return HardwareSnapshot(
|
|
tier: r.tier,
|
|
cpuBrand: r.cpuBrand,
|
|
cpuCores: r.cpuCores,
|
|
ramMb: r.ramMb.toInt(),
|
|
appleSilicon: r.appleSilicon,
|
|
dedicatedGpu: r.dedicatedGpu,
|
|
summary: r.summary,
|
|
);
|
|
}
|
|
|
|
/// Bundled curated model database. Refresh requires a hub
|
|
/// redeploy; the `lastReviewed` field shows how stale the
|
|
/// curation is.
|
|
Future<CuratedSnapshot> listSystemAiCuratedModels() async {
|
|
final r = await _client.listSystemAiCuratedModels();
|
|
return CuratedSnapshot(
|
|
lastReviewed: r.lastReviewed,
|
|
models: r.models
|
|
.map(
|
|
(m) => CuratedModelInfo(
|
|
id: m.id,
|
|
family: m.family,
|
|
paramsB: m.paramsB,
|
|
qualityTier: m.qualityTier,
|
|
minHwTier: m.minHwTier,
|
|
contextK: m.contextK,
|
|
notes: m.notes,
|
|
license: m.license,
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
}
|
|
|
|
/// Wipe the audit log on the active channel and seed a fresh
|
|
/// `chain.reset` marker. Refused server-side on `beta` /
|
|
/// `production`; the gRPC error surfaces as an exception so
|
|
/// the caller can show the operator why it was blocked.
|
|
/// Returns `(purged, channel)` so the UI can confirm what
|
|
/// just happened.
|
|
Future<({int purged, String channel})> clearEventLog({
|
|
required String reviewer,
|
|
required String reason,
|
|
}) async {
|
|
final r = await _client.clearEventLog(reviewer: reviewer, reason: reason);
|
|
return (purged: r.purged.toInt(), channel: r.channel);
|
|
}
|
|
|
|
/// Filesystem paths the daemon owns. Used by Doctor to wire
|
|
/// "Open log file" / "Open config file" buttons.
|
|
Future<DaemonPathsSnapshot> daemonPaths() async {
|
|
final r = await _client.daemonPaths();
|
|
return DaemonPathsSnapshot(
|
|
logPath: r.logPath,
|
|
dbPath: r.dbPath,
|
|
modulesDir: r.modulesDir,
|
|
flowsDir: r.flowsDir,
|
|
configPath: r.configPath,
|
|
pidPath: r.pidPath,
|
|
);
|
|
}
|
|
|
|
/// Remove an installed module by name. Returns the version
|
|
/// that was removed so the caller can confirm.
|
|
///
|
|
/// Pass [version] to target a specific version when multiple
|
|
/// versions of the same `(provider, name)` are installed
|
|
/// side-by-side. Empty / omitted lets the hub pick the
|
|
/// highest-version match.
|
|
Future<({String name, String version})> uninstallModule(
|
|
String name, {
|
|
String version = '',
|
|
}) async {
|
|
final r = await _client.uninstallModule(name, version: version);
|
|
return (name: r.name, version: r.version);
|
|
}
|
|
|
|
/// Read the operator's `default_scope:` plus the catalog-known
|
|
/// publisher shortlist. Used by Settings → DEFAULT SCOPE panel.
|
|
Future<({List<String> scope, List<String> knownProviders})>
|
|
getDefaultScope() async {
|
|
return _client.getDefaultScope();
|
|
}
|
|
|
|
/// Replace the operator's `default_scope:` and hot-swap on the
|
|
/// running hub. Caller passes the new ordered list; hub rejects
|
|
/// an empty list with InvalidArgument.
|
|
Future<({List<String> scope, List<String> knownProviders})> setDefaultScope(
|
|
List<String> scope,
|
|
) async {
|
|
return _client.setDefaultScope(scope);
|
|
}
|
|
|
|
/// Every installed version of one module, sorted newest-last.
|
|
/// Returns an empty list when nothing matches. Used by the
|
|
/// uninstall flow to show a picker when more than one version
|
|
/// is installed side-by-side.
|
|
Future<List<String>> installedVersions(String moduleName) async {
|
|
final caps = await _client.listCapabilities();
|
|
final versions =
|
|
caps
|
|
.where((c) => c.moduleName == moduleName)
|
|
.where((c) => c.kind.isEmpty || c.kind == 'wasm')
|
|
.map((c) => c.moduleVersion)
|
|
.toSet()
|
|
.toList()
|
|
..sort();
|
|
return versions;
|
|
}
|
|
|
|
/// Read the installed module's `MODULE.md` (or
|
|
/// `MODULE.<locale>.md`) from disk under
|
|
/// `~/.chain/modules/<module>/`. Air-gap friendly, no network.
|
|
///
|
|
/// `state` is one of:
|
|
/// - `'found'` — `text` holds the markdown
|
|
/// - `'no_docs'` — module installed but bundle had no docs
|
|
/// - `'not_installed'` — module isn't installed at all
|
|
Future<({String state, String text, String sourcePath})>
|
|
readInstalledModuleDocs(String name, {String locale = ''}) async {
|
|
final r = await _client.getInstalledModuleDocs(name, locale: locale);
|
|
final String state;
|
|
if (r.notInstalled) {
|
|
state = 'not_installed';
|
|
} else if (r.text.isEmpty) {
|
|
state = 'no_docs';
|
|
} else {
|
|
state = 'found';
|
|
}
|
|
return (state: state, text: r.text, sourcePath: r.sourcePath);
|
|
}
|
|
|
|
/// Invoke a Studio theme plugin's `theme` hook and unwrap
|
|
/// the result into Flutter-friendly `ColorScheme` slots.
|
|
///
|
|
/// Returns a record:
|
|
/// - `brightness`: echo of the input.
|
|
/// - `tokens`: 14 32-bit ARGB values in Material 3
|
|
/// order. Caller passes them to
|
|
/// `ColorScheme(...)` after deciding the
|
|
/// `brightness` enum.
|
|
///
|
|
/// Throws on hub-side errors (plugin not installed, plugin
|
|
/// declined the brightness, etc.) — caller routes the
|
|
/// exception through `friendlyError` like every other RPC.
|
|
Future<({String brightness, List<int> tokens})> invokePluginTheme({
|
|
required String capability,
|
|
required String brightness,
|
|
}) async {
|
|
final r = await _client.invokePluginTheme(
|
|
capability: capability,
|
|
brightness: brightness,
|
|
);
|
|
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.
|
|
Future<List<McpClientInfo>> listMcpClients() async {
|
|
final r = await _client.listMcpClients();
|
|
return _mapMcpClients(r);
|
|
}
|
|
|
|
/// Re-run discovery against every configured MCP server on
|
|
/// the running daemon. Returns the same shape as listMcpClients.
|
|
Future<List<McpClientInfo>> refreshMcpClients() async {
|
|
final r = await _client.refreshMcpClients();
|
|
return _mapMcpClients(r);
|
|
}
|
|
|
|
Future<List<McpClientInfo>> addMcpClient({
|
|
required String name,
|
|
required String endpoint,
|
|
String apiKeyEnv = '',
|
|
String description = '',
|
|
}) async {
|
|
final r = await _client.addMcpClient(
|
|
name: name,
|
|
endpoint: endpoint,
|
|
apiKeyEnv: apiKeyEnv,
|
|
description: description,
|
|
);
|
|
return _mapMcpClients(r);
|
|
}
|
|
|
|
Future<List<McpClientInfo>> removeMcpClient(String name) async {
|
|
final r = await _client.removeMcpClient(name);
|
|
return _mapMcpClients(r);
|
|
}
|
|
|
|
List<McpClientInfo> _mapMcpClients(ListMcpClientsResponse r) {
|
|
return r.clients
|
|
.map(
|
|
(s) => McpClientInfo(
|
|
name: s.config.name,
|
|
endpoint: s.config.endpoint,
|
|
apiKeyEnv: s.config.apiKeyEnv,
|
|
description: s.config.description,
|
|
toolCount: s.toolCount,
|
|
errorKind: s.errorKind,
|
|
message: s.message,
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
/// Snapshot of every configured n8n endpoint.
|
|
Future<List<N8nEndpointInfo>> listN8nEndpoints() async {
|
|
final r = await _client.listN8nEndpoints();
|
|
return _mapN8nEndpoints(r);
|
|
}
|
|
|
|
Future<List<N8nEndpointInfo>> refreshN8nEndpoints() async {
|
|
final r = await _client.refreshN8nEndpoints();
|
|
return _mapN8nEndpoints(r);
|
|
}
|
|
|
|
Future<List<N8nEndpointInfo>> addN8nEndpoint({
|
|
required String name,
|
|
required String baseUrl,
|
|
String apiKeyEnv = '',
|
|
String description = '',
|
|
}) async {
|
|
final r = await _client.addN8nEndpoint(
|
|
name: name,
|
|
baseUrl: baseUrl,
|
|
apiKeyEnv: apiKeyEnv,
|
|
description: description,
|
|
);
|
|
return _mapN8nEndpoints(r);
|
|
}
|
|
|
|
Future<List<N8nEndpointInfo>> removeN8nEndpoint(String name) async {
|
|
final r = await _client.removeN8nEndpoint(name);
|
|
return _mapN8nEndpoints(r);
|
|
}
|
|
|
|
List<N8nEndpointInfo> _mapN8nEndpoints(ListN8nEndpointsResponse r) {
|
|
return r.endpoints
|
|
.map(
|
|
(s) => N8nEndpointInfo(
|
|
name: s.config.name,
|
|
baseUrl: s.config.baseUrl,
|
|
apiKeyEnv: s.config.apiKeyEnv,
|
|
description: s.config.description,
|
|
workflowCount: s.workflowCount,
|
|
errorKind: s.errorKind,
|
|
message: s.message,
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
/// Active channel + per-channel daemon status snapshot.
|
|
Future<ChannelStatusSnapshot> channelStatus() async {
|
|
final r = await _client.channelStatus();
|
|
return ChannelStatusSnapshot(
|
|
active: r.active,
|
|
channels: r.channels
|
|
.map(
|
|
(c) => ChannelInfo(
|
|
name: c.name,
|
|
port: c.port,
|
|
running: c.running,
|
|
endpoint: c.endpoint,
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
}
|
|
|
|
/// Browse the bundled store index. All filters optional;
|
|
/// empty query returns the first [limit] entries.
|
|
Future<List<StoreItem>> searchStore({
|
|
String query = '',
|
|
String category = '',
|
|
String tag = '',
|
|
String status = '',
|
|
int limit = 50,
|
|
}) async {
|
|
final entries = await _client.searchStore(
|
|
query: query,
|
|
category: category,
|
|
tag: tag,
|
|
status: status,
|
|
limit: limit,
|
|
);
|
|
return entries
|
|
.map(
|
|
(e) => StoreItem(
|
|
name: e.name,
|
|
taglineEn: e.taglineEn,
|
|
taglineDe: e.taglineDe,
|
|
descriptionEn: e.descriptionEn,
|
|
descriptionDe: e.descriptionDe,
|
|
category: e.category,
|
|
tags: e.tags,
|
|
requiresCapabilities: e.requiresCapabilities,
|
|
requiresServices: e.requiresServices,
|
|
license: e.license,
|
|
repository: e.repository,
|
|
bestVersion: e.bestVersion,
|
|
status: e.status,
|
|
installed: e.installed,
|
|
featured: e.featured,
|
|
iconUrl: e.iconUrl,
|
|
screenshotUrls: e.screenshotUrls,
|
|
docsUrl: e.docsUrl,
|
|
kind: e.kind,
|
|
provider: e.provider,
|
|
sourceKind: e.sourceKind,
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
/// Install a module from a `.fai` bundle (URL or local path).
|
|
/// Returns the installed module's name + version on success;
|
|
/// throws on hub error (signature mismatch, sha256 fail, etc.).
|
|
Future<({String name, String version})> installModule({
|
|
required String source,
|
|
String expectedSha256 = '',
|
|
}) async {
|
|
final r = await _client.installModule(
|
|
source: source,
|
|
expectedSha256: expectedSha256,
|
|
);
|
|
return (name: r.name, version: r.version);
|
|
}
|
|
|
|
/// Saved flows known to the hub.
|
|
Future<List<SavedFlow>> listFlows() async {
|
|
final flows = await _client.listFlows();
|
|
return flows
|
|
.map(
|
|
(f) => SavedFlow(
|
|
name: f.name,
|
|
path: f.path,
|
|
sizeBytes: f.sizeBytes.toInt(),
|
|
requiredCapabilities: List<String>.from(f.requiredCapabilities),
|
|
),
|
|
)
|
|
.toList()
|
|
..sort((a, b) => a.name.compareTo(b.name));
|
|
}
|
|
|
|
/// Return the parsed input schema of a saved flow. Used by
|
|
/// Studio's Flows page to render a typed run-flow form
|
|
/// before calling [runSavedFlow]. Each entry's `type` is
|
|
/// the verbatim type tag from the flow YAML (`text`,
|
|
/// `bytes`, `json`, …).
|
|
Future<List<FlowInputDef>> getFlowDefinition(String name) async {
|
|
return _client.getFlowDefinition(name);
|
|
}
|
|
|
|
/// Run a saved flow with the supplied inputs. Text values
|
|
/// land as text Payloads, byte values as bytes Payloads —
|
|
/// flows that mix both (e.g. extract taking a `document:
|
|
/// bytes` plus a text-shaped option) flow through one RPC.
|
|
/// [fileMimeTypes] is keyed the same as [fileInputs]; missing
|
|
/// entries fall back to `application/octet-stream`. Modules
|
|
/// often gate on MIME (the `text.extract` module refuses
|
|
/// octet-stream), so callers should provide a real type.
|
|
/// Returns the named outputs as a map of typed [FlowOutput]
|
|
/// values so each page can render text / JSON / bytes / file
|
|
/// with the right widget.
|
|
Future<Map<String, FlowOutput>> runSavedFlow({
|
|
required String name,
|
|
Map<String, String> textInputs = const {},
|
|
Map<String, Uint8List> fileInputs = const {},
|
|
Map<String, String> fileMimeTypes = const {},
|
|
}) async {
|
|
final r = await _client.runSavedFlow(
|
|
name: name,
|
|
textInputs: textInputs,
|
|
fileInputs: fileInputs,
|
|
fileMimeTypes: fileMimeTypes,
|
|
);
|
|
return {
|
|
for (final entry in r.outputs.entries)
|
|
entry.key: FlowOutput.fromPayload(entry.value),
|
|
};
|
|
}
|
|
|
|
/// Live audit-event stream. The hub's StreamEvents RPC
|
|
/// optionally replays [backfill] historical events first, then
|
|
/// keeps the channel open and forwards every new event as soon
|
|
/// as it lands. Filtering by [types] is applied on the hub
|
|
/// side so this surface stays lean even when the log is busy.
|
|
///
|
|
/// Subscribe **before** triggering the work whose progress you
|
|
/// want to follow — otherwise the earliest `*.started` events
|
|
/// can arrive before the subscriber is ready and slip past.
|
|
Stream<AuditEvent> streamEvents({
|
|
int backfill = 0,
|
|
List<String> types = const [],
|
|
}) {
|
|
return _client
|
|
.streamEvents(backfill: backfill, types: types)
|
|
.map(
|
|
(e) => AuditEvent(
|
|
eventId: e.eventId,
|
|
timestamp: DateTime.tryParse(e.timestamp) ?? DateTime.now(),
|
|
type: e.eventType,
|
|
flowName: e.flowName.isEmpty ? null : e.flowName,
|
|
stepId: e.stepId.isEmpty ? null : e.stepId,
|
|
moduleName: e.moduleName.isEmpty ? null : e.moduleName,
|
|
moduleVersion: e.moduleVersion.isEmpty ? null : e.moduleVersion,
|
|
invocationId: e.invocationId.isEmpty ? null : e.invocationId,
|
|
flowExecution: e.flowExecution.isEmpty ? null : e.flowExecution,
|
|
durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(),
|
|
error: e.error.isEmpty ? null : e.error,
|
|
detail: e.detail.isEmpty ? null : e.detail,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<List<AuditEvent>> recentEvents({
|
|
int limit = 50,
|
|
List<String> types = const [],
|
|
}) async {
|
|
final events = await _client.eventLog(limit: limit, types: types);
|
|
return events
|
|
.map(
|
|
(e) => AuditEvent(
|
|
eventId: e.eventId,
|
|
timestamp: DateTime.tryParse(e.timestamp) ?? DateTime.now(),
|
|
type: e.eventType,
|
|
flowName: e.flowName.isEmpty ? null : e.flowName,
|
|
stepId: e.stepId.isEmpty ? null : e.stepId,
|
|
moduleName: e.moduleName.isEmpty ? null : e.moduleName,
|
|
moduleVersion: e.moduleVersion.isEmpty ? null : e.moduleVersion,
|
|
invocationId: e.invocationId.isEmpty ? null : e.invocationId,
|
|
flowExecution: e.flowExecution.isEmpty ? null : e.flowExecution,
|
|
durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(),
|
|
error: e.error.isEmpty ? null : e.error,
|
|
detail: e.detail.isEmpty ? null : e.detail,
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
Future<List<PendingApproval>> pendingApprovals() async {
|
|
final entries = await _client.listApprovals(statuses: ['pending']);
|
|
return entries
|
|
.map(
|
|
(e) => PendingApproval(
|
|
id: e.approvalId,
|
|
flowName: e.flowName,
|
|
stepId: e.stepId,
|
|
prompt: e.prompt,
|
|
showPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview,
|
|
createdAt: DateTime.tryParse(e.createdAt) ?? DateTime.now(),
|
|
expiresAt: e.expiresAt.isEmpty
|
|
? null
|
|
: DateTime.tryParse(e.expiresAt),
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
/// List approvals filtered by status. Empty [statuses] returns
|
|
/// every row (pending + decided + expired). Returned records
|
|
/// carry the decided-side fields so the History tab can render
|
|
/// "rejected by alice@studio at … because …".
|
|
Future<List<ApprovalRecord>> listApprovalsRecords({
|
|
List<String> statuses = const [],
|
|
int limit = 200,
|
|
}) async {
|
|
final entries = await _client.listApprovals(
|
|
statuses: statuses,
|
|
limit: limit,
|
|
);
|
|
return entries
|
|
.map(
|
|
(e) => ApprovalRecord(
|
|
id: e.approvalId,
|
|
flowName: e.flowName,
|
|
stepId: e.stepId,
|
|
prompt: e.prompt,
|
|
payloadPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview,
|
|
createdAt: DateTime.tryParse(e.createdAt) ?? DateTime.now(),
|
|
expiresAt: e.expiresAt.isEmpty
|
|
? null
|
|
: DateTime.tryParse(e.expiresAt),
|
|
status: e.status,
|
|
decidedAt: e.decidedAt.isEmpty
|
|
? null
|
|
: DateTime.tryParse(e.decidedAt),
|
|
decidedBy: e.decidedBy,
|
|
reason: e.reason,
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
Future<void> approve(String id, String reviewer) =>
|
|
_client.approve(approvalId: id, reviewer: reviewer);
|
|
|
|
Future<void> reject(String id, String reviewer, String reason) =>
|
|
_client.reject(approvalId: id, reviewer: reviewer, reason: reason);
|
|
|
|
/// Federation satellites currently connected to this hub
|
|
/// (primary side). Empty when none are connected.
|
|
Future<List<Satellite>> listSatellites() async {
|
|
final entries = await _client.listSatellites();
|
|
return entries
|
|
.map(
|
|
(e) => Satellite(
|
|
hubId: e.hubId,
|
|
displayName: e.displayName,
|
|
hubVersion: e.hubVersion,
|
|
wireVersion: e.wireVersion,
|
|
region: e.region,
|
|
capabilities: e.capabilities.toList(),
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
/// Issue a single-use federation bootstrap token enrolling
|
|
/// `name`. The result carries the primary CA so the operator can
|
|
/// bootstrap the satellite in one step (secure first connect).
|
|
Future<SatelliteEnrollment> issueSatelliteToken(
|
|
String name, {
|
|
String region = '',
|
|
}) async {
|
|
final r = await _client.issueBootstrapToken(name, region: region);
|
|
return SatelliteEnrollment(
|
|
token: r.token,
|
|
expiresAt: r.expiresAt,
|
|
primaryCaPem: r.primaryCaPem,
|
|
);
|
|
}
|
|
|
|
/// Composite "doctor" snapshot. One round-trip per piece, run
|
|
/// in parallel so the page populates fast.
|
|
Future<DoctorSnapshot> doctor() async {
|
|
final results = await Future.wait([
|
|
_client.listCapabilities().catchError((_) => <CapabilityEntry>[]),
|
|
_client
|
|
.listApprovals(statuses: const ['pending'])
|
|
.catchError((_) => <PendingApprovalEntry>[]),
|
|
_client.verifyEventChain().catchError((_) => VerifyEventChainResponse()),
|
|
_client.listServices().catchError((_) => <DeclaredService>[]),
|
|
_client.checkUpdate().catchError((_) => CheckUpdateResponse()),
|
|
_client.daemonPaths().catchError((_) => DaemonPathsResponse()),
|
|
]);
|
|
|
|
final caps = results[0] as List<CapabilityEntry>;
|
|
final approvals = results[1] as List<PendingApprovalEntry>;
|
|
final chain = results[2] as VerifyEventChainResponse;
|
|
final services = results[3] as List<DeclaredService>;
|
|
final update = results[4] as CheckUpdateResponse;
|
|
final paths = results[5] as DaemonPathsResponse;
|
|
|
|
// Module count = distinct module_name across capabilities.
|
|
final moduleNames = <String>{for (final c in caps) c.moduleName};
|
|
|
|
// Per-source-kind breakdown (0.12+). Pre-0.12 hubs return
|
|
// empty `source_kind` strings — group those under `kind`
|
|
// as a fallback so the doctor panel still shows a count.
|
|
final bySource = <String, int>{};
|
|
for (final c in caps) {
|
|
final key = c.sourceKind.isEmpty ? c.kind : c.sourceKind;
|
|
bySource[key] = (bySource[key] ?? 0) + 1;
|
|
}
|
|
|
|
return DoctorSnapshot(
|
|
moduleCount: moduleNames.length,
|
|
capabilityCount: caps.length,
|
|
capabilitiesBySource: bySource,
|
|
pendingApprovals: approvals.length,
|
|
eventChainTotal: chain.total.toInt(),
|
|
eventChainVerified: chain.verified.toInt(),
|
|
eventChainTamperedAt: chain.tamperedAt.isEmpty ? null : chain.tamperedAt,
|
|
services: services
|
|
.map(
|
|
(s) =>
|
|
ServiceEntry(name: s.name, endpoint: s.endpoint, tags: s.tags),
|
|
)
|
|
.toList(),
|
|
update: UpdateStatus(
|
|
channel: update.channel,
|
|
localVersion: update.localVersion,
|
|
latestVersion: update.latestVersion,
|
|
updateAvailable: update.updateAvailable,
|
|
manifestReachable: update.manifestReachable,
|
|
releaseNotesUrl: update.releaseNotesUrl.isEmpty
|
|
? null
|
|
: update.releaseNotesUrl,
|
|
reason: update.reason.isEmpty ? null : update.reason,
|
|
),
|
|
paths: DaemonPathsSnapshot(
|
|
logPath: paths.logPath,
|
|
dbPath: paths.dbPath,
|
|
modulesDir: paths.modulesDir,
|
|
flowsDir: paths.flowsDir,
|
|
configPath: paths.configPath,
|
|
pidPath: paths.pidPath,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class UpdateStatus {
|
|
final String channel;
|
|
final String localVersion;
|
|
final String latestVersion;
|
|
final bool updateAvailable;
|
|
final bool manifestReachable;
|
|
final String? releaseNotesUrl;
|
|
final String? reason;
|
|
|
|
const UpdateStatus({
|
|
required this.channel,
|
|
required this.localVersion,
|
|
required this.latestVersion,
|
|
required this.updateAvailable,
|
|
required this.manifestReachable,
|
|
this.releaseNotesUrl,
|
|
this.reason,
|
|
});
|
|
}
|
|
|
|
class DoctorSnapshot {
|
|
final int moduleCount;
|
|
final int capabilityCount;
|
|
final int pendingApprovals;
|
|
final int eventChainTotal;
|
|
final int eventChainVerified;
|
|
final String? eventChainTamperedAt;
|
|
final List<ServiceEntry> services;
|
|
final UpdateStatus update;
|
|
|
|
/// Filesystem paths the daemon owns. Empty fields when the
|
|
/// hub couldn't resolve them (older daemon, no $HOME).
|
|
final DaemonPathsSnapshot paths;
|
|
|
|
/// Per-source-kind capability count: bundle / system / mcp /
|
|
/// n8n / temporal / webhook (0.12+). Pre-0.12 hubs return
|
|
/// an empty map and the panel falls back to a flat count.
|
|
final Map<String, int> capabilitiesBySource;
|
|
|
|
const DoctorSnapshot({
|
|
required this.moduleCount,
|
|
required this.capabilityCount,
|
|
required this.pendingApprovals,
|
|
required this.eventChainTotal,
|
|
required this.eventChainVerified,
|
|
required this.eventChainTamperedAt,
|
|
required this.services,
|
|
required this.update,
|
|
required this.paths,
|
|
this.capabilitiesBySource = const {},
|
|
});
|
|
|
|
bool get chainHealthy => eventChainTamperedAt == null;
|
|
}
|
|
|
|
class ServiceEntry {
|
|
final String name;
|
|
final String endpoint;
|
|
final List<String> tags;
|
|
|
|
const ServiceEntry({
|
|
required this.name,
|
|
required this.endpoint,
|
|
required this.tags,
|
|
});
|
|
}
|
|
|
|
/// Three-way switch persisted across launches.
|
|
enum ThemeModeValue {
|
|
system('system'),
|
|
light('light'),
|
|
dark('dark');
|
|
|
|
final String wire;
|
|
const ThemeModeValue(this.wire);
|
|
|
|
static ThemeModeValue? fromWire(String? s) {
|
|
if (s == null) return null;
|
|
for (final v in values) {
|
|
if (v.wire == s) return v;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
|
|
/// Publisher segment of the fully-qualified identifier.
|
|
/// "fai" for legacy bundles, "fai.system" for built-ins,
|
|
/// operator-config service name for federated capabilities.
|
|
final String provider;
|
|
|
|
/// Transport that delivers the capability: bundle / system /
|
|
/// mcp / n8n / temporal / webhook. Empty string falls back
|
|
/// to deriving from `kind` for pre-0.12 hubs.
|
|
final String sourceKind;
|
|
|
|
/// Whether the version on this capability is a label rather
|
|
/// than a hard contract. True for federation sources whose
|
|
/// upstream service does not honour semver.
|
|
final bool versionAdvisory;
|
|
|
|
const CapabilityInfo({
|
|
required this.capability,
|
|
required this.version,
|
|
required this.moduleName,
|
|
required this.kind,
|
|
this.provider = '',
|
|
this.sourceKind = '',
|
|
this.versionAdvisory = false,
|
|
});
|
|
}
|
|
|
|
/// UI-side type, decoupled from the proto wire type so pages
|
|
/// don't import protobuf packages.
|
|
class ModuleSummary {
|
|
final String name;
|
|
final String version;
|
|
final List<String> capabilities;
|
|
|
|
const ModuleSummary({
|
|
required this.name,
|
|
required this.version,
|
|
required this.capabilities,
|
|
});
|
|
}
|
|
|
|
class SavedFlow {
|
|
final String name;
|
|
final String path;
|
|
final int sizeBytes;
|
|
|
|
/// Verbatim `use:` strings of every step in this flow,
|
|
/// deduplicated and sorted by the hub. Lets Studio decide
|
|
/// whether the flow is currently runnable (every entry
|
|
/// resolves to an installed module).
|
|
final List<String> requiredCapabilities;
|
|
|
|
const SavedFlow({
|
|
required this.name,
|
|
required this.path,
|
|
required this.sizeBytes,
|
|
required this.requiredCapabilities,
|
|
});
|
|
}
|
|
|
|
class ModuleDetail {
|
|
final String name;
|
|
final String version;
|
|
final List<String> capabilities;
|
|
final List<String> permissions;
|
|
final String directory;
|
|
|
|
/// MIME allow-list declared in the module's `module.yaml`.
|
|
/// Empty when the module didn't declare any — caller falls
|
|
/// back to its built-in extension heuristic.
|
|
final List<String> acceptsMime;
|
|
|
|
/// Declared inputs (schema v3 verbose form when present). The
|
|
/// flow editor uses these to render per-field input ports with
|
|
/// canonical names + i18n tooltips.
|
|
final List<ModuleFieldInfo> inputs;
|
|
|
|
/// Declared outputs (schema v3 verbose form when present).
|
|
/// Rendered by the flow editor as distinct output ports so a
|
|
/// step like `summarize` exposes its `response`,
|
|
/// `model_endpoint`, `model_name`, `model_digest` separately
|
|
/// instead of fanning them all out of one anchor.
|
|
final List<ModuleFieldInfo> outputs;
|
|
|
|
const ModuleDetail({
|
|
required this.name,
|
|
required this.version,
|
|
required this.capabilities,
|
|
required this.permissions,
|
|
required this.directory,
|
|
this.acceptsMime = const [],
|
|
this.inputs = const [],
|
|
this.outputs = const [],
|
|
});
|
|
}
|
|
|
|
/// One declared input or output field on a module. Mirrors
|
|
/// `proto.ModuleField`: name, type descriptor, and a locale
|
|
/// → description map for tooltips.
|
|
class ModuleFieldInfo {
|
|
/// Field name, e.g. "prompt" or "model_endpoint".
|
|
final String name;
|
|
|
|
/// Type descriptor (text / json / bytes / file).
|
|
final String type;
|
|
|
|
/// Locale tag → description. Use [descriptionFor] to look up
|
|
/// with English fallback.
|
|
final Map<String, String> description;
|
|
|
|
const ModuleFieldInfo({
|
|
required this.name,
|
|
required this.type,
|
|
this.description = const {},
|
|
});
|
|
|
|
/// Returns the description in [locale] when present, else the
|
|
/// English peer, else null. Studio passes its active locale's
|
|
/// short code (e.g. "de") and shows the result as a tooltip.
|
|
String? descriptionFor(String locale) {
|
|
final exact = description[locale];
|
|
if (exact != null && exact.isNotEmpty) return exact;
|
|
final en = description['en'];
|
|
if (en != null && en.isNotEmpty) return en;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
class AuditEvent {
|
|
final String eventId;
|
|
final DateTime timestamp;
|
|
final String type;
|
|
final String? flowName;
|
|
final String? stepId;
|
|
final String? moduleName;
|
|
final String? moduleVersion;
|
|
final String? invocationId;
|
|
final String? flowExecution;
|
|
final int? durationMs;
|
|
final String? error;
|
|
|
|
/// Free-form JSON-string carrying event-type-specific fields.
|
|
/// Surfaced verbatim in the audit drill-down dialog.
|
|
final String? detail;
|
|
|
|
const AuditEvent({
|
|
required this.eventId,
|
|
required this.timestamp,
|
|
required this.type,
|
|
this.flowName,
|
|
this.stepId,
|
|
this.moduleName,
|
|
this.moduleVersion,
|
|
this.invocationId,
|
|
this.flowExecution,
|
|
this.durationMs,
|
|
this.error,
|
|
this.detail,
|
|
});
|
|
}
|
|
|
|
class PendingApproval {
|
|
final String id;
|
|
final String flowName;
|
|
final String stepId;
|
|
final String prompt;
|
|
final String? showPreview;
|
|
final DateTime createdAt;
|
|
final DateTime? expiresAt;
|
|
|
|
const PendingApproval({
|
|
required this.id,
|
|
required this.flowName,
|
|
required this.stepId,
|
|
required this.prompt,
|
|
this.showPreview,
|
|
required this.createdAt,
|
|
this.expiresAt,
|
|
});
|
|
}
|
|
|
|
/// Full approval record including decided-side fields. Used by
|
|
/// the History tab so operators can review what they (or the
|
|
/// A federation satellite currently connected to this hub.
|
|
class Satellite {
|
|
final String hubId;
|
|
final String displayName;
|
|
final String hubVersion;
|
|
final int wireVersion;
|
|
final String region;
|
|
final List<String> capabilities;
|
|
|
|
const Satellite({
|
|
required this.hubId,
|
|
required this.displayName,
|
|
required this.hubVersion,
|
|
required this.wireVersion,
|
|
required this.region,
|
|
required this.capabilities,
|
|
});
|
|
}
|
|
|
|
/// The artifact a satellite operator needs to enrol: the single-use
|
|
/// bootstrap token plus the primary's CA cert (for a secure first
|
|
/// connect). Both are pasted into the satellite's config.
|
|
class SatelliteEnrollment {
|
|
final String token;
|
|
final String expiresAt;
|
|
final String primaryCaPem;
|
|
|
|
const SatelliteEnrollment({
|
|
required this.token,
|
|
required this.expiresAt,
|
|
required this.primaryCaPem,
|
|
});
|
|
|
|
/// A ready-to-paste `federation:` config block for the satellite.
|
|
String toConfigYaml(String name) {
|
|
final ca = primaryCaPem.trim().isEmpty
|
|
? ''
|
|
: '\n primary_ca: |\n${primaryCaPem.trimRight().split('\n').map((l) => ' $l').join('\n')}';
|
|
return 'federation:\n'
|
|
' upstream: https://<this-primary-host>:<federation-port>\n'
|
|
' hub_name: $name\n'
|
|
' bootstrap_token_env: FAI_BOOTSTRAP_TOKEN$ca';
|
|
}
|
|
}
|
|
|
|
/// system) decided.
|
|
class ApprovalRecord {
|
|
final String id;
|
|
final String flowName;
|
|
final String stepId;
|
|
final String prompt;
|
|
final String? payloadPreview;
|
|
final DateTime createdAt;
|
|
final DateTime? expiresAt;
|
|
|
|
/// One of: pending / approved / rejected / expired.
|
|
final String status;
|
|
final DateTime? decidedAt;
|
|
|
|
/// Reviewer handle (e.g. "alice@studio") or "system" for
|
|
/// auto-expired approvals. Empty until decided.
|
|
final String decidedBy;
|
|
|
|
/// Reject reason — empty for approve / pending / expired.
|
|
final String reason;
|
|
|
|
const ApprovalRecord({
|
|
required this.id,
|
|
required this.flowName,
|
|
required this.stepId,
|
|
required this.prompt,
|
|
required this.payloadPreview,
|
|
required this.createdAt,
|
|
required this.expiresAt,
|
|
required this.status,
|
|
required this.decidedAt,
|
|
required this.decidedBy,
|
|
required this.reason,
|
|
});
|
|
}
|
|
|
|
class SystemAiStatus {
|
|
final bool enabled;
|
|
final String provider;
|
|
final String endpoint;
|
|
final String model;
|
|
|
|
/// off / redacted / full.
|
|
final String privacyMode;
|
|
final String apiKeyEnv;
|
|
|
|
/// Number of cached System-AI explanations currently stored.
|
|
/// Surfaced in Doctor + Settings so operators see the cache
|
|
/// earning its keep.
|
|
final int cacheCount;
|
|
|
|
const SystemAiStatus({
|
|
required this.enabled,
|
|
required this.provider,
|
|
required this.endpoint,
|
|
required this.model,
|
|
required this.privacyMode,
|
|
required this.apiKeyEnv,
|
|
this.cacheCount = 0,
|
|
});
|
|
}
|
|
|
|
class AskAiResult {
|
|
/// "" on success; otherwise one of:
|
|
/// disabled / env_missing / network / http / parse / empty_response.
|
|
final String errorKind;
|
|
|
|
/// On success: the assistant's reply.
|
|
/// On failure: a human-readable diagnostic.
|
|
final String text;
|
|
|
|
/// Round-trip latency in ms (success only). For cache hits
|
|
/// this is the *original* generation latency.
|
|
final int latencyMs;
|
|
|
|
/// True iff the answer came from the persistent cache.
|
|
final bool cached;
|
|
|
|
/// ISO-8601 timestamp the cached entry was first generated.
|
|
/// Empty for fresh answers.
|
|
final String cachedAt;
|
|
|
|
/// How many times this entry has been served from cache.
|
|
/// 0 for fresh answers.
|
|
final int cacheHits;
|
|
|
|
const AskAiResult({
|
|
required this.errorKind,
|
|
required this.text,
|
|
required this.latencyMs,
|
|
this.cached = false,
|
|
this.cachedAt = '',
|
|
this.cacheHits = 0,
|
|
});
|
|
|
|
bool get isSuccess => errorKind.isEmpty;
|
|
|
|
/// One-line fix hint per error kind, mirroring
|
|
/// `docs/architecture/system-ai.md`. Empty on success.
|
|
/// Localized — pass the active [AppLocalizations].
|
|
String fixHint(AppLocalizations l) {
|
|
switch (errorKind) {
|
|
case '':
|
|
return '';
|
|
case 'disabled':
|
|
return l.sysAiFixDisabled;
|
|
case 'env_missing':
|
|
return l.sysAiFixEnvMissing;
|
|
case 'network':
|
|
return l.sysAiFixNetwork;
|
|
case 'http':
|
|
return l.sysAiFixHttp;
|
|
case 'parse':
|
|
return l.sysAiFixParse;
|
|
case 'empty_response':
|
|
return l.sysAiFixEmptyResponse;
|
|
default:
|
|
return l.sysAiFixUnknown(errorKind);
|
|
}
|
|
}
|
|
}
|
|
|
|
class ChannelInfo {
|
|
final String name;
|
|
final int port;
|
|
final bool running;
|
|
final String endpoint;
|
|
|
|
const ChannelInfo({
|
|
required this.name,
|
|
required this.port,
|
|
required this.running,
|
|
required this.endpoint,
|
|
});
|
|
}
|
|
|
|
class ChannelStatusSnapshot {
|
|
final String active;
|
|
final List<ChannelInfo> channels;
|
|
|
|
const ChannelStatusSnapshot({required this.active, required this.channels});
|
|
}
|
|
|
|
/// One configured n8n endpoint plus the last-discovery
|
|
/// report. Sister of [`McpClientInfo`].
|
|
class N8nEndpointInfo {
|
|
final String name;
|
|
final String baseUrl;
|
|
final String apiKeyEnv;
|
|
final String description;
|
|
final int workflowCount;
|
|
final String errorKind;
|
|
final String message;
|
|
|
|
const N8nEndpointInfo({
|
|
required this.name,
|
|
required this.baseUrl,
|
|
required this.apiKeyEnv,
|
|
required this.description,
|
|
required this.workflowCount,
|
|
required this.errorKind,
|
|
required this.message,
|
|
});
|
|
|
|
bool get healthy => errorKind.isEmpty && workflowCount > 0;
|
|
}
|
|
|
|
/// One configured MCP server plus the last-discovery report.
|
|
/// Surfaced by Studio's MCP-clients editor in Settings.
|
|
class McpClientInfo {
|
|
final String name;
|
|
final String endpoint;
|
|
final String apiKeyEnv;
|
|
final String description;
|
|
|
|
/// Number of tools the last discovery returned. 0 before
|
|
/// discovery has run or on failure.
|
|
final int toolCount;
|
|
|
|
/// Empty on success; otherwise one of:
|
|
/// invalid_endpoint / not_implemented / network / http /
|
|
/// server / parse / env_missing.
|
|
final String errorKind;
|
|
|
|
/// Human-readable diagnostic.
|
|
final String message;
|
|
|
|
const McpClientInfo({
|
|
required this.name,
|
|
required this.endpoint,
|
|
required this.apiKeyEnv,
|
|
required this.description,
|
|
required this.toolCount,
|
|
required this.errorKind,
|
|
required this.message,
|
|
});
|
|
|
|
bool get healthy => errorKind.isEmpty && toolCount > 0;
|
|
}
|
|
|
|
/// Filesystem paths the daemon owns. Studio's Doctor page
|
|
/// surfaces these as "Open" buttons so Windows operators who
|
|
/// never touch a shell can still inspect everything via the OS
|
|
/// file manager / default editor.
|
|
class DaemonPathsSnapshot {
|
|
final String logPath;
|
|
final String dbPath;
|
|
final String modulesDir;
|
|
final String flowsDir;
|
|
final String configPath;
|
|
final String pidPath;
|
|
|
|
const DaemonPathsSnapshot({
|
|
required this.logPath,
|
|
required this.dbPath,
|
|
required this.modulesDir,
|
|
required this.flowsDir,
|
|
required this.configPath,
|
|
required this.pidPath,
|
|
});
|
|
}
|
|
|
|
/// Detected host hardware — see
|
|
/// docs/architecture/system-ai.md → "Hardware tiers".
|
|
class HardwareSnapshot {
|
|
/// One of: tiny / small / balanced / large / unknown.
|
|
final String tier;
|
|
final String cpuBrand;
|
|
final int cpuCores;
|
|
final int ramMb;
|
|
final bool appleSilicon;
|
|
final bool dedicatedGpu;
|
|
|
|
/// Human-readable summary the editor renders verbatim.
|
|
final String summary;
|
|
|
|
const HardwareSnapshot({
|
|
required this.tier,
|
|
required this.cpuBrand,
|
|
required this.cpuCores,
|
|
required this.ramMb,
|
|
required this.appleSilicon,
|
|
required this.dedicatedGpu,
|
|
required this.summary,
|
|
});
|
|
|
|
/// Numeric rank used for "is `min_hw_tier` ≤ detected?"
|
|
/// comparisons. Unknown sits at -1 so it never qualifies as
|
|
/// "at least" anything; the editor falls back to size-only
|
|
/// heuristics in that case.
|
|
int get rank {
|
|
switch (tier) {
|
|
case 'tiny':
|
|
return 0;
|
|
case 'small':
|
|
return 1;
|
|
case 'balanced':
|
|
return 2;
|
|
case 'large':
|
|
return 3;
|
|
default:
|
|
return -1;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One curated model with editorial metadata. Wire-faithful
|
|
/// with `crates/fai_hub/system-ai/models.yaml`.
|
|
class CuratedModelInfo {
|
|
final String id;
|
|
final String family;
|
|
final double paramsB;
|
|
|
|
/// basic / good / excellent.
|
|
final String qualityTier;
|
|
|
|
/// tiny / small / balanced / large.
|
|
final String minHwTier;
|
|
final int contextK;
|
|
final String notes;
|
|
final String license;
|
|
|
|
const CuratedModelInfo({
|
|
required this.id,
|
|
required this.family,
|
|
required this.paramsB,
|
|
required this.qualityTier,
|
|
required this.minHwTier,
|
|
required this.contextK,
|
|
required this.notes,
|
|
required this.license,
|
|
});
|
|
|
|
/// Same rank as HardwareSnapshot; lets us compare with `<=`.
|
|
int get minHwRank {
|
|
switch (minHwTier) {
|
|
case 'tiny':
|
|
return 0;
|
|
case 'small':
|
|
return 1;
|
|
case 'balanced':
|
|
return 2;
|
|
case 'large':
|
|
return 3;
|
|
default:
|
|
return -1;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Curated database snapshot returned from the hub.
|
|
class CuratedSnapshot {
|
|
/// ISO date string from `models.yaml`, e.g. "2026-05-07".
|
|
final String lastReviewed;
|
|
final List<CuratedModelInfo> models;
|
|
|
|
const CuratedSnapshot({required this.lastReviewed, required this.models});
|
|
}
|
|
|
|
/// One row from the store-index search.
|
|
class StoreItem {
|
|
final String name;
|
|
final String taglineEn;
|
|
|
|
/// German tagline. Falls back to [taglineEn] in the UI when
|
|
/// the index entry does not ship a localized one.
|
|
final String taglineDe;
|
|
final String descriptionEn;
|
|
|
|
/// German long-form description. Falls back to [descriptionEn]
|
|
/// when missing.
|
|
final String descriptionDe;
|
|
final String category;
|
|
final List<String> tags;
|
|
final List<String> requiresCapabilities;
|
|
final List<String> requiresServices;
|
|
final String license;
|
|
final String repository;
|
|
final String bestVersion;
|
|
|
|
/// "published", "alpha", "planned", or empty when unknown.
|
|
final String status;
|
|
final bool installed;
|
|
|
|
/// True iff the store-index marks this entry as editorially
|
|
/// featured (curated quarterly in the bundled seed.yaml).
|
|
/// Drives the "Featured" strip at the top of the Store page.
|
|
final bool featured;
|
|
|
|
/// Optional icon URL — square PNG / SVG. Empty for entries
|
|
/// that don't supply one; UI falls back to a category icon.
|
|
final String iconUrl;
|
|
|
|
/// Ordered list of screenshot URLs; empty when none. Studio
|
|
/// renders them as a horizontally-scrollable strip in the
|
|
/// detail sheet.
|
|
final List<String> screenshotUrls;
|
|
|
|
/// Explicit docs URL override. When empty, the hub falls back
|
|
/// to the well-known raw-README paths off [repository].
|
|
final String docsUrl;
|
|
|
|
/// "native" (downloadable bundle) or "federated" (routes
|
|
/// through a bridge — MCP, n8n, …). Empty falls back to
|
|
/// "native".
|
|
final String kind;
|
|
|
|
/// Provider id for federated entries. Empty for native.
|
|
/// E.g. "filesystem" for an `mcp.filesystem.read_file` tool.
|
|
final String provider;
|
|
|
|
/// Transport delivering this entry. Distinct from `kind` —
|
|
/// `sourceKind` answers "HOW does the hub reach this", not
|
|
/// "is it installable". Values: "bundle", "mcp", "n8n",
|
|
/// "temporal", "webhook". Empty falls back to deriving
|
|
/// from `kind` for pre-0.12 hubs.
|
|
final String sourceKind;
|
|
|
|
bool get isFederated => kind == 'federated';
|
|
|
|
const StoreItem({
|
|
required this.name,
|
|
required this.taglineEn,
|
|
required this.taglineDe,
|
|
required this.descriptionEn,
|
|
required this.descriptionDe,
|
|
required this.category,
|
|
required this.tags,
|
|
required this.requiresCapabilities,
|
|
required this.requiresServices,
|
|
required this.license,
|
|
required this.repository,
|
|
required this.bestVersion,
|
|
required this.status,
|
|
required this.installed,
|
|
required this.featured,
|
|
required this.iconUrl,
|
|
required this.screenshotUrls,
|
|
required this.docsUrl,
|
|
required this.kind,
|
|
required this.provider,
|
|
this.sourceKind = '',
|
|
});
|
|
}
|