chain-studio/lib/data/hub.dart
flemming-it d82738e17e feat(studio): cached/Regenerate UX in audit Explain (v0.16.0)
Audit drilldown's explanation panel now surfaces whether the
answer came from the cache:
- "cached" pill next to the SYSTEM AI label, tooltip shows
  the original generation date + hit count.
- Latency renders as "orig 47000 ms" so the operator sees
  what a live call would have cost.
- Refresh icon next to the latency triggers Regenerate —
  drops the entry, re-asks the model.

System-AI editor shows a cache-status row when the cache is
non-empty with a one-click Clear button. Hidden on fresh
installs to keep the dialog quiet.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-07 17:29:36 +02:00

927 lines
26 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 '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<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';
/// 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);
}
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);
}
/// 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,
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<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 text inputs. Returns
/// the named outputs as a map of UI-friendly strings.
Future<Map<String, String>> runSavedFlow({
required String name,
required Map<String, String> 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 '<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();
}
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(),
),
]);
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;
// 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,
),
);
}
}
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;
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<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,
});
}
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,
});
}
/// 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;
final String descriptionEn;
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;
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,
});
}