chain-studio/lib/data/hub.dart
flemming-it 98a6414eaa
Some checks failed
Security / Security check (push) Failing after 1s
fix(store): localised docs, debounced typing, explicit-submit always AI
Three Stefan-found bugs in one pass:

1. Documentation rendered in English even when Studio was set
   to German. _loadDocs now passes Localizations.localeOf(...)
   to fetchModuleDocs(); the hub tries README.de.md before
   README.md when locale='de'.

2. 'Keine Treffer' flickered on every keystroke. _onAskTyping
   used to call _runSearch synchronously per character — a
   short 2-3-letter substring matched nothing, so the empty-
   state showed for a frame, then the next character revealed
   results. Add a 250ms typing debounce (Timer + cancel on
   dispose) so the search only re-runs once typing pauses.

3. Submit button did substring search even when System-AI was
   enabled — the _looksLikeQuestion heuristic only routed
   4+-word / question-mark queries through the LLM. The user
   reasonably expected an explicit submit (with the AI-icon
   showing on the button) to actually use AI. New behaviour:
   when system-AI is enabled, the submit button always calls
   _runAiQuery; live-typing keeps substring filtering. The
   heuristic helper was removed (now unused).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-24 22:07:40 +02:00

1306 lines
38 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, {String locale = ''}) async {
final r = await _client.fetchModuleDocs(name, locale: locale);
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(),
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.
/// 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;
/// 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;
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,
});
}