Three concrete fixes against today's user feedback.
- Flows that take binary inputs (extract / extract-summarize /
…) work from the Flows tab. The run-flow input dialog now
accepts the same `@/path/to/file` syntax `fai run --input`
uses on the CLI: any value beginning with `@` is read as
bytes and sent as a binary Payload; plain values still flow
through as text. The dialog hint copy and the example
placeholder reflect the new syntax. File-read failures
surface as a SnackBar before the run dialog opens, so a
typo in the path doesn't reach the hub. Threaded through
`_FlowRunDialog` and `HubService.runSavedFlow`, which both
carry separate `textInputs` and `fileInputs` maps now and
forward to the SDK's mixed-mode runSavedFlow (v0.14.0).
- The Welcome doc-reader's error path uses `FaiErrorBox` so
the actual underlying error is selectable + copy-to-
clipboard via the existing widget. Plus the loader throws
a richer error string that names *both* attempted asset
paths (`<slug>_<lang>.md` and the EN fallback) and the
underlying exception each, so the operator can paste a
diagnostic into a chat without us having to ship a
separate "how to read Flutter asset errors" doc.
- Doctor's empty Services panel had a horizontal RenderFlex
overflow at narrow widths because the long mono-spaced
hint ("add to ~/.fai/config.yaml under services:") and
the leading icon+text both demanded full intrinsic width
in a single Row. Now wraps via a `Wrap` widget so the
hint flows to a second line on narrow viewports and stays
in the same row when there's space.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
1290 lines
37 KiB
Dart
1290 lines
37 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_dart_sdk/fai_dart_sdk.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:shared_preferences/shared_preferences.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';
|
|
|
|
/// Read persisted endpoint and reconnect if it differs from
|
|
/// the default. Called once at app start.
|
|
Future<void> loadPersistedEndpoint() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final host = prefs.getString(_kHostKey);
|
|
final port = prefs.getInt(_kPortKey);
|
|
final secure = prefs.getBool(_kSecureKey);
|
|
if (host == null) return;
|
|
final endpoint = HubEndpoint(
|
|
host: host,
|
|
port: port ?? 50051,
|
|
secure: secure ?? false,
|
|
);
|
|
if (endpoint.toString() != _client.endpoint.toString()) {
|
|
await reconnect(endpoint);
|
|
}
|
|
}
|
|
|
|
/// Reconnect to a new endpoint and persist for next launch.
|
|
Future<void> reconnect(HubEndpoint endpoint) async {
|
|
await _client.close();
|
|
_client = HubClient(endpoint: endpoint);
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_kHostKey, endpoint.host);
|
|
await prefs.setInt(_kPortKey, endpoint.port);
|
|
await prefs.setBool(_kSecureKey, endpoint.secure);
|
|
}
|
|
|
|
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();
|
|
|
|
Future<List<ModuleSummary>> listModules() async {
|
|
final caps = await _client.listCapabilities();
|
|
|
|
// Group capabilities by module so Studio's UI maps a card
|
|
// to a module rather than a capability.
|
|
final byModule = <String, List<CapabilityEntry>>{};
|
|
for (final c in caps) {
|
|
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));
|
|
}
|
|
|
|
/// 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,
|
|
);
|
|
}
|
|
|
|
/// 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.
|
|
Future<({String name, String version})> uninstallModule(
|
|
String name,
|
|
) async {
|
|
final r = await _client.uninstallModule(name);
|
|
return (name: r.name, version: r.version);
|
|
}
|
|
|
|
/// Fetch a module's README markdown via the hub. Errors come
|
|
/// back as `errorKind` strings (not exceptions) so callers can
|
|
/// render fallback UI without try/catch ceremony.
|
|
Future<({String errorKind, String text, String sourceUrl})>
|
|
fetchModuleDocs(String name) async {
|
|
final r = await _client.fetchModuleDocs(name);
|
|
return (
|
|
errorKind: r.errorKind,
|
|
text: r.text,
|
|
sourceUrl: r.sourceUrl,
|
|
);
|
|
}
|
|
|
|
/// 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,
|
|
),
|
|
)
|
|
.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(),
|
|
),
|
|
)
|
|
.toList()
|
|
..sort((a, b) => a.name.compareTo(b.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.
|
|
/// Returns the named outputs as a map of UI-friendly strings.
|
|
Future<Map<String, String>> runSavedFlow({
|
|
required String name,
|
|
Map<String, String> textInputs = const {},
|
|
Map<String, Uint8List> fileInputs = const {},
|
|
}) async {
|
|
final r = await _client.runSavedFlow(
|
|
name: name,
|
|
textInputs: textInputs,
|
|
fileInputs: fileInputs,
|
|
);
|
|
return {
|
|
for (final entry in r.outputs.entries)
|
|
entry.key: _payloadToText(entry.value),
|
|
};
|
|
}
|
|
|
|
String _payloadToText(Payload p) {
|
|
if (p.hasText()) return p.text;
|
|
if (p.hasJson()) return p.json.toString();
|
|
if (p.hasBytes()) {
|
|
return '<bytes: ${p.bytes.data.length} bytes, ${p.bytes.mimeType}>';
|
|
}
|
|
if (p.hasFile()) {
|
|
return '<file: ${p.file.uri}>';
|
|
}
|
|
return '<unknown payload variant>';
|
|
}
|
|
|
|
Future<List<AuditEvent>> recentEvents({
|
|
int limit = 50,
|
|
List<String> types = const [],
|
|
}) 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};
|
|
|
|
return DoctorSnapshot(
|
|
moduleCount: moduleNames.length,
|
|
capabilityCount: caps.length,
|
|
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;
|
|
|
|
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,
|
|
});
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
|
|
const SavedFlow({
|
|
required this.name,
|
|
required this.path,
|
|
required this.sizeBytes,
|
|
});
|
|
}
|
|
|
|
class ModuleDetail {
|
|
final String name;
|
|
final String version;
|
|
final List<String> capabilities;
|
|
final List<String> permissions;
|
|
final String directory;
|
|
|
|
const ModuleDetail({
|
|
required this.name,
|
|
required this.version,
|
|
required this.capabilities,
|
|
required this.permissions,
|
|
required this.directory,
|
|
});
|
|
}
|
|
|
|
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.
|
|
String get fixHint {
|
|
switch (errorKind) {
|
|
case '':
|
|
return '';
|
|
case 'disabled':
|
|
return 'Configure System AI in Settings (Cmd+,) → System AI panel.';
|
|
case 'env_missing':
|
|
return 'The API-key env var is empty. `export <var>=<key>` in your shell, then `fai daemon restart`.';
|
|
case 'network':
|
|
return 'Hub cannot reach the configured endpoint. Is your provider running?';
|
|
case 'http':
|
|
return 'Provider returned a non-2xx status. Verify endpoint, model name, and API key.';
|
|
case 'parse':
|
|
return 'Provider returned an unexpected response shape. Non-OpenAI providers may need a compatibility proxy.';
|
|
case 'empty_response':
|
|
return 'Provider answered with no content. Try a different model or rephrase the prompt.';
|
|
default:
|
|
return 'Unknown failure kind ($errorKind). See system-ai.md.';
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
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,
|
|
});
|
|
}
|