chain-studio/lib/data/hub.dart
flemming-it 5313266cc4 feat(studio): first-run UX, recovery affordances, l10n, bundled fonts
- Connection-aware Welcome: when the hub is down, show a hero with a
  primary "Start hub" CTA + install fallback instead of a dead,
  all-unchecked onboarding checklist (the first-run cliff).
- Actionable binary-not-found (file picker + install link, not a
  "set FAI_BIN" dead end) and a connect-failure banner after
  repeated failed health polls.
- Localize six hardcoded English error/toast clusters (DE+EN ARB).
- Bundle Inter + JetBrains Mono as assets; drop the runtime
  google_fonts fetch (air-gap / KRITIS safe, no font-swap flash).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-06-11 23:52:18 +02:00

1614 lines
49 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 `~/.fai/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 `~/.fai/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
/// `~/.fai/modules/<module>/`. Air-gap friendly, no network.
///
/// `state` is one of:
/// - `'found'` — `text` holds the markdown
/// - `'no_docs'` — module installed but bundle had no docs
/// - `'not_installed'` — module isn't installed at all
Future<({String state, String text, String sourcePath})>
readInstalledModuleDocs(String name, {String locale = ''}) async {
final r = await _client.getInstalledModuleDocs(name, locale: locale);
final String state;
if (r.notInstalled) {
state = 'not_installed';
} else if (r.text.isEmpty) {
state = 'no_docs';
} else {
state = 'found';
}
return (state: state, text: r.text, sourcePath: r.sourcePath);
}
/// Invoke a Studio theme plugin's `theme` hook and unwrap
/// the result into Flutter-friendly `ColorScheme` slots.
///
/// Returns a record:
/// - `brightness`: echo of the input.
/// - `tokens`: 14 32-bit ARGB values in Material 3
/// order. Caller passes them to
/// `ColorScheme(...)` after deciding the
/// `brightness` enum.
///
/// Throws on hub-side errors (plugin not installed, plugin
/// declined the brightness, etc.) — caller routes the
/// exception through `friendlyError` like every other RPC.
Future<({String brightness, List<int> tokens})> invokePluginTheme({
required String capability,
required String brightness,
}) async {
final r = await _client.invokePluginTheme(
capability: capability,
brightness: brightness,
);
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);
/// 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
/// 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 = '',
});
}