// 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 'package:fai_dart_sdk/fai_dart_sdk.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 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 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'; /// Read the persisted theme mode (system / light / dark) for /// initial app startup. Defaults to system. Future 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 saveThemeMode(ThemeModeValue mode) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kThemeKey, mode.wire); } Future healthy() => _client.healthy(); Future> 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 = >{}; 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 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() 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 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 clearSystemLlmCache() { return _client.clearSystemLlmCache(); } /// Forget a single cached entry by prompt (for "Regenerate"). Future 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 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 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 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 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 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); } /// Active channel + per-channel daemon status snapshot. Future 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> 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, descriptionEn: e.descriptionEn, 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, ), ) .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> 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 text inputs. Returns /// the named outputs as a map of UI-friendly strings. Future> runSavedFlow({ required String name, required Map textInputs, }) async { final r = await _client.runSavedFlow( name: name, textInputs: textInputs, ); 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 ''; } if (p.hasFile()) { return ''; } return ''; } Future> recentEvents({ int limit = 50, List 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> 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> listApprovalsRecords({ List 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 approve(String id, String reviewer) => _client.approve(approvalId: id, reviewer: reviewer); Future 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 doctor() async { final results = await Future.wait([ _client.listCapabilities().catchError((_) => []), _client.listApprovals(statuses: const ['pending']) .catchError((_) => []), _client.verifyEventChain().catchError( (_) => VerifyEventChainResponse(), ), _client.listServices().catchError((_) => []), _client.checkUpdate().catchError( (_) => CheckUpdateResponse(), ), ]); final caps = results[0] as List; final approvals = results[1] as List; final chain = results[2] as VerifyEventChainResponse; final services = results[3] as List; final update = results[4] as CheckUpdateResponse; // Module count = distinct module_name across capabilities. final moduleNames = {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, ), ); } } 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 services; final UpdateStatus update; 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, }); bool get chainHealthy => eventChainTamperedAt == null; } class ServiceEntry { final String name; final String endpoint; final List 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 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 capabilities; final List 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 =` 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 channels; const ChannelStatusSnapshot({ required this.active, required this.channels, }); } /// 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 models; const CuratedSnapshot({ required this.lastReviewed, required this.models, }); } /// One row from the store-index search. class StoreItem { final String name; final String taglineEn; final String descriptionEn; final String category; final List tags; final List requiresCapabilities; final List requiresServices; final String license; final String repository; final String bestVersion; /// "published", "alpha", "planned", or empty when unknown. final String status; final bool installed; const StoreItem({ required this.name, required this.taglineEn, required this.descriptionEn, 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, }); }